Retour au Blog
GCperformancebenchmarkscommunity

Ce que le benchmark d'aya_koto nous a appris sur le GC de Perry

Il y a quelques semaines, Ayasaka-Koto (@axt_ayakoto sur X) a publié un benchmark de Perry face à Deno et Bun sur le problème AtCoder ABC451D, “Concat Power of 2.” Sa mesure : Perry tournait 3,85× plus lentement que Bun. Sa conclusion était polie mais ferme — Perry n'était pas prêt à être un runtime de programmation compétitive, et ne le serait peut-être pas même une fois mûri.

Nous lui devons une suite. Voici où nous en sommes arrivés sur le même benchmark, avec la même commande hyperfine, sur la même classe de machine :

Command                                Mean         Min      Max
Perry v0.5.875                         425.0 ± 78 ms  367 ms  745 ms
Bun 1.3.12                             430.7 ± 74 ms  376 ms  787 ms
Deno 2.7.14                            544.8 ± 140 ms 426 ms  984 ms

Perry vs Bun:   1.01× faster (statistical tie, within error)
Perry vs Deno:  1.28× faster
Perry vs aya_koto's published Perry number: 2.87× faster

Combler cet écart a demandé une investigation qui a commencé sur une mauvaise hypothèse, a trouvé un compromis architectural de GC réel mais délibéré, et a produit un résultat qui mérite selon nous d'être raconté — non parce que nous avons rattrapé, mais parce que la manière dont ce compromis apparaissait sous le profilage est intéressante en elle-même.

Le benchmark

L'abc451d-perry.ts d'aya_koto effectue une recherche récursive en profondeur sur des concaténations de chaînes de puissances de 2, dédupliquées via un Set<number> et triées. La fonction chaude est courte :

function search(before: string, powersOfTwoStr: string[]): string[] {
    const answers: string[] = [];
    if (before.length > 0) answers.push(before);
    const remainDigits = 9 - before.length;
    for (let i = 0; i < powersOfTwoStr.length; i++) {
        const after = powersOfTwoStr[i];
        if (after.length > remainDigits) break;
        const child = search(before + after, powersOfTwoStr);
        for (let j = 0; j < child.length; j++) answers.push(child[j]);
    }
    return answers;
}

La forme est toute l'histoire. Chaque appel alloue un nouveau string[]. La récursion est profonde — facteur de branchement jusqu'à environ 30 au sommet — et chaque frame parent garde son array answers vivant pendant qu'il itère sur l'array de l'enfant et pousse sur le sien. Allocations à courte durée de vie, récursion profonde, références vivantes éparpillées dans chaque bloc d'arène actif. C'est exactement la charge contre laquelle le GC de Perry n'était pas réglé.

La mauvaise hypothèse

Un lecteur avait laissé une note de bas de page sur l'article d'aya_koto signalant que le BigInt de Perry était en interne un entier de 1024 bits à longueur fixe, et que les programmes lourds en BigInt tournaient environ 4× plus lentement que Bun. ABC451D implique des puissances de 2 — de grands nombres semblaient plausibles — et le premier réflexe a donc été : BigInt est le coupable, on corrige le chemin BigInt, l'écart se ferme.

Faux. grep -i bigint abc451d-perry.ts n'a rien renvoyé. Le benchmark utilise number partout ; chaque valeur tient confortablement sous 2^53. La note BigInt était correcte, réelle, et un problème qui méritait d'être corrigé — et nous l'avons corrigé, séparément, en v0.5.736. Mais cela n'avait rien à voir avec ABC451D.

Le coût de poursuivre d'abord la mauvaise hypothèse a été d'environ une journée. La leçon — dont j'aimerais prétendre que nous la connaissions déjà — était : profilez avant de vous engager dans une théorie, même quand la théorie vient d'une source crédible et correspond à vos a priori. Surtout alors.

Reproduire le bench

La première chose que nous avons faite une fois que nous avons cessé de poursuivre BigInt a été de reproduire proprement les chiffres d'aya_koto. Nous nous attendions à atterrir près de ses 1,219 s sur Perry. Nous avons atterri à 2,998 s sur Perry v0.5.729.

C'est une régression de 2,5× entre la version qu'il a testée et notre main d'alors. Deno et Bun se sont reproduits à 50 % près de ses chiffres (matériel différent, dérive de version). L'écart de Perry était passé de 3,85× à 6,59× pendant que personne ne regardait.

