chore(brillig): Assert that the stack frame is large enough for the max number of registers needed by any instruction - #13306
Open
aakoshh wants to merge 3 commits into
Open
chore(brillig): Assert that the stack frame is large enough for the max number of registers needed by any instruction#13306aakoshh wants to merge 3 commits into
aakoshh wants to merge 3 commits into
Conversation
…tion inputs and results
aakoshh
requested review from
a team and
guipublic
and removed request for
a team and
guipublic
July 8, 2026 11:01
aakoshh
marked this pull request as draft
July 8, 2026 11:23
Contributor
Author
|
Hm, the fact that https://github.com/noir-lang/noir/actions/runs/28937560624/job/85853942060?pr=13306 fails suggest my assumption that inputs need to fit at the same time don't hold, or maybe not for every type of instruction. Ah yes, |
aakoshh
marked this pull request as ready for review
July 8, 2026 12:30
aakoshh
requested review from
a team and
TomAFrench
and removed request for
a team
July 8, 2026 12:53
Contributor
Author
|
Updated the design doc in 595d645 |
5 tasks
aakoshh
added a commit
that referenced
this pull request
Jul 8, 2026
Phase 0 prerequisite for the pluggable allocator seam (Phase 0.5).
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.
Problem Resolved
One of the tasks the design doc proposed in #13302
Goal
Convert the latent, reactive
"Stack frame too deep"panic inStack::allocate_registerinto an upfront diagnostic. An instruction's operands and scratch registers must all be
register-resident at the same program point — no allocator can spill around them — so there
is a hard lower bound on
max_stack_frame_size. If the layout is configured below that boundwe should say so clearly instead of failing deep inside codegen once an allocation overflows.
Summary of Changes
Three files (
+182 / -7):compiler/noirc_evaluator/src/brillig/brillig_gen/variable_liveness.rsVariableLiveness::min_live_count, computed in the same walk asmax_live_count(compute_max_live_count).max(distinct inputs, results) + scratch, folded withmaxacross allinstructions.
scratchcomes from the existinginstruction_scratch_demand.instruction_min_inputs: for most instructions the distinct operands fromvariables_used_in_instruction(aHashSet, so repeated operands likeadd v0, v0countonce; function values excluded).
MakeArrayis an exception — see below.are deduplicated, and the floor does not scale with
MakeArraylength.compiler/noirc_evaluator/src/brillig/brillig_gen/brillig_fn.rsFunctionContext::newnow assertsmin_live_count <= max_stack_frame_size - Stack::START_OFFSETwith a clear message ("needs at least N registers, but only M are usable …").
ltin a too-small frame; it accepts a framewhose usable slots meet the floor.
compiler/noirc_evaluator/src/brillig/brillig_ir/registers.rsStack::START_OFFSETmadepub(crate)so the floor can be compared against usableslots (
frame − START_OFFSET), not the raw frame.START_OFFSET = 2(sp[0]= previousstack pointer,
sp[1]= per-frame spill base pointer).Why
max(inputs, results) + scratch, and why against usable slotsThe first draft used
inputs + results + scratch <= max_stack_frame_size(matching thedesign doc's shorthand). Review pushed back: comparing against the full frame rather than
the usable slots relies on the reserved-slot count (2) coincidentally covering the
over-count, with no real relationship to why a result can reuse an input register.
To settle it, a frame-size sweep measured the smallest frame each single instruction compiles
in without the
"Stack frame too deep"panic (usable = frame − 2):i+r+smax(i,r)+sFindings:
inputs + results + scratchexactly — the result does not reuse an operand registerwhen there is a post-op check/scratch that keeps operands and result live together.
unchecked_add,store) never panic; they silently emitpossibly-fragile code at absurdly small frames, so their apparent floor of 1 is an artifact,
not a real capability. Result-register reuse (the "alias trick") happens only here.
i+r+s <= framehad a real false negative: signedltpanics at frame 7,but
6 <= 7passed the assertion. The+START_OFFSETslack was masking under-configuration.The exact rule
i+r+s <= frame − START_OFFSETwould be tightest, but it breaks two existingcompile-only regression tests that deliberately use sub-floor frames — notably
brillig_spill_does_not_cause_transient_spill_leak, whose bug requires the frame-4("2-register layout") to reproduce, so bumping it is not an option.
Decision:
max(inputs, results) + scratchcompared againstframe − START_OFFSET. Rationale:(rejecting a frame codegen could handle → breaking valid compilation) are far worse than
false negatives (missing a bad config → falling through to the existing panic, i.e. status
quo). Under-counting is the safe direction.
Documented caveat:
max(i,r)+sunder-counts the true floor byresults(≈1) forscratch-bearing instructions, because the "results reuse operands" assumption is empirically
false when scratch is present. So the assertion catches gross under-configuration but leaves a
±1 gap to the existing codegen panic.
FunctionContext::newtherefore validates only thisfloor, not full register pressure.
The
MakeArrayexception: streamed inputsMakeArraybreaks the "all operands must be register-resident together" assumption: itselements are written to the heap one at a time (
codegen_make_array→initialize_constant_array_comptimeconverts and stores each element in turn; the repeating-itemruntime loop only ever materializes a single item's subitems,
subitem_to_repeat_variables). Soa large array literal never needs all of its elements in registers at once.
Counting every distinct element as a simultaneous input therefore massively over-stated the floor
and caused a false positive:
test_programs/execution_success/brillig_large_arrayfailed tocompile (
min_live_countexceeded the frame). Fix:instruction_min_inputsspecial-casesMakeArraytotyp.element_types().len()— the per-item subitem count (1 for a scalar array,tuple-arity for tuple arrays), which is the true simultaneous bound and matches the size of
subitem_to_repeat_variables. Regression test:min_live_count_does_not_scale_with_make_array_length;verified against all 16
brillig_large_arrayvariants (including the small-stack one).Is
MakeArraythe only exception? It is the only instruction whose operands are streamed tothe heap rather than held in registers, so it is the only one where the distinct-operand
count over-states the floor. (
Callalso marshals many arguments, but into contiguousstack-frame slots that
codegen_callalready bounds-checks separately, so counting those isnot a false-positive source.)
MakeArrayscratch.codegen_make_arrayreservesensure_register_capacity(4)= the resultregister plus three transient temporaries (items_pointer, write_pointer, codegen temp). The result
is an SSA value already counted in the live set, so
instruction_scratch_demandreturns 3 forMakeArray, consistent with how it accounts for every other instruction's codegen temporaries.The floor for a scalar-element array is therefore
max(1 per-item, 1 result) + 3 = 4, matching theensure_register_capacity(4)reservation, and independent of the element count.This scratch also feeds
max_live_count, so twomax_live_countMakeArraytests move from4→7(their earlier expectation counted the elements + result but omitted the codegentemporaries).
brillig_spill_batch_reduces_consecutive_spill_opcodes(aMakeArrayat exactly 4usable slots) still passes — floor
4 ≤ 4— confirming this is not a false positive.The result-register reuse mechanism (and a needless spill it exposes)
How two live-through operands and a result fit in only 2 usable registers:
codegen_binaryallocates the result register first, then reloads operands, then emitsthe op (
brillig_binary.rs).convert_ssa_binary→convert_ssa_single_addr_value(
brillig_block.rs:1069) →convert_ssa_value(brillig_block.rs:982), whoseis_spilled→reload_spilled_value(brillig_block.rs:417) emits the spill-slot load.spill_value(brillig_block.rs:288) stores the value to a slot, then frees theregister — the value survives in memory.
operand's register at emit time; the alias is safe because a Brillig binary opcode reads
both sources before writing the destination.
Needless spill of the empty result register
Real codegen of
v2 = v0 + v1at frame=4 (2 usable slots, both operands live-through):Trace:
ensure_register_capacityspills the LRU value,which is input
v0(line 3). Resultv2takessp[2].{v2 (result, empty), v0 (just reloaded)}; the LRU victim isv2, so line 10 stores theresult register while it contains no live data.
So lines 8–10 (
const+add+store) are ~3 wasted opcodes spilling an empty register.Root cause: the victim choice uses LRU recency, not next use —
v2's next use isimminent (line 14) but its last touch is older than
v0's reload, so the recency proxy wronglyranks the about-to-be-written result as the coldest register.
This is exactly the behavior the design doc targets: the plan-based allocator reserves room for
inputs + results + scratch together (
before_instruction) so results draw a pre-reserved slotand never evict a sibling, and its furthest-next-use victim rule cannot evict them (their next
use is immediate). It is wasteful, not incorrect — the stale transient-spill record is cleaned
up by machinery already covered by
brillig_spill_does_not_cause_transient_spill_leakand..._reclaims_slot_when_it_dies.Possible cheap follow-up (independent of the allocator rewrite): teach
spill_valueto skipthe store when the victim is a defined-but-not-yet-written result.
User Documentation
Check one:
PR Checklist
cargo fmton default settings.