[JSC] for-of / array destructuring without an Array Iterator object, and one RegExpObject per /x/.test(s) literal site
Three commits on main (28f58fb055b8); independent of #628. Whichever of the two lands second resolves the CachedTypes revision lines; the interpreter hunks only sit near each other (this PR's are inside op_iterator_next, a new op_iterator_close_check and a new op_new_reg_exp_shared).
Why
A large bundled CLI application runs almost all of its code in the LLInt and Baseline tiers. In a 20-turn session it allocates ~590 MB of GC memory; 5% of that is JSArrayIterator objects, 99.9% of which come from one opcode: op_iterator_open in its fast-array mode (for (x of array) and array destructuring). Only the FTL's allocation sinking removes them today, and even DFG code allocates them inline. RegExp literals evaluated in hot functions (/x/.test(s)) allocate a RegExpObject per evaluation in every tier below the DFG's strength reduction.
What
[JSC] for-of and array destructuring over an Array do not allocate an Array Iterator object(useUnboxedFastArrayIteration, on).
In fast-array mode theiteratorregister holds a new never-exposed sentinel cell (the discriminator),nextholds the Int32 index (-1 once done, sticky, like the real iterator) anditerableholds the Array. No operand changes;op_iterator_nextgains a declared def ofm_next. Element access stays the generic bounds-checked path, so holes, accessors, and arrays that grow or shrink during the loop behave exactly as with the object. The only observable moment is IteratorClose: a newop_iterator_close_checkin front of the four generic-close sites that follow anop_iterator_openskips the close whilearrayIteratorProtocolWatchpointSet(which here also covers the absence ofreturnon the three prototypes) is valid, and otherwise materialises a real Array Iterator at the current index and lets the generic close run on it. It is always emitted, so cached bytecode produced under either option value runs under the other (cache format revision bumped).
LLInt: inline path for in-bounds Int32/Contiguous elements, inline watchpoint test at close, C++ for the rest. Baseline/LOL: the same two inline paths plus one operation. DFG/FTL: open is two constants; next is the existing array-step graph parameterised over where array and index live, with realCheck(ArrayUse)/Check(Int32Use)on the locals; close_check is aCompareEqPtrunder the watchpoint, or an in-graph materialisation when the watchpoint was already invalid at compile time (no exit loop). OSR entry/exit recover three ordinary locals; no phantom allocation.
Defence in depth: every C++ entryRELEASE_ASSERTsisJSArray(iterable)and an index in [-1, 2^32); the inline paths check cell type,ArrayTypeand indexing shape before touching the butterfly; the generator asserts the three registers are temporaries or argument slots and array patterns copy any other non-temporary right-hand side; the bytecode optimizer never rewritesop_iterator_close_check's operands. A missed discriminator calls an Int32 (TypeError), it does not touch memory.[JSC] /x/.test(s) and /x/.exec(s) use one RegExpObject per literal site(useSharedRegExpLiteralObjects, on).
For a literal withoutg/ythat is the receiver of.test(...)/.exec(...)the generator emitsop_new_reg_exp_shared(one strong metadata slot, written and read under the baseline CodeBlock's lock). Guards:regExpPrimordialPropertiesWatchpointSetand a new watchpoint onRegExp.prototype.test. The one path that handsthisto user code (testfalling back to a user-installedexec) receives a fresh copy. The hit path re-verifies the object is in its initial state (RegExp, flags, structure, lastIndex), so an object changed through the inspector's live-cell access is not handed out again. DFG: a constant under both watchpoints (not in unlinked DFG). Literals passed as arguments of String methods, andg/yliterals, are not shared.
Numbers
jsc shell, instructions (millions), base = a shell built from main, deterministic mode (--useConcurrentJIT=0, run-to-run noise 0.05%):
| LLInt | Baseline only | DFG only | all tiers | |
|---|---|---|---|---|
| for-of sum | 1500 -> 765 | 808 -> 347 | 298 -> 191 | 158 -> 128 |
const [a, b] = pair | 2339 -> 1367 | 1117 -> 684 | 327 -> 226 | 171 -> 149 |
| loop with early return | 1849 -> 1021 | 843 -> 466 | 265 -> 186 | 130 -> 118 |
generators, Map, Set, values() | 2691 -> 2428 | 1293 -> 1201 | 620 -> 597 | 604 -> 595 |
/x/.test and /x/.exec | 2459 -> 2329 | 798 -> 794 | 307 -> 287 | 313 -> 296 |
A broad pass over JSTests/microbenchmarks (1,649 files x 4 tier configurations) and the vendored suites found regressions in an earlier version of this PR; all are fixed in the first commit and re-measured against main:
| benchmark | earlier version | now |
|---|---|---|
| for-of-array-set-mixed | +14.7% | -3.0% |
| destructuring-array-default-value-can-not-throw / -can-throw | +10.65% / +10.0% | -17.5% / -15.7% |
| for-of-array-map-mixed | +6..7% | -4.5% |
| steady-state FTL for-of sum / early return / pair | +3.9 / +3.7 / +4.9% | -12.8 / -13.4 / -12.4% |
| object-get-prototype-of-primitive | +2.77% | -4.16% |
LLInt and Baseline iteration of Map / Set / string / keys() | +1.5..+4.8% | +-0.00% |
| JetStream2 Babylon | +4.5% | 0.0% (median of 6) |
What they were: the array and index of op_iterator_next came straight from GetLocal, so type-check hoisting moved the structure checks to the variable's SetLocal as CheckStructureOrEmpty and LICM could no longer hoist the butterfly/length/bounds chain, and at mixed Array+Set/Map sites the index's prediction made every GetByVal generic: the array and the index now go through a node of their own with a precise prediction. The step itself is one unsigned CompareBelow(index, length) (the index is a proven Int32 and "done" is -1). The non-array fast modes run main's exact instruction sequence first in the LLInt and Baseline, with the index-in-frame case out of line in a slow path of its own. op_iterator_close_check + jtrue are not counted in CodeBlock::bytecodeCost() so that functions with a for-of keep main's tier-up and inlining schedule (that was all of Babylon: one more large FTL compile). A new abstract-interpreter rule folds CompareEqPtr to false when the operand's proven type excludes the cell's type, which makes generic closes free in the DFG. The release-build guard against a sentinel reaching generic code stays (RELEASE_ASSERT(isSymbol()) in the cell arm of JSValue::synthesizePrototype and of JSCell::toStringSlowCase), paid for by moving the throw out of line.
The third commit fuses op_iterator_close_check + jtrue into one branch opcode with no boolean temporary: functions with array destructuring have main's frame size again, and var [a, b] = arguments (a generic iterator) is +0.11% Baseline-only, +0.14% all tiers, +0.40% interpreter-only vs main (was +0.61 / +0.17 / +0.37%). The opcode's own cost in the interpreter is 10 instructions per evaluation (+0.14%); the rest of the interpreter-only figure is a binary-layout effect in performLLIntGetByID (the m_seenProperties bloom filter hashes property-name addresses, one of which is a static in .data; in this binary it does not rule return out on %ArrayIteratorPrototype%, in main's it does; any relink can flip it either way). The fused opcode is still not counted in bytecodeCost(): measured again with it counted, Babylon is +4.2% (6196 M vs 5946 M; one more large FTL compile), not counted -0.1%.
Over the 340 microbenchmarks that touch iteration, destructuring, RegExp, spread, generators, Map/Set, calls: instruction sum -11.6% (all tiers), -8.3% (Baseline only), -5.4% (interpreter); 32-44 files faster by more than 1% in each configuration; the 1-3 files over +1% contain no for-of or pattern and overlap main's own spread on reruns. Other wins: destructuring-swap -36.7%, default-value-destructuring-array -36.9%, generator-fib -10.8%, for-of-iterate-array-entries -14.3%, large-map-iteration -10.4%.
Bytes allocated per iteration of the benchmark loop: 48 -> 0 (iteration) and 32 -> 0 (RegExp literal) in every tier (measured on the #628 stack, which has the counter).
The application (its build also carries #628), 20-turn session, one binary, options toggled: GC bytes allocated 585.6 -> 559.8 MB (-4.4%); Array Iterators created in the lower tiers ~237k -> 177; fresh RegExp literal objects in the lower tiers 54.4k -> 34.9k; instructions 19.25 -> 19.08 G (-0.9%); peak memory unchanged within noise.
Tests
10 new stress tests (for-of-array-index-in-frame-{close,close-realms,mutation,generator,osr,aliasing,realms}.js, regexp-literal-receiver-shared-object{,-tostring,-reevaluated}.js), each with run lines for default, no JIT, Baseline only, eager tiers, forceOSRExitToLLInt and the bytecode optimizer. Removing the materialise path makes 3 of them fail; removing the RegExp un-share hook makes its test fail.
Fourth commit (review follow-ups): RegExpObject::copyOfSharedLiteral() makes the copy with the realm's regExpStructure() instead of the shared object's own structure (only the inspector can have changed that; no difference observable from script). The tests use testLoopCount throughout (70 run lines, slowest 160 ms wall on a loaded machine; heaviest before: --useJIT=0 lines of osr / mutation at 570 / 1470 ms), the cases that need a realm whose watchpoints are intact get one (close-realms.js: every way out of a loop and "return" installed from a default value mid-pattern, with and without a throw, x three prototypes; sections 4, 5, 6b of shared-object.js; a second pass of -tostring.js with probe() kept out of the DFG). They pass on main (they describe behaviour it already has); with five faults injected into the change (always-share, copy returns this, materialized iterator one index short, holes not read through the prototype chain, realm check removed) each fault fails the same tests in at least as many run lines as before the rewrite.
Against shells built from main (cf1b36ec8703):
| suite | result |
|---|---|
| the new tests, every run line | pass |
913-file subset (iterator, for-of, destructuring, spread, generator, async, regexp, string methods) x 8 modes incl. eager, forceOSRExitToLLInt, collectContinuously, no DFG, bytecode optimizer | failure sets identical to main's shell (3/3/4/8/3/3/8/3) |
| test262, 23,658 files x default / no JIT / eager / Baseline only | the same 12 expected failures, 0 new |
| bytecode cache fill + forced hit: iterator list 546, regexp list 363; on/on, on -> off, off -> on, bytecode optimizer | 546/546; 356/7 = main's 7 |
| Debug+ASAN subset (463 files) x 5 modes | only failures main's ASAN shell has; no ASAN report |
| differential fuzzer vs main's shell: 2,563 programs x 11 variants release; ASAN set | 0 mismatches |
Each commit builds alone on main; the first passes its tests and the fuzzer without the second. On the #628 stack the series had also passed the full JSTests/stress (5,855 files) x 4 modes with failure sets identical to that base.
Not done: ARM64 and CLoop builds of the new asm were not built locally (only instruction forms already used in the file); a jump-style close_check; the DFG adds the iterator-protocol watchpoint at every close site even in functions that only loop over Maps (one recompile per function in programs that patch the iterator prototypes); the DFG keeps a plain NewRegExp at sites whose slot was empty when it parsed them.