Build DOM prototypes from a member layout the process shares - #132
Merged
Conversation
A DOM prototype was an ObjectInstance subclass of ours, and the engine
refuses such an object as a prototype-method inline-cache holder by
design: a host subclass keeps its own-property set outside the engine,
so no engine-side version can witness that a name is still owned by it.
Every warm member read on a node therefore re-walked to the prototype
and re-resolved the member, which is the one cost three rounds of
optimization never moved.
Prototypes are now instantiated from a JsObjectShape: an immutable
member layout keyed by (type, library set) and held for the whole
process, over which each engine gets an object of its own. The layout is
engine storage, so the carve-out admits it and the cache serves the warm
read. Reflecting over a type tree also happens once per process rather
than once per engine, and existence questions are answered off the
layout without materialising the member they are about.
Everything a prototype carried that belongs to one engine moved onto the
object as host state - the engine, the constructor - and everything that
belongs to the type moved into the shared description: the indexers, and
the event members, whose identity is now the key a node files its
handler under. Member implementations no longer close over an engine and
derive it from the receiver instead. Each proxy remembers what its
prototype knows and checks it against the prototype in force, so the
hottest path costs a field load rather than two type tests.
The window is the exception. Its members are copied onto the global
object and a bare "alert('hi')" calls with no receiver at all, so its
methods keep a function per engine. Its accessors stay shared: an
accessor read always has a receiver, the global object itself for a bare
"document", and that reaches the shaped window prototype.
The registration quirk is reproduced deliberately: a member named after
one of Object.prototype's is skipped, because that is what probing a
half-built prototype used to do. A test pins that nothing in AngleSharp
currently collides, so the choice is made rather than made by accident.
The proxies' own-property hooks are untouched.
Measured on an idle machine against Jint 4.15.2, per read with the loop
baseline subtracted: el.tagName 150.7ns -> 104.7ns, el.hasChildNodes()
158.7ns -> 111.7ns, a script-assigned property 124.0ns -> 101.9ns, an
accessor write at parity, allocation byte-identical on every row. One
whole scripted document costs 1.291ms/578.68KB -> 244.8us/213.54KB.
The Jint floor moves to 4.15.2 for a reason that is not an API one:
JsObjectShape is complete in 4.15.0, but a name absent from a shaped
prototype chain was slower to answer than one absent from a dictionary
chain until jint#2858, and WebIDL makes this binding ask exactly that on
every named lookup a collection serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma
marked this pull request as ready for review
July 29, 2026 07:26
Every exported type is published as a property of the window, and every one of those properties is copied onto the global object, so `HTMLDivElement` and `Image` and the other names a script may read bare are all backed by the same descriptor. It was a hand-written one carrying `CustomJsValue`, because the constructor object behind a name is only built once script reads it - a document names a handful of the types an assembly exposes. That flag is permanent on such a descriptor, and Jint's global-identifier cache declines a descriptor that could still compute a different value on the next read. It has no way to learn that a custom value became a constant, so the name stayed uncached for the rest of the process even after the one read that fixed its value forever. `PropertyDescriptor.CreateLazy` (Jint 4.15.3) is the same deferral written so the engine can see it end: the descriptor drops the flag the moment it holds a value and rejoins the caches that decline it. The attributes are unchanged - enumerable, neither writable nor configurable - and so is everything a script can observe, which `DeferredConstructorTests` covers in seventeen tests. The floor moves to 4.15.3 for that: it is an API the shipped assembly now uses, which is a stronger reason than the measured one that moved it to 4.15.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things this binding depends on were, until Jint 4.15.3, only observable through a diagnostic or not at all. `Engine.Advanced.HasSharedShape` is a supported predicate for "did the shaping actually happen", where `GetObjectRepresentation` is explicitly not part of Jint's compatibility contract. Instantiating a prototype from a member layout falls back to the ordinary per-object dictionary silently and correctly when it cannot shape an object, so nothing about a document would look wrong if a DOM prototype stopped being shaped - only every warm member read on every node would get slower again, which is the entire point of the previous commit. The three prototype kinds now assert it, and the two proxies assert the opposite: a host object of ours shares its layout with nothing, which is the refusal the prototypes had to be moved out of. The host-contract verifiers are the checks that catch a host object answering one of the engine's extension points in a way that contradicts another. This binding answers four of them - `TryGetOwnPropertyValue`, `ProbeOwnProperty`, `HasIndex`, and the semantics derived from not overriding `Get` - and the engine trusts every one without re-checking, so a wrong answer is silent: a member disappears from every enumeration, or a read that should have found an attribute resolves on the prototype. They used to be compiled out of Release, so reaching them meant building Jint from source in Debug, which is a thing a suite does once and then stops doing. Since 4.15.3 the shipped package reads an AppContext switch, so this run - against the very package the library ships against - is a verified one. The switch has to be set before the first use of any Jint type, because the gate behind it is read once at type initialization. A module initializer is the only hook early enough by construction, and it needs a declared attribute on net462 and net472, whose BCL carries none. `TheVerifiersAreRunningForThisSuite` proves the ordering from behaviour rather than from Jint's internal gate: a host whose probe contradicts its own descriptors throws exactly when something is verifying. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FlorianRappl
approved these changes
Jul 29, 2026
FlorianRappl
left a comment
Contributor
There was a problem hiding this comment.
Really great stuff here!
Contributor
Author
|
My integration campaign for Jint 4.15 draws to an end. Thanks for being one of the test subjects when shaping the integration APIs for performance and clarity 😄 |
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.
A DOM prototype was an
ObjectInstancesubclass of ours, and the engine refuses such an object as a prototype-method inline-cache holder by design: a host subclass keeps its own-property set outside the engine, so no engine-side version counter can witness that a name is still owned by it (jint#2823). Every warm member read on a node therefore had to re-walk to the prototype and re-resolve the member. That is the one cost the whole optimisation campaign never moved —el.tagNamesat still through #117, #121 and #130.Prototypes are now instantiated from a Jint
JsObjectShape: an immutable member layout keyed by(type, library set)and held for the whole process, over which each engine gets an object of its own. The layout is engine storage, so the inline-cache carve-out admits it and the cache serves the warm read. Two things follow for free: reflecting over a type tree happens once per process instead of once per engine, and existence questions are answered off the layout without materialising the member they are about.What moved
Idle machine, net8.0, Release, BenchmarkDotNet, both columns on released Jint 4.15.2. Access rows use
--launchCount 5so cross-process code layout lands in the StdDev rather than in the delta, and are quoted per read with theLoopOnlybaseline subtracted; that baseline agreed to 1.3% between the columns.el.tagNameel.hasChildNodes()el.marker = k; n += el.markerattrs['title']el.id = 's0'list[0],0 in list,list.length,el.hasOwnProperty(...)andel[Symbol.toStringTag]are unchanged within noise — each moves in both directions across repeated runs. Allocation is byte-identical on every row, verified per operation withGC.GetAllocatedBytesForCurrentThreadover thirteen shapes of access rather than read off the benchmark's rounded column.The setup figures are the shared layout paying off where it should: the second document in a process no longer re-describes a type the first one already described. For scale, the bare-document row on the last commit before the Jint 4.14 bump read 96.48 ms / 33.47 MB — the 33 MB per scripted window that started this campaign. (Its scripted-document row is not comparable:
list[0]threwEntryPointNotFoundExceptionon that code, so the script aborted early. Two access rows are unmeasurable there for the same reason.)Both tables measure the shapes commit alone. The two commits added afterwards for Jint 4.15.3 are not in them, and neither is claimed to move a row.
Why the Jint floor moves
On 4.15.1 the
attrs['title']row above was 7.5% slower, not 2% faster, and that was the one real cost of this change. WebIDL says an object with a named property getter consults it only for names the interface does not already define, so every named lookup on a DOM collection asksprototype.HasProperty(name)— and the answer is almost always "no", the deepest possible miss over the whole chain. Jint's probe lane could only answer a hit from the shared layout; on a miss it declined and fell through toGetOwnProperty, which repeated the key conversion and the same index lookup, once per chain level.It was the chain walk and nothing else: a variant that looks up an absent name and builds no descriptor at all (
attrs['nope']) showed the same +7.7% on its own.That was a Jint-side gap, reported with the measurements and a profile as jint#2857, fixed in jint#2858 and released in 4.15.2 — which is why the floor moved there rather than staying at the API minimum.
It moves once more, to 4.15.3, and this time for an ordinary API reason:
PropertyDescriptor.CreateLazyis used by the shipped assembly (below). That is the stronger of the two kinds of reason, so the perf argument above no longer has to carry the floor on its own.I deliberately did not work around the 4.15.1 gap in the binding — the workaround would have been a second answer to "does this interface define this name", and the right one already exists.
What moved where
(type, library set)— the member layout, the indexers, and the event members. The library set is part of the key because loading AngleSharp.Css genuinely changes an element's member set; shapes are cheap to have several of.JsObjectShapehost state — the engine and the constructor object.constructoris a per-realm slot resolved on first read, so naming a type no longer forces its prototype's members into existence, and readingconstructoroff an instance still lands on the object script reads off the window.DomEventInstancewas per engine and doubled as the key a node filed its handler under; it is now a process-sharedDomEventDefinition, so only the handler itself stays per node.DomPrototypeInstanceis gone.The window, which is the exception
A window's members are the one set a script reaches without naming a receiver: they are copied onto the global object, and a bare
alert('hi')calls withthisundefined, so a shared implementation has nothing to derive an engine from. The window prototype's methods therefore keep a function per engine (a per-realm slot). Its accessors are shared — an accessor read always has a receiver, the global object itself for a baredocument, and that reaches the shaped window prototype.btoa === window.btoastill holds, and there is a test for it.Reproduced on purpose
Registration used to write members onto a half-built prototype and skip any name its chain already carried, which silently dropped a DOM member named after one of
Object.prototype's. A builder has no object to probe, so the rule is reproduced from a declared-name set instead — and the names are read off the engine rather than copied into the source, so it stays tied to whatObject.prototypeactually carries.As of AngleSharp 1.6 nothing collides, so the rule currently drops nothing.
NoDomMemberCollidesWithAnObjectPrototypeNamepins that: if AngleSharp ever names a membertoString— the plausible one, since a browser really does put it onHTMLAnchorElement— it fails, and the choice between keeping the quirk and fixing it gets made rather than made by accident.What Jint 4.15.3 added, and what it is used for here
Engine.Advanced.HasSharedShape— adopted, and it turns an observation into an assertion. The shaping claim above was only checkable throughGetObjectRepresentation, which Jint documents as explicitly not part of its compatibility contract.HasSharedShapeis: it answers "does this object share its property layout with its siblings", and a host may pin the answer. That matters here more than in most bindings, because instantiating from a member layout falls back to the ordinary per-object dictionary silently and correctly when it cannot shape an object — nothing about a document would look wrong if a DOM prototype stopped being shaped, only every warm member read on every node would get slow again, which is the whole point of this PR.PrototypesAreShapedAndTheProxiesAreNotasserts it for the element, collection and window prototypes, and asserts the opposite for the two proxies: a host object of ours shares its layout with nothing, which is exactly the refusal the prototypes had to be moved out of.Host-contract verification — wired on, for every run. The verifiers are the checks that catch a host object answering one of the engine's extension points in a way that contradicts another. This binding answers four of them —
TryGetOwnPropertyValue,ProbeOwnProperty,HasIndex, and the semantics derived from not overridingGet— and the engine trusts each one without ever re-checking it, so a wrong answer is silent: a member vanishes from every enumeration, or a read that should have found an attribute resolves on the prototype instead. They used to be compiled out of Release, so reaching them meant building Jint from source in Debug, which is a thing a suite does once and then quietly stops doing. Since 4.15.3 the shipped package reads theJint.EnableHostContractVerificationAppContext switch, so this suite now sets it from a[ModuleInitializer]— the only hook early enough, since the gate behind the switch is read once at type initialization.TheVerifiersAreRunningForThisSuiteproves the ordering from behaviour rather than from Jint's internal gate: a host whose probe contradicts its own descriptors throws exactly when something is verifying. Every CI run is now a verified run, against the very package the library ships against.PropertyDescriptor.CreateLazy— adopted, one level away from where it was looked for. Not for the prototype members: a shaped prototype already materialises each member on first touch, and Jint documentsCreateLazyas the wrong tool for shaped storage — storing any raw descriptor under a string key moves such an object to the dictionary representation permanently. Where it does belong is the type-name globals. Every exported type is published as a property of the window and copied onto the global object, soHTMLDivElement,Imageand the rest were all backed by one hand-written descriptor carryingCustomJsValue, because the constructor object behind a name is only built once script reads it. That flag is permanent on such a descriptor, and the global-identifier cache declines a descriptor that could still compute a different value next read — it has no way to learn that a custom value became a constant.CreateLazyis the same deferral written so the engine can see it end: the flag drops the moment the value exists, and the name rejoins the cache. Attributes and everything script can observe are unchanged, whichDeferredConstructorTestscovers in seventeen tests.Engine.Advanced.AddLazyGlobal(post-construction) — declined. Every global here is known while the engine is still being built, and the globals are the window's own descriptors shared onto the global object, so one value resolves once for both; a post-construction install would be a second binding for the same name with nothing to gain.Options.AddImmutableCrossing— declined. It memoises reads of a wrapped CLR object the host promises never changes, and every CLR object crossing this boundary is a live DOM node whose properties change under the script that is reading them.Not touched
The proxies' own-property hooks from #130/#131 —
TryGetOwnPropertyValue,ProbeOwnProperty,HasIndexand theArrayLikeObjectcollection path — are unchanged. What did change on the proxies is where they read the indexers from, and that they now remember it instead of type-testing per lookup.The nuspec floor moves to
[4.15.3,5.0.0), for the API reason given above rather than only the measured one. The tests need 4.15.3 independently:HasSharedShapeand a Release build that can verify host contracts are both 4.15.3.Testing
243 tests green on net8.0, net462 and net472, in both Release (the CI configuration) and Debug — and, unlike the earlier runs of this branch, every one of those runs has the host-contract verifiers on, against released Jint 4.15.3 rather than against a Debug build of Jint from a local clone. That is now the default rather than a thing done once by hand.
New: shape sharing across two engines (same
DomShapeinstance, distinct prototype objects, both answeringHasSharedShape), the shaped-versus-host assertion described above, per-engine prototype isolation, patch-and-delete correctness across the shared layout's deopt, bare window members, the reproduced skip quirk, a GC test proving the process-held shapes retain no engine, and the verification pin.Also
UnreadConstructorStillExists, which pins the one member declared as a slot that produces its value on first read: its existence has to be answerable while it still has no value, and a wrong "missing" there is silent — it drops the property fromin,hasOwnPropertyand every enumeration with nothing failing.hasOwnPropertyis the assertion that catches it (infindsObject.prototype's ownconstructoreither way). It is load-bearing: it fails against a build of Jint that answers the unmaterialised slot as missing.The parked
chore/jint-4.15.1-semantics-pinbranch is folded in. Its prototype assertion had to change: the prototype is no longer a type of ours, so it is pinned by the shared layout instead — which is the property actually carrying the win.