Skip to content

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

Open
yuki-328 wants to merge 1 commit into
ScratchV-Compiler:mainfrom
yuki-328:topic17-pseudo-regalloc
Open

feat(regalloc): support pseudo instructions and accurate spills#56
yuki-328 wants to merge 1 commit 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 个变更文件
⚠️ 另有 11 个文件超过上限(最多 10 个)未审查

📁 benchmarks/test_regalloc/bench_cnn.py

🔴 Bug: Missing RISCVAEncoder import path validation — Line 19: If RISCVAEncoder raises on any valid-assembly edge case (e.g., .global, .section directives), the bench will false-fail. The broad exception list (IndexError, KeyError, TypeError, ValueError) doesn't catch AttributeError or custom exceptions the encoder might raise.

Suggestion: Catch Exception with a log, or check what exceptions the encoder's documented contract raises.

🟡 Regression: _validate_asm no longer detects unresolved vregs — The old code explicitly flagged v<digits> tokens that slipped past register allocation. The new encoder-based validation only confirms the assembly is syntactically valid — it won't tell you why it failed if the encoder errors out on a vreg. The error message loses diagnostic value.

Suggestion: If assembly fails, add a fallback check scanning for unresolved vregs before reporting.

🟡 Performance overhead: assemble called twice per run — The encoder is invoked once in _validate_asm and again in the actual codegen path (block_from_machine_instrs → emit). This doubles encoder work for validation, inflating benchmark times. If the benchmark measures sv_static_instrs via the same codegen, the validation assemble is wasted work.

🟡 stats["llvm_spill_slots"] and related keys may not exist when LLVM unavailable — The print uses if stats["llvm_available"]: guard for the first block, but the final "ScratchV regalloc" print block is outside the guard and looks safe — however, if LLVM code earlier sets these keys only when available, accessing them unconditionally could KeyError. Verify the key population logic.

💭 Unicode removal (/FAIL/PASS) is good for CI/logging compatibility, but consider PASS/FAIL prefixing on lines that print numeric stats too (currently no prefix on those lines) for grep-ability.


📁 benchmarks/test_regalloc/bench_dense.py

🔴 Breaking semantic change in "spills" key — Previously "spills" reported len(alloc._spill_slots) (number of spill slots allocated). Now it reports alloc.spill_store_count (number of spill store instructions). Any downstream consumer comparing across runs will see inconsistent values under the same key. If both are needed, give them distinct names.

🟡 Three keys share the same value"spills", "spill_stores", and "reg_spill_count" all map to alloc.spill_store_count. This creates confusion about which key is canonical. Either consolidate to one, or document why each alias exists (e.g., backward compat). If they're there for report schema compatibility, add a comment.

🟡 Accessing private attribute alloc._spill_slots"spill_slots": len(alloc._spill_slots) bypasses encapsulation. The other new fields (spill_stores, reloads) use public accessor properties. Prefer a public method/property for spill slot count to stay consistent with the rest of the changes.

💭 statistics.mean(times) will raise on empty list — If repeats=0, this crashes. Pre-existing issue, but now that you're touching this code it's a natural time to add a guard (e.g., times if times else [0]).

💭 Good simplification — Dropping the spill_counts list and inline ASM parsing in favor of centralized allocator state is a clean improvement. The final-run-only pattern also avoids unnecessary allocations in the loop.


📁 benchmarks/test_regalloc/bench_simple.py

🔴 Bug: Three keys map to the same value — Lines 78-80: "spills", "spill_stores", and "reg_spill_count" all equal alloc.spill_store_count. This is misleading — spills and reg_spill_count were different concepts in the old code (spill slots vs store operations). If they're intentionally the same, deduplicate; if not, fix the mapping.

🟡 Inconsistent access pattern — Line 79 accesses alloc._spill_slots (private underscore attribute) while line 80 uses alloc.spill_store_count (public). Suggest using a public property for spill slots too, e.g., alloc.spill_slot_count.

