Skip to content

feat(backend): 完善课题 18 安全指令调度与 A/B Benchmark 报告 - #59

Open
Mastttttter wants to merge 6 commits into
ScratchV-Compiler:mainfrom
Mastttttter:main
Open

Mastttttter wants to merge 6 commits into
ScratchV-Compiler:mainfrom
Mastttttter:main

Conversation

@Mastttttter

@Mastttttter Mastttttter commented Sep 14, 2026

Copy link
Copy Markdown

概述

完善课题 18 的局部指令调度器,修复汇编信息丢失、依赖遗漏和周期估算无法反映调度收益的问题。补充调度校验、编译器集成、实际执行验证及 CI Benchmark 报告。

主要改动

  • 复用共享汇编解析器,保留标签、指示行、注释和换行格式,限制指令移动范围。
  • 新增指令语义模块,统一整数及浮点寄存器别名,补齐 RAW、WAR、WAW 依赖,保持访存和浮点状态副作用的顺序。
  • 修正关键路径优先级计算,结合结果就绪时间和功能单元占用安排指令。
  • 新增独立校验器,检查指令完整性、寄存器读写来源和依赖顺序。校验失败时恢复原序;严格模式下终止编译。
  • 接入现有 --schedule 开关,新增 --schedule-strict、--schedule-report,支持结构化统计和 JSON 报告。
  • 将课题文档整理至 docs/topic-18/,纳入设计、实现计划、代码说明、SPEC Review 和 Benchmark 使用说明。

Benchmark 报告

参照 PR #35 的同输入 A/B 报告方式,将固定功能用例、实际编译输出和合成规模测试分别展示。

固定用例与真实执行

同一份汇编分别通过 CompilerDriver 的调度关闭和开启配置,再将前后汇编编码并使用真实 TinyFive 执行。

指标 调度前 调度后
━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━ ━━━━━━━━
源汇编指令数 4 4
───────────────────────── ──────── ────────
局部模型周期(估算) 5 4
───────────────────────── ──────── ────────
局部模型停顿(估算) 1 0
───────────────────────── ──────── ────────
编码机器指令数 4 4
───────────────────────── ──────── ────────
代码大小(字节) 16 16
───────────────────────── ──────── ────────
TinyFive 实际执行指令数 4 4

  • compiler_config_schedule=true
  • pipeline_matches_public_pass=true
  • 实际移动 2 条指令
  • backend=tinyfive、fallback=false
  • 全部 32 个整数寄存器及数据区 16 个字的结果一致
  • 模拟器缺失、执行失败或结果不一致时生成 FAIL 报告,并返回非零退出码

模型周期下降表示调度填补了 load-use 等待,不代表硬件实测加速。

CNN 汇编静态 A/B

对现有 CNN 编译步骤产生的同一份汇编切换调度开关,保留零收益及跳过结果。

当前 CNN 列表包含数字分支偏移,触发调度器的整份输入保留规则:

  • 源指令数:876 → 876
  • 比较状态:not_modeled
  • 模型覆盖率:0%,周期显示为 N/A
  • 执行状态:not_run

该结果用于展示当前支持范围,不声称已经完成 CNN 端到端执行验证或取得性能提升。

合成规模测试与 CI

  • 固定随机种子,覆盖 10~5000 条指令。
  • 记录输入哈希、模型周期、移动指令数、区域状态及优化器耗时。
  • 1000 条指令样例的模型估算从 2232 周期降至 1802 周期。
  • 默认跳过超过 1024 条指令的区域,未建模结果显示 N/A。
  • 三组报告均输出 JSON 和 Markdown,由 benchmark-reports artifact 收集,并写入 GitHub Actions Job Summary。

本地验证

  • 全量测试:867 passed,11 条未注册 pytest 标记警告。
  • 调度执行对比覆盖整数、浮点、访存、分支、调用、循环及随机指令块。
  • 固定用例 TinyFive A/B 报告通过,寄存器和内存结果一致。
  • CI YAML 和相关 Shell 步骤语法检查通过。

当前范围

调度在寄存器分配后的汇编阶段进行,仅在合法局部区域内移动指令。候选顺序必须通过校验,且在同一模型下严格减少周期才会被采用。

周期统计是局部静态估算;优化器耗时是主机执行调度 pass 的时间,两者均不作为目标程序硬件运行时间。

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

🤖 AI Code Review

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

📁 .github/workflows/ci.yml

