perf(engine): make for-in key dedup O(k) with a hash set#888
Conversation
for...in enumeration deduplicated keys across the prototype chain with TStringList.IndexOf on an unsorted list, an O(k) linear scan run once per key, so a full enumeration was O(k^2) in the total (own + inherited) key count. Replace the TStringList with a TOrderedStringMap<Boolean> hash set (ContainsKey + Add), the established string-set idiom in the codebase, in both execution engines: CreateForInEntriesArray (tree-walking interpreter) and ForInEntriesArray (bytecode VM). Membership stays case-sensitive (native string equality) and per-level enumeration order is unchanged (OrderForInPropertyKeys / VMOrderOwnPropertyStringKeys still own ordering; the set is membership-only), so behavior is identical. Add a regression test for same-name enumerable shadowing and dedup across a deep prototype chain at scale (runs in both modes), plus a scoped for-in benchmark under benchmarks/for-in/ with its own goccia.json enabling --compat-for-in-loop. Closes #809
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughReplaces for-in key deduplication upgrade
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
Suite TimingTest Runner (interpreted: 10,958 passed; bytecode: 10,958 passed)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Test runner worker shutdown frees thread-local heaps in bulk; that shutdown reclamation is not counted as GC collections or collected objects.
Benchmarks (interpreted: 436; bytecode: 436)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Benchmark runner performs explicit between-file collections, so collection and collected-object counts can be much higher than the test runner.
Measured on ubuntu-latest x64. |
Benchmark Results436 benchmarks · PR vs same-runner Interpreted: 🟢 35 improved · 🔴 27 regressed · 374 unchanged · avg +1.5% Typical per-run noise (median variance): interpreted ±3.0%, bytecode ±2.6%. Deltas within noise overlap and read as unchanged. arraybuffer.js — Interp: 🟢 1, 13 unch. · avg -0.7% · Bytecode: 🟢 2, 🔴 1, 11 unch. · avg -1.8%
arrays.js — Interp: 🟢 2, 17 unch. · avg +1.2% · Bytecode: 🟢 1, 🔴 3, 15 unch. · avg +0.5%
async-await.js — Interp: 6 unch. · avg +0.2% · Bytecode: 🟢 1, 🔴 1, 4 unch. · avg -0.5%
async-generators.js — Interp: 🔴 1, 1 unch. · avg -9.6% · Bytecode: 2 unch. · avg -0.6%
atomics.js — Interp: 🟢 1, 5 unch. · avg +2.1% · Bytecode: 🔴 4, 2 unch. · avg -4.8%
base64.js — Interp: 🔴 3, 7 unch. · avg -2.3% · Bytecode: 🔴 2, 8 unch. · avg -0.3%
classes.js — Interp: 🟢 1, 🔴 1, 29 unch. · avg +0.9% · Bytecode: 🔴 2, 29 unch. · avg -1.2%
closures.js — Interp: 11 unch. · avg +0.6% · Bytecode: 🟢 1, 🔴 2, 8 unch. · avg -3.7%
collections.js — Interp: 🟢 1, 11 unch. · avg +4.1% · Bytecode: 🟢 1, 🔴 1, 10 unch. · avg -4.0%
csv.js — Interp: 🔴 2, 11 unch. · avg -3.9% · Bytecode: 13 unch. · avg -1.5%
destructuring.js — Interp: 🟢 6, 16 unch. · avg +1.3% · Bytecode: 🟢 2, 🔴 3, 17 unch. · avg +0.9%
fibonacci.js — Interp: 8 unch. · avg +1.0% · Bytecode: 8 unch. · avg -7.6%
float16array.js — Interp: 🟢 1, 🔴 4, 27 unch. · avg -1.4% · Bytecode: 🟢 2, 🔴 2, 28 unch. · avg -0.8%
for-in/for-in.js — Interp: 🟢 3 · avg +249.7% · Bytecode: 🟢 3 · avg +347.1%
for-of.js — Interp: 🟢 3, 4 unch. · avg +3.3% · Bytecode: 🔴 2, 5 unch. · avg +2.4%
generators.js — Interp: 🟢 1, 3 unch. · avg +6.8% · Bytecode: 4 unch. · avg +0.9%
intl.js — Interp: 6 unch. · avg +3.3% · Bytecode: 6 unch. · avg +0.3%
iterators.js — Interp: 🟢 1, 🔴 2, 39 unch. · avg -2.2% · Bytecode: 🟢 2, 🔴 2, 38 unch. · avg -1.8%
json.js — Interp: 🟢 1, 22 unch. · avg +1.6% · Bytecode: 🔴 10, 13 unch. · avg -3.6%
jsx.jsx — Interp: 🟢 1, 20 unch. · avg +1.3% · Bytecode: 🔴 1, 20 unch. · avg -2.6%
modules.js — Interp: 9 unch. · avg +2.0% · Bytecode: 🔴 2, 7 unch. · avg -1.5%
numbers.js — Interp: 11 unch. · avg +0.9% · Bytecode: 🟢 1, 10 unch. · avg +1.2%
objects.js — Interp: 7 unch. · avg +1.8% · Bytecode: 7 unch. · avg -4.4%
promises.js — Interp: 12 unch. · avg -0.4% · Bytecode: 🔴 1, 11 unch. · avg -3.3%
property-access.js — Interp: 5 unch. · avg -2.9% · Bytecode: 5 unch. · avg -1.7%
regexp.js — Interp: 11 unch. · avg -0.4% · Bytecode: 11 unch. · avg +1.1%
strings.js — Interp: 🟢 2, 17 unch. · avg +0.3% · Bytecode: 🔴 1, 18 unch. · avg -2.8%
temporal.js — Interp: 6 unch. · avg -2.0% · Bytecode: 6 unch. · avg -0.7%
tsv.js — Interp: 🔴 2, 7 unch. · avg -1.1% · Bytecode: 9 unch. · avg -1.9%
typed-arrays.js — Interp: 🟢 4, 🔴 2, 16 unch. · avg +0.4% · Bytecode: 🟢 1, 🔴 4, 17 unch. · avg +1.9%
uint8array-encoding.js — Interp: 🟢 4, 🔴 7, 7 unch. · avg -6.5% · Bytecode: 🔴 3, 15 unch. · avg -5.5%
weak-collections.js — Interp: 🟢 2, 🔴 3, 10 unch. · avg -2.0% · Bytecode: 🟢 4, 🔴 2, 9 unch. · avg +14.1%
Deterministic profile diffDeterministic profile diff: no significant changes. Measured on ubuntu-latest x64. Each PR run also builds the |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/language/for-in-loop/basic-enumeration.js (1)
194-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the inherited
unique*suffix order too.This test proves the shared-key prefix and total count, but it never checks the order of the inherited unique keys. A regression that scrambles prototype-level unique-key order would still pass.
Suggested assertion
// All shared keys plus one unique key per level, no duplicates. expect(seen.length).toBe(SHARED + LEVELS); + expect(seen.slice(SHARED)).toEqual( + Array.from({ length: LEVELS }, (_, i) => "unique" + (LEVELS - 1 - i)), + );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/language/for-in-loop/basic-enumeration.js` around lines 194 - 199, The for-in enumeration test verifies the shared-key prefix and total length, but it does not assert the ordering of the inherited unique keys, so a prototype-level ordering regression could slip through. Extend the assertions in basic-enumeration.js after the existing seen.slice and length checks to validate the suffix order of the unique keys from each inherited level, using the existing seen, sharedKeys, SHARED, and LEVELS symbols to pinpoint the expected sequence.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/language/for-in-loop/basic-enumeration.js`:
- Around line 194-199: The for-in enumeration test verifies the shared-key
prefix and total length, but it does not assert the ordering of the inherited
unique keys, so a prototype-level ordering regression could slip through. Extend
the assertions in basic-enumeration.js after the existing seen.slice and length
checks to validate the suffix order of the unique keys from each inherited
level, using the existing seen, sharedKeys, SHARED, and LEVELS symbols to
pinpoint the expected sequence.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 01515fe1-e8cc-4ecc-9998-c8cddfb55fa3
📒 Files selected for processing (5)
benchmarks/for-in/for-in.jsbenchmarks/for-in/goccia.jsonsource/units/Goccia.Evaluator.passource/units/Goccia.VM.pastests/language/for-in-loop/basic-enumeration.js
test262 Conformance
Areas closest to 100%
Per-test deltas (+2 / -0)Newly passing (2):
Steady-state failures are non-blocking; regressions vs the cached main baseline (lower total pass count, or any PASS → non-PASS transition) fail the conformance gate. Measured on ubuntu-latest x64, bytecode mode. Areas grouped by the first two test262 path components; minimum 25 attempted tests, areas already at 100% excluded. Δ vs main compares against the most recent cached |
Strengthen the deep-chain for-in dedup test to also assert the order of the inherited level-unique keys (leaf to root), not just the shared-key prefix and total count, so a prototype-level enumeration-order regression cannot slip through.
Summary
for...indeduplicated keys across the prototype chain withTStringList.IndexOfon an unsorted list — an O(k) linear scan run once per key, making a full enumeration O(k²) in the total (own + inherited) enumerable key countk.TStringListwith aTOrderedStringMap<Boolean>hash set (ContainsKey+Add), the established string-set idiom in the codebase (cf.Goccia.Modules.Loader.pas). Dedup is now O(k).CreateForInEntriesArray(tree-walking interpreter,source/units/Goccia.Evaluator.pas) and the near-identicalForInEntriesArray(bytecode VM,source/units/Goccia.VM.pas). The issue text named only the interpreter site, but the same root cause exists in the VM andfor...inruns through it in--mode=bytecode; fixing both was confirmed in scoping.CaseSensitive := True), and per-level enumeration order is unchanged —OrderForInPropertyKeys/VMOrderOwnPropertyStringKeysstill own ordering; theVisitedset never influenced output order (that comes fromResult.Elements.Add).for...insemantic changes; no cross-unit refactor of the two duplicated functions (kept tight to the perf fix); theOrderForInPropertyKeysinsertion sort is untouched.Closes #809
Deferred follow-up (out of scope)
While testing I found a pre-existing, unrelated diagnostic bug: a disabled traditional
for(;;)loop (requires--compat-traditional-for-loop) whose body contains an interpolated template literal reports a misleading"Unterminated template literal"at EOF instead of the correct "enable--compat-traditional-for-loop" suggestion. Flagged separately; not addressed here.Testing
TOrderedStringMaphas its own unit testsCommands run (all green):
./build.pas testrunner→./build/GocciaTestRunner tests— 10,958/10,958 passed (tree-walking)./build/GocciaTestRunner tests --mode=bytecode— 10,958/10,958 passed (bytecode VM)tests/language/for-in-loop— 35/35 both modes (+2 new regression tests: same-name enumerable shadowing, and dedup + ordering across a 12-level / 40-shared-key prototype chain)./format.pas --check— 370 files, all formatted correctly./build/GocciaBenchmarkRunner benchmarks(whole-dir, no CLI flag, as CI runs it) — newfor...inbenchmark runs cleanly; its--compat-for-in-loopis picked up from the scopedbenchmarks/for-in/goccia.jsonvia per-file config discovery, so the benchmark lane stays green. Exit 0.