Nous n'avons pas bissecté quel commit a causé la régression — cela sortait du périmètre de cette investigation. Mais l'absence d'un garde-fou CI qui aurait attrapé la dérive est en soi un constat, et nous y reviendrons à la fin.

Diagnostic piloté par le profil

Compilé avec PERRY_DEBUG_SYMBOLS=1 et enregistré avec samply, le tableau du temps propre était sans ambiguïté :

% Self    Function
41.2%     perry_runtime::gc::try_mark_value
12.7%     perry_runtime::gc::drain_trace_worklist_inner
 9.0%     perry_runtime::gc::build_valid_pointer_set
 8.5%     perry_runtime::arena::arena_walk_objects_with_block_index
 5.6%     perry_runtime::gc::try_mark_value_or_raw
 4.2%     js_number_coerce
 3.1%     js_array_sort_with_comparator

76 % du temps propre était de la machinerie GC. Le temps inclusif confirmait : gc_collect_minor à 80 %, Arena::alloc à 76 %, js_array_alloc à 45 %, js_array_push_f64 à 22 %. Le search() récursif était chaud, mais il était chaud sous la phase de marquage du GC. Chaque appel déclenchait assez d'allocation pour provoquer une collecte.

Un microbenchmark de contrôle négatif a confirmé que le ralentissement n'était pas général. fib(80) × 100_000 en entiers serrés, sans allocation : Perry 6,1 ms contre Bun 24,7 ms — Perry 4× plus rapide. Le codegen pour les boucles chaudes sans allocation était déjà devant Bun. L'écart d'ABC451D se concentrait sur un chemin de code spécifique : le débit d'allocation plus le mark-sweep du GC sur cette forme d'allocation particulière.

L'indice accablant

Nous avions un flag — PERRY_GC_DIAG=1 — qui imprimait des statistiques GC par cycle. La sortie a été l'observation porteuse de toute l'investigation :

[gc-step] pre_in_use=67 MB  post_in_use=67 MB  sweep_freed=38 MB  block_reclaim=0  pct=57%
[gc-step] pre_in_use=100 MB post_in_use=100 MB sweep_freed=55 MB  block_reclaim=0  pct=55%
[gc-step] pre_in_use=119 MB post_in_use=119 MB sweep_freed=65 MB  block_reclaim=0  pct=55%
…
arena blocks: 61 → 84 → 100 → 116 → 131 → 145 → 157 → … → 270+

Chaque cycle, le même motif. Le sweep identifiait correctement que 55 à 60 % des objets alloués étaient morts. Et l'arène récupérait zéro bloc. Le tas grandissait de façon monotone tout au long de l'exécution, tandis que le GC continuait de payer le coût du mark-sweep sur un working set toujours plus grand.

Pourquoi block_reclaim=0 alors que plus de la moitié des objets étaient morts ? Parce que le GC d'arène de Perry récupère à la granularité du bloc. Un bloc de 1 Mo n'est réinitialisé que lorsque tous les objets qu'il contient sont morts. Dans ABC451D, le search() récursif garde des références vivantes — l'array answers du frame parent — éparpillées dans chaque bloc actif. Aucun bloc n'est jamais entièrement mort. Le mark-sweep identifie correctement les objets morts, n'a pas de chemin de récupération par objet, et donc ne fait rien avec eux. Le tas grandit, les déclenchements du GC s'enchaînent sur un tapis roulant, et le coût de chaque cycle grimpe à mesure que le working set grimpe.

Le compromis délibéré

La chose la plus instructive que nous avons trouvée n'était pas dans le profil. Elle était dans le sweep lui-même, à crates/perry-runtime/src/gc.rs:2733, sous forme d'un commentaire expliquant le design :

Nous ne poussons délibérément PAS les objets morts sur la ARENA_FREE_LIST globale. L'allocateur bump inline ne lit jamais la free list — il utilise le reset par bloc à la place. Pousser les objets morts sur la free list coûterait ~50ns par objet × ~700k objets par GC × ~12 cycles GC par benchmark = 420ms de pur gaspillage dans object_create.

C'est exactement correct pour la charge contre laquelle il a été réglé. object_create est un benchmark qui nous tient à cœur, où les allocations meurent dans une boucle serrée et où des blocs entiers se vident bel et bien entre les cycles. Ajouter une passe de free list par objet brûlerait 420 ms de comptabilité inutile pour cette charge, et le chemin de reset par bloc capture la même mémoire moins cher.

