Skip to content

feat(regalloc): support pseudo instructions and accurate spills - #56

Open
yuki-328 wants to merge 3 commits into
ScratchV-Compiler:mainfrom
yuki-328:topic17-pseudo-regalloc
Open

feat(regalloc): support pseudo instructions and accurate spills#56
yuki-328 wants to merge 3 commits into
ScratchV-Compiler:mainfrom
yuki-328:topic17-pseudo-regalloc

Conversation

@yuki-328

@yuki-328 yuki-328 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a central machine-instruction semantics table for defs, uses, immediates, control flow, calls, and pseudo-instruction metadata
  • make both linear-scan variants CFG-aware and share an executable spill/reload rewriter
  • fix greedy eviction/reload handling and caller-saved clobbers
  • lower and validate integer pseudos (mv, li, max, bnez, j, local call, labels)
  • report actual spill stores, reload loads, live pressure, and excess pressure in Topic17 benchmarks
  • add P1 implementation and AI self-review reports

Correctness fixes

  • keep distinct spilled sources in distinct physical registers
  • allow a destination to reuse a source only after preserving a still-live old value
  • canonicalize edge-live values across high-pressure CFG joins
  • keep global physical assignments stable across predecessor blocks
  • reject pseudo expansion when it would silently clobber a busy scratch register
  • avoid collisions between generated max labels and user labels
  • fix TinyFive word loads on current NumPy so execution validation reads all four bytes

Validation

  • full suite: 555 passed
  • randomized execution differential: 12 seeds x 2 linear-scan implementations
  • TinyFive checks for pseudo equivalence, RV32 li boundaries, max aliasing, branches, CFG paths, and spill reloads
  • arbitrary virtual-register names are checked for post-allocation leakage
  • CNN benchmark: pressure peak 11 with 19 registers, 0 spill slots/stores/reloads
  • Dense pressure benchmark: pressure peak 29 with 5 registers, 28 slots, 63 stores, 75 reloads

Current boundaries

  • executable proof covers the integer RV32IM pseudo path; floating-point pseudos currently have allocation metadata only
  • max accepts a register RHS or immediate zero
  • call supports local JAL-range targets; external/far relocation is not implemented
  • fixed allocatable physical-register interference for arbitrary hand-written MachineInstr input remains future work

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🤖 AI Code Review

共审查 10 个变更文件
⚠️ 另有 20 个文件超过上限(最多 10 个)未审查

📁 benchmarks/bench_regalloc_spill_compare.py

🔴 Bug: ScratchV spill counting misses float/wide opscompile_scratchv (lines 175-180) only matches sw/lw, but the module docstring says cases are f32 programs and the LLVM classifier handles sd/ld/fsd/fld/fsw/flw. If ScratchV ever emits a float spill (fsw/flw) or 64-bit spill (sd/ld), it will be silently undercounted — making the comparison ratio systematically wrong in ScratchV's favor.

Suggestion: Reuse the same regex (_STACK_ACCESS_RE) for both backends, or at minimum check all the same op mnemonics.


🔴 Bug: results[0] in main assumes non-empty results — line results[0].scratchv.physical_registers. While discover_cases raises on empty, this is a foot-gun. If someone passes a --cases directory that later changes, this becomes an IndexError with a poor message.

Suggestion: Guard with if not results: raise SystemExit("No cases matched").


🟡 Couples to private APIcompile_scratchv reads allocator._spill_slots (private). If the allocator renames that attribute, this benchmark breaks silently with a KeyError or AttributeError at runtime.

Suggestion: Expose a public property (e.g. allocator.spill_slot_count) or have the allocator return its stats directly.


🟡 Fragile string matching for ScratchVline.strip().startswith("sw ") + "(sp)" in line is brittle. A comment line like # sw foo, 0(sp) or a non-spill instruction containing (sp) as a substring could be miscounted. The LLVM side uses a strict anchored regex; ScratchV should match its precision.

Suggestion: Apply _STACK_ACCESS_RE uniformly (minus the ABI-frame classification) so both backends share the same detection logic.


💭 _peak_live is O(range × intervals) — For benchmark-scale inputs this is fine, but worth a comment noting the tradeoff. A sweep-line (sort events, track running count) would be O(n log n).

💭 format_table separator width"-" * 86 doesn't match the actual rendered width of the header/content. Minor cosmetic issue.


📁 benchmarks/regalloc_spill_cases/00_low_pressure_chain.dsl

