Skip to content

Commit 2a3d412

Browse files
olwangclaude
andcommitted
test(jit): lock nested-loop OSR + correct stale roadmap (S0–S3/#2/#3/#4 shipped)
Audit of the 9-item JIT follow-ups roadmap found the planning docs were stale: items #2/#3/#4/#5 were already shipped this session. Verified each green and corrected the docs to match reality. - jit_acceptance: add two nested-loop OSR regression tests (native_osr_nested_inner_loop_matches_interpreter + ..._with_dirty_outer_...) — both byte-parity with the interpreter and osr_entries > 0. The OSR pipeline is already multi-loop-aware (detect_natural_loops/select_osr_candidate_loop); detect_single_natural_loop is diagnostics-only, not the OSR gate. - vm-optimizing-jit-plan: J0.4 row → S0–S3 shipped (10 AllocatesResult helpers, mem_budget exact via Model-A refusal, §7.2 force-deopt-tested); S4 still future. - jit-followups-roadmap (new): #2 (deque-pop fusion, OSR in-region path), #3 (flat-list read inlining), #4 (nested-loop OSR), #5 (allocate-only heap writes) marked done; remaining = #1 (on-demand), #3-remainder flat-struct, string split-fold, and the hard precise-deopt main line #7→#6→#8. Runtime suite 403 passed (only red = known env-broken jit_perf_gate baseline); vm-jit lib 83/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 67633d6 commit 2a3d412

3 files changed

Lines changed: 284 additions & 5 deletions

File tree

crates/rsscript/tests/jit_acceptance.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,119 @@ fn main() -> Unit {
960960
assert_eq!(osr.stdout.trim_end(), "begin\n31\n31");
961961
}
962962

963+
/// Nested-loop OSR (roadmap #4), all-native variant: an OUTER accumulator loop with
964+
/// an INNER counting loop, wrapped by non-native I/O so the function is whole-native
965+
/// ineligible and only OSR can run a loop natively. The OSR pipeline is multi-loop-
966+
/// aware (`detect_natural_loops` → `select_osr_candidate_loop`): with both loops in
967+
/// the native subset the OUTER region out-scores the inner on length, so the whole
968+
/// nested `[outer-header, outer-exit)` region compiles as ONE native loop with the
969+
/// inner backedge as an in-region jump. Byte parity with the interpreter is the
970+
/// correctness net (a wrong ip-map over the nested CFG would diverge here).
971+
#[cfg(feature = "native-jit")]
972+
#[test]
973+
fn native_osr_nested_inner_loop_matches_interpreter() {
974+
let source = "\
975+
fn nested_sum(rows: Int, cols: Int) -> Int {
976+
Log.write(message: read \"begin\")
977+
let mut total = 0
978+
let mut i = 0
979+
while i < rows {
980+
let mut j = 0
981+
while j < cols {
982+
let idx = i * cols + j
983+
total = total + idx
984+
j = j + 1
985+
}
986+
i = i + 1
987+
}
988+
Log.write(message: read String.from_int(value: total))
989+
return total
990+
}
991+
992+
fn main() -> Unit {
993+
Log.write(message: read String.from_int(value: nested_sum(rows: read 40, cols: read 40)))
994+
return Unit
995+
}
996+
";
997+
let file = "jit-osr-nested.rss";
998+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
999+
let osr =
1000+
rsscript::reg_vm_eval_source_main_native_osr(file, source, std::iter::empty::<String>())
1001+
.expect("osr native run");
1002+
assert_eq!(
1003+
interp.stdout, osr.stdout,
1004+
"nested-loop OSR must be byte-identical to the interpreter (stdout)"
1005+
);
1006+
// i*cols+j over i in 0..40, j in 0..40 is a bijection onto 0..1599, so the total
1007+
// is sum(0..=1599) = 1599*1600/2 = 1279200.
1008+
assert_eq!(osr.stdout.trim_end(), "begin\n1279200\n1279200");
1009+
1010+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1011+
let (_osr2, stats) = executable
1012+
.eval_main_with_args_native_osr_with_stats(std::iter::empty::<String>())
1013+
.expect("osr native run (stats)");
1014+
assert!(
1015+
stats.osr_entries > 0,
1016+
"the nested hot loop must OSR natively: {stats:?}",
1017+
);
1018+
}
1019+
1020+
/// Nested-loop OSR (roadmap #4), DIRTY-outer variant: the outer loop body itself
1021+
/// does non-native I/O (`Log.write`), so the outer region fails the transform-
1022+
/// candidate filter and `select_osr_candidate_loop` picks the INNER loop. This
1023+
/// exercises the re-entrant case — the inner loop is OSR-entered, exits back to the
1024+
/// interpreted outer body each outer iteration, and re-enters native on the next
1025+
/// outer trip. Parity proves the repeated OSR entry/exit boundary stays correct.
1026+
#[cfg(feature = "native-jit")]
1027+
#[test]
1028+
fn native_osr_nested_inner_loop_with_dirty_outer_matches_interpreter() {
1029+
let source = "\
1030+
fn nested_sum(rows: Int, cols: Int) -> Int {
1031+
Log.write(message: read \"begin\")
1032+
let mut total = 0
1033+
let mut i = 0
1034+
while i < rows {
1035+
let mut j = 0
1036+
while j < cols {
1037+
let idx = i * cols + j
1038+
total = total + idx
1039+
j = j + 1
1040+
}
1041+
Log.write(message: read String.from_int(value: i))
1042+
i = i + 1
1043+
}
1044+
Log.write(message: read String.from_int(value: total))
1045+
return total
1046+
}
1047+
1048+
fn main() -> Unit {
1049+
Log.write(message: read String.from_int(value: nested_sum(rows: read 3, cols: read 200)))
1050+
return Unit
1051+
}
1052+
";
1053+
let file = "jit-osr-nested-dirty.rss";
1054+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
1055+
let osr =
1056+
rsscript::reg_vm_eval_source_main_native_osr(file, source, std::iter::empty::<String>())
1057+
.expect("osr native run");
1058+
assert_eq!(
1059+
interp.stdout, osr.stdout,
1060+
"dirty-outer nested-loop OSR must be byte-identical to the interpreter (stdout)"
1061+
);
1062+
// Per outer i: sum_{j<200}(i*200+j) = 200*200*i + sum(0..=199) = 40000*i + 19900.
1063+
// i=0,1,2 ⇒ 19900 + 59900 + 99900 = 179700. The outer prints i each trip.
1064+
assert_eq!(osr.stdout.trim_end(), "begin\n0\n1\n2\n179700\n179700");
1065+
1066+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1067+
let (_osr2, stats) = executable
1068+
.eval_main_with_args_native_osr_with_stats(std::iter::empty::<String>())
1069+
.expect("osr native run (stats)");
1070+
assert!(
1071+
stats.osr_entries > 0,
1072+
"the inner hot loop must OSR natively even with a dirty (I/O) outer body: {stats:?}",
1073+
);
1074+
}
1075+
9631076
/// OSR × J3 (Pending #1) correctness: a hot loop that constructs and matches a
9641077
/// *non-escaping* scalar `Option<Int>` each iteration, wrapped by non-native I/O
9651078
/// (`Log.write` before/after) in the SAME function — so the function is
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# JIT 后续优化路线图(按难度从低到高)
2+
3+
> 这是 [`vm-optimizing-jit-plan.md`](./vm-optimizing-jit-plan.md) 的聚焦补充,只记录
4+
> **还没做、接下来可能做** 的 JIT 性能项,按**实现难度从低到高**排序。每项标注难度 /
5+
> ROI / 前置依赖 / 解锁什么 / 做法概要 / 风险。基准数据见
6+
> [`../../benchmarks/vm-jit/README.md`](../../benchmarks/vm-jit/README.md)
7+
8+
## 背景:现在到了什么程度
9+
10+
JIT 性能有两条轴:
11+
- **轴 A(覆盖率 / eligibility)**——让更多代码"能 native 跑"。VM→native 的大头在这(15–50×)。
12+
- **轴 B(代码质量)**——缩小 native 与手写 Rust 的差距。
13+
14+
**轴 A 的容易收益基本吃完**:OSR×J2/J3、native 递归、scalar replacement、checked-int 消除、
15+
堆读全覆盖(Int/Bool/Float)、可回滚的堆写(field/list/map/deque + flat 就地写)、query-folding
16+
都已 shipped。剩下的核心判断:
17+
18+
> **native codegen 已接近 Rust;瓶颈在覆盖率,尤其"堆分配"还几乎全 bail。**
19+
> 最后一块"一把解锁一大片"的通用解是 **J0.4(native 堆分配 + 写后精确 deopt)**
20+
21+
铁律(所有项都遵守):**native 只搬运,解释器定语义;能用可回滚兜底就别碰精确 deopt。**
22+
23+
---
24+
25+
## 排序总表
26+
27+
| # | 项目 | 难度 | ROI | 前置 | 一句话 |
28+
|---|---|---|---|---|---|
29+
| 1 | Handle-value / 非 Int key 集合写 || **** || 机械镜像,但 host helper 干主活,native 只省派发 |
30+
| 2 | ~~Deque-pop Option native 融合~~**已支持(OSR 路径)** || 低–中 || OSR 用的 in-region scalar-replace(`passes.rs:5662`)已 seed deque-pop 并以"恒 Some tag + bail-on-empty 兜 None"融合;测试 `native_osr_enters_loop_with_transactional_deque_pop_front_int` 绿。(整函数 pass 未 seed,但无关紧要——热循环走 OSR) |
31+
| 3 | 轴 B:热堆读 host-call 边界内联 |||| 堆读现比 Rust 慢 ~13×,瓶颈是每次跨调用边界 |
32+
| 4 | ~~嵌套循环 OSR~~**已支持** | 中–高 ||| OSR 管线本就多循环感知(`detect_natural_loops``select_osr_candidate_loop`);嵌套/兄弟循环已可 OSR(新增回归测试坐实) |
33+
| 5 | ~~**J0.4 S1–S3:仅分配的 native 堆写**~~**已完成** | 中–高 | **最高** | 无(不需 J0.1/J0.5) | 解锁 alloc-bound(string/json/集合构造);10 个 `AllocatesResult` helper 已落地并验证 |
34+
| 6 | J0.5:生成代码内 `VmLimits` 记账 ||| 无(S4 的前置) | 让 native 在 budget/cancel armed 时也能跑(沙箱场景) |
35+
| 7 | 完整 J0.1:内联帧链 + 堆值重建 | 很高 | 高(地基) | 无(S4 的前置) | 精确 deopt 的硬核;输出测不出,需定向 repro |
36+
| 8 | **J0.4 S4:任意别名堆就地写** | 很高 | 高/广 | J0.1 + J0.5 | "随便写"的通用解锁;最后的大魔王 |
37+
| 9 | async / 挂起函数 native | 很高 | 类相关 || 架构性;park/resume × native,可能不做 |
38+
39+
**⚠️ 2026-06-28 核实结论:本路线图前段大多已完成。** #3(堆读内联,flat-list 部分)、#4(嵌套循环 OSR)、
40+
#5(J0.4 S1–S3 仅分配堆写)**都已 shipped 并验证**(docs 之前是 stale 的)。**真正未做的**只剩:
41+
#3-remainder(flat-struct 字段读内联,比原评级更难)、native `string_byte` 读 helper + `split` 折叠
42+
(`string_text` 的真 gate)、#2(deque-pop 融合,低 ROI)、#1(Handle-value 写,低 ROI)、以及那条**最硬的
43+
精确-deopt 主线 #7 J0.1 → #6 J0.5 → #8 S4**(多 session 大投入,需主人定 scope/风险)、#9(async,缓做)。
44+
**下一步最该做的是主线 #7(完整 J0.1)**——它是 #8 和"边写边精确续跑"的地基,且独立解锁 heap-payload
45+
variant / live-out 的 OSR。
46+
47+
**(历史)推荐切入顺序:** ~~**#5(J0.4 S1–S3)**~~**#5 已完成**——
48+
10 个 `AllocatesResult` helper(`StringFromInt`/`StringConcat`/`StringSlice`/`StringPadLeft`/`StringSplit`/
49+
`StringLiteral`/`JsonParse`/`JsonField`/`BytesSlice`/`ListNewInt`)经 `JIT_HEAP_RESULTS` 输出表 +
50+
`escaping_output_handle` 逃逸分析 + Model-A(`mem_budget` armed 时拒绝 native)落地并验证
51+
(`native_string_from_int_return_allocates_heap_result` / `native_string_concat_handle_feeds_string_len` /
52+
§7.2 双子 `native_heap_result_force_deopt_leaves_output_table_empty`)。**下一步主线:**
53+
**#3(轴 B 堆读内联)→ #4(嵌套循环 OSR)→ #7(J0.1)→ #6(J0.5)→ #8(S4)**;**#1 / #2** 按需,**#9** 缓做。
54+
55+
---
56+
57+
## 逐项详述
58+
59+
### 1. Handle-value / 非 Int key 集合写 —— 难度低,ROI 低(按需)
60+
- **现状缺口:** 集合里 Handle 类型的 value 只有读(`ListGetHandle` / `FieldHandle`),**没有写**:
61+
没有 `MapInsertHandle` / `ListPushHandle` / `SetInsertHandle` / `FieldSetHandle`;map/set 只支持
62+
`jit_int_key`(Int key),`Map<String,_>` / `Set<String>` 虽合法(String 是 Hashable)但 native 不认。
63+
(注:**Float 不能做 key**——不是 Hashable,checker 直接拒,这条不存在。)
64+
- **做法:** 沿用 `MapInsertFloat` 套路,加 Handle 版 helper;key 复用解释器自己的 `VmMapKey` /
65+
哈希函数(**绝不在 native 重写哈希语义**),native 只在堆表里搬 handle + COW 回写。
66+
- **为什么 ROI 低:** 贵的部分(字符串哈希、堆分配、哈希表机制)无论如何都在 host helper 里跑,
67+
native 只能削掉外面那薄薄一层派发(占总成本百分之几)。**唯一有意义的场景**是"一个热循环里混着
68+
一个这种操作,导致整个循环没法 native"——那时补它是为了**保住周围 native 循环**,不是加速这次写。
69+
- **触发条件:** profiling 显示真有这种热循环再做;否则不投。
70+
71+
### 2. Deque-pop Option native 融合 —— 难度中,ROI 低–中
72+
- **现状缺口:** `match Deque.pop_front()`**Int 和 Float 都进不了 native**`DequePopFront` 的 dst 是
73+
`Option`,要靠 J3(`native_scalar_replace_options`)把它和后面的 `MatchOption` 融合掉;但 J3 只从
74+
`MakeSome`/`LoadNone` 播种 OPT,**不认 deque-pop 直接产出的 Option**。而且 deque-pop 的 native 形态是
75+
"空则 bail",和 J3"产出 is_some 判别位"的模型对不上。
76+
- **做法:** 扩 J3——把 `DequePopFront/Back` 也当 Option 生产者播种,并协调"bail-on-None"与"is_some 判别"
77+
两种模型(或给 deque-pop 一条独立的融合路径)。`DequePop*Float` 的 helper + lowering 已就绪
78+
(parity 验证过),融合一通它们立刻 native。
79+
- **收益:** Int + Float 一起解锁,但 deque-pop 热循环本就不常见,ROI 有限。
80+
81+
### 3. 轴 B:热堆读 host-call 边界内联 —— 难度中,ROI 中
82+
- **现状:** native 标量已接近 Rust(0.9–2.1×),但**堆读比 Rust 慢 ~13×**(`native_read_heap`),
83+
因为每次 `list_len`/`list_get` 都跨 host-helper 调用边界(§7.1)。
84+
- **做法:** 把最热的只读 helper(`list_len`/`list_get_int/float`/`field_*`)的快路径**内联进生成代码**
85+
(直接读 `TypedVec` 的长度 / 缓冲指针,慢路径再 fall back 到 host call),削掉调用 + 寄存器保存开销。
86+
- **收益:** 对已经 native 的 heap-heavy 代码直接提速;通用、风险中等(要小心 §7.2 的指针 pin 协议与边界检查)。
87+
- **注意:** 这是**轴 B(代码质量)**,不增加覆盖率;和 J0.4 正交,可独立做。
88+
89+
### 4. 嵌套循环 OSR —— ✅**已支持**(原评:难度中–高,ROI 中)
90+
- **核实(2026-06-28):** 这条的"缺口"是**误判**`detect_single_natural_loop` **不在 OSR 管线里**——它只用于
91+
诊断报告(`mod.rs:2512`)和单测。OSR 管线全程用**多循环感知**`detect_natural_loops`
92+
`detect_natural_loop_at``select_osr_candidate_loop``mapped_osr_loop`,**嵌套/兄弟循环本就支持**
93+
- 外层全 native 时:外层 region 更长、score 更高,整块嵌套 `[外头, 外出)` 编成**一个** native loop,
94+
内层 backedge 只是 region 内的 `Jump`(vm-jit 块构造器支持任意可归约 CFG / 多 backedge)。
95+
- 外层体有非 subset op(I/O 等)时:外层落选,`select_osr_candidate_loop` 直接选**内层**循环;
96+
内层 OSR-entry/exit 在每个外层迭代**可重入**(`osr_cache` 命中、`osr_state` 不被一次性消费)。
97+
- **回归测试(新增):** `native_osr_nested_inner_loop_matches_interpreter`(外层全 native)+
98+
`native_osr_nested_inner_loop_with_dirty_outer_matches_interpreter`(脏外层,内层可重入)——
99+
两者均 byte-parity 解释器且 `osr_entries > 0`
100+
- **`string_text` 的真正 gate:** 不是嵌套循环,而是**未落地的 native string-READ helper**
101+
(`string_len`/`string_byte`,已设计未 land,因为之前"没有消费者")。嵌套循环既已支持,这个消费者
102+
现在存在了——若要推进 `string_text`,下一步是 land 这两个只读 helper(§7.2-safe)。
103+
104+
### 5. J0.4 S1–S3:仅分配的 native 堆写 —— ✅**已完成**(难度中–高,ROI 最高,无 J0.1/J0.5 前置)★旗舰
105+
- **完成状态(2026-06-28 核实):** 已落地并验证。10 个 `AllocatesResult` host helper
106+
(`vm-jit/src/lib.rs` 578–756:`ListNewInt` / `StringFromInt` / `StringConcat` / `StringSlice` /
107+
`StringPadLeft` / `StringSplit` / `StringLiteral` / `JsonParse` / `JsonField` / `BytesSlice`)在 S0 的
108+
`JIT_HEAP_RESULTS` 输出表之上 `publish_heap_result` 分配一个**全新无别名** `VmValue`,经
109+
`escaping_output_handle` 逃逸分析(`translate.rs` 802–896 + gate 1837/1880)确保结果真被返回/下游消费。
110+
**`mem_budget` 用 Model-A 精确兜底**(`tier.rs` 868–881:armed 时拒绝 native),所以无需在 helper 内记账、
111+
也无双重计费——比计划里"在边界处计 `mem_budget`"更简洁且同样精确。§7.2 由"bail 即清空输出表 + `JitHeapResultsGuard`"
112+
保证,force-deopt 测试坐实。测试:`native_string_from_int_return_allocates_heap_result` /
113+
`native_string_concat_handle_feeds_string_len` / `native_heap_result_force_deopt_leaves_output_table_empty`
114+
(reg_vm/tests.rs)+ vm-jit lib 83/0。
115+
- **基准注脚:** 如计划所述,alloc-bound 基准多为**已 fold 的字符串**,所以**基准搬针有限**;更广的
116+
"集合构造器就地写"收益落在 **#8(S4)**。S1–S3 的价值是把"一分配就退回解释器"这块通用能力补齐。
117+
- **解锁:** 所有 **alloc-bound** kernel(反复 `String.concat``String.from_int`、建新 Map/List、json 构造,
118+
~138ms)——现在它们因"一分配就退回解释器"而全 parity。
119+
- **做法(effect-after-commit,不是回滚):** 在 S0(已 ship 的堆结果返回 ABI)之上,让 native **分配一个
120+
全新、无别名的值类型**,在 host-helper 边界处**`mem_budget`**,并在**一个不会再 bail 的尾段提交**——
121+
这样任何 bail 都发生在分配之前,没有 double-apply,**因此既不需要 J0.1 也不需要 J0.5**
122+
- **代价:** 中等偏大(~450–650 行 + parity 测试)。**它打破 §7.2(从"无副作用"变"有受控副作用")**,
123+
所以要补:① 替换等价性(replacement-equivalence)论证;② 一条 `mem_budget` 的 differential。
124+
**S1 一落地,`mem_budget` 就必须加入 native eligibility 判定。**
125+
- **建议:** 这是接下来**最该先做**的一项——单点解锁面最大,且不依赖最难的 deopt 重建。
126+
127+
### 6. J0.5:生成代码内 `VmLimits` 记账 —— 难度高,ROI 中(S4 前置)
128+
- **现状:** 现在 native 在 `step_budget`/`mem_budget`/`cancel` **armed 时直接拒绝**(Model-A 兜底)。
129+
- **做法:** 在生成代码里 inline 地 tick/poll 这些预算(`VmLimitsSnapshot` 机制),让 native 在受限下也能跑。
130+
- **为什么重要:** rss 本质是**沙箱**,"受限执行下也要快"是真实场景;同时它是 S4 的前置。
131+
- **风险:** 记账必须跨 bail 边界精确(不重不漏),且不能拖垮热循环。
132+
133+
### 7. 完整 J0.1:内联帧链 + 堆值/live-out 重建 —— 难度很高,ROI 高(地基),S4 前置
134+
- **缺口三块:** ① 内联 leaf 区域内 deopt 的**逻辑帧链**状态图格式;② **堆 payload 的 variant/Result**
135+
在循环后仍活时的**值重建**(把 native 造的复合堆值跨 bail 重新物化);③ live-out 复合值重建(perf)。
136+
- **为什么最硬:** 要在**任意 bail 点**把 native 那套被打散的状态(寄存器、解构的复合值、内联帧链)**完整
137+
翻译回**解释器状态,还要堆停在一致中间态。**这种 bug 输出测不出来**(differential 抓不到),必须为每条
138+
slice 写定向 repro。等价于 HotSpot 的 deopt + scope descriptors + 逃逸对象 rematerialization——业界最硬子系统之一。
139+
- **定位:** 这是"大魔王"的真正核心,S4 与"边写边精确续跑"都建在它上面。
140+
141+
### 8. J0.4 S4:任意别名堆就地写 —— 难度很高,ROI 高/广,需 J0.1 + J0.5
142+
- **解锁:** 真正的"**随便写**"——就地改"调用方也持有引用"的堆,且写后能精确续跑。这是通用 native 分配/写的终态。
143+
- **为什么放最后:** 它是单体式的,**同时**要 J0.1(精确 deopt 重建)和 J0.5(生成代码内记账)。在它们就位前无法安全做。
144+
145+
### 9. async / 挂起函数 native —— 难度很高,类相关,可能不做
146+
- **现状:** 挂起函数(`await`/`task_group`)天然 native-ineligible,整类异步代码走解释器。
147+
- **做法:** native 里支持 park/resume 帧状态——架构性大改,§7.2/deopt 交互复杂。
148+
- **定位:** 收益只覆盖异步密集代码;优先级最低,先评估是否值得。
149+
150+
---
151+
152+
## 一句话收尾
153+
154+
> ~~接下来最该做的是 #5~~**#5(J0.4 S1–S3,仅分配的 native 堆写)已完成**(2026-06-28 核实:
155+
> 10 个 `AllocatesResult` helper 落地 + 测试绿;运行期 401 functional pass / vm-jit 83/0,唯一红是
156+
> 容器内已知坏的 perf-gate 基准)。**接下来该做 #3(轴 B:热堆读 host-call 边界内联)**——独立、广收益、
157+
> 与已 native 的 heap-heavy 代码复利。再往上走 **#4(嵌套循环 OSR)→ #7 J0.1 → #6 J0.5 → #8 S4** 这条
158+
> 精确-deopt 主线(JIT 最复杂的一块)。**#1 / #2** 按需,**#9** 缓做。

0 commit comments

Comments
 (0)