Performance improvement opportunities from a QuickJS port (measured on Octane)

Aperta
#602 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 2 giorni

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
35/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
c, javascript

Direzione di ricerca

Start by comparing the reported ideas with the current QuickJS sources, especially js_shape_prepare_update, close_var_refs, JS_MaybeGC, libregexp, and dtoa.c, which the issue names. Pick one optimization and first establish a benchmark and regression test for it; the issue provides Octane measurements but no QuickJS patch or specific test files. Done means the selected change is validated in QuickJS with correctness tests and reproducible performance measurements.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Hi! I've been working on silkjs (https://github.com/julia-script/silkjs), which started as a port of QuickJS in my language Silk (https://github.com/silk-lang/silk). I've been hill-climbing performance with AI, and I'm approaching jitless v8 performance.
I'm huge fan of QuickJS, it has been an amazing base to build on, so I wanted to share the changes that I think could carry over, in case any of them are useful. I'm happy to spend the tokens and open some PRs here if you prefer.

Here are the results I got

Image Image

I checked each one against current master (VERSION 2026-06-04), so as far as I can tell none of these are there yet. The numbers are from silkjs, not QuickJS. Ir means callgrind instruction counts. Some gains were partly silkjs catching up on its own overhead, so expect smaller numbers in QuickJS for those.

Biggest wins
  1. Per-site cache for get_field. A 4096-entry table keyed by bytecode address, holding a shape version and a slot index for the receiver's own shape and for prototypes up to 3 levels up. Own-shape hits alone gave Richards -18% wall time; depth-1 prototype hits added Richards -9% and DeltaBlue -11%. One catch: shapes edited in place (js_shape_prepare_update) need a version stamp, since the pointer alone is not a safe key. Caching put_field on existing properties gave nothing; caching property adds gave RayTrace -1.2% Ir.

  2. Frameless calls for tiny functions. The compiler marks a function as a leaf when its body uses only side-effect-free opcodes, forward branches, and at most a put_field right before return. Calls to it run on scratch slots above the caller's frame, borrow values without refcount changes, and bail to the normal path on anything slow. A 64-miss budget turns it off for functions that keep bailing. DeltaBlue -25.6% Ir, Richards -7.1%. Most of the gain comes from borrowing; a version that kept refcounting was 2% slower.

  3. Free closure cycles when a frame returns. In EarleyBoyer about 70% of what cycle collections scanned was dead pairs of a recursive local function and its own captured variable. When a frame with captured variables exits, we run a small trial deletion over up to 16 just-closed var refs and their closures, and free the dead ones right away. In QuickJS this would sit in close_var_refs. Earley went from 71 to 2 collections per 33 iterations, and from 8.5 to 4.8 ms per iteration.

  4. Adaptive GC threshold. JS_MaybeGC always sets the next threshold to 1.5x the current size. If a collection frees less than 1/8 of the heap, we grow the factor up to 3x, since refcounting already handles acyclic garbage. Splay -12.5% Ir, and SplayLatency went from behind QuickJS to about 1.3x ahead. Peak memory did not change on the suite. This one is a few lines.

  5. libregexp scan shortcuts. In order of gain on Octane RegExp:

    • Skip ahead to the first position where the first consuming opcode (char, char_i, range, space, not_space, ^) can match, instead of trying every position. RegExp -30% Ir.
    • A 32-byte Latin-1 bitmap next to the range table for classes.
    • Literal and small-set search 16 bytes at a time (memchr or SSE2/NEON).
    • Dedicated star/plus opcodes for x* and x+ over a single character, so they loop without the backtrack stack.
    • Flat alternation chains where each alternative jumps straight to the end (/aaa|bbb|ccc/ went from 1490 to 340 Ir in the matcher).
    • Skip save_reset when the quantifier max is 1 or less.
  6. Shortest-digits dtoa. Schubfach for free-format radix-10 output (the TODO in dtoa.c mentions ryu): float to string -58% Ir. Our previous code, ported from the pinned QuickJS, was also not shortest for 54 powers of two, for example 2^-1016. I couldn't confirm that on master, so it may already be fixed. Parsing decimals with 15 or fewer digits as mantissa / 10^k (exact) gave string to float -54%.

Smaller ones
  • x == null still compiles to null + eq. Turning it into is_undefined_or_null gave Richards -2.4% Ir. Next to it, dup put_var x drop could become put_var x for globals (global writes 341 to 261 Ir). The optimizer already does this for put_loc, put_arg and put_var_ref.
  • Re-densify arrays. while (--i >= 0) a[i] = 0 on a fresh Array turns it into a slow array for good. Converting back once the properties are exactly length plus 0..len-1 gave Crypto -13% Ir.
  • Lazy arguments and call/apply forwarding. When arguments is only used as f.apply(x, arguments), skip building the object and apply on the frame's args. Entering f.call(this, ...) and f.apply(this, arguments) inline instead of recursing gave RayTrace -17% Ir. Running the Prototype-style this.initialize.apply(this, arguments) constructor as the construct frame itself gave another -22%.
  • Objects born with their final shape. For constructors that only do this.x = arg, cache the final shape per constructor and fill the object without a frame or per-property transitions. Doing the same for object literals at their bytecode site helped too. EarleyBoyer -17.8% Ir with the related caches, and Splay -22% together with a string-join change.
  • Fused loads. get_loc; get_field, get_loc; get_length and get_loc a; get_loc i; get_array_el as single opcodes that read in place without retaining the receiver: Richards -4.7%, RayTrace -3.3%, EarleyBoyer -3.2% Ir. This has to be done at compile time. Peeking at the next opcode at run time made other code 10 to 13% slower.
  • RegExp builtins. When RegExp.prototype and the regexp still have their original shapes, match, replace, split, exec and test can skip the observable exec/flags/lastIndex lookups. /x/gi match went from 386 to 60 ms per 200K calls.
  • GC list walks. Prefetching two nodes ahead in each pass (Earley about 3% faster), and walking the third pass backwards since its order doesn't matter (simulated L2 misses 856K to 710K).
  • Object spread. For a plain source with only data properties, adopt its shape and copy the property array in one go (2.06x to 0.74x of QuickJS's Ir on our clone microbenchmark).
  • Small things: cached strings for integers 0 to 255, two digits per step in u32toa, an Int hash for Map/Set keys, in-place x += y on uniquely held BigInts, aligning JS_CallInternal to 64 bytes (Crypto swung about 15% with its alignment), and polling interrupts only on backward jumps.
Things that did not help (in case they save you time)
  • A put_field cache for existing properties, a global prototype-chain memo instead of per-site entries, and a 4x shape hash instead of 2x.
  • Fusing Int compares with the following branch: fewer instructions, same or worse time.
  • A young-generation cycle collection, and wider GC steps: no gain on Earley, worse on Splay.
  • Prefetching a dying object's children, or freeing dead objects recursively on the spot: both slower.
  • Hot-function link ordering and -Os: flat or worse overall.
  • Tail duplication to unshare the dispatch jump: fewer mispredicts, but no time change.

Happy to share more detail, numbers or test cases for any of these. Thanks for QuickJS!

Lingua principale
C
Stelle
11.1k
Fork
1.2k
Merge medio
11h 20m
PR unite (30g)
1

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di bellard/quickjs

Tutte le issue di bellard/quickjs

Issue simili

Altre issue su C

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.