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
27 changes: 27 additions & 0 deletions changelog.d/7916-eager-class-shape-stamping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
### feat(codegen/runtime): stamp canonical class ShapeIds at birth

Compiled class instances now receive the stable ShapeId of their canonical
keys array during allocation instead of waiting for the first by-name property
lookup to stamp them. Module initialization mints one id beside each rooted
`@perry_class_keys_*` global; both the inline bump allocator and the outlined
allocator load that scalar and install it in the header's shape word before the
object is published. Scalar-replaced receivers use the same path when they
materialize.

This is #6759 C3 rung 2 and the next prerequisite for #7916's remaining header
shrink: rung 1 made the shape word valid for class instances, while this rung
makes it present from birth so a guard can depend on it without a lazy-stamp
window. The allocation-time parent id remains only as the ShapeId-exhaustion
fallback; inheritance already comes from the class registry after #7981.

The object representation is deliberately unchanged in this rung: a two-field
literal remains **56 bytes** and `retain` remains **168 MB written for 48 MB of
numeric payload** (3.5x amplification). The next guard-migration/header-layout
rungs consume the invariant established here; this PR does not claim a memory
or wall-clock improvement by itself. Per-object storage added: **0 bytes**.

Regression coverage proves both allocation forms consume the id minted at
module init, and a runtime test reads a fresh class instance's shape word before
any by-name access so lazy self-healing cannot make the test pass vacuously.

