Repository navigation
codegen: cold paths stay cold through the statepoint rewrite (N4) - #14407
Merged
Merged
Conversation
added 6 commits
November 10, 2026 04:51
…tepoint rewrite (experiment)
A pass before rewrite-statepoints-for-gc copies `cold` from a helper's declaration to its call sites and weights the branches that lead only into such a call; the loop poll's call, the shared cold element read and the F64-kind store note are declared cold; the guarded arithmetic fast path expects two Numbers. PERRY_COLD_PATHS=0 restores the previous code.
Marking the older cold helper list (inline-cache misses) as well grew TypeScript-from-source by 725 KB of code for 0.04% fewer instructions.
|
Caution Review failedThe pull request is closed.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CENSUS-residual lever N4 ("cold paths that do not clobber"), cause g (spills). The census left the split of the stack traffic by cause unmeasured; this PR measures it first, and the result changes the fix: the hot path pays for the cold calls because LLVM is never told they are cold, not because of their calling convention. No convention changes.
Branch head:
9377102e3b. The branch merges currentorigin/main(b453cf8) by merge, not rebase.Step 1: where the stack traffic comes from (measured)
Main (482d9c9), chess built for callgrind (
PERRY_TARGET_CPU=znver3), exact per-instruction counts, tool/root/CHB/n4/s1/spill.py. Every instruction with a frame-relative memory operand is one access of a stack slot. From each access the tool walks the function's CFG to the neighbouring accesses of the same slot (backward from a load, forward from a store): two points where the value is in a register anyway. The calls on the way are what the value has to survive in between.perry_array_read_cold_shared956.0 M,js_gc_loop_safepoint842.3 M, both and nothing else 669.4 M. Then cold sites ofjs_string_addref_if_heap_string247.0 M,js_array_note_numeric_write_value170.6 M,js_gc_note_slot_layout_aware141.5 M,js_write_barrier_slot_validated_parent97.1 M,js_array_record_enter_counted86.7 M,js_dynamic_bitand76.2 M. Evaluate: the cold read 230.1 M and the poll 227.6 M of its 254.3 M.rip-relative loads or operands from the NaN-box table. Not split here.alloca ptr addrspace(1)promoted to SSA;rewrite-statepoints-for-gcrelocates it at each collecting call, and the backend writes it to its stack-map slot right before the call and reloads it right after, inside the block of the call. For a cold call that is cold-block traffic (about 0 Ir). The explicitperry.native.homearrays are address-taken slots: 1.2 M in search.perry_array_index_normalize_i32(6.7 M calls per site inmake,unmake,evaluate),js_string_addref_if_heap_stringat function entry, the push helpers, births and real callees. Not this lever.Why the allocator spills on the hot path
No branch in the chess IR carried
!prof, andjs_gc_loop_safepointandperry_array_read_cold_sharedwere not declaredcold. Declaring them would not have been enough:rewrite-statepoints-for-gcruns before the optimizer, replaces a collecting call bygc.statepoint(..., @callee, ...), and keeps the attributes of the call site only (unit testthe_statepoint_rewrite_keeps_only_the_call_site_attributeshows both halves). So LLVM's static estimate weighed every poll and cold arm like the code around it (icmp ne i32 %armed, 0even reads as likely), and saving a value around them on their own path did not look cheaper than keeping it in memory.What changes
inprocess/cold_paths.rs, a module pass before the statepoint rewrite, for the helpers that exist only as the slow arm of an inline fast path (COLD_ARM_HELPERSinmodule/linkage.rs):coldcall-site attribute, which the rewrite keeps on the statepoint;branch_weights2000 : 1, the weightsllvm.expectgives. A branch that already has weights keeps them; a branch with two cold sides gets none.cold:js_gc_loop_safepoint(behind the arming word),perry_array_read_cold_shared(behind the plain-array guards),js_array_note_numeric_write_value(behind the F64-kind test; it clears the kind, so it runs once per array).-,*,/and bitwise fast path (lower_guarded_numeric_arith) is wrapped inllvm.expect(…, true). Its other arm callsjs_dynamic_*, which cannot be cold by name: the same helpers are the direct path of BigInt arithmetic.+is not touched: its slow arm is also string concatenation.gc.relocateandgc-liveof a marked and an unmarked function: equal.Not marked, on purpose: the older
COLD_HELPERS(the inline-cache misses andjs_write_barrier_root_nanbox). Their declaration sayscold, and under statepoints that reaches LLVM only where the call is not a statepoint. The first version of this pass marked every call of acoldcallee. Measured on the experiment build (main 482d9c9), marking only those older helpers: TypeScript compiled from source +725 036 B of code (+1.13%) for 17 092.8 → 17 086.0 M instructions (−0.04%); three.js skin +37 547 B (+0.15%) for 5 460.3 → 5 455.6 M (−0.09%). That is size for nothing, so the pass names the slow-arm helpers instead. The list's comment says so.Why no register-preserving convention
The same tool on this build, on vs
PERRY_COLD_PATHS=0(callgrind build,/root/CHB/n4/s-r3.txt):attackedgoes from 400.5 M of stack traffic to 89.7 M in search,evaluatefrom 687.4 M to 237.9 M.attackedevery poll and cold read has about ten instructions around itscall(stores, reloads, a constant rematerialized). That is the size growth below. A callee that preserves the registers would delete those instructions.extern "C"entry has to save what the JS code's target features let the caller keep live (x86-64preserve_allcovers the vector registers of the caller's feature level, AArch64's covers a different set), and it puts a new kind of frame under every poll, which the collector's stack walk has to step over: the AArch64 walk follows the frame-pointer chain and decodes prologues, the x86-64 walk steps by unwind rows. That needs its own GC-stress pass on both targets.doublein a register is a copy that codegen re-reads from its root after the call (root_reload.rs). A preserved register is in the position a callee-saved register is in today.Switch
PERRY_COLD_PATHS=0(alsooff,false): no pass, the three helpers are declared as before, nollvm.expecton the arithmetic guard. It is a compile-cache input (compile_env.rs, testcold_path_switch_is_a_cache_input). The experiment build on main's 482d9c9 gave main's own chess counts with the switch off (33 147.9 / 772.6 / 3 095.1 / 1 130.9 M); the head's off arm is not compared against a main build at its merge base.Results (qb9,
instructions:u)Build
c4edbf06cc: this PR's code on main 71926f2. Every gate below ran on it. Main moved twice afterwards; the head merges it, and the last two rows of the gates table say what was checked after each merge. On vsPERRY_COLD_PATHS=0on the same binary, two runs each, identical between runs, checksums equal in every run.The change is larger than the stack traffic it removes (search, callgrind build: −1 168 M instructions, −889 M stack traffic): the cold blocks also leave the loops, and the jumps around them go.
Mac (arm64, the ship target),
/usr/bin/time -linstructions retired. Release build ofc4edbf06cc, chess from~/projects/chess, two runs each, checksums equal on and off:test_cold_paths.tsmatches node there, on and off. No GC-stress run on the Mac (no instrumented runtime was built there).What each part gives, from an experiment build with the cold set as a knob (qb9, main 482d9c9, weights 1 : 1 048 575, the older cold helpers marked too; off = 33 147.9 / 772.6 / 3 095.1 / 1 130.9 M):
coldonlyjs_array_note_numeric_write_valuellvm.expectweights (2000 : 1), not the worker-interrupt poll's 1 048 575 : 1.llvm.expecton the arithmetic guard is the rest of the way to the table above: about −0.7% on search, −1.0% on perft and −0.8% on legal (the difference between the last experiment row and the head; it also contains the change of weights and of the base).Gates
llvm.expect)control-flow/test_cold_paths.tsand 6 array fixtures) × on, off, check mode,PERRY_GC_MOVING_LOOP_POLLS=1, GC stress on, GC stress off, GC stress at the default rate: 49 of 49 MATCHPERRY_GC_SCHEDULE_SEED=7 PERRY_GC_SCHEDULE_RATE=1(instrumented runtime, a moving minor at every handled safepoint): 664 forced copying minors, 9 860 moved objects, 84 070 loop polls taken; output equal to node, on and offtest_issue63_arrandtest_issue63_escape, which print elapsed times. At rate 1, 1 051 programs reach a loop poll (the instrument exits 70 for the 834 that do not, in both arms); one runs into the 60 s timeout in both armstest_issue63_arr,test_issue63_escape(elapsed times),test_date(the clock),test_math(Math.random),test_gap_this_param_receiver_routes(a pid in a warning)PERRY_VERIFY_FACTS=1test_gap_s8b_computed_fields,test_gap_10554_fn_identity_own_module)c4edbf06cc)num_seeds_audit2 passed, perry-hir 881 passed, perry-runtime 6643 passed, 0 failed. perrycompile_env: 5 passed, 1 failed:codegen_env_vars_are_build_cache_inputs("shared list must be sorted and unique"), the failure main already has-D warningsscripts/check_file_size.shcrates/perry-stdlib/src/streams.rs(2448 lines against an asserted 2409), as it did on main 22ba61b itself; no file of this PR is involvedc4edbf06cc; chess on/off above;test_cold_paths.tsMATCH on and off98af3d327egit merge-treeclean; not builtPERRY_GC_STRESS, which earlier gate scripts of this lane set, does not exist in the tree: those stress arms were ordinary runs. The stress here isPERRY_GC_SCHEDULE_SEEDwith the instrumented archives (scripts/build_instr_runtime.sh), set at compile time and at run time.Size (every executable section; on vs off)
.text,perry_js_text,.init,.plt,.plt.got,.fini. Every program grows.transpileModule× 3)The growth is the mechanism itself, not a side effect. Off, a value that is live across a poll or a cold read is spilled once and reloaded where it is used. On, it stays in its register on the likely path, and each cold call gets its own stores before and reloads after.
attackedhas 13 polls and 7 cold reads and grows by 1 093 B (to 6 250 B) while its stack traffic falls from 400.5 M to 89.7 M instructions. Per symbol: chess 38 functions grow, 1 shrinks by 4 B; tssrc 3 656 grow, 623 shrink; the largest single changes in skin, tssrc and Supabase are inlining shifts between a function and its specialized clone that cancel out (loadBufferView+9 183 against its$idx_u31clone −9 230).What was done to keep it small: the older cold helpers are not marked (that alone was +725 KB on tssrc, see above), and the weights are
llvm.expect's. What would remove it: a convention under which the cold helpers preserve the caller's registers (above).Programs that do not spend their time in polled loops over arrays pay the size and gain little: tssrc −0.09% instructions for +0.32% code.
PERRY_COLD_PATHS=0is the way back.Not done, not covered
js_gc_note_slot_layout*,js_write_barrier_slot*,js_typed_feedback_array_set_*): search −0.9%, evaluate −0.8%. These helpers are the regular path of every pointer store, so they cannot be cold by name; each guard needs its ownllvm.expect;js_array_record_*,js_rel_lt): search −0.8%, perft −1.1%. The constant-array for-of work removes them where the array is constant;.length(js_value_length_property_f64): search −0.1%.COLD_HELPERSlist is left as it is: declaredcold, not seen as cold by LLVM where the call is a statepoint.llvm.expectalone is not isolated by a build of its own (the estimate above is a difference between two builds).llvm.expect), and no other target was built. Shadow-stack targets get the same pass.perry_array_read_cold_sharedis not always cold: evaluate'sown[file - 1]at file 0 takes it on 424 591 of about 7 M reads in search. The weights say 1 in 2000; the measured result with them is evaluate −9.19%.