Skip to content
Closed
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
29 changes: 29 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,22 @@ pub(crate) struct StablePackedNumericAccess {
pub object_spill_base: String,
}

#[derive(Clone, Debug)]
pub(crate) struct StablePackedReadCache {
/// The cache is keyed by the scalar loop counter rather than assumed to
/// expire on the back edge. This remains correct through `continue` edges
/// and lets LLVM promote all three slots without relying on block layout.
pub valid_slot: String,
pub counter_slot: String,
/// A boxed JS value. It is not a GC root: any call that could move a
/// pointer dirties the associated proof before entering the callee, and a
/// dirty cache is never loaded.
pub value_slot: String,
/// Compile-time source-order state. The first lowered occurrence only
/// populates the slots; later occurrences emit a runtime hit/miss test.
pub has_producer: bool,
}

#[derive(Clone, Debug)]
pub(crate) struct StablePackedLoopFact {
pub counter_local_id: u32,
Expand All @@ -1678,6 +1694,19 @@ pub(crate) struct StablePackedLoopFact {
/// use, after those temporaries, so none of their runtime loads can leave a
/// stale raw address.
pub revalidate_before_indexed_read: bool,
/// Path-sensitive validity bit for a nested-derived raw receiver. Calls
/// set it before entering the callee; a successful exact revalidation
/// clears it. LLVM promotes the compiler-private alloca to SSA, so the
/// clean hot arm is one branch and no runtime call.
pub revalidation_dirty_slot: Option<String>,
/// Non-root cache paired with `revalidation_dirty_slot`. It is read only
/// on the clean arm; a call dirties the proof before a moving collection,
/// and successful revalidation refreshes this word before clearing it.
pub revalidation_live_raw_slot: Option<String>,
/// One exact `array[counter]` result shared by repeated occurrences in the
/// same source iteration. A hit additionally requires a clean revalidation
/// proof, so observable calls force an exact reread at the next occurrence.
pub repeated_read_cache: Option<StablePackedReadCache>,
pub live_receiver_handle: Option<String>,
/// Admission scanned the complete indexed range and proved every value is
/// an untagged IEEE Number. This is requested only when the indexed value
Expand Down
Loading
Loading