Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/8839-guarded-ecs-entity-index-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Nested stable-packed ECS loops now reuse an admitted receiver proof on call-free paths and cache
repeated reads of the same entity index. Semantic calls invalidate both facts before execution; the
next indexed access reloads the rooted receiver and revalidates it, with an exact generic read on
failure rather than replaying prior iteration effects. This preserves getters, proxies, exceptions,
mutation, and moving-GC behavior while making the unchanged Wolf `simple_iter` kernel 8.82% faster
in an 11-pair controlled cohort (11/11 wins and 30/30 semantic-oracle passes).
89 changes: 88 additions & 1 deletion crates/perry-codegen/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ pub struct RegCounter {
/// callee is UB, so the registry, not the emitting code, is the single
/// source of truth. `None` for functions built outside a module (tests).
preserve_none_fns: RefCell<Option<Rc<RefCell<HashSet<String>>>>>,
/// Compiler-private validity bits for nested stable-packed loop proofs.
///
/// A guarded inner receiver may keep its raw address across call-free
/// direct-load/store arms. Every actually executed runtime/indirect call
/// that can collect or run semantic heap work dirties the active proofs
/// before control can enter the callee; the next indexed read then reloads
/// its GC root and revalidates. Root bookkeeping and write barriers are
/// proof-preserving: they neither collect nor change JS-visible receiver
/// state. Keeping this at the call-emission choke point makes invalidation
/// path-sensitive: cold IC misses dirty the proof, while their unexecuted
/// hot siblings do not impose a revalidation on every read.
stable_packed_revalidation_slots: RefCell<Vec<String>>,
}

impl RegCounter {
Expand All @@ -101,9 +113,29 @@ impl RegCounter {
eh_unwind_labels: RefCell::new(Vec::new()),
shadow_slot_allocas: RefCell::new(HashSet::new()),
preserve_none_fns: RefCell::new(None),
stable_packed_revalidation_slots: RefCell::new(Vec::new()),
}
}

pub(crate) fn push_stable_packed_revalidation_slot(&self, slot: String) {
self.stable_packed_revalidation_slots
.borrow_mut()
.push(slot);
}

pub(crate) fn pop_stable_packed_revalidation_slot(&self, expected: &str) {
let actual = self
.stable_packed_revalidation_slots
.borrow_mut()
.pop()
.expect("stable-packed revalidation slot stack underflow");
debug_assert_eq!(actual, expected);
}

fn stable_packed_revalidation_slots(&self) -> Vec<String> {
self.stable_packed_revalidation_slots.borrow().clone()
}

/// Install the module's `preserve_nonecc` symbol registry (#8175). Called
/// once per function by `LlModule::define_function`; the shared cell means
/// registration order does not matter — reads happen at call-emission and
Expand Down Expand Up @@ -278,6 +310,26 @@ impl LlBlock {
format!("%r{}", self.counter.next())
}

/// Invalidate every nested packed receiver whose live raw address may be
/// observed after this call. Intrinsics cannot enter Perry or user code.
/// Shadow-stack operations and write barriers are also safe: both families
/// are noncollecting GC bookkeeping and cannot mutate the guarded object's
/// JS-visible shape, prototype, length, or indexed values. Every other
/// direct call stays conservative, including unknown GC-leaf helpers that
/// may perform a semantic write without collecting.
fn dirty_stable_packed_revalidations_before_call(&mut self, direct_callee: Option<&str>) {
if direct_callee.is_some_and(|callee| {
callee.starts_with("llvm.")
|| callee.starts_with("js_shadow_")
|| callee.starts_with("js_write_barrier")
}) {
return;
}
for slot in self.counter.stable_packed_revalidation_slots() {
self.store(crate::types::I1, "1", &slot);
}
}

