From eda27f46711e39f53a8215410d72df36eaa5f7f3 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 6 Jul 2026 15:39:20 +0300 Subject: [PATCH 1/2] Serve nested-scope global reads/writes from the global-binding cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identifier-level global-binding cache previously required the current lexical environment to BE the global environment, so reads of globals from loop bodies and closures (sw/Date in stopwatch, helper functions in the dromaeo family) walked the environment chain and did the property lookup every time. - TryGetValidatedGlobalDescriptor now accepts nested scopes via a bounded walk mirroring the slot-cache walk: any intermediate with-object or declarative environment holding the name (e.g. sloppy direct eval var injection) bails to full resolution; the per-read probes are the correctness pin. Hop 0 keeps its single identity compare up front. - Cache population no longer requires hop-0 resolution (read and write sides), and the cache admits any plain writable data descriptor without CustomValue indirection instead of requiring MutableBinding; writers now re-check Writable through the cached reference since defineProperty flips flags in place without bumping the validated versions. - LazyPropertyDescriptor clears CustomJsValue once materialized — from then on it is semantically a plain data descriptor, which admits materialized global built-ins (Date & co) to this cache and the member-write cache. Co-Authored-By: Claude Fable 5 --- .../Specialized/LazyPropertyDescriptor.cs | 14 ++- .../Expressions/JintAssignmentExpression.cs | 9 +- .../Expressions/JintIdentifierExpression.cs | 89 ++++++++++++++----- .../Expressions/JintUpdateExpression.cs | 4 +- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs b/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs index fac78d633b..aa1a167f94 100644 --- a/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs +++ b/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs @@ -19,7 +19,19 @@ internal LazyPropertyDescriptor(T state, Func resolver, PropertyFlag protected internal override JsValue? CustomValue { - get => _value ??= _resolver(_state); + get + { + var value = _value; + if (value is null) + { + _value = value = _resolver(_state); + // Once materialized this is semantically a plain data descriptor; clearing the + // flag lets value reads/writes skip the CustomValue indirection and admits the + // descriptor to the global-binding and member-write inline caches. + _flags &= ~PropertyFlag.CustomJsValue; + } + return value; + } set => _value = value; } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs index dbbd3911e9..622fdb74c2 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs @@ -962,8 +962,10 @@ private JsValue SetValue(EvaluationContext context) // identifiers to a single field test; the rest stays out-of-line. if (left._cachedGlobalEnv is not null) { + // Writable is re-checked through the cached reference: defineProperty flips the + // flag in place without bumping the versions the validator checks. var cachedGlobalDescriptor = left.TryGetValidatedGlobalDescriptor(engine, env); - if (cachedGlobalDescriptor is not null) + if (cachedGlobalDescriptor is not null && cachedGlobalDescriptor.Writable) { return AssignToCachedGlobalBinding(context, left, right, cachedGlobalDescriptor, hasEvalOrArguments, nameAnonymousFunction, strict); } @@ -1011,8 +1013,9 @@ private JsValue SetValue(EvaluationContext context) // Populate the global-binding cache from the write side too, so write-first // patterns benefit from the next access on. Must run after the set: the set - // may have created the property (bumping the shape version). - if (ReferenceEquals(environmentRecord, env) && environmentRecord is GlobalEnvironment globalEnv) + // may have created the property (bumping the shape version). No hop + // restriction — the validator re-walks with shadow probes on every use. + if (environmentRecord is GlobalEnvironment globalEnv) { left.TryRememberGlobalBinding(globalEnv); } diff --git a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs index d03a9a7197..239ef52f14 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs @@ -23,10 +23,11 @@ internal sealed class JintIdentifierExpression : JintExpression private DeclarativeEnvironment? _cachedSlotEnv; private int _cachedSlotIndex = -1; - // Version-based inline cache for global bindings: when top-level code (current lexical - // env IS the global env) resolves to a plain writable MutableBinding data property on the - // real GlobalObject, remember the descriptor. Valid while the global object's own-property - // shape and the set of global lexical declarations are unchanged — value writes mutate the + // Version-based inline cache for global bindings: when resolution (from any scope depth) + // lands on a plain writable data property of the real GlobalObject, remember the + // descriptor. Valid while the global object's own-property shape and the set of global + // lexical declarations are unchanged AND a bounded walk from the current env reaches the + // global env without an intermediate that could own the name — value writes mutate the // descriptor in place and bump neither version. Mirrors JintMemberExpression's read cache. internal GlobalEnvironment? _cachedGlobalEnv; private Runtime.Descriptors.PropertyDescriptor? _cachedGlobalDescriptor; @@ -232,8 +233,10 @@ public override JsValue GetValue(EvaluationContext context) _cachedSlotIndex = slotIndex; } } - else if (ReferenceEquals(identifierEnvironment, env) && identifierEnvironment is GlobalEnvironment globalEnv) + else if (identifierEnvironment is GlobalEnvironment globalEnv) { + // No hop restriction: the validator re-walks with shadow probes on every read, + // so nested-scope reads of globals (loop bodies, closures) can use the cache. TryRememberGlobalBinding(globalEnv); } @@ -252,31 +255,77 @@ public override JsValue GetValue(EvaluationContext context) } /// - /// Validates the global-binding cache for the current environment: the current lexical env - /// must be the cached GlobalEnvironment itself (top-level code) and neither the global - /// object's own-property shape nor the global lexical declaration set may have changed. - /// Returns the cached plain writable data descriptor, or null on miss. + /// Validates the global-binding cache for the current environment: neither the global + /// object's own-property shape nor the global lexical declaration set may have changed, + /// and a bounded walk from the current lexical env must reach the cached GlobalEnvironment + /// without passing an environment that could own the name (mirrors the slot-cache walk: + /// with-objects and declarative envs holding the binding — e.g. sloppy direct eval injected + /// a var — bail to full resolution; the per-read probes are what make nested-scope use + /// safe). Returns the cached plain writable data descriptor, or null on miss. /// [MethodImpl(MethodImplOptions.NoInlining)] internal Runtime.Descriptors.PropertyDescriptor? TryGetValidatedGlobalDescriptor(Engine engine, Environment env) { var cachedGlobalEnv = _cachedGlobalEnv; - if (cachedGlobalEnv is not null - && ReferenceEquals(env, cachedGlobalEnv) - && ReferenceEquals(cachedGlobalEnv._engine, engine) - && cachedGlobalEnv._global._propertiesVersion == _cachedGlobalShapeVersion - && cachedGlobalEnv._lexicalMutations == _cachedGlobalLexicalVersion) + if (cachedGlobalEnv is null) { - return _cachedGlobalDescriptor; + return null; + } + + // Hop 0 (top-level code) keeps the single identity compare up front — it is by far + // the most common case and must not pay for the nested-scope walk below. + if (ReferenceEquals(env, cachedGlobalEnv)) + { + return ReferenceEquals(cachedGlobalEnv._engine, engine) + && cachedGlobalEnv._global._propertiesVersion == _cachedGlobalShapeVersion + && cachedGlobalEnv._lexicalMutations == _cachedGlobalLexicalVersion + ? _cachedGlobalDescriptor + : null; + } + + if (!ReferenceEquals(cachedGlobalEnv._engine, engine) + || cachedGlobalEnv._global._propertiesVersion != _cachedGlobalShapeVersion + || cachedGlobalEnv._lexicalMutations != _cachedGlobalLexicalVersion) + { + return null; + } + + var key = _identifier.Key; + var search = env; + for (var hops = 0; hops < MaxSlotCacheChainDepth; hops++) + { + if (search is ObjectEnvironment) + { + return null; + } + + if (search is DeclarativeEnvironment intermediate && intermediate.HasBinding(key)) + { + return null; + } + + search = search._outerEnv; + if (search is null) + { + return null; + } + + if (ReferenceEquals(search, cachedGlobalEnv)) + { + return _cachedGlobalDescriptor; + } } return null; } /// - /// Caches the resolved global binding when it is a plain writable MutableBinding data - /// property on the real GlobalObject and no lexical declaration shadows it. Both reads - /// and writes may then operate on the descriptor directly while the versions hold. + /// Caches the resolved global binding when it is a plain writable data property on the + /// real GlobalObject (no CustomValue indirection — materialized lazy built-ins qualify, + /// live accessor-like descriptors never do) and no lexical declaration shadows it. Reads + /// may then use the descriptor directly while the versions hold; writers must re-check + /// through the cached + /// reference because defineProperty flips flags in place without bumping the versions. /// internal void TryRememberGlobalBinding(GlobalEnvironment globalEnv) { @@ -287,10 +336,10 @@ internal void TryRememberGlobalBinding(GlobalEnvironment globalEnv) return; } - if (globalObject._properties!.TryGetValue(identifier.Key, out var descriptor) + if (globalObject._properties?.TryGetValue(identifier.Key, out var descriptor) == true && descriptor.IsDataDescriptor() && descriptor.Writable - && (descriptor._flags & Runtime.Descriptors.PropertyFlag.MutableBinding) != Runtime.Descriptors.PropertyFlag.None) + && (descriptor._flags & Runtime.Descriptors.PropertyFlag.CustomJsValue) == Runtime.Descriptors.PropertyFlag.None) { _cachedGlobalEnv = globalEnv; _cachedGlobalDescriptor = descriptor; diff --git a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs index 42df0de08f..ab1eb92a67 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs @@ -172,7 +172,9 @@ private JsValue UpdateNonIdentifier(EvaluationContext context) if (_leftIdentifier!._cachedGlobalEnv is not null && !context.OperatorOverloadingAllowed) { var descriptor = _leftIdentifier.TryGetValidatedGlobalDescriptor(engine, engine.ExecutionContext.LexicalEnvironment); - if (descriptor is not null && descriptor._value is { } current) + // Writable is re-checked through the cached reference: defineProperty flips the + // flag in place without bumping the versions the validator checks. + if (descriptor is not null && descriptor.Writable && descriptor._value is { } current) { if (_evalOrArguments && StrictModeScope.IsStrictModeCode) { From bb2f90f7c230066b1ba0d35a34047c79f35d6427 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 6 Jul 2026 16:02:02 +0300 Subject: [PATCH 2/2] Keep hop-0 global validation compact: split nested walk out of line Classic (var-heavy) stopwatch validates the global cache ~12 times per inner-loop iteration at hop 0; folding the nested-scope walk into the same method cost it +3.5% through method growth alone. The hop-0 arm keeps its single identity compare in a compact method; the bounded walk moves to a separate non-inlined method that only nested-scope hits pay for. Co-Authored-By: Claude Fable 5 --- .../Expressions/JintIdentifierExpression.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs index 239ef52f14..7cefbd3b4e 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs @@ -272,8 +272,9 @@ public override JsValue GetValue(EvaluationContext context) return null; } - // Hop 0 (top-level code) keeps the single identity compare up front — it is by far - // the most common case and must not pay for the nested-scope walk below. + // Hop 0 (top-level code) keeps the single identity compare up front and this method + // compact — it is by far the most common case (var-heavy scripts validate here many + // times per loop iteration) and must not pay for the nested-scope walk's code size. if (ReferenceEquals(env, cachedGlobalEnv)) { return ReferenceEquals(cachedGlobalEnv._engine, engine) @@ -283,6 +284,12 @@ public override JsValue GetValue(EvaluationContext context) : null; } + return TryGetValidatedGlobalDescriptorNested(engine, env, cachedGlobalEnv); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Runtime.Descriptors.PropertyDescriptor? TryGetValidatedGlobalDescriptorNested(Engine engine, Environment env, GlobalEnvironment cachedGlobalEnv) + { if (!ReferenceEquals(cachedGlobalEnv._engine, engine) || cachedGlobalEnv._global._propertiesVersion != _cachedGlobalShapeVersion || cachedGlobalEnv._lexicalMutations != _cachedGlobalLexicalVersion)