🟡 spill_slots vs spill_stores semantics unclearlen(alloc._spill_slots) (total spill slot capacity allocated) vs alloc.spill_store_count (actual store operations generated) could differ significantly in complex cases. Without a comment or a test asserting they sometimes differ, readers will assume they're interchangeable.

🟡 No stability check for new metricstimes is averaged across repeats runs, but all the new spill/pressure metrics are taken from a single final run. If any of these are non-deterministic (e.g., depend on object hash ordering), the reported values could vary. Consider averaging them like times or documenting why a single run suffices.

💭 Nit: _alloc exposure — The _alloc field exposes the full allocator, making all these new metrics trivially derivable by consumers. This reduces the value of pre-computing them in the dict. Consider whether _alloc should stay or the redundant keys should be removed.


📁 benchmarks/test_regalloc/regalloc.md

🟡 冗余键 spillsspill_stores 指向同一来源 — 二者都映射到 alloc.spill_store_count。虽然标了"兼容键",但文档中没有注明弃用计划或建议迁移路径,读者可能不清楚该用哪个。

Suggestion: 加一行说明 spills 将在何时/哪个版本移除,或者指向 spill_stores 为规范名称。


🟡 reg_spill_countspill_stores 语义重复 — 二者都引用 alloc.spill_store_count,但一个是"接口规范键"、一个是"指标键",区别未说清。如果二者永远相等,建议合并或明确声明二者的使用场景差异。


💭 物理寄存器数 28→19 变更缺少上下文 — 从 28 减到 19 意味着寄存器池大幅缩水,这直接影响 spill 率基线。建议在脚注或 4.2 附近简要说明是否移除了浮点寄存器(f0f31)或只计数了整数集,避免读者误以为之前的 benchmark 用了更大寄存器集。


💭 pressure_peak 计算方式描述模糊 — "live interval 精确重叠扫描"是算法描述而非来源。建议写成 alloc.pressure_peak 或类似代码路径,与其他行的格式(如 len(alloc.alloc_map))保持一致。


整体评估:文档结构改善明显,spill/reload 拆分合理,reg_spill_count 静态 vs 动态的说明尤其有价值。主要问题集中在别名键的治理上。


📁 docs/topic17_AI自审报告.md

🟡 Section 3 编号与额外修复的关系不清晰 — 段首说"自审发现并修复了 5 类真实问题",列出 5 项后追加一句"同时修复了同一 vreg 的纯重定义被误判...",读者可能误以为这是第 6 类问题或遗漏的计数。建议在段首改为"5 类主要问题(及一处附带修复)",或将其纳入编号列表。

🟡 max 展开与 scratch 可用性的关系未说明 — Section 1 表格称 max 通过 bge + 两条 copy + j 展开,需 1 个临时寄存器。Section 3 item 5 说"无可用 scratch 时明确报错"。但 max rd, rs1, 0 理论上可以用 zero 省去一条 copy。若实现未做此优化,建议在 Section 6 边界中注明当前 max 实现不利用 zero 做零操作数简化,而非仅声明"立即数只支持 0"。

🟡 Section 5 benchmark 表格 "未执行编码" 两行缺少原因说明 — Simple/Dense 标注"合成压力 IR"但未说明为何不能编码。读者无法判断是因为缺少真实语义数据、IR 结构不满足 selector 前提,还是其他原因。一行脚注即可。

💭 Section 4 item 1 "token 级检查" — 表述略模糊。是检查汇编输出文本的 token,还是 IR 阶段的字符串?建议在上下文加半句限定,避免读者误解为运行时字符串扫描。

💭 表格对齐 — Section 1 的伪指令表格中 max rd, rs1, rs2 一行的"右侧仅允许寄存器或立即数 0"信息量较大,而其它行的"分配语义"列均很简短。可考虑将此约束移到备注列或 Section 6 边界说明中,保持表格列信息密度一致。

💭 Section 6 "fixed-register interference 建模" — 对非本项目读者的友好度较低。可加一句"即 selector 阶段若使用 t0t6 中的寄存器,当前分配器不会将其标记为固定占用"。


