Skip to content

Adopt Jint 4.16.0: fewer per-render allocations, much cheaper engine construction - #6

Merged
davidwhitney merged 5 commits into
davidwhitney:mainfrom
lahma:perf/jint-416
Aug 16, 2026
Merged

Adopt Jint 4.16.0: fewer per-render allocations, much cheaper engine construction#6
davidwhitney merged 5 commits into
davidwhitney:mainfrom
lahma:perf/jint-416

Conversation

@lahma

@lahma lahma commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Jint 4.16.0 shipped the APIs this project had to work around when #5 went in, so this takes them
and measures what each one is worth. Five commits, each severable and measured on its own.

What is here

Update Jint to 4.16.0. Two engine changes land on the render path without JsxCore asking. A
repeat import no longer goes through the module loader: the engine records the referrer and
specifier the first time it loads a module and answers later imports from that record, so the
Resolve this renderer performed twice per render — once for the framework's server entry, once
for the view — along with the full path and the Uri it built each time, is gone. That is the
constant ~3.1 KB every steady-state row below drops. The rest is interpreter work the views sit
on: the cheapest built-in calls run without a frame, a function's own let/const live in fixed
slots, and resolving an inherited global no longer allocates a string for the name.

Ask the engine what a module is called. ModuleFactory.LocationOf is now public, so the rule
this loader kept a copy of belongs to the engine again. A prepared module's name is what relative
imports inside it resolve against, and a copy that drifts by a character sends them to the wrong
directory with nothing in the error to say why. Byte-identical allocation, as it should be.

Take the whole-render deadline from the engine. OperationDeadlineConstraint is the same
design RenderDeadline was — armed by the host, deaf to the per-entry reset, non-positive means no
budget, a real OperationCanceledException for the token — so 86 lines here become a registration.
The engine reports an elapsed budget in its own words, which do not say whose budget it was, so the
render still says that itself and keeps the engine's report underneath: the existing timeout and
abort tests pass untouched, which is the point.

Build the .NET globals bridge only for the renders that read it. A registered global is
resolved from the request's service scope, and every render resolved all of them and wrapped each
one for the engine whether the view asked or not. The JavaScript side was already lazy —
dotnet:globals hands out proxies that touch the bridge only when a member is read, which is what
lets a client-rendered view import one harmlessly — and the host now matches it. The factory is
declared once per engine and before the engine's clean surface is captured, so returning the
engine to the pool puts it back to unresolved and the next render resolves against the next
request's scope. That also means no render mutates the global object at all, where before every one
did.

Prepare modules the cheaper way while the views can still be recompiled. Preparation does two
walks — the parse, and an analysis pass that pre-publishes the interpreter's bookkeeping onto the
tree — and the second is a trade, not a saving: paid once per parse, redeemed once per engine that
reads it. A dev loop throws every parse away on the next edit after one or two engines have read
it; a server keeps its parses for the life of the pool. Watching already says which shape this is.
The numbers for both are below.

Measurements

BenchmarkDotNet, default job, [MemoryDiagnoser], serial on an idle machine (Ryzen 9 5950X, .NET
10.0.11). Baseline is e22968a, measured at the start and again at the end of the session: the two
runs agree to within 3% on the microsecond rows and 1.5% on the millisecond ones, and every
allocation figure is byte-identical between them, so that is the noise floor these deltas are read
against. Each row builds its own project, renderer and pool, so no row measures another's warm-up.

Rows: Minimal is a one-module view with no model on a warm engine; Dashboard is a
two-module view over a twenty-order model; ReadHead evaluates only the head export;
ColdStart drops the pool and rebuilds one engine; Pool fill (4 / 16) drops the pool and
renders concurrently, which forces that many engines to be built for one compilation.

Preact — production shape (analysis on)

Baseline This branch Δ time Δ alloc
Minimal 83.0 µs / 32.06 KB 80.7 µs / 28.88 KB −2.8% −9.9%
Dashboard 2488 µs / 2760.34 KB 2462 µs / 2757.12 KB −1.1% −0.1%
ReadHead 117.6 µs / 69.65 KB 113.5 µs / 66.52 KB −3.4% −4.5%
ColdStart 6035 µs / 4629.28 KB 6073 µs / 4563.32 KB +0.6% −1.4%
Pool fill (4) 3691 µs / 3006.58 KB 3895 µs / 2847.56 KB +5.5% −5.3%
Pool fill (16) 5509 µs / 7893 KB 5982 µs / 7315 KB +8.6% −7.3%

