Skip to content

Answer a wrapped dictionary's existence questions from ContainsKey - #2969

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/objectwrapper-probe
Aug 11, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/objectwrapper-probe

Conversation

@lahma

@lahma lahma commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Draft-quality until its gate lands: the benchmark tables (new InteropDictionaryProbeBenchmark + controls + full SunSpider/Dromaeo) are being measured now and will be posted here before this should merge.

ObjectWrapper had no ProbeOwnProperty override, so every existence question — in, hasOwnProperty, and the per-key filter behind Object.keys/for-in/spread/JSON.stringify — fell to the base implementation, whose answer is the read: TryGetValue, FromObject on the value (for an object value, a whole nested ObjectWrapper), a PropertyDescriptor around it — then both discarded. Dictionary members are the one thing the wrapper never caches, so this repeated on every call: Object.keys over a wrapped JSON document paid it once per key. #2839 compiled ContainsKey, but only HasProperty's non-string-keyed arm could reach it — the one shape whose keys never enumerate.

The override answers Enumerable on a ContainsKey hit and otherwise falls through to base — it never decides a key is missing, because a wrapped dictionary still resolves CLR members for names the dictionary does not carry. The only possible disagreement is a target whose own ContainsKey and TryGetValue disagree, which the compiled lanes already trust. Precedence mirrors GetOwnProperty's own order. TypeDescriptor.CanTestDictionaryKey exists because ContainsDictionaryKey returns false both for "absent" and for "there was no ContainsKey to ask" — and reading the second as the first is exactly the silent key-dropping the probe contract warns against.

Proof it engages: a counting IDictionary shows exactly one ContainsKey and zero TryGetValue for 'a' in doc. The in-box agreement checker covers plain keys, object-valued keys, a key shadowing a CLR member, a CLR member name, an absent name, an index-shaped name, a symbol, and a script-redefined non-enumerable key; the embedder-side half lives in Jint.Tests.PublicInterface/HostDictionaryEnumerationTests.cs with the host dictionary as oracle. Green under JINT_HOST_CONTRACT_VERIFICATION=1 on both projects — which verifies every consumed probe against GetOwnProperty — and AGENTS.md's dictionary gotcha records the new lane.

The new benchmark's own InMiss row is the honest control: a miss can only get slower (one extra compiled ContainsKey on top of the resolver lookup it already paid), and by how much is a number the gate reports rather than hides. ObjectValues and ReadMember are value-needing controls no existence answer can serve.

Full solution green on both TFMs; test262 exactly 99,738 / 0 / 157.

🤖 Generated with Claude Code

An ObjectWrapper caches the descriptor it resolves for a reflected member in
_properties, so a second question about that name is a bag lookup. It
deliberately does not for a dictionary key, because the dictionary can change
under the wrapper - which left every existence question re-running the whole
read: TryGetValue, then FromObject on the value, then a PropertyDescriptor
around it, and then discarding both to answer a yes/no. FromObject on an object
value builds a whole nested ObjectWrapper, so Object.keys over a JSON-shaped
document wrapped one child per object-valued key, per call, and threw them all
away. in, hasOwnProperty, propertyIsEnumerable, for..in, Object.assign, spread
and JSON.stringify all reach the same probe.

ObjectWrapper now overrides ProbeOwnProperty and asks the target's own
ContainsKey, which sebastienros#2839 already compiled - it was reachable only from
HasProperty's non-string-keyed arm, the one shape whose keys never enumerate.

The lane only ever answers; it never decides a key is missing. A false from
ContainsKey falls through to the descriptor path, because a wrapped dictionary
still resolves CLR members for names the dictionary does not carry (Count, and
every method) and that arm is not this method's to reproduce. So a miss costs
one extra compiled ContainsKey on top of a resolver lookup it already paid, and
the only way probe and GetOwnProperty can disagree is a target whose own
ContainsKey and TryGetValue disagree with each other - which the compiled
dictionary lanes already trust everywhere else.

The three things that outrank a dictionary key are checked in GetOwnProperty's
own order: a stored descriptor (Object.freeze, Object.defineProperty, a host
SetOwnProperty), the pending length forwarder, and a symbol key. The crossing
memo needs no check of its own - it holds descriptors for keys the dictionary
has, carrying the flags every dictionary member gets, so it can only agree.
TypeDescriptor gains CanTestDictionaryKey because ContainsDictionaryKey answers
false both for "absent" and for "there was no ContainsKey to ask", and reading
the second as the first is exactly the silent key-dropping this contract warns
about.

Pinned from both sides. OwnPropertyProbeTests is the in-box checker - it probes
a spread of keys first and then requires each answer to match the descriptor,
across a plain key, an object-valued key, a key shadowing a CLR member, a CLR
member name, an absent name, an index-shaped name, a symbol, and a key a script
has redefined non-enumerable. HostDictionaryEnumerationTests asserts the
script-visible half from an embedder's vantage, with the host dictionary itself
as the oracle, including that a counting IDictionary implementation sees exactly
one ContainsKey and no TryGetValue for `'a' in doc`.

InteropDictionaryProbeBenchmark is the new gate: InHit, HasOwnProperty,
ObjectKeys and ForIn should improve in both columns, with ObjectValues and
ReadMember as the controls that need the value and must not move, and InMiss as
the control for what asking costs when the answer is no.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma

lahma commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark gate

Targeted — InteropDictionaryProbeBenchmark (new in this PR) vs the merge-base 20bbf8725:

Row base cand time Δ alloc
InHit 74.2 µs 49.8 µs −32.8% 31.86 KB → 624 B (−98.1%)
HasOwnProperty 191.6 µs 153.6 µs −19.8% 31.86 KB → 624 B (−98.1%)
ObjectKeys 824.1 µs 520.5 µs −36.8% −31.8%
ForIn 1.656 ms 1.320 ms −20.3% −37.2%
ObjectValues 1.228 ms 1.020 ms −16.9% −29.5%
InMiss (cost-of-asking control) 77.5 µs 83.1 µs +7.2% flat
ReadMember (control) 111.8 µs 109.3 µs −2.2% flat

The allocation column is the byte-deterministic channel and confirms every designed row: the discarded per-probe descriptor and nested wrapper are gone. ObjectValues improves despite being listed as a value-needing control, mechanically: its enumerability filter no longer materializes a descriptor per key before the values are read. InMiss +7.2% is the predicted, disclosed cost — one extra compiled ContainsKey ahead of the resolver lookup a miss already paid, ≈ 5.6 ns per miss.

Neighbours: InteropNestedDictionaryBenchmark and ImmutableCrossingBenchmark (whose read paths this diff does not enter) showed one +11.0% flag in pair 1; the order-reversed second pair exonerated it — every row within ±2.4%, allocation byte-flat in every run.

Wide — SunSpider (26) + Dromaeo (24), single pair: allocation flat on all 50 rows. No SunSpider or Dromaeo script wraps a CLR dictionary, so no row executes the new code; the time swings (both directions, up to ±12%) appear on exactly those unreachable rows and contradict the movements the unrelated #2968 candidate showed on the same rows against the same baseline — single-draw baseline noise, reported as such.

Machine idle, serial, default BDN job, both sides from their own worktree CWD; the new benchmark class was ported byte-identical to the baseline side so both ran the same harness.

@lahma
lahma marked this pull request as ready for review August 10, 2026 21:28
@lahma
lahma merged commit c75ebbc into sebastienros:main Aug 11, 2026
5 checks passed
@lahma
lahma deleted the perf/objectwrapper-probe branch August 11, 2026 06:05
lahma added a commit that referenced this pull request Aug 13, 2026
Full re-measure of both suites (script + interop, one session, default job, idle
machine: 96 script rows and 20 interop rows) on the 4.16.0 release candidate
7b56c83. Every figure in the narrative sections is recomputed from the new
reports; no number is carried forward from the 4.15.0 tables.

Where the table stands now:

- Jint is fastest outright on 5 of 12 scripts (minimal ~345x V8's compiled lane,
  evaluation-modern ~80x, linq-js ~6.7x, dromaeo-core-eval-modern ~5%,
  dromaeo-object-regexp-modern 1.25x ahead of V8's fresh-context lane and 1.46x
  ahead of its compiled lane), fastest managed engine on 10 of 12, and fastest
  interpreter on all 12
- V8 keeps the tight-loop rows: base64 9.8x, object-string 6.6x, stopwatch 6.0x,
  3d-cube 3.4x, json-parse 2.2x, plus narrow leads on object-array (1.08x) and
  array-stress (1.09x) - array-stress being the one script row that changes
  hands, out of the rank-1 tie it held at 4.15.0
- Allocation: Jint is lowest of the managed engines on 10 of 12 scripts (Okojo
  on object-array, NiL.JS on minimal) and on all four interop rows, 3.9x-12.4x
  under the nearest managed competitor there
- Interop: rank 1 on string-passing, and back into a rank-1 tie with NiL.JS on
  collection-traversal (1,251.0 vs 1,242.7 us, 0.7% apart); rank 2 on
  method-calls (NiL.JS ahead) and property-access (YantraJS by 2.6%). Plain
  ClearScript costs 8.6x-11.2x against Jint, FastProxy 3.4x-7.0x

Adds a "What changed for 4.16.0" section: proper tail calls (#2975, which
measured -15.6% time and -40.4% allocation on the Jint-only controlflow-recursive
row against 4.15.3), the fast-call lane's growth (#2968, #2980, #2984), the
wrapped-dictionary probe lane (#2969), and the disclosed cost of the join-hole
re-read (#3003, +3.4% on hole-heavy joins). The comparison against the 4.15.0
tables is stated as directional only - the two sessions ran on .NET 10.0.10 ->
10.0.11 with YantraJS 1.2.419 -> 1.2.422 in between, so no row-for-row delta is
claimed - and the two stale prose claims naming 4.15.0 outside the history
sections are refreshed from this session's data.

Environment: AMD Ryzen 9 5950X, .NET 10.0.11 (SDK 10.0.400), BenchmarkDotNet
0.15.8, default job, otherwise idle machine. ClearScript's V8 lanes land within
~3% of the 2026-07-28 session on the identical package.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant