From 9553aaeaa7b7c1c6f4d21bb95289a0a23421bf17 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 11:51:13 +0300 Subject: [PATCH] Skip the prototype walk for array hole reads when the chain is provably clean Reading a hole (or any absent index) fell off the dense fast path into a full prototype-chain probe that allocates a JsString per index - a loop summing a 75%-holey array allocated 7 MB per op and the in operator the same. The write fast path already trusts CanUseFastAccess (no exotic own descriptors, prototype identity intact, no index properties placed on Array.prototype/Object.prototype - QuickJS guards its array fast path with the same idea as a single std_array_prototype flag); the read side now trusts it too: Get(uint), the indexed Get(JsValue) miss and HasProperty return undefined/false directly when the invariant holds. Index getters set NonDefaultDataDescriptorUsage, prototype index additions set ObjectChangeFlags.ArrayIndex, and subclass or custom prototypes fail the identity check - all of those fall through to the unchanged walk. New tests pin each escape hatch. ArrayHoleTraversalBenchmark (default job): | Lane | Before | After | |---------------- |---------------------:|------------------:| | SumHoley | 21.03 ms / 7,015.7 KB | 14.50 ms / 1.32 KB | | InOperatorHoley | 16.70 ms / 7,015.7 KB | 8.45 ms / 1.32 KB | | SumDense | 10.44 ms | within noise | | Join/IndexOf | unchanged | unchanged | -31% / -49% time and -99.98% allocation on the holey lanes. Co-Authored-By: Claude Fable 5 --- Jint.Tests/Runtime/ArrayTests.cs | 47 ++++++++++++++++++++++++++++++ Jint/Native/Array/ArrayInstance.cs | 36 ++++++++++++++++++++--- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Jint.Tests/Runtime/ArrayTests.cs b/Jint.Tests/Runtime/ArrayTests.cs index dead66448..ddb107b29 100644 --- a/Jint.Tests/Runtime/ArrayTests.cs +++ b/Jint.Tests/Runtime/ArrayTests.cs @@ -24,6 +24,53 @@ public void FilterSkipsHoles() Assert.Equal("[1,3]", result); } + [Fact] + public void HoleReadFindsInheritedIndexOnArrayPrototype() + { + // The pristine-prototypes shortcut must disengage the moment Array.prototype (or + // Object.prototype) gains an index property: hole reads and `in` then walk the chain. + var engine = new Engine(); + var result = engine.Evaluate(""" + var a = [1, , 3]; + var before = [a[1], 1 in a, a[10], 10 in a]; + Array.prototype[1] = 'ap'; + Object.prototype[10] = 'op'; + var after = [a[1], 1 in a, a[10], 10 in a]; + JSON.stringify([before, after]); + """).AsString(); + + Assert.Equal("[[null,false,null,false],[\"ap\",true,\"op\",true]]", result); + } + + [Fact] + public void HoleReadHonorsIndexGetterOnArrayItself() + { + // an exotic own descriptor clears the fast-access invariant on the instance + var engine = new Engine(); + var result = engine.Evaluate(""" + var a = [1, 2, 3]; + Object.defineProperty(a, '5', { get: function () { return 'got'; } }); + [a[5], 5 in a, a[7] === undefined].join(','); + """).AsString(); + + Assert.Equal("got,true,true", result); + } + + [Fact] + public void HoleReadWalksCustomPrototypeChain() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + var proto = { 1: 'inherited' }; + var a = [0]; + a.length = 3; + Object.setPrototypeOf(a, proto); + [a[1], 1 in a, a[2] === undefined, 2 in a].join(','); + """).AsString(); + + Assert.Equal("inherited,true,true,false", result); + } + [Fact] public void FilterSubclassUsesSpecies() { diff --git a/Jint/Native/Array/ArrayInstance.cs b/Jint/Native/Array/ArrayInstance.cs index f969d4bd5..cbc12eb26 100644 --- a/Jint/Native/Array/ArrayInstance.cs +++ b/Jint/Native/Array/ArrayInstance.cs @@ -606,6 +606,15 @@ internal JsValue Get(uint index) { if (!TryGetValue(index, out var value)) { + // Reading a hole walks the prototype chain, but when nothing relevant has changed + // (no exotic descriptors here, prototypes pristine — the same invariant the write + // fast path trusts) the walk provably yields undefined: skip it and the per-hole + // JsString the probe would allocate. + if (CanUseFastAccess) + { + return Undefined; + } + value = Prototype?.Get(JsString.Create(index)) ?? Undefined; } @@ -614,9 +623,18 @@ internal JsValue Get(uint index) public sealed override JsValue Get(JsValue property, JsValue receiver) { - if (IsSafeSelfTarget(receiver) && IsArrayIndex(property, out var index) && TryGetValue(index, out var value)) + if (IsSafeSelfTarget(receiver) && IsArrayIndex(property, out var index)) { - return value; + if (TryGetValue(index, out var value)) + { + return value; + } + + // hole/out-of-range read with pristine prototypes: see Get(uint) + if (CanUseFastAccess) + { + return Undefined; + } } if (CommonProperties.Length.Equals(property)) @@ -662,9 +680,19 @@ public sealed override bool Set(JsValue property, JsValue value, JsValue receive public sealed override bool HasProperty(JsValue property) { - if (IsArrayIndex(property, out var index) && GetValue(index, unwrapFromNonDataDescriptor: false) is not null) + if (IsArrayIndex(property, out var index)) { - return true; + if (GetValue(index, unwrapFromNonDataDescriptor: false) is not null) + { + return true; + } + + // absent index with pristine prototypes: the chain walk provably finds nothing + // (same invariant as Get; exotic own descriptors clear CanUseFastAccess) + if (CanUseFastAccess) + { + return false; + } } return base.HasProperty(property);