The two pool-fill rows are the one cost in this PR and they are Jint's, not JsxCore's: they appear
at the version bump and none of the later commits moves them. Building an engine over Preact's
small module graph got slower in 4.16 while allocating less — the load phase the engine gained does
more bookkeeping per module, and a small graph never earns it back the way React's does below. It
is engine construction only; steady-state rendering is faster and lighter. I will chase it upstream.

Preact — while watching (analysis off)

Baseline This branch Δ time Δ alloc
ColdStart 6035 µs / 4629.28 KB 5099 µs / 4395.43 KB −15.5% −5.1%
Pool fill (4) 3691 µs / 3006.58 KB 2770 µs / 2812.64 KB −25.0% −6.5%
Pool fill (16) 5509 µs / 7893 KB 4729 µs / 7813 KB −14.2% −1.0%

Steady-state rows are unchanged from the table above, to the byte.

React

Baseline This branch Δ time Δ alloc
Minimal 161.6 µs / 36.98 KB 162.2 µs / 35.80 KB +0.4% −3.2%
Dashboard 6470 µs / 932.87 KB 6131 µs / 886.17 KB −5.2% −5.0%
ReadHead 119.1 µs / 67.60 KB 111.9 µs / 66.52 KB −6.0% −1.6%
ColdStart 145.8 ms / 72.3 MB 105.8 ms / 54.1 MB −27.4% −25.2%
Pool fill (4) 189.7 ms / 170.0 MB 136.2 ms / 98.5 MB −28.2% −42.0%
Pool fill (16) 327.0 ms / 565.7 MB 211.7 ms / 280.5 MB −35.3% −50.4%

All of that is the version bump — measured on the bump commit alone, it is within noise of the
branch tip. React resolves its graph out of node_modules, so the per-import resolution the engine
stopped repeating was a much larger share of building an engine than it is for Preact. While
watching, the same rows go to −46.3%, −45.8% and −40.0% against baseline.

The globals bridge

Four registered globals resolved from a scope per render, which is the shape the change is for.

Baseline This branch Δ time Δ alloc
View reads none of them 85.8 µs / 33.86 KB 79.8 µs / 28.88 KB −6.9% −14.7%
View reads one 94.1 µs / 35.47 KB 91.8 µs / 32.28 KB −2.4% −9.0%

Isolating the lazy bridge from the version bump under it, measured as two pairs in opposite orders:
a view that reads none of them is 3.9% and 5.0% faster and 1.89 KB lighter (byte-identical in both
pairs), and a view that reads one is unchanged — the first pair said +2.6% and the second −0.1%, so
that was the envelope rather than a cost. It scales with how many globals are registered, since
what a render no longer does is resolve and wrap all of them.

The one decision worth a second opinion

StaticAnalysis = false is conditioned on WatchForChanges, which is deliberate but arguable. At
sixteen engines the trade turns over: the time is still −20.3% (Preact) and −7.4% (React), but the
allocation crosses to +6.9% and +1.1%, because the part each engine does for itself is paid per
engine while the parse it saves is paid once. A dev loop never reaches that; a production pool of
ProcessorCount engines does. Turning it on everywhere is a one-line change and would also erase
the Preact pool-fill cost noted above, at that allocation. I left it where the win is unambiguous —
say the word if you would rather have the other trade.

Tests

ParseOnlyPreparationTests renders the same view prepared both ways and pins that they produce the
same markup, including eight concurrent renders sharing one parse-only tree, which is the case that
depends on engines filling that state in themselves. GlobalsBridgeTests pins that a registered
global is not resolved for a render that never reads it, and that one which does read it still gets
its own request's instance. The existing timeout, abort and per-request-global tests are unchanged
and pass as they are.

lahma added 5 commits August 14, 2026 10:49
Two of the engine's changes land on the render path without anything here
having to ask for them. A repeat import no longer goes through the module
loader: the engine records the referrer and specifier the first time it loads
a module and answers later imports from that record, so the Resolve this
renderer performs twice per render -- once for the framework's server entry,
once for the view -- along with the full path and the Uri it builds each time,
is gone. The rest is interpreter work the views sit on: the cheapest built-in
calls run without a frame, a function's own let and const live in fixed slots,
and resolving an inherited global no longer allocates a string for the name.