🟡 Shell 引号不一致 — Summary 段中新代码用 "$GITHUB_STEP_SUMMARY" 引号包裹,但周围既有代码均为裸 $GITHUB_STEP_SUMMARY。虽然新写法更正确,但混用会让读者疑惑是否有意图差异。建议统一加上引号(或至少在同一代码块内保持一致)。

💭 循环变量命名for report in inst_scheduler_report inst_scheduler_cnn inst_scheduler_synthetic 列表较长,与上方两个独立 benchmark 步骤无对应关系,未来维护容易漏加。可考虑将报告文件名集中定义在一个变量/数组中,步骤和 summary 共用。


📁 :memory:.ses

🔴 State/session data committed to VCS:memory:.ses is SQLite in-memory DB session data. This is ephemeral runtime state, not source code. Should be in .gitignore.

🔴 Potential sensitive identifiers — The UUID (d74aeda2-...) may be a session ID or user identifier. Committing these enables session hijacking or user enumeration.

🟡 No schema or documentation — Two opaque values with zero context. A future reader has no way to know what 1789389046682 (future timestamp ≈ 2026-10-15) or the UUID represent.

💭 Filename :memory: is a red flag — Conventionally means in-memory/temporary. Persisting it to disk and version control suggests a missing .gitignore rule for *.ses.

Action: Revert this commit, add *.ses to .gitignore, and ensure session state is never tracked in VCS.


📁 benchmarks/bench_inst_scheduler.py

🔴 Scope changed silentlybench_schedule used to time build_dag + schedule; now times schedule_assembly (parse + region split + schedule + reassemble). The function name, docstring ("through the production scheduler"), and removed bench_build_dag all still imply scheduler-only timing. Either rename (bench_pipeline, bench_end_to_end) or re-split the measurement, otherwise readers will misattribute the numbers.

🔴 SchedInst loses defines/uses — Old code passed explicit sets; new code omits them and relies on schedule_assembly re-parsing raw_line to rebuild dependencies. That works only if the parser is the authoritative source. This makes SchedInst a thin raw_line wrapper here — consider accepting list[str] and dropping the intermediate objects entirely.

🟡 Positional constructionSchedInst(index, op, operands, raw_line=...): if field order changes upstream, this breaks silently. Use keyword args for the first three too.

🟡 Third source can equal dstoperands = [dst, src, rng.choice(groups[chain])] may produce add x5, x5, x5. Legal RISC-V but creates artificial self-dependencies that skew scheduler behavior. Exclude dst from the pool, or document it.

🟡 Duplicate validationbench_schedule raises ValueError on repeats < 1, and main also calls parser.error. Pick one; the in-function check is dead code if only main is used, but useful if bench_schedule is imported.

🟡 Hardcoded seedrow["seed"] = 42 after _gen_instructions(size) uses default. Thread seed through as a parameter so it isn't silently wrong if the default changes.

🟡 No error handling — A single parse failure in schedule_assembly aborts the entire sweep. Consider catching and recording the failure per row so one bad size doesn't kill the report.

💭 statistics.stdev(times) if repeats > 1 else 0 returns int 0 when repeats==1, but stdev returns float. Use 0.0 for type consistency.

💭 Language mismatch: docstring/comments are English, _markdown output is Chinese ("课题 18:合成指令调度 Benchmark", "减少", "耗时均值"). Pick one — or intentionally keep both if the report audience differs.

💭 Markdown table header "无收益区域" is computed as sum(row["status"] == "no_improvement" for row in stats["regions"]) — depends on stats["regions"] existing. If a future refactor drops that key, bench_schedule KeyErrors. Consider stats.get("regions", []).

💭 args.json.parent.mkdir(parents=True, exist_ok=True) works for bare report.json (parent is .), but consider normalizing with resolve() in case a user passes a relative path they'd expect to be created next to cwd.


📁 benchmarks/cases/inst_scheduler_feature.asm

🟡 Fall-through risk — No terminating instruction (e.g. j . or nop+ret) after sw. If the runner executes this case linearly within a larger image, control falls through to whatever follows.

🟡 Missing unscheduled baseline in comment — Line 4 says "model cycles 5 -> 4" but doesn't say where 5 comes from. Add the pipeline model (e.g. "RAW stall on lw→add adds 1 cycle") so the expected delta is verifiable without the scheduler implementation.

💭 Consider an explicit # UNSCHEDULED ORDER header — Since this file tests scheduler reordering ability, it'd help to mark the current source order as intentionally suboptimal so future readers don't "fix" it.

Everything else checks out — the expected register/memory state is correct, and the addi is genuinely independent of the lw→add dependency chain.


📁 benchmarks/run_inst_scheduler_case.py

