perf: 执行期 session 字符串 GC 阈值回收(超 32MiB 触发,跳过对象 sweep)

new_string_owned 分配前做廉价阈值检查(1 次 usize 比较 + 阈值读取),
超 session_gc_threshold 即调 collect_strings_only:clear_all_marks →
完整 mark(现成)→ sweep_session_strings(现成),跳过对象 sweep 搬移,
builtin 层对象裸指针跨分配点零悬垂面。触发检查在登记之前——返回值
尚未根化,登记后回收会被本次 GC 判死致悬垂。分配前触发亦使 GC 期间
新串仍在栈上未登记,天然安全。

长循环字符串累积场景(REPL 常驻 / test262 长测试 / kernel_batch 复用)
session 内存从无界变为有界(≤ 阈值 + 存活串);bench gc_trigger_count
恢复有效(string_build 由 0 → 2 次触发)。07.2 rope 死节点回收依赖本机制。
This commit is contained in:
2026-08-15 12:12:03 +08:00
parent 24ed868d7a
commit 354ab2f192
5 changed files with 295 additions and 2 deletions
+187 -2
View File
@@ -534,6 +534,56 @@ impl SessionGc {
} }
} }
/// 仅回收 session 字符串:完整 mark(对象只置位不搬移)→ 按存活集清扫字符串。
///
/// 刻意跳过对象 sweep 搬移——builtin/dispatch 层持有跨分配点的 session 对象
/// 裸指针,移动式 sweep 会使其悬垂;对象执行期回收仍只在 reset 统一进行。
/// 字符串每串独立 Box、地址稳定,清扫无需 forwarding 与根重写。
///
/// # 副作用
/// - 清空全部对象 mark 位;按存活集释放死串;累计 `total_collections` 与时长统计。
///
/// # 注意事项
/// - mark 的 DFS 以 `is_gc_marked` 短路,残留 true 会导致后续完整收集漏标,
/// 本路径不跑对象 sweep(sweep 内清位不会执行),必须先 `clear_all_marks`。
/// - 不搬移对象,`session_bytes_allocated` 保持对象账目,手动扣掉字符串账目后
/// 由 `sweep_session_strings` 补回存活串字节,与完整收集的最终账目一致。
pub(crate) fn collect_strings_only(&mut self, vm: &mut Vm) {
let start = Instant::now();
vm_info!("[GC] strings-only cycle #{} start", self.total_collections + 1);
// 清残留 mark 位:防止本次字符串 mark 因历史 true 短路而漏标。
self.clear_all_marks(vm);
// 完整 mark:复用现有实现,产出 live_strings;对象仅置位、不搬移。
self.mark(vm);
// 扣减字符串账目(保留对象账目),再补回存活串字节。
let object_bytes = vm.gc_state.session_object_ptrs.len() * size_of::<JsObject>();
vm.gc_state.session_bytes_allocated = object_bytes;
let freed_bytes = self.sweep_session_strings(vm);
let elapsed = start.elapsed();
self.total_collections += 1;
self.last_collection_duration_us = elapsed.as_micros() as u64;
self.max_collection_duration_us = self.max_collection_duration_us.max(self.last_collection_duration_us);
self.min_collection_duration_us = self.min_collection_duration_us.min(self.last_collection_duration_us);
vm_info!(
"[GC] strings-only cycle #{} end: {} bytes freed, {:.1}ms",
self.total_collections,
freed_bytes,
elapsed.as_secs_f64() * 1000.0,
);
}
pub(crate) fn maybe_collect_strings_only(&mut self, vm: &mut Vm) {
if self.should_collect(vm) {
self.collect_strings_only(vm);
}
}
pub(crate) fn maybe_collect(&mut self, vm: &mut Vm) { pub(crate) fn maybe_collect(&mut self, vm: &mut Vm) {
if self.should_collect(vm) { if self.should_collect(vm) {
self.collect(vm); self.collect(vm);
@@ -647,7 +697,8 @@ mod tests {
} }
#[test] #[test]
fn gc_roots_contains_registers_frames_and_root_roots() { let mut vm = Vm::new(); fn gc_roots_contains_registers_frames_and_root_roots() {
let mut vm = Vm::new();
let root = plain_object(&mut vm); let root = plain_object(&mut vm);
let frame_obj = plain_object(&mut vm); let frame_obj = plain_object(&mut vm);
let saved_this = plain_object(&mut vm); let saved_this = plain_object(&mut vm);
@@ -746,7 +797,8 @@ mod tests {
} }
#[test] #[test]
fn mark_phase_reaches_cycles_and_unreachable_are_unmarked() { let mut vm = Vm::new(); fn mark_phase_reaches_cycles_and_unreachable_are_unmarked() {
let mut vm = Vm::new();
let root = plain_object(&mut vm); let root = plain_object(&mut vm);
let reachable = plain_object(&mut vm); let reachable = plain_object(&mut vm);
let unreachable = plain_object(&mut vm); let unreachable = plain_object(&mut vm);
@@ -1201,4 +1253,137 @@ mod tests {
assert!(after >= before + expected); assert!(after >= before + expected);
} }
fn vm_with_threshold(bytes: usize) -> Vm {
let mut cfg = KernelConfig::minimal();
cfg.set_session_gc_threshold(bytes);
let core = KernelCore::new(cfg);
Vm::with_kernel_core(core)
}
#[test]
fn runtime_threshold_triggers_strings_only_collection() {
let mut vm = vm_with_threshold(1);
// 第一次分配不触发(累计 0 < 阈值),登记后超阈值;后续每次分配前先回收。
let seed = vm.new_string_owned("seed".repeat(16));
let seed_ptr = seed.as_string_ptr_mut();
assert!(vm.gc_state.session_string_ptrs.contains(&seed_ptr));
// 分配前触发回收:seed 无根引用 → 判死释放,返回值本身存活(登记前 GC 不触碰它)。
let returned = vm.new_string_owned("returned".repeat(16));
let returned_ptr = returned.as_string_ptr_mut();
assert!(vm.gc_state.session_gc.total_collections >= 1);
assert!(!vm.gc_state.session_string_ptrs.contains(&seed_ptr));
assert!(vm.gc_state.session_string_ptrs.contains(&returned_ptr));
}
#[test]
fn strings_only_collection_preserves_all_root_kinds() {
// 阈值 1:每次 new_string_owned 分配前自动触发回收,逐步验证各类根的保护。
let mut vm = vm_with_threshold(1);
// 寄存器根:直接持有 session 串。
let reg_str = vm.new_string_owned("reg-root".repeat(16));
vm.regs[0] = reg_str;
// 存活对象属性根:session 对象经 promote 后持有 session 串(分配即触发回收,
// reg_str 仍在寄存器,prop_str 挂到对象后才被下次回收看到)。
let obj = plain_object(&mut vm);
let prop_str = vm.new_string_owned("prop-root".repeat(16));
unsafe {
(*obj).set_prop_at(0, prop_str);
}
let obj_session = vm.promote_object(obj);
vm.regs[1] = JsValue::from_js_object(obj_session);
// 非 session 根对象属性:epoch 根对象(在寄存器)持有 session 串,mark 走
// 非 session 根扫描路径保护。
let epoch_obj = plain_object(&mut vm);
let epoch_str = vm.new_string_owned("epoch-root".repeat(16));
unsafe {
(*epoch_obj).set_prop_at(0, epoch_str);
}
vm.regs[2] = JsValue::from_js_object(epoch_obj);
// 死串:无任何根引用,最后手动触发一轮回收它。
let dead = vm.new_string_owned("dead".repeat(16));
let dead_ptr = dead.as_string_ptr_mut();
vm.maybe_collect_session_strings();
assert!(vm.gc_state.session_string_ptrs.contains(&reg_str.as_string_ptr_mut()));
assert!(vm.gc_state.session_string_ptrs.contains(&prop_str.as_string_ptr_mut()));
assert!(vm.gc_state.session_string_ptrs.contains(&epoch_str.as_string_ptr_mut()));
assert!(!vm.gc_state.session_string_ptrs.contains(&dead_ptr));
// 存活串内容可读,地址稳定。
assert_eq!(unsafe { (*reg_str.as_string_ptr_mut()).as_str() }, "reg-root".repeat(16));
assert_eq!(unsafe { (*prop_str.as_string_ptr_mut()).as_str() }, "prop-root".repeat(16));
assert_eq!(unsafe { (*epoch_str.as_string_ptr_mut()).as_str() }, "epoch-root".repeat(16));
}
#[test]
fn strings_only_collection_does_not_move_objects() {
let mut vm = vm_with_threshold(1);
let obj = plain_object(&mut vm);
let obj_session = vm.promote_object(obj);
vm.regs[0] = JsValue::from_js_object(obj_session);
let before: Vec<_> = vm.gc_state.session_object_ptrs.clone();
let s = vm.new_string_owned("x".repeat(64));
vm.regs[1] = s;
// 对象指针逐一相同:不搬移、不重写根。
assert_eq!(vm.gc_state.session_object_ptrs, before);
assert_eq!(vm.regs[0].as_js_object_ptr(), obj_session);
assert_eq!(vm.session_object_count(), 1);
}
#[test]
fn multiple_strings_only_cycles_keep_objects_alive() {
let mut vm = vm_with_threshold(1);
let obj = plain_object(&mut vm);
let obj_session = vm.promote_object(obj);
let live = vm.new_string_owned("keep".repeat(32));
unsafe {
(*obj_session).set_prop_at(0, live);
}
vm.regs[0] = JsValue::from_js_object(obj_session);
// 连续多轮触发字符串回收:对象 mark 残留位被显式清理,对象与挂载串持续存活。
for _ in 0..5 {
for _ in 0..8 {
let _ = vm.new_string_owned("dead".repeat(64));
}
assert!(vm.gc_state.session_object_ptrs.contains(&obj_session));
assert_eq!(unsafe { (*obj_session).get_prop_at(0) }, live);
}
assert!(vm.gc_state.session_string_ptrs.contains(&live.as_string_ptr_mut()));
}
#[test]
fn strings_only_collection_byte_accounting_matches_survivors() {
let mut vm = vm_with_threshold(1);
let obj = plain_object(&mut vm);
let obj_session = vm.promote_object(obj);
vm.regs[0] = JsValue::from_js_object(obj_session);
let live = vm.new_string_owned("live".repeat(8));
let live_ptr = live.as_string_ptr_mut();
vm.regs[1] = live;
let dead = vm.new_string_owned("dead".repeat(8));
let _ = dead.as_string_ptr_mut();
vm.maybe_collect_session_strings();
// 账目 = 对象头 + 存活串(size_of::<JsString>() + len),死串不再计入。
let expected = size_of::<JsObject>() + (size_of::<JsString>() + unsafe { (*live_ptr).len() });
assert_eq!(vm.gc_state.session_bytes_allocated, expected);
assert!(!vm.gc_state.session_string_ptrs.contains(&dead.as_string_ptr_mut()));
}
#[test]
fn runtime_collection_below_threshold_is_noop() {
let mut vm = Vm::new();
let s = vm.new_string_owned("small".repeat(4));
vm.regs[0] = s;
assert_eq!(vm.gc_state.session_gc.total_collections, 0);
assert!(vm.gc_state.session_string_ptrs.contains(&s.as_string_ptr_mut()));
}
} }
+8
View File
@@ -804,6 +804,14 @@ impl Vm {
self.gc_state.session_gc = session_gc; self.gc_state.session_gc = session_gc;
} }
/// 执行期字符串阈值回收:仅回收 session 字符串(跳过对象搬移)。热路径只在
/// 超阈值后进入,`mem::take` 不承担每次分配的开销。
pub(crate) fn maybe_collect_session_strings(&mut self) {
let mut session_gc = std::mem::take(&mut self.gc_state.session_gc);
session_gc.maybe_collect_strings_only(self);
self.gc_state.session_gc = session_gc;
}
/// 只读访问 session GC 的统计(回收次数、存活/死亡对象数、释放字节等)。 /// 只读访问 session GC 的统计(回收次数、存活/死亡对象数、释放字节等)。
pub fn session_gc_stats(&self) -> &SessionGc { pub fn session_gc_stats(&self) -> &SessionGc {
&self.gc_state.session_gc &self.gc_state.session_gc
+11
View File
@@ -363,8 +363,19 @@ impl Vm {
} }
/// 同 `new_string`,但以 move 接收 `String`,避免一次克隆。 /// 同 `new_string`,但以 move 接收 `String`,避免一次克隆。
///
/// # 副作用
/// - 累计 session 字符串字节超阈值时,触发一次仅字符串的 GC(对象不搬移)。
///
/// # 注意事项
/// - 触发检查在登记之前:返回值尚未写入任何执行根,若登记后回收会被本次 GC
/// 判死并释放,返回即悬垂;此时 `s` 仍在栈上、未被登记,GC 不会触碰它。
/// - 热路径仅 1 次 usize 比较 + 阈值读取,超限才进回收路径。
pub fn new_string_owned(&mut self, s: String) -> JsValue { pub fn new_string_owned(&mut self, s: String) -> JsValue {
let len = s.len(); let len = s.len();
if self.gc_state.session_bytes_allocated >= self.kernel_core.config().session_gc_threshold {
self.maybe_collect_session_strings();
}
let ptr = Box::into_raw(Box::new(JsString::new(s))); let ptr = Box::into_raw(Box::new(JsString::new(s)));
self.gc_state.session_string_ptrs.push(ptr); self.gc_state.session_string_ptrs.push(ptr);
self.gc_state.session_bytes_allocated += std::mem::size_of::<JsString>() + len; self.gc_state.session_bytes_allocated += std::mem::size_of::<JsString>() + len;
+81
View File
@@ -0,0 +1,81 @@
use oxide_compiler::compiler::Compiler;
use oxide_kernel::kernel::{KernelConfig, KernelCore};
use oxide_parser::Allocator;
use oxide_vm::vm::Vm;
fn compile(source: &str) -> oxide_bytecode::module::CompiledModule {
let allocator = Allocator::default();
let program = oxide_parser::parse(&allocator, source).expect("parse");
Compiler::new().compile(&program).expect("compile")
}
fn vm_with_threshold(bytes: usize) -> Vm {
let mut config = KernelConfig::minimal();
config.set_session_gc_threshold(bytes);
Vm::with_kernel_core(KernelCore::new(config))
}
/// 长循环拼接产生海量 session 字符串:执行期自动触发仅字符串 GC,
/// 结果正确且 session 字节有界(不触发则累计 ~20 MiB 远超低阈值)。
#[test]
fn long_string_loop_triggers_runtime_gc_and_bounds_session() {
let mut vm = vm_with_threshold(4096);
let module = compile("var s; for (var i = 0; i < 500000; i++) { s = 'str' + i; } s.length");
let result = vm.run(&module).expect("run");
assert_eq!(format!("{}", result), "9");
assert!(vm.session_gc_stats().total_collections > 0, "执行期应触发字符串 GC");
assert!(
vm.session_bytes_allocated() < 1_000_000,
"session 字节应受阈值约束,实际 {}",
vm.session_bytes_allocated()
);
}
/// 拼接结果写 global(逃逸根),跨 run 后触发执行期 GC 仍可读——
/// 验证非会话存活串不因执行期回收丢失。
#[test]
fn session_string_survives_across_runs_with_runtime_gc() {
let mut vm = vm_with_threshold(4096);
let first = compile("globalThis.kept = 'he' + 'llo'; 0");
vm.run(&first).expect("run1");
let second = compile("var s; for (var i = 0; i < 500000; i++) { s = 'y' + i; } globalThis.kept");
let result = vm.run(&second).expect("run2");
let text = vm.lookup_str(result).expect("kept 应为字符串").to_string();
assert_eq!(text, "hello");
assert!(vm.session_gc_stats().total_collections > 0, "run2 应触发执行期字符串 GC");
}
/// reset(轻量重置)保留 session 字符串:跨 eval 存活语义不被执行期 GC 破坏。
#[test]
fn reset_preserves_session_strings_after_runtime_gc() {
let mut vm = vm_with_threshold(4096);
let first = compile("globalThis.kept = 'x' + 'y'; 0");
vm.run(&first).expect("run1");
vm.reset();
let second = compile("globalThis.kept");
let result = vm.run(&second).expect("run2");
let text = vm.lookup_str(result).expect("kept 应为字符串").to_string();
assert_eq!(text, "xy");
}
/// full_reset 清空 session 内存:执行期回收释放过的死串不与完全重置的
/// 整体释放路径重复释放(无双重释放崩溃),重置后引擎可继续运行。
#[test]
fn full_reset_clears_session_after_runtime_gc() {
let mut vm = vm_with_threshold(4096);
let first = compile("var s; for (var i = 0; i < 200000; i++) { s = 'a' + i; } s.length");
vm.run(&first).expect("run1");
assert!(vm.session_gc_stats().total_collections > 0, "run1 应触发执行期字符串 GC");
vm.full_reset();
assert_eq!(vm.session_bytes_allocated(), 0, "full_reset 后 session 字节应为 0");
let second = compile("'ok' + '!'");
let result = vm.run(&second).expect("run2");
let text = vm.lookup_str(result).expect("拼接结果应为字符串").to_string();
assert_eq!(text, "ok!");
}
+8
View File
@@ -0,0 +1,8 @@
var ITERATIONS = 1000000;
var sum = 0;
for (var i = 0; i < ITERATIONS; i++) {
var s = "str" + i;
sum += s.length;
}
sum