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

Abierto
#602 2 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 3 días

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
35/100
Tipo de issue
Nueva funcionalidad
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
c, javascript

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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!

Lenguaje dominante
C
Estrellas
11.1k
Forks
1.2k
Merge medio
11 h 20 min
PR fusionados (30 d)
1

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de bellard/quickjs

Todos los issues de bellard/quickjs

Issues similares

Más issues de C

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.