Skip to content

Skip identifier walk through empty FunctionEnvironments#2419

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/skip-empty-callee-env
Apr 26, 2026
Merged

Skip identifier walk through empty FunctionEnvironments#2419
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/skip-empty-callee-env

Conversation

@lahma

@lahma lahma commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closures with zero parameters and zero local declarations — a tight inner method like sw.Start() in the stopwatch benchmark — end up with a FunctionEnvironment whose _slots and _dictionary are both null. Their own bindings are nothing. Identifier reads inside the body always resolve outward through the closure chain, but the env-walk in JintEnvironment.TryGetIdentifierEnvironmentWithBinding{,Value} still pays a virtual TryGetBinding / HasBinding per layer.

This PR skips those empty FunctionEnvironment layers outright in the chain walk:

if (record is FunctionEnvironment fenv
    && fenv._slots is null
    && fenv._dictionary is null
    && record._outerEnv is not null)
{
    record = record._outerEnv;
    continue;
}

Only FunctionEnvironment is targeted — ModuleEnvironment may carry indirect-import bindings even when slots/dict are null, and DeclarativeEnvironment block scopes effectively always have slots when they exist.

The hot-path benefit is small in isolation — the slot-binding cache from #2416 already short-circuits this on cache hits — but it speeds up first-call resolve and cache-miss fallback paths.

Benchmark (stopwatch.js)

Ryzen 9 5950X / .NET 10.0.7. Baseline + PR taken back-to-back. Run-to-run StdDev was high on this session (4-17ms), so individual numbers should be read with caution; the directional pattern is consistent though.

Method Modern main PR Δ time
Execute var 249.5 ms 203.5 ms -18%
Execute_ParsedScript var 250.2 ms 257.1 ms +3% (noise)
Execute let 300.0 ms 290.9 ms -3%
Execute_ParsedScript let 253.1 ms 242.8 ms -4%
Allocated all ~38.5 MB ~38.5 MB flat

Most of the gain is on the parse-each-iteration Execute (var) path where the slot cache from #2416 starts cold each iteration; here the empty-env skip removes a virtual call per identifier read in the warmup phase. Allocations are unchanged — this is purely a control-flow tweak.

Validation

  • Jint.Tests (net10.0): 2861 passed, 0 failed.
  • Jint.Tests.Test262 (net10.0): 98567 passed, 506 skipped, 0 failed (clean run; transient flakes under concurrent test load are unrelated).

The skip is gated on is FunctionEnvironment plus _slots is null && _dictionary is null, so any env that could ever resolve a binding is preserved. record._outerEnv is not null ensures we still hit the global lookup at the chain end.

Stacked work

Fifth of six related performance changes. The perf/drop-interlocked branch (originally #2418) was abandoned after CI revealed that pool fields on JintFunctionDefinition.State are genuinely shared across parallel test fixtures via the static Test262 harness script cache, so the atomic exchanges are required.

Last in the queue is perf/block-env-reset on lahma/jint — small block-env reset inlining. Will be PR'd after this one lands.

🤖 Generated with Claude Code

Closures with zero parameters and zero local declarations (a tight
inner method like sw.Start() in the stopwatch benchmark) end up with a
FunctionEnvironment whose _slots and _dictionary are both null — their
own bindings are nothing. Identifier lookups inside the body always
resolve outward through the closure chain, but the env-walk still pays
a virtual TryGetBinding/HasBinding per layer.

Skip those empty FunctionEnvironments outright in
TryGetIdentifierEnvironmentWithBinding{,Value}. Only FunctionEnvironment
is targeted (ModuleEnvironment may carry indirect-import bindings even
when slots/dict are null; DeclarativeEnvironment block scopes effectively
always have slots when they exist).

Hot-path benefit is small in isolation — Change 3's slot cache already
short-circuits this on cache hits — but it speeds up first-call resolve
and cache-miss fallback paths.

stopwatch benchmark: noise dominated this run (StdDev 49-76ms from
concurrent machine load). Allocations unchanged from Change 4, confirming
the change is correctness-preserving and the apparent regression is
contention, not a real cost.

98567 test262 + 2861 Jint.Tests pass; 0 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit a1b9610 into sebastienros:main Apr 26, 2026
4 checks passed
@lahma
lahma deleted the perf/skip-empty-callee-env branch April 26, 2026 05:32
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