Refs #6759 and #7916.
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,7 @@ pub(super) fn compile_closure(
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ pub(super) fn compile_module_entry(
shadow_slot_clears_after_stmt: main_shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down Expand Up @@ -1490,6 +1491,7 @@ pub(super) fn compile_module_entry(
shadow_slot_clears_after_stmt: init_shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ pub(super) fn compile_function(
temp_roots: crate::rooting::TempRootPool::default(),
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ pub(super) fn compile_method(
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down Expand Up @@ -1577,6 +1578,7 @@ pub(super) fn compile_static_method(
shadow_slot_clears_after_stmt,
arena_state_slot: None,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
cached_lengths: HashMap::new(),
bounded_index_pairs: Vec::new(),
packed_f64_loop_facts: Vec::new(),
Expand Down
12 changes: 11 additions & 1 deletion crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use perry_hir::Module as HirModule;
use crate::module::LlModule;
use crate::runtime_decls;
use crate::strings::StringPool;
use crate::types::{LlvmType, DOUBLE, I64};
use crate::types::{LlvmType, DOUBLE, I32, I64};

pub(crate) mod arguments;
mod artifacts;
Expand Down Expand Up @@ -841,6 +841,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
&mut used_class_keys_globals,
);
llmod.add_internal_global(&global_name, I64, "0");
llmod.add_internal_global(
&crate::typed_shape::shape_id_global_name_from_keys_global(&global_name),
I32,
"0",
);

// Build the packed-keys string. Format: each field name
// followed by `\0`. Parent classes contribute their fields
Expand Down Expand Up @@ -1019,6 +1024,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
&mut used_class_keys_globals,
);
llmod.add_internal_global(&global_name, I64, "0");
llmod.add_internal_global(
&crate::typed_shape::shape_id_global_name_from_keys_global(&global_name),
I32,
"0",
);
class_keys_globals_map.insert(c.name.clone(), global_name.clone());
let mut packed_keys = String::new();
let mut total_field_count = c.fields.len() as u32;
Expand Down
16 changes: 16 additions & 0 deletions crates/perry-codegen/src/codegen/string_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,22 @@ pub(super) fn emit_string_pool(
// the evacuation `try_rewrite_value` raw fallback already handle).
let addr_i64 = blk.ptrtoint(&global_ref, I64);
blk.call_void("js_gc_register_global_root", &[(I64, &addr_i64)]);

// #6759 C3 rung 2: mint the canonical ShapeId beside the canonical
// keys array. Every compiled `new C()` path loads this immutable u32
// and writes it into the receiver's shape word at birth. The keys
// global is registered first, so the shape record and every future
// instance refer to the rooted/rewriteable canonical array.
let shape_id = blk.call(
I32,
"js_object_shape_id_for_keys",
&[(I64, &arr), (I32, &fc_str)],
);
let shape_global = format!(
"@{}",
crate::typed_shape::shape_id_global_name_from_keys_global(global_name)
);
blk.store(I32, &shape_id, &shape_global);
}

// Register the parent-class chain for every class with a parent.
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,11 @@ pub(crate) struct FnCtx<'a> {
/// subsequent sites for the same class load from the slot.
pub class_keys_slots: std::collections::HashMap<String, String>,

/// Per-class cached ShapeId global slots, paired one-for-one with
/// [`Self::class_keys_slots`]. Shape ids are scalar metadata rather than GC
/// pointers, so these entry-hoisted copies need no shadow-slot binding.
pub class_shape_slots: std::collections::HashMap<String, String>,

/// Per-arr-local cached `arr.length` slots — populated by
/// `lower_for` when it spots the well-known shape
/// `for (...; i < arr.length; ...) { body }` and proves via
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/gc_call_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect {
// probe gates backstop the audit.
"js_closure_alloc_singleton"
| "js_object_alloc_class_inline_keys"
| "js_object_alloc_class_inline_keys_stamped"
| "js_array_push_f64"
| "js_array_length"
| "js_array_slice_values"
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-codegen/src/lower_call/alloc_hot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const INLINE_SLOW_CALL: &str = "call ptr @js_inline_arena_slow_alloc(";
const INLINE_FAST_BLOCK: &str = "\nalloc.fast";
/// Emitted only by the outlined allocator.
const OUTLINED_CALL: &str = "call i64 @js_object_alloc_class_inline_keys";
/// Rung 2's outlined entry has an explicit ShapeId argument.
const STAMPED_OUTLINED_CALL: &str = "call i64 @js_object_alloc_class_inline_keys_stamped(";
/// One mint per class at module init, never per allocation.
const SHAPE_MINT_CALL: &str = "call i32 @js_object_shape_id_for_keys(";
/// The immutable id is hoisted to the function-entry setup like keys_array.
const SHAPE_GLOBAL_LOAD: &str = "load i32, ptr @perry_class_shape_id_";

const N_ID: u32 = 11;
const WALK_ID: u32 = 700;
Expand Down Expand Up @@ -296,6 +302,11 @@ fn a_self_recursive_function_inlines_its_bump_allocator() {
"the outlined allocator is still emitted for the recursive function's \
only `new` site:\n{ir}"
);
assert!(
ir.contains(SHAPE_MINT_CALL) && ir.contains(SHAPE_GLOBAL_LOAD),
"the inline allocator did not consume the class ShapeId minted at module init; \
newborn instances would keep the allocation-time parent word until a lazy lookup:\n{ir}"
);
}

/// The anti-bloat half. Identical module minus the self-call: one call site, no
Expand All @@ -315,6 +326,13 @@ fn a_cold_straight_line_function_keeps_the_outlined_allocator() {
!ir.contains(INLINE_SLOW_CALL),
"the inline bump allocator reached a cold site:\n{ir}"
);
assert!(
ir.contains(STAMPED_OUTLINED_CALL)
&& ir.contains(SHAPE_MINT_CALL)
&& ir.contains(SHAPE_GLOBAL_LOAD),
"the cold allocation did not pass its module-init ShapeId to the stamped \
outlined allocator:\n{ir}"
);
}

#[test]
Expand Down
52 changes: 45 additions & 7 deletions crates/perry-codegen/src/lower_call/new_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,31 @@
use perry_hir::Class;

use crate::expr::FnCtx;
use crate::types::{I32, I64, I8, PTR};
use crate::types::{I1, I32, I64, I8, PTR};

/// Load the immutable ShapeId paired with a class's canonical keys global.
///
/// As with `class_keys_slots`, cache it in a function-entry alloca: the inline
/// allocation slow path is an opaque runtime call, so LLVM will not reliably
/// hoist the module-global load out of a hot loop by itself. Unlike the keys
/// pointer this scalar is not a GC root and needs no shadow-slot binding.
pub(super) fn load_class_shape_id(
ctx: &mut FnCtx<'_>,
class_name: &str,
keys_global_name: &str,
) -> String {
let shape_slot = if let Some(slot) = ctx.class_shape_slots.get(class_name).cloned() {
slot
} else {
let shape_global =
crate::typed_shape::shape_id_global_name_from_keys_global(keys_global_name);
let slot = ctx.func.entry_init_load_global(&shape_global, I32);
ctx.class_shape_slots
.insert(class_name.to_string(), slot.clone());
slot
};
ctx.block().load(I32, &shape_slot)
}

/// #7469: is the `new` site being lowered inside a **loop body**?
///
Expand Down Expand Up @@ -336,19 +360,21 @@ fn emit_instance_alloc_inner(
s
};
let keys_ptr = ctx.block().load(I64, &keys_slot);
let shape_id = load_class_shape_id(ctx, class_name, &keys_global_name);
ctx.pending_declares.push((
"js_object_alloc_class_inline_keys".to_string(),
"js_object_alloc_class_inline_keys_stamped".to_string(),
I64,
vec![I32, I32, I32, I64],
vec![I32, I32, I32, I64, I32],
));
ctx.block().call(
I64,
"js_object_alloc_class_inline_keys",
"js_object_alloc_class_inline_keys_stamped",
&[
(I32, &cid_str),
(I32, &parent_cid_str),
(I32, &field_count.to_string()),
(I64, &keys_ptr),
(I32, &shape_id),
],
)
} else {
Expand Down Expand Up @@ -455,6 +481,7 @@ fn emit_instance_alloc_inner(
s
};
let keys_ptr = ctx.block().load(I64, &keys_slot);
let shape_id = load_class_shape_id(ctx, class_name, &keys_global_name);

// Inline bump-allocator IR.
let blk = ctx.block();
Expand Down Expand Up @@ -540,10 +567,21 @@ fn emit_instance_alloc_inner(
let oh_word_1: u64 = OBJECT_TYPE_REGULAR | ((cid as u64) << 32);
blk.store(I64, &oh_word_1.to_string(), &oh_addr_1);

// Second 8 bytes: parent_class_id (u32, low) | field_count (u32, high)
// Second 8 bytes: ShapeId (u32, low) | field_count (u32, high).
// Rung 0 removed the last inheritance consumer of this word; the
// parent edge was registered during module init. Keep the old
// parent value only if the process-global ShapeId range was
// exhausted and init returned 0, preserving the lazy fallback.
let oh_addr_2 = blk.gep(I8, &raw, &[(I64, "16")]);
let oh_word_2: u64 = (parent_cid as u64) | ((field_count as u64) << 32);
blk.store(I64, &oh_word_2.to_string(), &oh_addr_2);
let has_shape_id = blk.icmp_ne(I32, &shape_id, "0");
let shape_word = blk.select(I1, &has_shape_id, I32, &shape_id, &parent_cid.to_string());
let shape_word64 = blk.zext(I32, &shape_word, I64);
let oh_word_2 = blk.or(
I64,
&shape_word64,
&((field_count as u64) << 32).to_string(),
);
blk.store(I64, &oh_word_2, &oh_addr_2);

// Third 8 bytes: keys_array pointer. The keys_ptr we loaded
// above is an i64 (carries the ArrayHeader address); store as
Expand Down
75 changes: 39 additions & 36 deletions crates/perry-codegen/src/lower_call/scalar_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,44 +545,47 @@ fn materialize_scalar_receiver(
.and_then(|parent| ctx.class_ids.get(parent).copied())
.unwrap_or(0);
let parent_class_id_str = parent_class_id.to_string();
let (obj_handle, has_stable_keys) =
if let Some(keys_global_name) = ctx.class_keys_globals.get(class_name).cloned() {
let keys_slot = if let Some(slot) = ctx.class_keys_slots.get(class_name).cloned() {
slot
} else {
let slot = crate::expr::entry_init_load_rooted_global(ctx, &keys_global_name, I64);
ctx.class_keys_slots
.insert(class_name.to_string(), slot.clone());
slot
};
let keys_ptr = ctx.block().load(I64, &keys_slot);
ctx.pending_declares.push((
"js_object_alloc_class_inline_keys".to_string(),
I64,
vec![I32, I32, I32, I64],
));
let obj_handle = ctx.block().call(
I64,
"js_object_alloc_class_inline_keys",
&[
(I32, &class_id_str),
(I32, &parent_class_id_str),
(I32, &field_count_str),
(I64, &keys_ptr),
],
);
emit_materialized_scalar_receiver_typed_shape_init(ctx, class_name, &obj_handle);
(obj_handle, true)
let (obj_handle, has_stable_keys) = if let Some(keys_global_name) =
ctx.class_keys_globals.get(class_name).cloned()
{
let keys_slot = if let Some(slot) = ctx.class_keys_slots.get(class_name).cloned() {
slot
} else {
(
ctx.block().call(
I64,
"js_object_alloc",
&[(I32, &class_id_str), (I32, &field_count_str)],
),
false,
)
let slot = crate::expr::entry_init_load_rooted_global(ctx, &keys_global_name, I64);
ctx.class_keys_slots
.insert(class_name.to_string(), slot.clone());
slot
};
let keys_ptr = ctx.block().load(I64, &keys_slot);
let shape_id = super::new_alloc::load_class_shape_id(ctx, class_name, &keys_global_name);
ctx.pending_declares.push((
"js_object_alloc_class_inline_keys_stamped".to_string(),
I64,
vec![I32, I32, I32, I64, I32],
));
let obj_handle = ctx.block().call(
I64,
"js_object_alloc_class_inline_keys_stamped",
&[
(I32, &class_id_str),
(I32, &parent_class_id_str),
(I32, &field_count_str),
(I64, &keys_ptr),
(I32, &shape_id),
],
);
emit_materialized_scalar_receiver_typed_shape_init(ctx, class_name, &obj_handle);
(obj_handle, true)
} else {
(
ctx.block().call(
I64,
"js_object_alloc",
&[(I32, &class_id_str), (I32, &field_count_str)],
),
false,
)
};

for (field, slot) in field_slots {
let value = ctx.block().load(DOUBLE, &slot);
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1027,7 +1027,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
I64,
&[I32, I32, I32, I64],
);
module.declare_function(
"js_object_alloc_class_inline_keys_stamped",
I64,
&[I32, I32, I32, I64, I32],
);
module.declare_function("js_build_class_keys_array", I64, &[I32, I32, PTR, I32]);
module.declare_function("js_object_shape_id_for_keys", I32, &[I64, I32]);
// Inline bump-allocator state accessor + slow path. The codegen
// calls `js_inline_arena_state` once per JS function entry, caches
// the returned pointer in a stack slot, and reads/writes the
Expand Down
14 changes: 14 additions & 0 deletions crates/perry-codegen/src/typed_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,17 @@ pub(crate) fn raw_f64_mask_global_name_from_keys_global(keys_global_name: &str)
.map(|suffix| format!("perry_typed_shape_raw_f64_mask_{}", suffix))
.unwrap_or_else(|| format!("perry_typed_shape_raw_f64_mask_{}", keys_global_name))
}

/// The module-global ShapeId paired with one canonical class keys array.
///
/// Keeping the name derived from the already-unique keys global means aliases
/// and sanitized-name collisions necessarily share the same pair. The id is
/// minted once, immediately after `js_build_class_keys_array`, and loaded by
/// every compiled construction path so class instances arrive birth-stamped
/// instead of waiting for their first by-name lookup (#6759 C3 rung 2).
pub(crate) fn shape_id_global_name_from_keys_global(keys_global_name: &str) -> String {
keys_global_name
.strip_prefix("perry_class_keys_")
.map(|suffix| format!("perry_class_shape_id_{}", suffix))
.unwrap_or_else(|| format!("perry_class_shape_id_{}", keys_global_name))
}
Loading
Loading