It also brings the two APIs this project had to hand-write against, which the
next commits adopt.
A prepared module is named when it is prepared, and that name is what the
engine hands back as the referencing location when the module's own relative
imports are resolved. Deriving it here meant keeping a copy of a rule this
project does not own: a name that differs from the engine's by a character
sends every relative import in that module to the wrong base directory, with
nothing in the error to say why.

Jint 4.16 publishes the rule as ModuleFactory.LocationOf for this reason. It
agrees with the copy for every specifier resolved here, which all carry an
absolute file Uri, and it keeps agreeing if that ever stops being true.
… here

The render's own budget was a hand-written constraint: armed before the engine
goes to work, cleared when it returns to the pool, and deliberately deaf to the
per-entry reset the engine performs every time the host calls in. Jint 4.16
ships that shape as OperationDeadlineConstraint, down to the details that made
it worth writing -- a non-positive budget means no budget rather than one that
has already run out, a budget too large to add to the clock is clamped instead
of overflowing into the past, an aborted token raises a real
OperationCanceledException so a host filtering for one still sees it, and the
constraint stays cheap enough for the engine to keep its tight-loop path.

So there is nothing left here worth maintaining a copy of, only a message: the
engine reports that a budget elapsed without saying whose, and a host reading
the log wants to know it was the render timeout it configured. The render says
that itself and keeps the engine's own report underneath, so the exception a
caller catches is unchanged.
A registered global is resolved from the request's service scope, so it can be
a database context, a localizer, or anything else the scope builds on demand.
Every render resolved all of them and wrapped each one for the engine, whether
the view asked or not -- and most views ask for none. An application that
registers six globals paid for six service resolutions and six wrappers to
render a page that mentions none of them.

The JavaScript side was already lazy about this: dotnet:globals hands out
proxies that touch the bridge only when a member is read, which is what lets a
client-rendered view import a global harmlessly. The host is now lazy in the
same way. The engine holds the global as a factory declared once per engine,
run the first time a view reads it and not at all otherwise.

Declaring it before the engine's clean surface is captured is what makes it a
per-render bridge with no per-render write. Returning an engine to the pool
restores that surface, which puts an unresolved lazy global back to unresolved,
so the next render resolves against the next request's scope -- the behaviour
the two tests for a global held at module scope already pin. Installed after
the capture instead, it would be a property the restore removes and the next
render adds again, which is a mutation of the global object per render and
costs every warmed global lookup in the engine its cached answer.

The bridge is built as a whole on first read rather than a member at a time, so
a view reading one of six globals still resolves six. That is the next step
rather than this one; the step here is that a view reading none resolves none.
Preparing a module does two walks: the parse, and an analysis pass that writes
the interpreter's own bookkeeping onto the tree it produced. The second costs
about as much as the first, and it is paid once for a parse however many
engines go on to read it -- so it is a trade rather than a saving, and which
way it falls depends on how many engines that is.

An application being developed answers that badly. Every edit recompiles, and
every recompile throws this compilation's parses away for a new set that one
or two engines will read before the next edit does it again, so the analysis
is paid for readers who never arrive. A server that will not recompile answers
it the other way: its parses last for the life of the pool and every engine in
it reads them.

Measured on this branch, same binary with the flag on and off, Preact first
and React after it. One engine: -16.7% and -26.0% to build it and render.
Four: -28.3% and -24.4%. Sixteen, which no dev loop reaches: the time is still
-20.3% and -7.4%, but the allocation has crossed over to +6.9% and +1.1%,
because the part each engine does for itself is paid sixteen times while the
parse it saves is paid once. Steady-state rendering is untouched either way,
to the byte.

Watching is the difference between those two shapes, and it is already the
flag that says which one this is. Nothing about what a view renders changes
either way, which the two new tests state -- including several engines
rendering at once from one shared parse, since with the analysis declined they
fill in that state themselves as they reach it.
@lahma
lahma marked this pull request as ready for review August 15, 2026 16:52
@davidwhitney

Copy link
Copy Markdown
Owner

Beautiful. Thanks very much.

@davidwhitney
davidwhitney merged commit 07f62b4 into davidwhitney:main Aug 16, 2026
3 checks passed
@lahma
lahma deleted the perf/jint-416 branch August 16, 2026 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants