Skip to content

Commit 98b9956

Browse files
author
Ralph Küpper
committed
perf(codegen): put the numeric array push's GC bookkeeping behind one live test
The inline array-append tier emitted `js_string_addref_if_heap_string`, `js_gc_note_slot_layout` and a seq_cst load of `PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT` on EVERY element. On `bench/push_num.ts` — 20,000,000 pushes of a double into a `number[]` — all three are dead on all 20M of them. The static proof that retires them cannot be made for the shape that matters: `keep.push(base + j)` is an `Expr::Binary { Add }`, and `expr_produces_non_pointer_bits_by_construction` answers `false` there unconditionally, because `+` is string concatenation for non-numeric operands. This is #7511's answer to the identical problem on class-field stores, applied to the array append: ask the question ONCE inline, on the live bits, and branch over all three calls. The array's half of the proof rides the header test the `nofwd` block already performs — the integrity mask widens from 0x0407 to 0x3C07, so reaching the inline store additionally proves ELEMENT_SHAPE, TYPED_LAYOUT_INTACT and ALL_POINTERS clear, the three states in which `js_gc_note_slot_layout` does real work for a non-pointer value. A guard, not an elision: Perry does not validate declared types, so a `number`-annotated value that is a heap string at runtime takes the guarded arm and records the slot exactly as it always did.
1 parent 1ee158d commit 98b9956

4 files changed

Lines changed: 523 additions & 2 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
### Fixed / Performance
2+
3+
**`arr.push(<number>)` no longer pays three GC-bookkeeping obligations per element.**
4+
5+
The inline array-append tier (`apush.inbounds`) emitted, on *every* element:
6+
`js_string_addref_if_heap_string`, `js_gc_note_slot_layout`, and a seq_cst load
7+
of `PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT` to gate `js_write_barrier_slot`.
8+
On `gc-handoff/bench/push_num.ts` — 20,000,000 pushes of a double into a
9+
`number[]` — all three are dead on all 20M of them.
10+
11+
The static proof that retires them (`array_store_needs_layout_note`
12+
`expr_produces_non_pointer_bits_by_construction`) cannot be made for the shape
13+
that matters. `keep.push(base + j)` is an `Expr::Binary { Add }`, and that arm
14+
answers `false` unconditionally, because `+` is string concatenation for
15+
non-numeric operands. It fires only for a bare canonical-i32 local, which is why
16+
`keep.push(j)` compiles to a materially different loop than `keep.push(base + j)`
17+
does.
18+
19+
This is #7511's answer to the identical problem on class-field stores, applied to
20+
the array append: ask the question ONCE inline, on the live bits, and branch over
21+
all three calls. `emit_may_carry_heap_pointer_check` — already the codegen mirror
22+
of `layout_pointer_bearing_bits` and `decode_heap_addr`, already contract-tested
23+
over the whole 16-bit tag space — is the predicate. The store itself stays
24+
unconditional and outside the branch; only the bookkeeping moves.
25+
26+
The array's own half of the proof rides the header test the `nofwd` block already
27+
performs: the integrity mask widens from `0x0407` to `0x0407 | 0x3800` for a
28+
numeric push, so reaching the inline store additionally proves
29+
`GC_ARRAY_ELEMENT_SHAPE`, `GC_OBJ_TYPED_LAYOUT_INTACT` and
30+
`GC_LAYOUT_ALL_POINTERS` all clear — the three states in which
31+
`js_gc_note_slot_layout` does real work for a non-pointer value. An array in any
32+
of them takes `js_array_push_f64`, which notes the slot exactly as before. That
33+
costs those arrays the inline store and can never cost correctness.
34+
35+
**This is a guard, not an elision.** Perry does not validate declared types, so a
36+
`number`-annotated value that is a heap string at runtime takes the guarded arm
37+
and records the slot exactly as it always did. `the_guarded_arm_still_reaches_
38+
every_call_it_moved` asserts the calls are still emitted, precisely so a future
39+
"simplification" to an outright elision fails here rather than as heap corruption.
40+
41+
Gated on `is_numeric_expr`, so a pointer-pushing loop (`churn`, `tree`,
42+
`push_cls`) emits byte-identical IR and pays nothing for a test it would always
43+
fail.

crates/perry-codegen/src/expr/array_push.rs

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ use crate::types::{DOUBLE, I1, I16, I32, I64, I8};
6666
use super::{
6767
array_store_needs_layout_note, array_store_needs_write_barrier,
6868
emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_with_flags_on_block,
69-
emit_jsvalue_slot_store_with_value_bits_on_block, emit_root_nanbox_store_on_block,
69+
emit_jsvalue_slot_store_with_value_bits_on_block, emit_layout_note_slot_on_block,
70+
emit_may_carry_heap_pointer_check, emit_root_nanbox_store_on_block,
7071
emit_typed_feedback_register_site, emit_write_barrier,
7172
emit_write_barrier_slot_generation_tested, expr_has_numeric_pointer_free_array_layout,
7273
lower_expr, lower_expr_native, nanbox_pointer_inline, raw_f64_layout_fact, unbox_to_i64, FnCtx,
@@ -84,6 +85,142 @@ use super::{
8485
/// expression, never an operand — a consumed `n = arr.push(x)` always
8586
/// computes the real length). When set, the placeholder constant is returned
8687
/// without emitting the call.
88+
/// The `nofwd` admission test for a #7830 numeric push: the historical
89+
/// integrity mask `0x0407` PLUS the three `_reserved` states in which
90+
/// `js_gc_note_slot_layout` does real work for a **non-pointer** value stored
91+
/// into a `GC_TYPE_ARRAY`. Every other state that function can be in is a
92+
/// provable no-op for such a value (see
93+
/// [`emit_numeric_push_store_pointer_tested`]).
94+
///
95+
/// * `0x0407` `FROZEN|SEALED|NO_EXTEND|ARRAY_DESCRIPTORS` — the historical
96+
/// integrity bits, unchanged in meaning and in destination.
97+
/// * `0x0800` `GC_ARRAY_ELEMENT_SHAPE` — a live element-shape proof (#7480).
98+
/// `note_element_store` must CLEAR it when a non-object lands in the array,
99+
/// and that call sits ahead of every early return in `layout_note_slot`.
100+
/// * `0x1000` — `GC_OBJ_TYPED_LAYOUT_INTACT` as `layout_note_slot` reads it
101+
/// (`GC_ARRAY_RAW_F64_HOLES` as `gc::types` writes it for an array; the two
102+
/// share the bit and are disjoint by `obj_type`). Set, it routes into the
103+
/// typed-descriptor probe, whose `slot_index >= slot_count` arm downgrades.
104+
/// * `0x2000` `GC_LAYOUT_ALL_POINTERS` — a non-pointer store into an
105+
/// all-pointer array calls `layout_mark_unknown`, which is a real state
106+
/// change, not a no-op.
107+
///
108+
/// `GC_LAYOUT_SIDE_MASK` is deliberately absent. Skipping the note there leaves
109+
/// a stale set bit over a non-pointer, and `mark_field_into_worklist`
110+
/// re-validates every slot word, so the cost is one rejected visit and never a
111+
/// stranded child — the identical argument `class_field_store_needs_layout_note`
112+
/// already ships.
113+
///
114+
/// Failing this test costs the push its inline store: it takes
115+
/// `js_array_push_f64`, which notes the slot exactly as it always did. So a
116+
/// widening here can only ever be slower, never wrong — the same direction of
117+
/// approximation `emit_may_carry_heap_pointer_check` documents.
118+
///
119+
/// `0x0407 | 0x3800` == `0x3C07` == 15367.
120+
const ARRAY_PUSH_NUMERIC_CLEAN_I16: &str = "15367";
121+
122+
/// #7830 — the inline array append's GC bookkeeping behind ONE live test.
123+
///
124+
/// The `apush.inbounds` store used to pay `js_string_addref_if_heap_string` +
125+
/// `js_gc_note_slot_layout` unconditionally and then an `ldar` on
126+
/// `PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT` for the barrier gate — three
127+
/// cross-crate obligations on *every* element of a `number[]` push loop, where
128+
/// all three are dead. `bench/push_num.ts` is 20M such pushes.
129+
///
130+
/// The static proof that would retire them (`array_store_needs_layout_note` →
131+
/// `expr_produces_non_pointer_bits_by_construction`) cannot be made for the
132+
/// shape that matters: `keep.push(base + j)` is an `Expr::Binary { Add }`, and
133+
/// that arm answers `false` unconditionally because `+` is string concatenation
134+
/// for non-numeric operands. It fires only for a bare canonical-i32 local
135+
/// (`keep.push(j)`), which is why the same loop is ~1.7x faster written that
136+
/// way. This is #7511's answer to the identical problem on class fields: ask
137+
/// the question ONCE inline, on the live bits, and branch over all three.
138+
///
139+
/// Why each obligation is dead when the test says no:
140+
///
141+
/// * `js_string_addref_if_heap_string` is tag-checked and a no-op for every
142+
/// non-`STRING_TAG` value — `emit_may_carry_heap_pointer_check` admits
143+
/// `STRING_TAG`, so a string always takes the guarded arm.
144+
/// * `js_write_barrier_slot` opens with `barrier_child_prologue`, which returns
145+
/// immediately when `decode_heap_addr(child) == 0`. The predicate is a
146+
/// superset of every address that decoder resolves.
147+
/// * `js_gc_note_slot_layout` for a non-pointer value is a no-op in every
148+
/// layout state EXCEPT three, and reaching this block already PROVES all
149+
/// three clear: [`ARRAY_PUSH_NUMERIC_CLEAN_I16`] widens the `nofwd`
150+
/// integrity mask to cover them, so the array's half of the proof costs a
151+
/// wider constant on an `and` that was being emitted anyway, and this block
152+
/// has only the value left to test.
153+
///
154+
/// Gated on `value_is_numeric` at the call site, so a pointer-pushing loop
155+
/// (`churn`, `tree`, `push_cls`) emits byte-identical IR to before rather than
156+
/// paying the predicate for a test it always fails. That also keeps
157+
/// `js_array_note_numeric_write` out of the picture: it is already statically
158+
/// elided for exactly this class of value.
159+
#[allow(clippy::too_many_arguments)]
160+
fn emit_numeric_push_store_pointer_tested(
161+
ctx: &mut FnCtx<'_>,
162+
arr_handle: &str,
163+
value_double: &str,
164+
value_bits_override: Option<&str>,
165+
string_addref_needed: bool,
166+
layout_note_needed: bool,
167+
write_barrier_needed: bool,
168+
) -> (String, String, Option<String>) {
169+
let (length, element_addr, value_bits) = {
170+
let blk = ctx.block();
171+
let length = blk.safe_load_i32_from_ptr(arr_handle);
172+
let length_i64 = blk.zext(I32, &length, I64);
173+
let byte_offset = blk.shl(I64, &length_i64, "3");
174+
let with_header = blk.add(I64, &byte_offset, "8");
175+
let element_addr = blk.add(I64, arr_handle, &with_header);
176+
let element_ptr = blk.inttoptr(I64, &element_addr);
177+
// GC_STORE_AUDIT(BARRIERED): the slot write itself is unconditional;
178+
// only the bookkeeping moves behind the live test below, and the
179+
// barrier's own first test is a subset of that predicate.
180+
blk.store(DOUBLE, value_double, &element_ptr);
181+
let value_bits = value_bits_override
182+
.map(ToOwned::to_owned)
183+
.unwrap_or_else(|| blk.bitcast_double_to_i64(value_double));
184+
(length, element_addr, value_bits)
185+
};
186+
let bookkeeping_idx = ctx.new_block("apush.gc_bookkeeping");
187+
let done_idx = ctx.new_block("apush.gc_bookkeeping.done");
188+
let bookkeeping_label = ctx.block_label(bookkeeping_idx);
189+
let done_label = ctx.block_label(done_idx);
190+
{
191+
let blk = ctx.block();
192+
let may_carry_pointer = emit_may_carry_heap_pointer_check(blk, &value_bits);
193+
blk.cond_br(&may_carry_pointer, &bookkeeping_label, &done_label);
194+
}
195+
ctx.current_block = bookkeeping_idx;
196+
{
197+
let blk = ctx.block();
198+
if string_addref_needed {
199+
blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]);
200+
}
201+
if layout_note_needed {
202+
emit_layout_note_slot_on_block(blk, arr_handle, &length, &value_bits);
203+
}
204+
}
205+
if write_barrier_needed {
206+
// `arr_handle` reached here through the `nofwd` header test, so it is a
207+
// live, non-forwarded GC array user pointer — the precondition for
208+
// reading its header byte. The generation test stays: this arm is
209+
// reached for real pointer children too.
210+
emit_write_barrier_slot_generation_tested(
211+
ctx,
212+
arr_handle,
213+
arr_handle,
214+
&element_addr,
215+
&value_bits,
216+
"apush",
217+
);
218+
}
219+
ctx.block().br(&done_label);
220+
ctx.current_block = done_idx;
221+
(length, element_addr, None)
222+
}
223+
87224
fn emit_array_handle_length(
88225
ctx: &mut FnCtx<'_>,
89226
array_handle: &str,
@@ -423,6 +560,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) ->
423560
let value_is_numeric = is_numeric_expr(ctx, value);
424561
let require_numeric_layout =
425562
value_is_numeric && expr_has_numeric_pointer_free_array_layout(ctx, &array_expr);
563+
// #7830 — the inline append's three GC-bookkeeping calls behind ONE
564+
// live test of the stored bits, exactly #7511's class-field shape.
565+
// See `emit_numeric_push_store_pointer_tested` for why each call is
566+
// dead when the test says "no pointer, no watched layout state",
567+
// and why the gate is `value_is_numeric` rather than unconditional.
568+
let guarded_numeric_bookkeeping = value_is_numeric
569+
&& !declared_all_pointer
570+
&& (layout_note_needed || string_addref_needed || write_barrier_needed);
426571
// #7634: spec order (receiver Reference, then argument) is only
427572
// observable when the argument can rebind the receiver. When it
428573
// can, take the rooted spec-ordered arm; when it cannot — the hot
@@ -764,6 +909,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) ->
764909
// notes the slot); it can never cost correctness.
765910
let admitted_bits = blk.and(I16, &obj_flags, "62599");
766911
blk.icmp_eq(I16, &admitted_bits, "40960")
912+
} else if guarded_numeric_bookkeeping {
913+
// #7830 — the array's half of the guard, folded into the
914+
// integrity test rather than emitted as a second one:
915+
// same `and`, same `icmp`, a wider constant. Reaching
916+
// the inline store now additionally proves the three
917+
// `_reserved` states in which `js_gc_note_slot_layout`
918+
// does real work for a NON-pointer value, so the store's
919+
// guard has only the value left to test. See
920+
// `emit_numeric_push_store_pointer_tested`.
921+
let admitted_bits = blk.and(I16, &obj_flags, ARRAY_PUSH_NUMERIC_CLEAN_I16);
922+
blk.icmp_eq(I16, &admitted_bits, "0")
767923
} else {
768924
// FROZEN(0x1)|SEALED(0x2)|NO_EXTEND(0x4)|ARRAY_DESCRIPTORS(0x400).
769925
let integrity_bits = blk.and(I16, &obj_flags, "1031");
@@ -796,7 +952,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) ->
796952
// between the store and the barrier would run with the
797953
// old→young edge unrecorded. The block is split here rather
798954
// than the call being sunk to the end of the block.
799-
let (length, element_addr, barrier_value_bits) = {
955+
let (length, element_addr, barrier_value_bits) = if guarded_numeric_bookkeeping {
956+
emit_numeric_push_store_pointer_tested(
957+
ctx,
958+
&arr_handle,
959+
&v,
960+
v_bits.as_deref(),
961+
string_addref_needed,
962+
layout_note_needed,
963+
write_barrier_needed,
964+
)
965+
} else {
800966
let blk = ctx.block();
801967
let length = blk.safe_load_i32_from_ptr(&arr_handle);
802968
let length_i64 = blk.zext(I32, &length, I64);

0 commit comments

Comments
 (0)