Skip to content

Bypass the array-covariance check on exact-JsValue[] dense element stores - #2751

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/stelemref-covariance
Jul 24, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/stelemref-covariance

Conversation

@lahma

@lahma lahma commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Storing into ArrayInstance._dense (a JsValue?[]) with a concrete element such as JsNumber paid the CLR covariant array-store helper (CastHelpers.StelemRef / StelemRef_Helper) on every write, because JsValue is a non-sealed base type and the JIT can't prove the stored value matches the array's exact element type.

_dense's element type wasn't guaranteed exact — the JsValue[] items constructor assigned it directly and C# array covariance lets a caller pass a subtype array (e.g. JsString[]) through that parameter. This PR makes it exact at that single ingestion point (copy into a fresh JsValue[] only when the incoming runtime type isn't exactly JsValue[]; the common case is one type check, no copy). With _dense provably exactly JsValue[], the covariance check is pure overhead, so the hot in-range dense stores now route through a small WriteDenseUnchecked helper (MemoryMarshal.GetArrayDataReference + Unsafe.Add on net8.0+, plain store otherwise), with the exactness and in-bounds invariants asserted in Debug. It mirrors the existing Arguments.WriteNoTypeCheck precedent, for the dense backing store.

Converted sites: TryWriteExistingDense, TryAppendDense, WriteArrayValueUnlikely, and both WriteArrayValue overloads (the Set / SetIndexValue / Engine.PutValue assignment path). Grow / sparse / hole paths are unchanged. As a bonus the normalization also removes a latent ArrayTypeMismatchException that a subtype-backed _dense would throw on the first heterogeneous element write.

Why

Profiled with the ultra ETW profiler on the Mozilla Kraken audio-fft / audio-beat-detection workloads (tight numeric loops that write JS arrays heavily). CastHelpers.StelemRef(_Helper) was ~1.05% of self-time and split evenly between the dense fast paths and the Engine.PutValue array-index assignment path.

After the change it's gone from the sampled hotspots (~0%), Engine.PutValue self stayed flat, and steady-state execution improved ~1.5–2.7% wall on both scenarios (min-of-N, measured in both orderings to rule out thermal bias).

Testing

  • dotnet build -c Release: 0 warnings / 0 errors, all TFMs.
  • Test262: 99431 passed, 0 failed (unchanged from baseline).
  • Jint.Tests.Runtime (Release): net10.0 3866/0, net472 3803/0.
  • Debug run with the new Debug.Asserts live across the array / engine / TypedArray suites: no assertion fired, empirically confirming _dense is exact and every converted store is in bounds.

🤖 Generated with Claude Code

…ores

Storing into ArrayInstance._dense (a JsValue?[]) with a concrete element such as
JsNumber went through the CLR covariant array-store helper (CastHelpers.StelemRef
/ StelemRef_Helper) on every write, because JsValue is a non-sealed base type and
the JIT cannot prove the stored value matches the array's exact element type.

_dense's element type was not guaranteed exact: the `JsValue[] items` constructor
assigned it directly, and C# lets a covariant subtype array be passed there. Make
it exact at that single ingestion point (copy into a fresh JsValue[] only when the
incoming array's runtime type is not exactly JsValue[]; the common exact case is a
single type check, no copy). With _dense provably exactly JsValue[], the covariance
check on its stores is pure overhead, so the hot in-range dense stores now go through
a WriteDenseUnchecked helper (MemoryMarshal.GetArrayDataReference + Unsafe.Add on
net8.0+, plain store otherwise), with the exactness and in-bounds invariants asserted
in Debug. Converted sites: TryWriteExistingDense, TryAppendDense,
WriteArrayValueUnlikely, and both WriteArrayValue overloads (the Set / SetIndexValue /
Engine.PutValue assignment path). Grow / sparse / hole paths are unchanged. As a bonus
the normalization also removes a latent ArrayTypeMismatchException a subtype-backed
_dense would throw on the first heterogeneous write.

Profiled with ultra (ETW) on the Mozilla Kraken audio-fft / audio-beat-detection
workloads: CastHelpers.StelemRef(_Helper) fell from ~1.05% of self-time to ~0
(no longer a sampled hotspot), Engine.PutValue self stayed flat, and steady-state
execution improved ~1.5-2.7% wall on both scenarios. Full Test262 (99431/0) and
Jint.Tests.Runtime (3866/0) remain green; a Debug run with the new asserts live
fired none across the array/engine/TypedArray suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBRJrW2fufm2vWksvQbGXc
@lahma
lahma merged commit 68a2ee1 into sebastienros:main Jul 24, 2026
4 checks passed
@lahma
lahma deleted the perf/stelemref-covariance branch July 24, 2026 07:54
lahma added a commit that referenced this pull request Jul 24, 2026
#2753)

JsValueListBuilder accumulates array contents into a JsValue?[] rented from
ArrayPool<JsValue?>.Shared, which is always an exact JsValue[] at runtime (Rent never
hands back a covariant subtype array). But its per-element stores — Add, AddHole and
GrowAndAdd — used a plain array[pos] = value, so each write paid the CLR array-covariance
check (stelem.ref -> CastHelpers.StelemRef / StelemRef_Helper) because JsValue is not
sealed. AddRange/ToArray/Grow already use bulk Span/Array.Copy and were unaffected.

Route the three element stores through a WriteUnchecked helper
(MemoryMarshal.GetArrayDataReference + Unsafe.Add on net8.0+, plain store otherwise),
with the exact-type and in-bounds invariants asserted in Debug. This is the same
technique already applied to ArrayInstance's dense stores (#2751), here for the pooled
builder that backs array construction across the engine — spread, Array.from/of,
Array.prototype map/filter/concat, and JSON.parse arrays.

Profiled with ultra (ETW) on Kraken json-parse-financial: the StelemRef driven by the
inlined builder.Add inside JsonParser.ParseJsonArray is gone (StelemRef there 1.39% ->
0.89%, the residual being the separate object shape-slot store), ~2-3% faster wall on the
parse loop. Full Test262 (99431/0) and Jint.Tests.Runtime (3866/0) stay green; a targeted
script (JSON.parse round-trips, sparse-array holes, spread, Array.from/of, map/filter/
concat, and a 5000-element grow-heavy build) matches expected values.


Claude-Session: https://claude.ai/code/session_01YBRJrW2fufm2vWksvQbGXc

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Jul 24, 2026
…2754)

A shape-mode JsObject keeps its first InlineCapacity (4) slot values in an in-object
[InlineArray] (field-offset access, no array store) and spills the surplus into an
_overflow JsValue[]. The two overflow writers — SetSlot and TryShapeAdd — used a plain
_overflow[i] = value, so every write past the fourth property paid the CLR array-covariance
check (stelem.ref -> CastHelpers.StelemRef / StelemRef_Helper) because JsValue is not sealed.

_overflow is always an exact JsValue[]: every assignment allocates new JsValue[] (InitShape,
the first-overflow case, TryAdoptShapeFrom) and growth uses Array.Resize, none of which yields
a covariant subtype. Route both stores through a WriteOverflowUnchecked helper
(MemoryMarshal.GetArrayDataReference + Unsafe.Add on net8.0+, plain store otherwise), asserting
the exact-type and in-bounds invariants in Debug. Same technique as ArrayInstance's dense stores
(#2751) and JsValueListBuilder (#2753), here for the hidden-class slot storage that backs every
shape-mode object property write beyond the in-object slots.

Profiled with ultra (ETW) on a wide-object construction micro (8-property objects, 4 spilling to
overflow): CastHelpers.StelemRef(_Helper) went from ~2.1% of self-time to 0 (no longer a sampled
function). Full Test262 (99431/0) and Jint.Tests.Runtime (3866/0) stay green; a targeted script
(40-property objects, in-place overflow updates, 100 shape-sharing records, mixed value types,
object spread, delete/dictionary-deopt, JSON round-trips of wide records) matches expected values.


Claude-Session: https://claude.ai/code/session_01YBRJrW2fufm2vWksvQbGXc

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
legrab added a commit to legrab/pocok that referenced this pull request Aug 14, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.14.0 to
4.15.3.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.15.3

Jint 4.15.3 rounds out the 4.15 embedder line: every item here answers
friction a real integration reported while adopting the host-integration
surface 4.15.0 introduced. Everything is additive — no option defaults
changed and no behavior changes for existing code.

- **`Engine.Advanced.AddLazyGlobal`** (#​2862) — install a lazy global
on a live engine, so a host whose globals are computed from per-request
data can defer building them until script reads the name; the same PR
adds `Engine.Advanced.WithRestoredGlobals(snapshot, action)`, the
`try`/`finally` every snapshot-reusing host was writing by hand.
- **`PropertyDescriptor.CreateLazy`** (#​2865) — a public lazy property
descriptor that materializes once and then rejoins the read and write
inline caches, which a hand-rolled `CustomJsValue` descriptor never
could; it is the sanctioned way to build for any host object property
what `AddLazyGlobal` does for a global.
- **`Options.AddImmutableCrossing(params Type[])`** (#​2863) — a host
promise that instances of the declared CLR types do not change while
they are exposed to the engine, in exchange for which a wrapped object
memoizes its resolved reads. On the nested-document walk it was built
for that measures −43% to −84% time and −99% allocation against the
undeclared path, with dictionary and `JsonNode` sources converging to
identical steady-state cost. It is a promise: a declared object mutated
anyway will serve stale reads.
- **Host-contract verification from the shipped package** (#​2864) — set
the `Jint.EnableHostContractVerification` AppContext switch before the
first use of any Jint type and the checks that catch a host answering
one extension point in a way that contradicts another run in Release,
throwing with a descriptive message. Embedders can now run their suites
against the exact package they deploy instead of building a Debug Jint
from source, and CI now runs this repository's own host suites that way
too (#​2866).
- **`Engine.Advanced.HasSharedShape`** (#​2861) — a stable, pinnable
predicate for whether `JsObject.Create`, `CreateFromEntries` or
`JsObjectShape.Instantiate` actually produced a shared-layout object,
which the explicitly non-contractual `ObjectRepresentation` diagnostic
could never be.
- **`JsString.Create(string)` is now public** (#​2860) — the counterpart
of `JsNumber.Create`, answering the empty string and single-character
ASCII from interned instances instead of allocating.
- **Documentation** (#​2859) — an unresolvable reference's `Base` holds
an internal sentinel rather than `undefined`, and resolver authors
returning it were leaking that sentinel string into scripts; the docs
and the in-repo sample now show the right idiom.

## What's Changed
* Make JsString.Create(string) public by @​lahma in
sebastienros/jint#2860
* Document that an unresolvable reference's base is a sentinel, not
undefined by @​lahma in sebastienros/jint#2859
* Add engine-reuse ergonomics: post-construction lazy globals and a
snapshot scope by @​lahma in
sebastienros/jint#2862
* Give hosts a stable predicate for "did the shaping actually happen" by
@​lahma in sebastienros/jint#2861
* Make the host-contract verifiers reachable, and complete by @​lahma in
sebastienros/jint#2864
* Give hosts a lazy descriptor that rejoins the caches once it holds a
value by @​lahma in sebastienros/jint#2865
* Let a host promise a wrapped object is immutable and have its reads
memoized by @​lahma in sebastienros/jint#2863


**Full Changelog**:
sebastienros/jint@v4.15.2...v4.15.3

## 4.15.2

Jint 4.15.2 is a fix release.

- **Async and generator suspension** — loop iteration state is preserved
across suspensions in async generators and `for await...of` (#​2852), an
`await` suspending a right-hand side no longer stores the suspension
sentinel into the target (#​2855), and suspension-node resolution
unwraps correctly (#​2856).
- **Correctness** — calling and `instanceof` work on bound functions
whose target is itself bound (#​2853), and inherited accessors reached
through `ObjectInstance.TryGetValue` receive the original receiver
(#​2854).
- **Performance** — the builtin-shape probe lane answers an
authoritative miss without falling back to the slow path, which
named-index misses on shaped objects were paying on every probe
(#​2858); and the `JsObject.Create` values span is now
nullable-annotated so a lazy slot's required `null` needs no suppression
(#​2851).

## What's Changed
* Annotate the layout values span so a lazy entry's null needs no
suppression by @​lahma in sebastienros/jint#2851
* Unwrap a JintStatement suspension node so for-await resumes into the
right branch by @​HermanusMuellerEU in
sebastienros/jint#2856
* Fix calling and instanceof on bound functions whose target is itself
bound by @​HermanusMuellerEU in
sebastienros/jint#2853
* Preserve loop iteration state across suspensions in async generators
and for-await-of by @​HermanusMuellerEU in
sebastienros/jint#2852
* Pass the original receiver to inherited accessors in
ObjectInstance.TryGetValue by @​HermanusMuellerEU in
sebastienros/jint#2854
* Do not store the suspension sentinel when an await suspends a
right-hand side by @​HermanusMuellerEU in
sebastienros/jint#2855
* Answer an authoritative miss from the builtin-shape probe lane by
@​lahma in sebastienros/jint#2858

## New Contributors
* @​HermanusMuellerEU made their first contribution in
sebastienros/jint#2856

**Full Changelog**:
sebastienros/jint@v4.15.1...v4.15.2


## 4.15.1

Jint 4.15.1 is a small refinement release shaped by the first real-world
adoptions of 4.15.0's host-integration surface — every change answers a
need a shipping embedder hit within days of the release. No behavior
changes for existing code, with one deliberate spec-path improvement:
`Object.freeze` no longer forces lazily-declared properties into
existence just to validate attribute-only redefinitions (so freezing
`globalThis` no longer materializes every lazy global).

- **`JsObjectLayout` lazy slots** (#​2850) — a fresh shaped object per
item can now defer expensive members: declare `AddLazy(name, factory)`
on the layout, pass per-instance state to `JsObject.Create`, and the
member materializes on first read while every item keeps sharing one
hidden class. In the motivating host shape (a 15-member event envelope
with 4 expensive members), builds measure ~3.6× faster with 4× fewer
allocations than the eager layout, and ~1.6× faster than the
dictionary-mode workaround it replaces.
- **Observability for host tests** —
`Engine.Advanced.GetPropertyAccessSemantics` (#​2847) lets a test pin
the access semantics the engine derived for a host type, and
`GetInteropConversionDiagnostics` (#​2848) counts CLR array crossings so
a host can audit its `ArrayConversion` exposure — including through
dependencies it doesn't own. Both carry the same non-contractual,
diagnostics-only framing as `GetObjectRepresentation`.
- **`PropertyFlag.NonWritable` / `OnlyConfigurable`** (#​2849) complete
the named combination lattice for the descriptor shapes hosts actually
build.
- **Documentation** (#​2846) — the contracts a real adoption tripped
over, stated where an embedder will find them: `JsonSerializer` reuse
and its `Undefined` sentinel, the `BigInt.prototype.toJSON` escape
hatch, what does *not* route through `GetOwnProperties()`, and the
snapshot reuse recipe.

## What's Changed
* Document the contracts a real adoption tripped over by @​lahma in
sebastienros/jint#2846
* Let a test observe the access semantics the engine derived for a host
type by @​lahma in sebastienros/jint#2847
* Name the two PropertyFlag combinations hosts actually build by @​lahma
in sebastienros/jint#2849
* Count CLR array conversions so a host can audit its crossing semantics
by @​lahma in sebastienros/jint#2848
* Let a layout declare lazy slots so a shaped object can defer expensive
members by @​lahma in sebastienros/jint#2850


**Full Changelog**:
sebastienros/jint@v4.15.0...v4.15.1


## 4.15.0

Jint 4.15.0 is an **embedder-focused release**: the host-integration
surface was widened after auditing six real-world integrations, engine
reuse got first-class support, and an adversarial pre-release review
verified every change since 4.14.0 test-first. **No option defaults
changed.** One behavior change to note: re-importing a module whose
evaluation failed now rethrows the recorded error instead of returning a
namespace (#​2827).

### Highlights

**Host objects**

- Answer reads value-direct with `TryGetOwnPropertyValue` (#​2808) and
existence/enumerability questions without materializing descriptors with
`ProbeOwnProperty` (#​2803); access semantics are derived from the type
automatically (#​2804). Warm host reads cost zero probes, and Debug
builds verify every answer.
- `ArrayLikeObject` (#​2835, #​2841) projects a live indexed collection
by implementing two members — indexed reads, `for-of`, spread, generics
and `JSON.stringify` cost one virtual call per element.
- `JsObjectShape` (#​2830, #​2836, #​2840) declares shared prototypes
once per process with lazily materialized per-realm members — and a
shaped prototype can serve the prototype-method inline cache, which no
host subclass can.
- First adopter: a DOM binding cut indexed-read allocations by 60% and
existence probes to zero.

**Engine reuse**

- `CaptureGlobalSnapshot` / `RestoreGlobalSnapshot` (#​2834) restore a
configured global between evaluations: top-level `let`/`const` cleared
(nothing else can), stale promise continuations fenced, warm per-engine
caches kept. Configuration reuse — deliberately not an isolation
boundary.
- Fresh-engine hosts register globals lazily (`AddLazyGlobal`, #​2805)
or selectively via `Prepared<T>.ReferencedGlobals` (#​2831). The two
compose with the snapshot.

**Interop**

- CLR member accessors are shared process-wide (#​2798, made effective
for extension-method hosts in #​2829); compiled lanes cover dictionary
writes, indexers, statics and omitted optional arguments (#​2839); host
delegates invoke through arity-typed thunks with no argument array
(#​2799, #​2843).
- Typed converter registration (#​2794) and `EnumConversionMode.Name`
(#​2796) keep the lanes a blanket converter used to cost.
- JSON parses from char and UTF-8 spans (#​2832) and serializes into
`IBufferWriter<byte>` (#​2822).
- `NullPropagatingReferenceResolver.Instance` (#​2833) makes nullish
member reads yield `undefined` through a recognized inline lane.

**Performance, gated**

- Against 4.14.0 on idle hardware: **every Dromaeo row improved**
(`object-regexp` −25% with 48% fewer allocations, `object-string` −21%,
`string-base64` −15%); SunSpider improved on eleven scripts, zero
regressions.
- Fast-call coverage widened across dozens of built-ins, with
per-argument guards and register-based rest calls: `Math.max(a,b)` −22%,
`push(x,y)` −19% (#​2828, #​2843, #​2844).
- `encodeURI` on clean input −85%; dense `toReversed`/`with` up to −86%
(#​2843).

On the [engine comparison
benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md),
Jint 4.15.0 is the fastest engine outright on 5 of 12 scripts — taking
`dromaeo-object-regexp-modern` from native V8 at −42% — the fastest
managed engine on 10 of 12, the fastest interpreter on all 12, and
8.9×–11.6× ahead of ClearScript (native V8) on every interop row.

## What's Changed
* Replace xUnit Assert.* with AwesomeAssertions across the test suites
by @​lahma in sebastienros/jint#2742
* Cache interop invokers process-wide instead of per-Engine by @​lahma
in sebastienros/jint#2743
* Compile CLR property and field access instead of reflecting per hit by
@​lahma in sebastienros/jint#2744
* Convert an indexer hit by the indexer type, not the member type by
@​lahma in sebastienros/jint#2746
* Measure the execution timeout against an inline deadline by @​lahma in
sebastienros/jint#2747
* Consult reference resolver for a call to an unresolvable identifier by
@​poissoncorp in sebastienros/jint#2750
* Trim per-call overhead from the interop method fast lane by @​lahma in
sebastienros/jint#2745
* Bypass the array-covariance check on exact-JsValue[] dense element
stores by @​lahma in sebastienros/jint#2751
* Sort integer-index property keys by value instead of re-parsing each
key by @​lahma in sebastienros/jint#2752
* Bypass the array-covariance check on JsValueListBuilder element stores
by @​lahma in sebastienros/jint#2753
* Bypass the array-covariance check on JsObject overflow slot stores by
@​lahma in sebastienros/jint#2754
* Bypass the array-covariance check on callback argument arrays (sort,
groupBy, array iteration) by @​lahma in
sebastienros/jint#2755
* Memoize the converted value of a stable reference-typed interop
property by @​lahma in sebastienros/jint#2756
* Replace StrictModeScope with a Strict flag on the execution context by
@​lahma in sebastienros/jint#2757
* CI: reliably seed the cross-OS Test262 cache and bump actions to
latest by @​lahma in sebastienros/jint#2758
* Bump the testing group with 2 updates by @​dependabot[bot] in
sebastienros/jint#2760
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#2759
* Bump the js-engine-comparisons group with 1 update by
@​dependabot[bot] in sebastienros/jint#2761
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.14.0...v4.15.3).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.14.0&new-version=4.15.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
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