🟡 Test case is too simple to actually exercise spill logic — The linear chain means each temp has a 1-instruction live range, so the only live values at any point are the 4 function inputs (a,b,c,d) + 1 temp. This is essentially trivial — any working register allocator handles it. If the goal is to validate that low-pressure cases don't spill, consider also adding a DAG with overlapping but short live ranges (e.g., t01 = mul(t00, c); t02 = mul(t00, d); t03 = add(t01, t02)) to prove the allocator isn't over-spilling under moderate pressure.

🟡 No assertion / expected output — The file defines the input but nothing encodes the expected outcome (no spill? register count ≤ N? specific schedule?). Without a paired .expected file or metadata, the test harness must infer pass/fail purely from absence of spill — but how do you verify that? If the harness checks "zero spills for this case," that's implicit and fragile. Consider adding a companion file or comment at the top:

# Expected: 0 spills, ≤ 5 live registers at peak

💭 Inputs reused cyclically creates hidden parallelisma is used at t00, t03, t07; b at t00, t04, t08, etc. This makes inputs effectively "immortal" across the entire chain. While intentional, it's worth a brief comment noting that the 4 input operands are the dominant register pressure source here, so the test is really measuring "can we keep 4 immortal values + 1 temp in registers."

# Expected: 0 spills (4 immortal inputs + 1 short-lived temp ≤ register budget)
# Peak register pressure: 5

📁 benchmarks/regalloc_spill_cases/01_wide_fanout_32.dsl

🟡 Division-by-zero risk — Lines 4, 8, 12, 16, 20, 24, 28, 32: div on inputs ad and on intermediate results. If the test harness doesn't guarantee non-zero denominators, this will trap or produce NaN. Consider adding a comment at the top stating the harness precondition (e.g., # requires a,b,c,d != 0).