pub fn next_reg(&self) -> String {
self.reg()
}
Expand Down Expand Up @@ -1245,6 +1297,7 @@ impl LlBlock {
args: &[(LlvmType, &str)],
gc_leaf: bool,
) -> String {
self.dirty_stable_packed_revalidations_before_call(Some(func_name));
// #835 + #846: record this emission against the FFI provenance
// registry. The driver consults the registry after all per-module
// codegen finishes to auto-link the providing crate.
Expand Down Expand Up @@ -1285,6 +1338,7 @@ impl LlBlock {
}

pub fn call_void(&mut self, func_name: &str, args: &[(LlvmType, &str)]) {
self.dirty_stable_packed_revalidations_before_call(Some(func_name));
// #835 + #846: same registry hook as `call` — see comment there.
crate::ext_registry::record_ffi_call(func_name);
self.counter
Expand Down Expand Up @@ -1353,6 +1407,7 @@ impl LlBlock {
args: &[(LlvmType, &str)],
gc_leaf: bool,
) -> String {
self.dirty_stable_packed_revalidations_before_call(None);
let r = self.reg();
// Indirect targets (closures, method pointers) can always throw.
if let Some(lpad) = self.counter.current_eh_unwind_label() {
Expand Down Expand Up @@ -1492,7 +1547,7 @@ fn format_args(args: &[(LlvmType, &str)]) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DOUBLE, I64};
use crate::types::{DOUBLE, I64, PTR};
use std::thread;

fn fresh() -> LlBlock {
Expand Down Expand Up @@ -1611,6 +1666,38 @@ mod tests {
.contains("call double @js_nanbox_string(i64 %handle)"));
}

#[test]
fn active_stable_packed_proofs_are_dirtied_only_by_executed_non_intrinsic_calls() {
let mut b = fresh();
b.counter
.push_stable_packed_revalidation_slot("%proof_dirty".to_string());
b.call(DOUBLE, "llvm.fabs.f64", &[(DOUBLE, "%value")]);
b.call_void("js_shadow_slot_bind", &[(I64, "0"), (PTR, "%root")]);
b.call_void("js_write_barrier_root_nanbox", &[(I64, "%bits")]);
b.call(DOUBLE, "js_dyn_index_get", &[(DOUBLE, "%object")]);
b.call_indirect(DOUBLE, "%callback", &[(DOUBLE, "%value")]);
b.counter
.pop_stable_packed_revalidation_slot("%proof_dirty");
b.call(DOUBLE, "js_dyn_index_get", &[(DOUBLE, "%object")]);

let ir = b.to_ir();
assert_eq!(
ir.matches("store i1 1, ptr %proof_dirty").count(),
2,
"{ir}"
);
assert!(
ir.find("call double @llvm.fabs.f64") < ir.find("store i1 1, ptr %proof_dirty"),
"proof-preserving calls must not dirty the proof: {ir}"
);
let first_dirty = ir.find("store i1 1, ptr %proof_dirty").unwrap();
assert!(
ir.find("@js_shadow_slot_bind").unwrap() < first_dirty
&& ir.find("@js_write_barrier_root_nanbox").unwrap() < first_dirty,
"GC bookkeeping must preserve the proof: {ir}"
);
}

#[test]
fn direct_gc_leaf_call_places_the_callsite_attribute_after_arguments() {
let mut b = fresh();
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/expr/i32_fast_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,11 @@ fn lower_expr_native_u32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result<LoweredValue>
if let Some(lowered) = lower_packed_u32_loop_index_get(ctx, e)? {
return Ok(lowered);
}
if let Expr::IndexGet { object, index } = e {
if let Some(lowered) = super::try_lower_proven_view_checked_u32_load(ctx, object, index)? {
return Ok(lowered);
}
}
if let Some(lowered) = crate::expr::lower_expr_value(ctx, e)? {
let value = match lowered.rep {
NativeRep::I32 | NativeRep::U32 | NativeRep::BufferLen => Some(lowered.value),
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ use inline_dyn_typed_array::lower_inline_dyn_typed_array_get;
/// object: a type-confused, `unbox`ed-pointer-plus-wrong-offset write,
/// not merely a missed optimization.
fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool {
if matches!(object, Expr::LocalGet(id) if ctx.buffer_view_slots.contains_key(id)) {
return true;
}
// This predicate selects only runtime-validated typed-array helpers (or a
// `buffer_view_slots` proof that invalidates on assignment), as documented
// above. Preserve the declared kind as a hint for that dynamic fallback;
Expand Down
Loading
Loading