Adopt Jint 4.16.0: fewer per-render allocations, much cheaper engine construction - #6
Merged
Merged
Conversation
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
marked this pull request as ready for review
August 15, 2026 16:52
Owner
|
Beautiful. Thanks very much. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Resolvethis renderer performed twice per render — once for the framework's server entry, oncefor the view — along with the full path and the
Uriit built each time, is gone. That is theconstant ~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/constlive in fixedslots, and resolving an inherited global no longer allocates a string for the name.
Ask the engine what a module is called.
ModuleFactory.LocationOfis now public, so the rulethis 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.
OperationDeadlineConstraintis the samedesign
RenderDeadlinewas — armed by the host, deaf to the per-entry reset, non-positive means nobudget, a real
OperationCanceledExceptionfor 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:globalshands out proxies that touch the bridge only when a member is read, which is whatlets 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, .NET10.0.11). Baseline is
e22968a, measured at the start and again at the end of the session: the tworuns 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
headexport;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)
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)
Steady-state rows are unchanged from the table above, to the byte.
React
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.
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 = falseis conditioned onWatchForChanges, which is deliberate but arguable. Atsixteen 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
ProcessorCountengines does. Turning it on everywhere is a one-line change and would also erasethe 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
ParseOnlyPreparationTestsrenders the same view prepared both ways and pins that they produce thesame markup, including eight concurrent renders sharing one parse-only tree, which is the case that
depends on engines filling that state in themselves.
GlobalsBridgeTestspins that a registeredglobal 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.