🟡 No expected-outcome metadata — The file has a descriptive comment but no indication of what "correct" looks like for this test (e.g., # expected: 12 spills on x86-64 with 16 GPRs). Regalloc benchmarks are typically validated against spill/reload counts or a known-good lowering — that context would help future contributors interpret results and catch regressions.

💭 Reduction tree pairing could be commented — The tree pairs (v00,v01), (v02,v03), … but only every even-indexed v-value feeds forward into its own chain (v00→v04→v08→…). A short note explaining that this dual-dependency (chain + tree) is what keeps all 32 values simultaneously live would make the design intent obvious at a glance, since it's the key mechanism creating pressure.


📁 benchmarks/regalloc_spill_cases/02_double_use_40.dsl

🟡 Reverse chain first op is mul, all others are add — Line 85: r00 = mul(v39, v38). The rest of the reverse chain uses add. If this asymmetry is intentional (different opcode mix to test different patterns), it's fine and matches the comment's intent. If unintentional, consider add for consistency with r01r38.

💭 No assertion/expected-output companion — This file defines a valid pressure scenario but there's no accompanying expected-result file or reference output. If the benchmark framework requires one, it may be missing. If results are only compared structurally (spill count, etc.), this is fine.


The test design itself is sound: the 4-wide v-chain dependency (v[i] depends on v[i-4]), combined with the forward f and reverse r accumulation passes, correctly keeps all 40 values live across both passes. Clean structure, clear naming, good comment. No correctness issues.


📁 benchmarks/regalloc_spill_cases/03_lifetime_holes_36.dsl

Review: benchmarks/regalloc_spill_cases/03_lifetime_holes_36.dsl


🟡 Comment inaccurate — "inactive" ≠ dead — Lines 1–2 & 62: The header says "36 anchors" and the g-chain comment claims it "creates a region where the anchors are inactive." All 36 v-values remain live across the g-chain (they're used later in the q-chain). "Inactive" here means "not referenced" but they still hold registers. Consider rewording to "not referenced" to avoid confusion with liveness.

🟡 Undercounted live pressure — 54 values, not 36 — Lines 37–54: The 18 e-values (e00e17) are defined before the g-chain and consumed only in the er-chain after the q-chain. They are also live across the entire g-chain and q-chain. During the g-chain, ~54 long-lived values coexist (36 v + 18 e), not 36. The header comment should reflect this for anyone reasoning about the intended register pressure.

🟡 Missing documentation of g-chain pressure contribution — Lines 63–83: The g-chain creates up to 2 live intermediates at a time (current + previous before the next instruction consumes it). Minor compared to the 54 v+e values, but worth noting the total is ~56 live values at peak. A brief comment stating the expected peak live-register count would help future readers verify the benchmark matches its intent.

💭 Good structure — The reverse-order q-chain (q00 uses v35q34 uses v00) maximizes the overlap of live ranges among the anchors, which is exactly what you want for a spill-pressure test. The er-chain consuming e-values after the q-chain extends their lifetimes unnecessarily for the "hole" narrative — consider moving er00er16 before the g-chain if the intent is to isolate v-value pressure.


📁 benchmarks/regalloc_spill_cases/04_hot_cold_48.dsl

🟡 Misleading "use frequency" axis — Lines 2, 131: The header claims this exposes spill choices that ignore "use frequency," but every v value is used exactly twice (once in the v chain, once in the out chain) and every h value exactly once. That's a 2:1 ratio — most allocators won't differentiate on that. The axis that actually varies here is live range length (v spans ~36 instructions across the hot chain; h spans 1). Either rename the axis to "liveness / reuse distance" or restructure the test to genuinely vary use frequency.

🟡 "Scheduling freedom" is not actually exercised — The h chain is strictly serial (h01←h00, h02←h01, …), so scheduling freedom inside the hot chain is zero, same as in the v and out chains. If spilling-under-scheduling-freedom is a claim of this benchmark, add a second independent hot chain (or a DAG instead of a chain) so there are movable ops; otherwise drop the "scheduling freedom" claim from the header comment.

💭 Undocumented div asymmetry — The v chain includes div (v03, v07, …, v47); the h chain does not. If div is intentionally avoided to keep per-instruction latency low (making the chain genuinely "hotter"), say so. As written it reads as accidental.

💭 Wording: "short-lived hot chain" (line 2) — The chain is 32 instructions long; only its values are short-lived. "Chain of short-lived values" is clearer.

💭 Wording: line 131 — "each result has a short lifetime" → "each h value is consumed by exactly one subsequent instruction" is more precise and helps readers reason about what the allocator should do.

💭 Edge case on use count — v47 is used only once (in out47), unlike v00–v46 which are used twice. Tiny inconsistency; either add a second use or don't advertise the uniform "twice" pattern implicitly.


📁 benchmarks/regalloc_spill_cases/README.md

🟡 Ambiguous phrasing — "Does either backend spill below capacity?" in the 00_low_pressure_chain row. "Below capacity" is unclear — do you mean "avoid spilling" or "spill less than the available registers"? Suggest: "Does either backend avoid spilling when under register pressure?"

🟡 Missing context on "19 default integer registers" — RISC-V has 31 GPRs; readers will wonder why 19. A brief parenthetical (e.g., "after excluding zero, sp, ra, reserved caller/argument regs") would prevent confusion, especially for readers unfamiliar with the pipeline.

🟡 Table column "Pressure shape" uses bare numbers (32, 40, 36, 48) without units. Are these live-value counts, DSL statement counts, or register slots? A short clarification (e.g., "32 simultaneously-live values") in the column header or first entry would help.

💭 Caveat placement — The important "pipeline-level, not isolated algorithm comparison" disclaimer is buried at the very bottom. A reader scanning the top three lines will see "Register-Spill Benchmark" and may over-interpret. Consider adding a one-line caveat right after the opening paragraph or in the title/subtitle.

💭 --llvm-opt-level 0..3 — Verify that the RISC-V LLVM backend actually accepts all four levels; some targets silently cap or alias levels. If it does work, fine; if not, document the actual supported range.

💭 Trailing whitespace — Several lines (e.g., lines 12–14, the continuation of the --json-output example) may have trailing spaces from copy-paste. Not blocking, but worth a git diff --check.


📁 benchmarks/test_regalloc/bench_cnn.py

🔴 Semantics change on reg_spill_count — was len(alloc._spill_slots) (previous line: spill_counts[-1] = len(alloc._spill_slots)), now it's alloc.spill_store_count. Same name, different meaning (slots vs. emitted store instructions). Any downstream consumer comparing numbers across runs will silently misread. Either rename the key (e.g. reg_spill_stores) or keep the old definition and add a new key.

🟡 ALL_REGS vs _INT_REGS is a big behavioral shift — CNN involves float ops; if ALL_REGS includes F regs, the allocator may now allocate F regs for integer operands (or vice versa), producing subtly wrong code that the encoder may or may not catch. Confirm this is intended and that RISCVAEncoder().assemble() actually rejects miscategorized reg usage. If not, _validate_asm will say PASS on broken code.

🟡 Broad exception catching in _validate_asmexcept (IndexError, KeyError, TypeError, ValueError) misses AttributeError, StopIteration, or any encoder bug that throws something else. For a validation step used in a benchmark, one unexpected exception crashes the whole bench. Suggest except Exception as exc: with the same formatting — a validation helper shouldn't propagate.

🟡 Verify _llvm_compare returns llvm_available and llvm_error — the diff guards on stats["llvm_available"] / stats["llvm_error"] but the diff doesn't show _llvm_compare being updated to emit those keys. If not added there, bench_allocate will KeyError.

🟡 Mixed private/public access on alloclen(alloc._spill_slots) (private) next to alloc.spill_store_count / alloc.reload_load_count / alloc.peak_active (public). Either expose a spill_slot_count accessor or drop the _spill_slots line if spill_store_count + reloads is sufficient signal.

💭 Import style inconsistency — earlier in the same file it was from .bench_utils import _KNOWN_OPS (relative), now switched to from benchmarks.test_regalloc.bench_utils import llvmlite_ir_to_riscv (absolute). Pick one; relative is preferable for intra-package imports.

💭 asm_errors[:3] no longer meaningful — the new encoder returns a single aggregated error, so slicing to 3 is dead code. Not wrong, just noise.


📁 benchmarks/test_regalloc/bench_dense.py

🔴 Semantic regression: spills field changed meaning — Previously spills = len(spill_slots) (slot count). Now spills = alloc.spill_store_count (store count). These differ when a slot has multiple stores/loads. Any downstream consumer asserting on spills gets wrong values silently.
Suggestion: Keep spills → slot count, use the new explicit field names for store/load counts only.

🟡 Triplicate for same valuespills, spill_stores, and reg_spill_count all resolve to alloc.spill_store_count. Drop the redundant aliases or document why they exist for compatibility.

🟡 Lost integration check — The old code parsed generated ASM to count actual lw reload instructions, cross-validating the counter against real output. Now it trusts alloc.reload_load_count blindly. If the allocator records a reload but fails to emit it (or vice versa), the benchmark won't catch the drift.
Suggestion: At minimum, add an assert (or a debug-mode check) that alloc.reload_load_count matches the actual line count in code.

🟡 _spill_slots is privatelen(alloc._spill_slots) reaches into an underscore attribute from outside the class. If this gets renamed during refactoring, the benchmark breaks silently (no type checking on dict keys).
Suggestion: Expose a public property (e.g. alloc.spill_slot_count) or document that _spill_slots is a stable API surface.

💭 Per-iteration variability removed — Old code tracked spill_counts per iteration. If allocation is ever non-deterministic (e.g. due to hash ordering), the benchmark can't report variance. Probably fine for linear scan, just noting.


📁 benchmarks/test_regalloc/bench_simple.py

🔴 **Semantic change in `spills`** — Was `len(alloc._spill_slots)` (slot count),
now `alloc.spill_store_count` (store count). These may differ (one store per slot
only if no coalescing/dedup). Any downstream consumer reading `spills` silently
gets different data. Either keep `spills` = `len(alloc._spill_slots)` for
compatibility or flag this as a breaking change in the benchmark contract.

🟡 **Redundant fields** — `spills`, `spill_stores`, and `reg_spill_count`
all equal `alloc.spill_store_count`. Three names for one value is confusing.
Keep the canonical one and alias the rest only if external tooling depends on
them; otherwise drop the duplicates.

🟡 **Private attribute access** — `len(alloc._spill_slots)` reads an underscore-prefixed
attribute. If this benchmark is used across modules, a public accessor (or
reusing an existing one like `spill_store_count`) avoids coupling to internals
that may change.

💭 **New attributes assumed to exist** — `spill_store_count`, `reload_load_count`,
`pressure_peak`, `pressure_excess_peak` are now required on `LinearScanAllocator`.
No type-check or fallback. Consider a one-time `assert hasattr(...)` guard or
a test that exercises `bench_allocate` end-to-end so attribute errors surface
during CI, not during a benchmark run.


⚠️ 未审查的文件

  • benchmarks/test_regalloc/regalloc.md
  • docs/topic17_AI自审报告.md
  • docs/topic17_P1实现报告.md
  • docs/topic17_benchmark文档.md
  • scratchv/backend/inst_select_ext.py
  • scratchv/backend/instruction_select.py
  • scratchv/backend/machine_semantics.py
  • scratchv/backend/regalloc_cfg.py
  • scratchv/backend/regalloc_linear.py
  • scratchv/backend/regalloc_linear_v1_5.py
  • scratchv/backend/regalloc_metrics.py
  • scratchv/backend/regalloc_rewrite.py
  • scratchv/backend/register_alloc.py
  • scratchv/backend/riscv_encoder.py
  • scratchv/simulator/tinyfive.py
  • tests/test_regalloc_metrics.py
  • tests/test_regalloc_p1.py
  • tests/test_regalloc_pseudo.py
  • tests/test_regalloc_spill_compare.py
  • tests/test_simulator.py

yuki-328 and others added 2 commits September 5, 2026 22:59
Integrate the provided benchmark patch with the canonical 19-register bank and preserve the pseudo-instruction lowering helpers.

Co-authored-by: KangjieZhang <KangjieZhang1112@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant