diff --git a/crates/oxide_cli/src/bench/leak_detect.rs b/crates/oxide_cli/src/bench/leak_detect.rs index 9b8423b3..941951a9 100644 --- a/crates/oxide_cli/src/bench/leak_detect.rs +++ b/crates/oxide_cli/src/bench/leak_detect.rs @@ -450,17 +450,17 @@ pub fn run_mem_closure_dead_leak(kernel: &Arc) -> ExitCode { report_series("closure_dead_leak", "iter", &series) } -/// 校准用例:重源单次 run 后 `full_reset()`,读双 arena 保留锚 -/// (`arena_retained_bytes`,reset 边界换新 Bump 后恰 0),再 50 轮轻源 +/// 校准用例:重源单次 run 后 `full_reset()`,读 session 账目锚 +/// (`session_bytes_allocated`,全量重置清表归零后恰 0),再 50 轮轻源 /// (每轮 `full_reset()`)采 VmRSS,度量 full_reset 归还路径的跨轮内存 /// 增量(斜率 + 窗口总增量),期望走平。 /// /// # 注意事项 /// 不走 VM 池:drop clean 路径与池内归还走同一 `full_reset`,池锁/condvar -/// 噪音与本测面无关。重源瞬时对象全 epoch 分配、不逃逸 global;锚 == 0 为 -/// 确定性主门槛(in-engine,免分配器噪音),RSS 序列为次锚(进程级辅助指标, -/// 峰后无新增量——归还 chunk 可能滞留分配器自由链不回 OS,不要求回落 -/// 峰前)。 +/// 噪音与本测面无关。重源瞬时对象不逃逸 global,全量重置后 session 账目 +/// 清零;锚 == 0 为确定性主门槛(in-engine,免分配器噪音),RSS 序列为次锚 +/// (进程级辅助指标,峰后无新增量——归还 chunk 可能滞留分配器自由链不回 +/// OS,不要求回落峰前)。 pub fn run_mem_pool_high_water(kernel: &Arc) -> ExitCode { const LIGHT_ROUNDS: usize = 50; @@ -484,7 +484,7 @@ pub fn run_mem_pool_high_water(kernel: &Arc) -> ExitCode { return ExitCode::FAILURE; } vm.full_reset(); - let anchor = vm.arena_retained_bytes(); + let anchor = vm.session_bytes_allocated(); eprintln!("[pool_high_water] anchor after full_reset: {anchor} bytes (expect 0)"); if anchor != 0 { return ExitCode::FAILURE; @@ -898,6 +898,25 @@ mod tests { assert_eq!(r2, 1.0); } + /// 全量重置后 session 账目锚恰 0:`mem_pool_high_water` 的确定性主门槛 + /// (锚 == 0)依赖全量重置清表归零,重源 run 后账目非零作对照。 + #[test] + fn session_accounting_zero_after_full_reset() { + let kernel = KernelCore::new(KernelConfig::standard()); + let mut vm = Vm::with_kernel_core(Arc::clone(&kernel)); + let allocator = Allocator::default(); + let program = oxide_parser::parse(&allocator, "var o = { a: 1 }; o").expect("parse"); + let hash = compiled_module_hash(&program); + let module = kernel + .code_forge() + .get_or_insert_with(hash, || Compiler::new().compile(&program)) + .expect("compile"); + vm.run(&module).expect("run"); + assert!(vm.session_bytes_allocated() > 0); + vm.full_reset(); + assert_eq!(vm.session_bytes_allocated(), 0); + } + /// 窗口采样器:正斜率序列出判定且斜率还原正确。 #[test] fn sampler_verdict_on_rising_series() { diff --git a/crates/oxide_vm/src/session_gc.rs b/crates/oxide_vm/src/session_gc.rs index ea3dd123..d6973e3f 100644 --- a/crates/oxide_vm/src/session_gc.rs +++ b/crates/oxide_vm/src/session_gc.rs @@ -593,7 +593,8 @@ impl SessionGc { /// /// # 副作用 /// - `session_object_ptrs` 仅剩存活对象;`session_bytes_allocated` 重算为 - /// 存活对象字节(串/BigInt/cell 分量由各清扫路径自行补回); + /// 存活对象字节(串分量由串清扫补回;BigInt/cell 不在手工账目内, + /// 由 `run_alloc_bytes` 公式按表长单列); /// - `total_*` 与 `last_collection_*` 统计按对象口径更新(绝对赋值, /// 串/BigInt/cell 清扫在其上累加)。 /// @@ -729,15 +730,14 @@ impl SessionGc { pub(crate) fn sweep_session_bigints(&mut self, vm: &mut Vm) -> u64 { let old = vm.gc_state.session_bigint_ptrs.borrow_mut().drain(..).collect::>(); let mut freed = 0u64; - let mut live_bytes = 0usize; let mut live = Vec::with_capacity(old.len()); for ptr in old { if ptr.is_null() { continue; } if self.live_bigints.contains(&ptr) { - // 存活——地址不变,无需重写。 - live_bytes += size_of::(); + // 存活——地址不变,无需重写。BigInt 不入手工账目, + // 存活字节由 `run_alloc_bytes` 公式按表长单列。 live.push(ptr); } else { // SAFETY: ptr 在 session_bigint_ptrs 中但不可达,恰好释放一次。 @@ -748,7 +748,6 @@ impl SessionGc { } } *vm.gc_state.session_bigint_ptrs.borrow_mut() = live; - vm.gc_state.session_bytes_allocated = vm.gc_state.session_bytes_allocated.saturating_add(live_bytes); self.total_bytes_freed = self.total_bytes_freed.saturating_add(freed); self.last_collection_bytes_freed = self.last_collection_bytes_freed.saturating_add(freed); @@ -759,8 +758,9 @@ impl SessionGc { /// `Box::from_raw` 释放。存活 cell 不被搬移——Box 地址稳定——因此无需 /// forwarding 表与根指针重写。在 BigInt 清扫之后运行。 /// - /// cell 不在 `session_bytes_allocated` 账目口径内(与字符串/BigInt 补回口径 - /// 不同),故只累计释放字节统计、不重算存活账目。 + /// cell 不在 `session_bytes_allocated` 手工账目口径内(串分量在账目内, + /// BigInt/cell 由 `run_alloc_bytes` 公式按表长单列),故只累计释放字节 + /// 统计、不重算存活账目。 /// 返回释放的字节数。 pub(crate) fn sweep_session_cells(&mut self, vm: &mut Vm) -> u64 { let old = vm.gc_state.session_cell_ptrs.borrow_mut().drain(..).collect::>(); @@ -881,7 +881,8 @@ impl SessionGc { /// 本路径不跑对象 sweep(sweep 内清位不会执行),故开头与结尾都清位—— /// 维持"mark 之后必由清位收尾"的不变量,strings-only 结束不留残留。 /// - 不搬移对象,`session_bytes_allocated` 保持对象账目,手动扣掉字符串账目后 - /// 由 `sweep_session_strings` 补回存活串字节,与完整收集的最终账目一致。 + /// 由 `sweep_session_strings` 补回存活串字节(BigInt/cell 不在手工账目内), + /// 与完整收集的最终账目一致。 pub(crate) fn collect_strings_only(&mut self, vm: &mut Vm) { let start = Instant::now(); @@ -967,7 +968,8 @@ impl SessionGc { /// /// # 副作用 /// - `session_object_ptrs` 仅剩存活,`session_bytes_allocated` 按存活整体 - /// 重算(对象 + 串 + BigInt;本路径不扫串/BigInt,串 GC 归 strings-only); + /// 重算(对象 + 串;本路径不扫串,串表整体计入,串 GC 归 strings-only; + /// BigInt/cell 不在手工账目内,由公式按表长单列); /// - `gc_watermark` = 当前分配包络 + 阈值增量; /// - 累计/时长统计更新;mark 位全清(收集后无残留)。 pub(crate) fn collect_in_run(&mut self, vm: &mut Vm) { @@ -987,8 +989,9 @@ impl SessionGc { // 死 cell 随死对象出表:存活 cell 经 mark 种子 + 对象边入存活集,此处清扫。 self.sweep_session_cells(vm); - // 整体口径重算:对象分量已由清扫核心写入,此处补串与 BigInt 分量 - // (本路径不扫串/BigInt,按当前表整体计入,与 strings-only 口径一致)。 + // 整体口径重算:对象分量已由清扫核心写入,此处补串分量(本路径不扫 + // 串,串表整体计入,与 strings-only 口径一致;BigInt/cell 不在手工 + // 账目内,由 `run_alloc_bytes` 公式按表长单列)。 let mut string_bytes: usize = 0; for &ptr in &vm.gc_state.session_string_ptrs { if ptr.is_null() { @@ -997,12 +1000,7 @@ impl SessionGc { // SAFETY: ptr 在字符串表登记,收尾前有效。 string_bytes += size_of::() + unsafe { (*ptr).payload_bytes() }; } - let bigint_bytes = vm.gc_state.session_bigint_ptrs.borrow().len() * size_of::(); - vm.gc_state.session_bytes_allocated = vm - .gc_state - .session_bytes_allocated - .saturating_add(string_bytes) - .saturating_add(bigint_bytes); + vm.gc_state.session_bytes_allocated = vm.gc_state.session_bytes_allocated.saturating_add(string_bytes); // 抬高下次触发水位:当前分配包络 + 阈值增量——存活包络超阈值时不每指令 // 重复触发无死对象可回收的白跑。 diff --git a/crates/oxide_vm/src/session_gc/tests.rs b/crates/oxide_vm/src/session_gc/tests.rs index 601ef660..1f78130e 100644 --- a/crates/oxide_vm/src/session_gc/tests.rs +++ b/crates/oxide_vm/src/session_gc/tests.rs @@ -1539,6 +1539,38 @@ fn strings_only_collection_byte_accounting_matches_survivors() { assert!(!vm.gc_state.session_string_ptrs.contains(&dead.as_string_ptr_mut())); } +#[test] +fn run_alloc_formula_lists_bigint_and_cell_via_tables() { + let mut vm = Vm::new(); + let obj = plain_object(&mut vm); + vm.regs[0] = JsValue::from_js_object(obj); + let s = vm.new_string_owned("formula".repeat(4)); + vm.regs[1] = s; + let b = vm.new_bigint(num_bigint::BigInt::from(7_i128)); + vm.regs[2] = b; + // 未根 cell:收集后不可达,随 sweep 出表。 + vm.gc_state.alloc_cell(JsValue::int(9), true); + + // 公式 = 手工账目 + BigInt 表长 × 尺寸 + cell 表长 × 尺寸,三分量两两不相交。 + assert_eq!( + vm.run_alloc_bytes(), + vm.gc_state.session_bytes_allocated + + vm.gc_state.session_bigint_ptrs.borrow().len() * size_of::() + + vm.gc_state.session_cell_ptrs.borrow().len() * size_of::() + ); + // BigInt 不在手工账目内:账目 = 对象(头 + 堆数据,本例无堆区)+ 串。 + let string_bytes = size_of::() + unsafe { (*s.as_string_ptr_mut()).payload_bytes() }; + assert_eq!(vm.gc_state.session_bytes_allocated, size_of::() + string_bytes); + + // 完整收集后:死 cell 出表、存活 BigInt 留表,账目保持对象 + 串口径。 + vm.collect_session_gc(); + assert_eq!(vm.gc_state.session_cell_ptrs.borrow().len(), 0); + assert_eq!(vm.gc_state.session_bigint_ptrs.borrow().len(), 1); + assert_eq!(vm.gc_state.session_bytes_allocated, size_of::() + string_bytes); + // 深采样全量重算 = 轻层公式 + 逐对象堆数据重算(本例无堆区,两值相等)。 + assert_eq!(vm.run_alloc_bytes_full(), vm.run_alloc_bytes() as u64); +} + #[test] fn runtime_collection_below_threshold_is_noop() { let mut vm = Vm::new(); diff --git a/crates/oxide_vm/src/vm/dispatch_loop.rs b/crates/oxide_vm/src/vm/dispatch_loop.rs index 86a45763..1c23ad87 100644 --- a/crates/oxide_vm/src/vm/dispatch_loop.rs +++ b/crates/oxide_vm/src/vm/dispatch_loop.rs @@ -134,7 +134,7 @@ impl Vm { if bytes > self.gc_state.session_bytes_peak { self.gc_state.session_bytes_peak = bytes; } - // 单 run 分配包络高水位:O(1) 三计数器读(双 arena + session 账目)。 + // 单 run 分配包络高水位:O(1) 读(手工账目 + BigInt/cell 表长)。 let alloc = self.run_alloc_bytes(); if alloc > self.gc_state.run_alloc_peak { self.gc_state.run_alloc_peak = alloc; diff --git a/crates/oxide_vm/src/vm/gc_hooks.rs b/crates/oxide_vm/src/vm/gc_hooks.rs index 7102f313..7bc9c59e 100644 --- a/crates/oxide_vm/src/vm/gc_hooks.rs +++ b/crates/oxide_vm/src/vm/gc_hooks.rs @@ -275,45 +275,34 @@ impl Vm { self.gc_state.session_bytes_peak } - /// 双 arena(epoch + session 对象)当前 chunk 已分配字节之和。 - /// - /// # 注意事项 - /// reset 边界读 = 换新 Bump 后空 arena 恰 0(空 chunk 哨兵计 0); - /// run 中途读 = 当前 chunk 用量(非全 arena 口径——run 计量走 - /// [`Self::run_alloc_bytes`],勿据此断言)。 - pub fn arena_retained_bytes(&self) -> usize { - self.epoch.bump().allocated_bytes() + self.gc_state.session_epoch.allocated_bytes() - } - /// 本 run 累计分配字节的高水位:`run_alloc_bytes` 的顶层指令边界 /// 采样上界,run 边界(reset/full_reset)重起算。留存内存观测锚。 pub fn run_alloc_peak(&self) -> usize { self.gc_state.run_alloc_peak } - /// 本 run 累计分配字节:epoch arena + session 对象 arena + session 手工堆 - /// 账目(session 串 + session 对象及其属性向量 + GC 后补回的 BigInt)。 - /// 单次 run 内单调不减(执行期对象不回收、 - /// 串 GC 只降手工堆账目而 arena 不减);run 边界(reset)后重新起算, - /// 供单 run 分配上限判定。 + /// 本 run 累计分配字节:session 手工堆账目(session 对象及其属性向量 + + /// session 串)+ BigInt 表长 × `size_of::` + cell 表长 × + /// `size_of::`。三分量两两不相交(BigInt 与 cell 不在手工账目内), + /// O(1) 读,供单 run 分配上限判定。收集释放后读数回落存活集量级; + /// run 边界(reset/full_reset)后账目与表清零重起算。 /// - /// 注意:手工堆账目只在 promote/字符串分配/GC 回收点更新,执行期对象 + /// 注意:手工堆账目只在对象分配/字符串分配/GC 回收点更新,执行期对象 /// 属性区(元素/属性向量扩容)增长对其不可见——上限判定须配合 /// [`Self::run_alloc_bytes_full`] 的深采样层。 pub(crate) fn run_alloc_bytes(&self) -> usize { - self.epoch.bump().allocated_bytes() - + self.gc_state.session_epoch.allocated_bytes() - + self.gc_state.session_bytes_allocated + self.gc_state.session_bytes_allocated + + self.gc_state.session_bigint_ptrs.borrow().len() * std::mem::size_of::() + + self.gc_state.session_cell_ptrs.borrow().len() * std::mem::size_of::() } - /// 本 run 累计分配字节的全量重算版:base 只取两个 arena 计数器 - /// (epoch + session 对象 arena),手工堆逐一重算——已登记对象的堆数据 - /// (属性/元素向量 + upvalue 列表 + native 状态盒)、session 串、BigInt - /// 与 upvalue cell 的容量。三分量(arena / 对象堆数据 / 串-BigInt-cell) - /// 两两不相交,且不含 session 手工堆账目——无交叠不双计,重算即属性区 - /// 扩容等账目盲区的兜底。 + /// 本 run 累计分配字节的全量重算版:base 与 [`Self::run_alloc_bytes`] 同 + /// 一口径(手工账目 + BigInt/cell 表长),其上逐一重算已登记对象的堆数据 + /// (属性/元素向量 + upvalue 列表 + native 状态盒)容量——重算即属性区 + /// 扩容等账目盲区的兜底。base 的对象分量是最近分配/回收点的账目快照, + /// 重算取当前容量,读数偏高,上限判定偏安全侧。 pub(crate) fn run_alloc_bytes_full(&self) -> u64 { - let mut bytes = (self.epoch.bump().allocated_bytes() + self.gc_state.session_epoch.allocated_bytes()) as u64; + let mut bytes = self.run_alloc_bytes() as u64; for &ptr in self.gc_state.session_object_ptrs.iter() { if ptr.is_null() { continue; @@ -321,12 +310,6 @@ impl Vm { // SAFETY: ptr 来自对象表登记,dispatch 安全点处仍有效。 bytes += SessionGc::object_heap_data_bytes(unsafe { &*ptr }); } - for &ptr in &self.gc_state.session_string_ptrs { - // SAFETY: ptr 来自字符串表登记,收尾前始终有效。 - bytes += (std::mem::size_of::() + unsafe { (*ptr).payload_bytes() }) as u64; - } - bytes += (self.gc_state.session_bigint_ptrs.borrow().len() * std::mem::size_of::()) as u64; - bytes += (self.gc_state.session_cell_ptrs.borrow().len() * std::mem::size_of::()) as u64; bytes } diff --git a/crates/oxide_vm/src/vm_state.rs b/crates/oxide_vm/src/vm_state.rs index ce5b9ee8..ac293375 100644 --- a/crates/oxide_vm/src/vm_state.rs +++ b/crates/oxide_vm/src/vm_state.rs @@ -28,9 +28,10 @@ pub(crate) struct GcState { pub(crate) session_gc: SessionGc, pub(crate) session_object_ptrs: Vec<*mut JsObject>, pub(crate) session_string_ptrs: Vec<*mut JsString>, - /// BigInt 堆 box(`Box`)追踪表。`RefCell` 使 `&self` 的 - /// `convert_immutables` 也能登记新 box。与字符串不同,BigInt 不参与 - /// mark/sweep 回收(值量少),只在 full_reset 统一释放。 + /// BigInt 堆 box 追踪表。`RefCell` 使 `&self` 的分配入口(常量池 + /// `convert_immutables`)也能登记新 box。BigInt 参与 mark/sweep 回收 + /// (死 BigInt 随收集释放,存活字节由 `run_alloc_bytes` 公式按表长 + /// 单列),full_reset 为收尾兜底(表已空时 no-op)。 pub(crate) session_bigint_ptrs: RefCell>, /// upvalue cell(`Box`)追踪表。`RefCell` 使 `&self` 的分配入口也能 /// 登记新 box。cell 独立堆分配、地址稳定,不参与对象搬移(原地 sweep diff --git a/crates/oxide_vm/src/vm_support/tests.rs b/crates/oxide_vm/src/vm_support/tests.rs index 072dc6ab..21e38a58 100644 --- a/crates/oxide_vm/src/vm_support/tests.rs +++ b/crates/oxide_vm/src/vm_support/tests.rs @@ -449,8 +449,8 @@ fn reset_drops_stale_uncaught_value() { assert!(vm.last_uncaught_value.is_none(), "full_reset 应清空未捕获异常侧通道"); } -/// run 边界换新 Bump 后双 arena 保留锚恰 0:重源 run 冲高水位, -/// full_reset 后 epoch/session 两 arena 均空(容量不跨 reset 保留)。 +/// 全量重置后 epoch/session 两 arena 均空:容量不跨 reset 保留。对象 +/// Box 化后 arena 零分配,此不变量是分配包络公式排除 arena 计数器的前提。 #[test] fn full_reset_zeroes_arena_retained() { let mut vm = Vm::new();