perf(regex): resume JS-level searches on non-ASCII strings from the previous call's position - #10205
proggeramlug wants to merge 2 commits into
Conversation
…revious call's position (#10164) A JavaScript exec/test/search/matchAll step binds its subject afresh on every call, so on a non-ASCII (WTF-8) string each search paid a seek from the nearer end and a loop over one string did quadratic work. A per-thread four-entry table now remembers where the last such search stopped and hands that position to the next search on the same string. "The same string" is decided without a traced edge or per-object state: the concealed address, the byte and UTF-16 lengths, and a new per-thread heap generation must all match. The generation advances on entry and exit of a HeapChange scope around every event that frees or moves heap memory (copying minor, cycle Sweep and Reclaim steps, minor-prelude evacuation with a nested compaction scope, in-place promotion, gc_realloc, the synchronous sweep). Debug builds assert at every primitive that makes object memory reusable or evacuates a young object that a scope is open. RegExpHeader is unchanged (56 bytes); ASCII strings never consult the table. Tests: one per event kind asserting the generation advanced and the kind's own scope opened; a funnel assertion that can say no; linearity of a JS-level non-ASCII loop with and without positions; a moved string; and a different same-layout string at a freed string's address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Landed on |
|
Heads-up from a merge-train validation on current The test expects |
Part of #10164 and #10165. A JavaScript-level
exec/test/search/matchAllloop over one non-ASCII string now resumes each search from where the previous call's search stopped, instead of seeking from an end of the string.Problem
Within one compound operation (
split,replace, globalmatch), #10181 already carries the search position. A JS-level loop instead runs one search per call and binds its subject afresh each time (#10183). On a non-ASCII (WTF-8) string, a search that starts atlastIndexwithout a position pays a seek from the nearer end, so a loop over one string does quadratic work. At n = 40,000 records, the #10183 measurements recorded 42–44 s for these loops, against 7–8 ms on Node. The reruns against the pre-Perex revision on #10164 and #10165 show both non-ASCII workloads timing out at 100k.Change
Position table (
regex/perex_position_hint.rs): a per-thread table of four plain-data entries. Each entry holds a concealed address, a heap generation, byte and UTF-16 lengths, and aperex::input::Position.execute_with_resourceslooks a position up for a freshly bound non-ASCII subject and records the search's final position afterwards. The identity is re-read after the search, because a collection during it may have moved the string.RegExpHeaderstays one 56-byte record. The collector has nothing new to scan and no traced edge, and the address is compared for equality only, never read as a pointer.Same string, decided without per-object state: the address, both lengths and the thread's heap generation must all match.
Positioncontract, and Perex still refuses a mismatched layout.Heap generation (
gc/heap_generation.rs):HeapChange::begin(kind)advances the generation when the scope opens and again when it closes. Opening covers addresses recorded before the event; closing covers any recorded by a JS callback during it. Scopes may nest.Scopes:
CopyingMinorrun_copied_minor_attemptSweepSweeparm ofGcCycleState::step, andIncrementalSweepState::finish_unbounded(synchronous sweeps)ReclaimReclaimarm ofGcCycleState::stepEvacuationatomic_finalize_minor_prelude's evacuation branch, which includes forwarding-stub releaseCompactionevacuate_selected_old_pages_collectingPromotionfinish_in_place_promotionReallocgc_reallocFunnel enforcement: debug builds panic if a primitive that makes object memory reusable, or evacuates a young object, runs with no scope open. The asserted primitives:
reset_region_to_zero,copying_reset_from_spaces_and_flip,arena_reset_empty_blocksand its incremental step, survivor and old dead-block reclaim, the free-list filter);old_free_push;dealloc;move_young;release_evacuated_original_forwarding_stubs.The first full debug run with the assertions in place found 41 hits, every one a test calling a primitive directly. Every production path was already inside a scope. Those tests now open a scope.
Not asserted, and why:
gc/oldgen.rsis also at the 2,000-line cap.scripts/gc_runtime_root_holders.jsonrecords the#[cfg(test)]HINT_USEScounter astest_only. It also re-audits thePASS1_MARKEDcensus window for thegc/cycle.rsandgc/mod.rshunks. TheSweepscope opens beforestep_sweeptakes the snapshot, and only increments two thread-local integers.Tests
gc::tests::heap_generation, one test per kind through its production entry point: copying minor, in-place promotion, full sweep, emergency full, malloc free, incremental reclaim, minor-prelude evacuation, old-page compaction, moving realloc. Each asserts that the generation advanced and that its own kind's scope opened, so removing a nested scope still fails.perex_position_hint::cross_call_positions_keep_a_js_level_non_ascii_loop_linear: work at 2n/n is below 2.2× with positions (and positions were used) and above 3.0× with positions disabled.a_moved_string_does_not_reuse_its_position: a copying minor moves the string, and the next call does not use the old position.another_string_at_the_same_address_after_a_free_does_not_use_the_position: a string with the same byte and UTF-16 lengths but a different arrangement is allocated at the freed string's address; the address equality is asserted as a precondition. It must not use the position, and its match must be correct.perex_reuse_positions_keep_a_non_ascii_global_loop_linear's unpositioned control now disables cross-call positions too.Fault injections
Each ran as one cargo test, with the source restored byte-identical afterwards. All 13 FAILED, as required.
CopyingMinorscopePromotionscopeSweepscopeReclaimscopeEvacuationscopeCompactionscopeReallocscopeMeasurements (perrymaster, release builds,
main5d3bf85f9vs this branch)The #10183 reproducer at n = 40,000 is a single run on a shared host; results match Node.
mainre.execloop,/([ä中Ö漢]+)([0-9]+)/guover"ä中12 Ö漢345😀".repeat(n)matchAll, same subjectBoth loops are now linear: 43 → 90 → 314 ms at 10k / 20k / 40k for
exec, against main's 1,647 → 6,480 → 28,486 ms. They are still 40–60× Node, which is the per-call cost tracked in #10166.ASCII per-call cost, measured as
perf stat -e instructions:uover 1,000,000 short-string calls minus the build-only program (the #10166 probes, 2 runs per arm):mainre.test(v)re.exec(v)with captures/_[0-9]+/gtest withlastIndex = 0Validation (perrymaster,
--locked)cargo fmt --all -- --checkcargo check -p perry-runtime --no-default-features --features full --lib(regex feature off): 0 warningscargo check -p perry-runtime --lib --tests: no warnings outside the knownglobal_this_webassembly.rsdead codecargo test -p perry-runtime --lib -- --test-threads=1: 3724 passed, 1 failed. The failure isnative_stack::tests::stack_top_respects_custom_thread_stack_sizes, red on main.cargo clippy -p perry-runtime --lib --tests: identical tomain(train 181), with no new warningsscripts/run_lint_gates.shscript tier: 76 of 77 pass. The failure is public benchmark evidence freshness, identical on main. Also checked:gc_runtime_root_holders.py,check_file_size.sh.https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9