Skip to content

Build the constructor of an exposed type when script first names it - #121

Merged
FlorianRappl merged 1 commit into
AngleSharp:develfrom
lahma:perf/lazy-dom-constructors
Jul 26, 2026
Merged

Build the constructor of an exposed type when script first names it#121
FlorianRappl merged 1 commit into
AngleSharp:develfrom
lahma:perf/lazy-dom-constructors

Conversation

@lahma

@lahma lahma commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the recent perf batch, and the last structural item from #72.

Setting up an engine gave every exposed type a constructor object: 178 of them, each with its own prototype link, toString function and property descriptors. A document names a handful. The reflection deciding what to build was already cached per type by CreatorCache; the objects built from it are engine-affine and were not.

The property is still registered for every type, with the attributes it had — PropertyFlag.OnlyEnumerable is bit-for-bit what new PropertyDescriptor(value, false, true, false) produced — so enumerating the window is unchanged. Only the object behind the property waits for the first read.

constructor had to move

DomConstructorInstance's ctor is what installs constructor on the type's prototype. Deferring naively would make screen.constructor undefined, because an object can reach script without its global name ever being read. So the prototype now owns its constructor and pulls it in at the end of Initialize(); reading the name off the window and reading constructor off an instance still arrive at the same object.

AddConstructorFunctions and AddInstances stay eager on purpose — they cover one type each (Image, Screen), so deferring them would add paths for no measurable gain.

Numbers

The reported loop from #72 — 100 engines, all retained — net8.0, workstation GC, serial alternating runs:

before after
median per engine 2.89 / 2.96 ms 1.82 / 1.88 ms (−36%)
per retained engine 398.3 KB 187.5 KB (−53%)
100 engines 38.9 MB 18.3 MB
total allocation over the run 100.4 MB 60.5 MB

For 100 documents that actually run a script (build 50 elements, querySelectorAll, new CustomEvent, dispatchEvent): 8.28 → 7.68 ms, 735.0 → 527.2 KB per instance. The absolute saving is the same as in the bare case — a real page names two or three constructors, so the win does not erode.

Two things this does not do, stated plainly:

  • Cold start is unchanged. The first engine in a process is 184.2 → 183.2 ms (n=5, overlapping ranges — noise). That cost is JIT plus first-time reflection over the type tree; the constructor loop was ~1 ms of it. The win is entirely in second-and-later engines and in retained memory.
  • These globals no longer enter Jint's global-identifier inline cache. PropertyFlag.CustomJsValue cannot be cleared after materialization from outside Jint, and self-replacing the descriptor would mutate the property dictionary while Object.values(window) iterates it. Measured worst case, 2M reads of HTMLDivElement: 82.4/81.1 ms before vs 83.3/80.9 ms after — no regression, but it is a real property of the mechanism.

PropertyFlag.CustomJsValue is the hook Jint documents as surviving its read inline caches (UnwrapJsValue re-reads the flag on every access rather than snapshotting the value); overriding Get would not have been safe. Verified against the 4.14.0 tag, which is what this package consumes.

Tests

133/133 → 151/151 on net8.0, net462 and net472, warning-clean on all four TFMs.

The 18 new tests in DeferredConstructorTests also pass against unmodified devel — they pin existing contract rather than asserting the new behaviour into existence. Separately, a fingerprint over all 180 window own-property names, all 373 globalThis names, Object.keys/for...in counts, descriptor attributes, identity, instanceof, typeof, delete/assign semantics and .constructor for 20 different objects is byte-identical before and after.

Every exposed type got a constructor object as the engine was set up: 178
of them per engine, each with its own prototype object, toString function
and property descriptors. A document names a handful of them.

The property is still registered for every type, with the attributes it
had, so nothing about enumerating the window changes - only the object
behind it waits for the first read. The prototype keeps the constructor,
so reading the name off the window and reading "constructor" off an
instance still arrive at the same object; a prototype reached through an
instance asks for its constructor itself, since that path never names the
type.

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl
FlorianRappl merged commit be831d3 into AngleSharp:devel Jul 26, 2026
5 checks passed
@lahma
lahma deleted the perf/lazy-dom-constructors branch July 27, 2026 06:58
@FlorianRappl FlorianRappl added this to the v1.0 milestone Jul 27, 2026
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