🔴 Assertion couples benchmark to _run_asm_passes being a pure scheduling pass — ~L128: if before != source: raise AssertionError("disabled scheduling changed the baseline assembly"). If _run_asm_passes ever gains another transform (normalization, comment stripping, whitespace folding), schedule=False stops being identity and this fires a false "scheduling changed baseline" failure, masking the real cause.
Suggestion: diff the baseline against the source explicitly per-transform, or gate on schedule=False producing source only when no other asm passes are enabled — and make that precondition explicit (e.g. assert baseline.config has scheduling off and no other post-passes on).

🟡 Two private APIs drive the whole comparison — ~L124, L133: baseline._run_asm_passes(source, []) and driver._run_asm_passes(source, warnings, stats). The A/B result is only as trustworthy as an unexported method's signature, which can change without breaking the import.
Suggestion: if the driver exposes a public pass-run/compile_asm entry point, use it; otherwise add a thin internal __all__-exported hook and a doc comment noting the contract this benchmark relies on.

🟡 stats/warnings silently come from the last repeat only — ~L134-L141: both are re-declared inside the for loop, so the reported scheduling stats and warnings reflect only repeat N. If a later pass is nondeterministic (dict ordering, region discovery), the reported stats may not match the after text being validated and executed.
Suggestion: either capture stats from the iteration whose output you compare/execute, or assert after/stats are stable across repeats and report the discrepancy when they're not.

🟡 Hard-coded magic numbers in the report are under-documented — ~L210-L214 register indices (5, 6, 7, 28) and memory_words[1] labeled memory[1028] are only correct for the current feature case; the constant name doesn't convey it.
Suggestion: derive these from INITIAL_REGISTERS / DATA_ADDRESS (or name them OBSERVED_REGISTERS / OBSERVED_MEM_OFFSET) so a case swap can't produce a report that silently shows the wrong cells.

🟡 Scheduling stats schema is trusted rather than validated — ~L166, L183-L200, L300: s["model"], s["regions"], s["diagnostics"], s["modeled_instructions"], s["applied_regions"], s["saved_cycles"], s["original_cycles"]/s["final_cycles"]/s["original_stalls"]/s["final_stalls"], and per-region start_line/end_line/instructions/status are all index-accessed. A schema drift raises KeyError mid-markdown-build, producing no report at all from main's broad except.
Suggestion: validate the stats dict against an expected schema (or a small TypedDict) at the top of run_case and fail with a clear message before reporting.

🟡 No bound on repeats or input size — ~L336: parser.add_argument("--repeats", type=int, default=3) only checks >= 1. A large repeats or a large input assembly makes CI hang.
Suggestion: cap repeats (or document it), and consider a size guard on the case file.

💭 Label-exclusion heuristic in _instruction_count is fragile — ~L37: line.raw.split("#", 1)[0].strip().endswith(":"). This misses a label written with a tab/space before the colon only in edge casings, and could miscount a directive whose text ends in :.
Suggestion: rely on the parser's own label/opcode classification if _asm_parser exposes it, rather than re-deriving from raw.

💭 _execute mixes a long validation block with execution — ~L46-L103: 40 lines of preconditions precede the actual load/run. Splitting "validate feature-case contract" from "execute" would make the safety envelope easier to audit and to reuse if a second harness appears.

💭 Address fields assumed to be numeric register names — ~L70-L80: address.base != "x10" and reg.startswith("x") assume the parser always emits xN rather than symbolic names. Fine if guaranteed, but a one-line comment noting that invariant would prevent a future parser change from silently disabling the whole execution path.


📁 docs/topic-18/18-指令调度器Benchmark.md

This is a documentation review, not code. The document is well-structured with appropriate caveats. A few observations:

🟡 CNN benchmark is currently useless but buried — Line ~50: "当前 standalone CNN 列表包含数字分支偏移…会触发调度器的整份输入保留规则" means the CNN static A/B always returns not_modeled. This is the document's most important limitation yet sits mid-paragraph. Consider moving it to a prominent callout box or making it the first sentence of the "实际编译输出" section so readers don't assume CNN scheduling is validated.

🟡 Missing prerequisites — The document references TinyFive, .venv/bin/python, and specific benchmarks modules but never states required setup (how to install TinyFive, what Python version, what dependencies). A reader landing here cold will hit failures without context.

🟡 Ambiguous failure semantics across modes — Fixed-case mode: "运行器要求用例确实发生有收益的换序" (fail if no reordering). Synthetic/CNN mode: "零收益是有效测量结果,不导致失败". These are correct but the contrast is implicit. A one-line note in the "统计口径与 CI" section would prevent confusion when someone writes new benchmark cases.