C'est un mauvais ajustement pour la forme d'ABC451D, où les références vivantes restent éparpillées et où le reset par bloc ne se déclenche jamais. L'architecture avait un compromis délibéré encodé en elle, et nous n'avions jamais benchmarké le cas où le compromis va dans le mauvais sens.

C'est la vraie leçon. Le GC n'était pas cassé. Il était réglé pour une distribution de motifs d'allocation différente de celle que représente le bench d'aya_koto, et nous n'avions pas remarqué que la distribution pour laquelle il était réglé excluait une classe de charges réelles — recherche récursive, parcours d'arbres, tout ce qui maintient un état vivant à chaque niveau de la pile tout en faisant de l'allocation à courte durée de vie en dessous.

Ce qui n'a pas marché

Avant d'arriver à un vrai correctif, plusieurs leviers d'apparence plausible se sont révélés être de mauvais leviers. Nous les rapportons avec des chiffres parce qu'ils ont été la moitié la plus intéressante de l'investigation :

  • PERRY_GEN_GC_EVACUATE=1 — Perry avait déjà une passe d'évacuation par copie en opt-in. L'activer pour ABC451D : 11,4 secondes, quatre fois plus lent que la baseline. La passe tourne à chaque cycle qu'elle soit utile ou non, et son coût de copie par objet plus réécriture des références est catastrophique quand le live set est constitué de petits objets à courte durée de vie. À conserver pour les charges qui en bénéficient, mais pas la réponse ici.
  • PERRY_GEN_GC=0 (mark-sweep complet au lieu de générationnel) — 3,06 s, essentiellement identique à la baseline. Le choix de la stratégie n'est pas ce qui est contraignant ; c'est l'absence de récupération par objet.
  • Nettoyage structurel du ValidPointerSet (commit 0fa42e0b). Fusion des deux vecteurs triés séparés (pointeurs d'arène et pointeurs malloc) en un seul, ajout d'un préfiltre de plage min/max, inlining du rejet de tag de try_mark_value. A divisé par deux le coût par appel de contains() — qui était la boucle interne chaude signalée par le profil. Le bench ABC451D est passé de 3,07 s à 3,21 s. Du pareil au même, dans le bruit. Le changement apporte toujours de la valeur pour les charges où contains() est réellement la contrainte contraignante (benchmarks de forme ECS, chaînes de compose hono), mais ce n'était pas la contrainte contraignante ici. Le volume absolu d'appels — piloté par la pression d'allocation alimentant la phase de marquage — dominait même à coût par appel nul.

Le motif à travers les trois : la stratégie du GC et les coûts de boucle interne par appel étaient du second ordre. La contrainte contraignante était l'absence d'un chemin de récupération pour les objets morts dans les blocs qui ne se vident pas complètement. Tant que cela n'était pas adressé, rien d'autre ne faisait bouger l'aiguille.

Où nous en sommes arrivés

Entre v0.5.737 et v0.5.875, sur environ 137 versions patch, l'écart s'est fermé. Nous sommes délibérés en l'écrivant : nous n'avons pas bissecté jusqu'à un unique commit héros. Le correctif a atterri à travers une série de changements dans le sous-système GC qui ont rendu le compromis délibéré “pas de free list par objet” conditionnel plutôt que permanent — quand block_reclaim reste à zéro sur des cycles consécutifs, le sweep commence à peupler une free list par buckets de taille et l'allocateur bump gagne un chemin de repli. Le séquençage exact et la contribution de chaque patch nécessiteraient une bissection soignée que nous devons mais n'avons pas encore faite.

Le résultat, sur le bench et la commande exacts d'aya_koto, sur Apple M-series, macOS 26.4 :

Perry v0.5.875: 425.0 ± 78 ms  (367 – 745)
Bun 1.3.12:     430.7 ± 74 ms  (376 – 787)
Deno 2.7.14:    544.8 ± 140 ms (426 – 984)

Deux notes d'honnêteté sur ce tableau. D'abord, la marge de 1,01× de Perry sur Bun est dans les barres d'erreur — le mot correct est “à égalité,” pas “plus rapide.” Ensuite, la variance sur les trois runtimes est significative (le max de Perry est de 745 ms contre une moyenne de 425 ms), et une exécution isolée peut atterrir dans l'une ou l'autre queue. Nous avons montré le min et le max à côté de la moyenne pour cette raison ; nous préférons que vous voyiez la dispersion.

Ce qui reste imparfait

Quelques points que nous ne maquillons pas :

La régression de 1,2 s à 3,0 s survenue entre la mesure d'aya_koto et le début de cette investigation nous dit que nous n'avions pas de garde-fou CI attrapant cette classe de ralentissement. Nous ajoutons abc451d-perry.ts et une petite suite environnante à la CI de Perry comme porte de régression de perf avant que ce billet ne soit publié. Si ce bench se dégrade silencieusement dans une future release, cela devrait faire échouer un build, pas un benchmark d'un critique dans trois mois.

Le correctif relâche un compromis délibéré dans une direction spécifique. Nous surveillons le benchmark object_create et ses semblables — les charges que le choix originel “pas de free list” protégeait — pour nous assurer que le chemin de free list conditionnel ne les fait pas régresser. Les premiers chiffres sont dans le bruit, mais c'est le genre de chose où la confiance vient du temps, pas d'une seule exécution de benchmark.

Nous n'avons pas bissecté la plage de 137 versions. Nous le ferons. Cela importe pour la documentation, et cela importe pour comprendre lesquels des mécanismes de free list conditionnelle font le travail.

Crédit

L'article d'aya_koto était exactement le genre de compte rendu dont un projet open-source a besoin et qu'il reçoit rarement. Il a mesuré soigneusement, publié son dépôt de test, pointé des frictions spécifiques dans le chemin d'installation, et atteint la conclusion honnête que Perry n'était pas prêt pour le cas d'usage qu'il évaluait. Cette conclusion était correcte au moment où il l'a faite. Elle serait restée correcte plus longtemps s'il n'en avait pas parlé.

Son dépôt de test est à github.com/AXT-AyaKoto/perry-ts-test-2026-0421. Son article est à zenn.dev/aya_koto/articles/553ce04b1d5ac4. Les deux valent la lecture même après cette suite — l'article surtout, parce qu'il documente une évaluation honnête d'un compilateur en phase précoce par quelqu'un qui n'a aucune raison d'être poli.

Deux points spécifiques de son article que nous devrions noter. La friction du chemin d'installation qu'il a signalée — que le haut de perryts.com pointait vers une méthode tandis que les docs en recommandaient une autre — a été corrigée ; le chemin npm est désormais l'option mise en avant sur la page d'accueil, en accord avec les docs. La frustration des “choses hors du doc des limitations qui ne compilent pas” qu'il a signalée — nous avons parcouru chaque fichier .ts de son dépôt de test face au Perry actuel ; les vraies lacunes ont fait l'objet d'issues, et les limitations documentées ont été étoffées.

La note BigInt de son article était, comme discuté plus haut, sans rapport avec ABC451D mais réelle en elle-même — l'implémentation BigInt de Perry était en effet un entier de 1024 bits à largeur fixe sous le capot, et les programmes lourds en BigInt le payaient. C'est corrigé en v0.5.736, avec un chemin inline pour les petites valeurs et num-bigint comme repli en précision arbitraire. Le crédit revient ici au lecteur qui a laissé la note sur l'article d'aya_koto ; nous ne savons pas qui il est, mais si vous lisez ceci : merci.

Reproduction

Si vous voulez reproduire ces chiffres vous-même :

git clone https://github.com/AXT-AyaKoto/perry-ts-test-2026-0421.git /tmp/aya-koto-bench
cd /tmp/aya-koto-bench

npm install -g @perryts/perry@0.5.875
perry abc451d-perry.ts -o abc451d-perry

# Sanity (should print 328 for input 69):
./abc451d-perry < abc451d-input.txt

# The article's exact command:
hyperfine --warmup 10 --runs 100 --export-markdown abc451d-bench.md \
  './abc451d-perry < abc451d-input.txt' \
  'deno run --quiet --allow-all abc451d-deno.ts < abc451d-input.txt' \
  'bun run abc451d-bun.ts < abc451d-input.txt'

Vos chiffres varieront selon le matériel et les versions de runtime. S'ils varient d'une manière qui semble fausse, ouvrez une issue — nous préférons en être informés.

Source : github.com/PerryTS/perry — Issues : github.com/PerryTS/perry/issues

— Ralph

Cet article te plaît ? Reçois le suivant.

De brèves notes sur les releases de Perry et ce qu'on prépare ensuite.

Quelques e-mails par mois. Désabonnement à tout moment.