📁 docs/topic17_P1实现报告.md

🟡 Section 6:回归测试数量偏小 — 仅 24 个随机直线程序(12 seeds × 2 种算法),在验证复杂 CFG + spill 正确性时覆盖率有限。考虑至少增加 CFG 分支路径覆盖的随机测试,或在文中明确说明该数字对应的风险接受范围。

🟡 Section 6:"TinyFive LW 四字节读取回归" — 这是测试基础设施 bug(验证器只读低 8 位),不应出现在实现报告的验证结果主体中。建议移至"测试基础设施修复"小节或附录,避免混淆"功能正确性"与"验证器修正"。

🟡 Section 1:P0 修复与 P1 新交付混合描述 — "同时修复了 P0 和 AI 自审中暴露的执行错误"与前面"两项 Wiki 交付目标"并列,读者难以区分哪些是新增功能、哪些是修补。建议拆分为"交付"与"修复"两个小节,或在每个修复项后标注来源(P0/AI 自审)。

🟡 Section 6:"两寄存器高压程序执行结果为 7" — 缺少该程序的来源、预期值推导依据和完整汇编输出引用。若为内部 fixture,请给出文件路径;否则读者无法复现或验证这个数字的意义。

💭 Section 4:JAL 范围限制 — "超过 JAL 范围的外部/远符号仍应交给 ELF relocation"是已知边界,建议在此处补一句估算的 JAL 范围(±1MB / ±2MB 取决于 20-bit offset),便于读者判断何时会触发此限制。

💭 Section 7:性能成本缺失 — Dense 场景 spill stores=63、reloads=75,但报告未提及由此引入的运行时/代码量膨胀比例。即使定性说明"预期指令数膨胀约 X%"也会提升报告完整性。


📁 docs/topic17_benchmark文档.md

🟡 spills 语义变更未标注spills 的来源从 alloc._spill_slots 变为 alloc.spill_store_count,含义从"分配槽数"变为"生成汇编中的 store 数"。如果已有消费者依赖旧语义,这是 breaking change。建议在"兼容键"旁加注"⚠️ 语义已变更,请迁移至 spill_stores"。

🟡 spillsspill_stores 完全重复 — 两者均映射 alloc.spill_store_count,但文档未明确说明 spillsspill_stores 的别名且计划废弃。建议显式标注 deprecation 状态,避免使用者困惑选哪个。

🟡 pressure_peak 来源描述模糊 — "CFG 修正后的 live interval 重叠扫描"缺少上下文。建议补一句解释什么是"CFG 修正",或引用对应实现文件的章节/函数名。

🟡 pressure_excess_peak 公式中"物理寄存器数"未具名 — 写为 max(0, pressure_peak - _INT_REGS) 更清晰,读者不必回翻表格找 19 这个数字来自哪里。

💭 寄存器列表缺少 t7t8_INT_REGS 列出 t0t6(7 个)+ s0s11(12 个)= 19,数学正确。但标准 RISC-V 有 t7 和 t8 也是纯临时寄存器,未使用的原因值得在脚注中说明(例如:与函数参数或 spill slot 寄存器冲突)。


📁 scratchv/backend/instruction_select.py

🟡 Missing fallback for unsupported operand kinds_emit_move assumes src.kind is either "imm" or a register kind. If a memory operand or other kind ever reaches this method (e.g., via a future codegen path), the else branch will emit MV with an invalid source, producing an illegal instruction silently.

Suggestion: Add an explicit guard:

elif src.kind == "reg":
    self._emit(MachineOp.MV, dst, src, comment=comment)
else:
    raise NotImplementedError(f"Cannot emit move for operand kind: {src.kind}")

🟡 All 8 call sites replaced unconditionally — The replacement is correct (MV with an immediate source is never valid), but consider whether MachineOp.LI should have the comment parameter in a different position. Currently LI is emitted as _emit(MachineOp.LI, dst, src, comment=comment), which places src (an immediate) in the src1 position. Verify that MachineInstr semantics for LI expect the immediate in src1 rather than requiring special handling.