💭 Register state could be more explicit — "初始状态固定为 a0=1024t2=7、其它寄存器为零" — listing all registers actually touched (t0, t1, t3, a0, t2) would help future maintainers understand which values the expected-result check covers, especially since the check covers "全部 32 个整数寄存器."

💭 Table column alignment is fragile — The | ---: | right-alignment for numeric columns is fine in GitHub-flavored Markdown, but if this document is ever rendered in a non-GFM context, the alignment breaks. Minor, but worth noting since the section header says "CI 报告" implying it may be consumed in multiple renderers.


📁 docs/topic-18/18-指令调度器SPEC-Review.md

🟡 **Circular framing weakens conclusion** — §7.1 rejects SPEC as V1 guide, but §2.1 praises its defect analysis as "全文最有价值". The document simultaneously says "don't use SPEC" and "use SPEC's best section." Clarify: extract 16.2 into a standalone checklist, then reject the rest. Otherwise the reader gets confused about what to actually do.

🟡 **Self-inconsistency on text-level maturity** — §2.1 says all 8 SPEC defect diagnoses are "精确", but §3.4 says `_asm_parser.classify_def_use` is more mature than SPEC assumes, implying some diagnoses are overblown. Reconcile: which of the 8 defects are actually mitigated by existing code? This undermines either the praise or the criticism.

🟡 **Fail-stop argument (4.3) is debatable, not "过于严格"** — LLVM and GCC also fail-fast on scheduling errors. For an educational compiler, silently producing suboptimal output is arguably worse than failing loudly—it teaches students that correctness violations are acceptable. Consider framing this as "phase-gated": fail-stop in dev, warn+skip in production builds.

🟡 **"V1 vs V2" framing misses technical debt cost** — Deferring MachineInstr refactoring (§3.3) and post-RA integration (§3.2) to V2 means building a scheduler on a known-wrong data structure. The 200-300 LOC estimate for linear-scan refactoring (3.1) is small compared to the cost of a scheduler that can never be integrated. Quantify: how much scheduler code would need rewriting if V2 finally does the refactoring?

🟡 **Asymmetric absorption (Section 6)** — Lists 5 things SPEC should absorb from detailed design, but only 1 thing detailed design should absorb from SPEC (§16.2 defects). Add what the detailed design should also take: the 11 invariants, the component decomposition, and the micro-architecture model interface.

🟡 **"5-15% CPI improvement" claim is unsubstantiated** — The intro states this as justification without citing any benchmark, kernel profile, or academic reference. Either add data or soften to "potentially meaningful."

🟡 **Scoring lacks methodology** — 9/10, 4/10, 8/10 with no rubric. "架构正确性 9/10" loses 1 point for what? Without criteria, scores feel arbitrary and can't guide decisions.

💭 **§1 and §7 repeat the same conclusion** — "总体评价" table and "是否建议按 SPEC 实现?" both say "no, too many gaps." Merge into one verdict, keep the table, cut §7.1's prose.

💭 **"不容置疑的架构常识" (2.4)** — Text-level scheduling has historical precedents (early GCC, many JITs). "不容置疑" is too strong; "industry consensus" is more defensible.

💭 **Code references lack verification trail** — 8 code locations cited (e.g., `compiler.py:407-413`) but no commit SHA or verification date. If the codebase moves, all cross-references break. Add a "verified at" timestamp.

📁 docs/topic-18/18-指令调度器代码说明.md

Code Review: docs/topic-18/18-指令调度器代码说明.md


🟡 Section 3 — 条件分支的边界规则不够明确

"分支和返回固定在本段最后" 对 beq a1, a2, done 这种 条件分支 + 函数内后续代码 的场景没有说清楚。建议明确:分支指令结束当前段,目标标签开启新段,两条之间的指令不在同一段内。否则实现者可能误以为可以跨条件分支调度。

另外 "遇到看不懂的指令,也在这里截断" — 截断后该段内其他指令是否仍参与排序,还是整段保序?建议补充说明。


🟡 Section 5 — 访存保序依赖 Section 4 的语义表

"第一版所有读写内存的指令彼此保持原顺序" 需要先识别出哪些指令是访存指令。但 Section 4 说指令语义表是 新建 的 (schedule_semantics.py),当前代码的识别能力有缺陷。建议明确:在语义表完成前,访存保序这一项 无法独立实现,两者需要一起落地。


🟡 Section 7 — 缺少完整的指令延迟表

文档多处引用具体延迟(lw=2, mul=3, add=1, 除法=16),但从未汇总成一张表。实现者需要知道所有指令的延迟才能计算 ready_time。建议新增一张表:

指令类别 示例 延迟(拍)
整数加法/逻辑 add, and 1
乘法 mul 3
内存访问 lw, sw 2
除法 div 16
分支/跳转 beq, jal 1
... ... ...

同时 ret 在表格中占 1 拍但未单独列出,建议补上或归入 "分支/跳转" 类并加注。


🟡 Section 8 — 验证检查缺少分支目标完整性

检查列表中有 "越过标签、调用、跳转等边界",但没有检查 分支目标本身是否被破坏。例如 beq a1, a2, done 中的 done 标签如果在排序中被移动或删除,分支会跳转到错误位置。Section 2 说标签留在原处,但 Section 8 的检查项应该显式覆盖这一点。


🟡 Section 9 — 新文件归属不明

schedule_semantics.pyschedule_model.pyschedule_verify.py 三个新文件未说明放在哪个包下。从上下文推测应在 scratchv/backend/,建议明确写出完整路径,避免实现时放错位置。


💭 Section 6 — 建议补充公式

优先级计算的文字描述准确,但增加一行公式会更清晰:

priority[i] = latency[i] + max(priority[j] for j in successors(i), default=0)

并注明确认:ret / nop 这类无后继的指令,priority = latency 本身。


💭 Section 7 — 同速排序时的平局策略

"只有新顺序更快才使用;一样快或更慢就保留原序" — 如果多条合法排序耗时相同但移动数量不同,是否偏好移动更少的?这在 debug 时会有用。建议补充一条平局策略。


💭 LLVM 行号引用

多处引用本地 LLVM 源码的精确行号(如 ScheduleDAGInstrs.cpp:303)。如果 LLVM 版本更新,行号会漂移。建议改为引用函数名 + 文件路径,行号作为辅助信息。


总体评价

文档技术准确性高,bug 分析有据可查,LLVM 对比恰当。上述问题都是文档完备性层面的改进,不涉及方案方向性错误。


📁 scratchv/backend/__init__.py

This change looks clean — straightforward re-export addition, no issues found.

🟡 Minor: consider grouping related exports — Lines 41-44: ScheduleConfig, ScheduleResult, schedule_assembly are appended after machine_instrs_from_scheduled but before ExtendedInstructionSelector. Since they're from the same .inst_scheduler module, consider whether the __all__ order should mirror the import order for consistency (currently it does, which is good). No action needed — just noting the pattern is consistent.


📁 scratchv/backend/schedule_verify.py

🔴 KeyError: DAG check can crash on missing instructions — Lines 44–45:
positions[pred.inst.id] raises a raw KeyError if a DAG-referenced ID isn't in the candidate. Wrap with a ScheduleError or validate that all DAG instruction IDs are a subset of positions.keys() beforehand.

try:
    pred_pos, node_pos = positions[pred.inst.id], positions[node.inst.id]
except KeyError as e:
    raise ScheduleError(f"DAG references missing instruction: {e}") from None

🟡 Silent no-op when dag is empty — Line 21:
Dependency-order verification is skipped entirely when dag=(). If a caller forgets to pass it, ordering bugs go undetected. Consider making dag required, or at minimum asserting dag is non-empty when the schedule is non-empty.

🟡 Unannotated local variable — Line 11:
reads = {} bypasses the type checker. Use:

reads: dict[tuple[str, str], int | None] = {}

This also makes the "None means incoming value" contract explicit in the type.

🟡 Imprecise return annotation — Line 8:
tuple[dict, dict] hides the actual structure. Prefer:

tuple[dict[tuple[str, str], int | None], dict[str, int]]

💭 Docstring could name the invariants checked — Line 18:
"Reject changes to identity, boundaries, register values or effects" is good but skims over the DAG-order and pinned-instruction checks. A bullet list of the 6–7 invariant categories would make it a better API contract.



⚠️ 未审查的文件

  • scratchv/compiler.py
  • scratchv/main.py
  • tests/test_inst_scheduler.py
  • tests/test_inst_scheduler_integration.py
  • tests/test_inst_scheduler_report.py
  • tests/test_inst_scheduler_safety.py

@Mastttttter Mastttttter changed the title feat(backend): 完善课题 18 安全局部指令调度、验证与 Benchmark feat(backend): 完善课题 18 安全指令调度与 A/B Benchmark 报告 Sep 14, 2026
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