💭 Naming_emit_move is good, but _emit_copy might read more naturally for a pseudo-instruction that "copies" a value regardless of source type. Minor.

💭 Docstring accuracy — "for either a register or an immediate" should also mention the fallback behavior (or lack thereof) to guide future contributors.


📁 scratchv/backend/machine_semantics.py

🟡 Bug risk: virtual_register_defs_uses string formatting may not match allocator expectations — Line 249: str(operand.value) extracts just the name, while linear_scan_operands (line 265) uses str(operand).lstrip("%"). If these produce different strings (e.g. one includes a % prefix or type suffix, the other doesn't), the allocator and emitter will track different names for the same virtual register, silently breaking spill/reload decisions.
Suggestion: Use a shared helper (e.g. vreg_name(operand)) in both functions to guarantee consistency.

🟡 Inconsistent return type from linear_scan_operands — Lines 273–278: When target_from_comment is False, the function returns instr.comment as-is, which could be None. When True, it normalizes to "". Callers must defensively check for None in one code path but not the other.
Suggestion: Always return str — e.g. comment = instr.comment or "" at the top, or document the contract explicitly.

🟡 virtual_register_defs_uses can silently include immediates as virtual registers — Line 246: The _names_at check uses operand.kind == "vreg", but immediate_positions are not consulted. If an immediate happens to carry a .kind of "vreg" (e.g. from a misconfigured intermediate IR), it would be treated as a live virtual register. This is unlikely but the check is fragile — consider explicitly skipping positions in immediate_positions to make the invariant self-documenting.

💭 Repeated standalone MachineOpSemantics(...) calls for common layoutsFABS_D, FNEG_D, FMV_S, FMV_S_X, LI_D all define near-identical semantics inline. A small constant set (_DEF_USE_PSEUDO, _STORE_PSEUDO, etc.) would reduce drift risk — a future edit to FMV_S might miss FNEG_D.

💭 _MISSING_SEMANTICS raises at import time — This is a good defensive check. Consider adding a similar assertion that no MachineOp in OP_SEM has contradictory flags (e.g. is_label + is_terminator, or defs referencing positions outside [0,1,2]). A quick validation loop would catch typos at module load.


📁 scratchv/backend/regalloc_cfg.py

🟡 Fragile target extraction — Line 64 (inst.operands[-1]): Assumes the branch/jump target is always the last operand. If instruction formats ever place the target elsewhere or add trailing annotations, this silently returns wrong targets. Consider a dedicated method or field on the instruction instead.

🟡 apply_cfg_liveness mutates intervals in place — Lines 156–160 directly modify interval.start and interval.end. Callers that expected immutability will get surprising side effects, and the sorted() return doesn't restore original values on failure paths. Consider constructing new interval objects instead.

🟡 Block uses set doesn't account for intra-block kill — Line 105: block.uses |= inst.uses - block.defines correctly excludes uses shadowed by earlier defines, but a use followed by a re-define in the same block still appears in block.uses (since block.defines is empty when the use is processed). This is overly conservative but not incorrect — just note that block.uses is an upper bound on true live-in.

🟡 No cycle detection for backward edges — The iterative liveness loop processes reversed(blocks) but never checks for cycles in the CFG. A back-edge (e.g., from a loop condition) converges fine due to the changed loop, but there's no termination guarantee if block.successors ever references a name not in by_name (e.g., a stale reference from a refactored code path). Adding a max-iteration cap would make this defensive.

💭 slots=True on both dataclasses — These instances are numerous in allocation passes; slots=True reduces per-instance memory overhead significantly with no readability cost.

💭 instruction_to_block maps on inst.id — If two instructions share an id (or id is not guaranteed unique), the dict silently overwrites. A debug assertion in the loop would catch this early.



⚠️ 未审查的文件

  • 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_simulator.py

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