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
25 changes: 25 additions & 0 deletions benchmarks/compiler_output/fixtures/scalar_replacement_literals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,29 @@ function scalarReplacementChecksum(): number {
return values[0] + values[1] + values[2] + values.length;
}

class Position {}
class Velocity {}

let aggregateChecksum = 0;

function consumeAggregate(initializers: { component: unknown }[]): void {
for (let i = 0; i < initializers.length; i++) {
const initializer = initializers[i];
if (initializer.component === Position) aggregateChecksum += 1;
if (initializer.component === Velocity) aggregateChecksum += 2;
}
}

function scalarAggregateCallChecksum(): number {
aggregateChecksum = 0;
const iterations = 500_000;
for (let i = 0; i < iterations; i++) {
consumeAggregate([{ component: Position }, { component: Velocity }]);
consumeAggregate([{ component: Position }]);
consumeAggregate([{ component: Velocity }]);
}
return aggregateChecksum;
}

console.log(scalarReplacementChecksum());
console.log(scalarAggregateCallChecksum());
30 changes: 28 additions & 2 deletions benchmarks/compiler_output/workloads.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1349,50 +1349,76 @@ function_contains = "scalarReplacementChecksum"
regex_none = ["@js_object_get_field", "@js_object_set_field", "@js_array_get", "@js_array_set"]
detail = "scalar-replaced literals do not use runtime property or array access helpers"

[[workloads.scalar_replacement_literals.ir_checks]]
name = "known_aggregate_call_no_object_or_array_heap_alloc"
function_contains = "scalarAggregateCallChecksum"
regex_none = ["@js_object_alloc", "@js_object_alloc_with_shape", "@js_array_alloc"]
detail = "known fixed-aggregate consumers scalar-replace descriptor objects and carrier arrays"

[[workloads.scalar_replacement_literals.ir_checks]]
name = "known_aggregate_call_no_property_or_array_runtime_access"
function_contains = "scalarAggregateCallChecksum"
regex_none = ["@js_object_get_field", "@js_object_set_field", "@js_array_get", "@js_array_set"]
detail = "known fixed-aggregate consumers read scalar fields without runtime aggregate helpers"

[[workloads.scalar_replacement_literals.stdout_checks]]
name = "scalar_replacement_checksum"
equals = "17\n"
equals = "17\n3000000\n"
detail = "scalar-replacement fixture stdout checksum"

[workloads.scalar_replacement_literals.native_rep_checks]
function_contains = "scalarReplacementChecksum"
allow_materialization_reasons = ["runtime_api"]

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "scalar_object_literal_store"
source_function = "scalarReplacementChecksum"
expr_kind = "ScalarObjectLiteralInit"
consumer = "scalar_object_field_store"
native_rep_name = "js_value"
access_mode = "none"

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "scalar_object_field_get"
source_function = "scalarReplacementChecksum"
expr_kind = "ScalarObjectFieldGet"
consumer = "scalar_object_field_load"
native_rep_name = "js_value"
access_mode = "none"

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "scalar_object_field_set"
source_function = "scalarReplacementChecksum"
expr_kind = "ScalarObjectFieldSet"
consumer = "scalar_object_field_store"
native_rep_name = "js_value"
access_mode = "none"

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "scalar_array_literal_store"
source_function = "scalarReplacementChecksum"
expr_kind = "ScalarArrayLiteralInit"
consumer = "scalar_array_element_store"
native_rep_name = "js_value"
access_mode = "none"

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "scalar_array_index_get"
source_function = "scalarReplacementChecksum"
expr_kind = "ScalarArrayIndexGet"
consumer = "scalar_array_element_load"
native_rep_name = "js_value"
access_mode = "none"

[[workloads.scalar_replacement_literals.native_rep_checks.require_records]]
name = "known_aggregate_call_scalar_fields"
source_function = "scalarAggregateCallChecksum"
expr_kind = "ScalarAggregateFieldInit"
consumer = "scalar_object_field_store"
native_rep_name = "js_value"
access_mode = "none"
notes_contains = "carrier_array=elided"
min = 4

[workloads.width_aware_buffer_kernels]
source = "benchmarks/compiler_output/fixtures/width_aware_buffer_kernels.ts"
kind = "width_aware_buffer_kernels"
Expand Down
66 changes: 66 additions & 0 deletions benchmarks/issue-8692/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Issue #8692 benchmark evidence

Measured 2026-08-24 on an Apple M1 Max running Darwin 25.5.0. The issue
worktree is based on commit `8224d879a`; the baseline arm uses the same branch
build with `PERRY_TYPED_ARRAY_RMW=0`. Node is v26.5.1. All Perry inputs were
compiled with `PERRY_NO_AUTO_OPTIMIZE=1`, the release runtime, `--no-cache`, and
no PGO.

## Reduced reproduction

The input is `repro.js`: 1,000 `Uint32Array` elements, 2,000 iterations, and
2,000,000 total dynamic indexed updates. Both arms and Node returned checksum
`2000`.

Protocol: three warmups followed by 11 alternating enabled/disabled process
pairs. Times below are medians of the elapsed time measured inside the program.
RSS is the median of three `/usr/bin/time -l` process runs. Binary sizes are
exact bytes.

| Build | Median | Paired wins | RSS | Executable |
| --- | ---: | ---: | ---: | ---: |
| `PERRY_TYPED_ARRAY_RMW=0` | 80.315 ms | — | 13,500,416 B | 14,734,768 B |
| guarded direct RMW | 14.760 ms | 11/11 | 13,565,952 B | 14,734,768 B |
| Node v26.5.1 | 2.620 ms | — | — | — |

The guarded lowering is **5.30x faster** than the disabled baseline. RSS rises
by 65,536 bytes (0.49%) and executable-size delta is zero. This result does not
claim Node parity.

The optimized specialized function's `ta.rmw.load`/`ta.rmw.store` blocks contain
`load i32`, `uitofp`, `fadd`, and `store i32`; they contain none of
`js_typed_array_index_get_dynamic`, `js_dynamic_string_or_number_add`, or
`js_typed_array_index_set_dynamic`. The emitted IR retains a full generic
get/add/set block for precondition failure and a set-only block for post-RHS
invalidation. The native-representation artifact records
`TypedArrayRmw.guarded_direct_uint32_add` as `checked_native`, its exact-index and
bounds guard, the GC-visible receiver reload, and a separate explicit dynamic
fallback record. A compiler test also records the rejection reason
`rhs_not_canonical_number`.

## `ecs-benchmark` simple iteration

Source: `ooflorent/ecs-benchmark` at
`7b53a36606118e8b2a450a2ba4919939c86bbd2e`. Each wrapper imports the repository's
unchanged `simple_iter` case and calls `setup(1000)`. Iteration counts were
calibrated per case to keep Perry samples near the upstream harness's roughly
500 ms target. Node, enabled Perry, and disabled Perry all returned the same
case name, iteration count, and `semantic: "completed"` record.

After one process warmup, seven enabled/disabled pairs ran simultaneously so
both arms saw the same shared-host load; child creation order alternated. RSS
was captured on every measured process. The table reports the median elapsed
time of each arm. The upstream README documents typical run-to-run variance of
1–4%, so the increases below are neutral. More decisively, the final Mach-O
`__text` section is byte-identical between enabled and disabled binaries for all
four cases: this optimization is not selected by these workloads.

| Case | Iterations | Disabled | Enabled | Delta | Disabled / enabled RSS | Size delta | `__text` SHA-256 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| `wolf-ecs/simple_iter` | 250 | 254.540 ms | 259.059 ms | +1.78% | 202,113,024 / 202,244,096 B | 0 B | `b2987e304a8b402d699d80d145a1a1b01391e83591a36e130a4591ffcd9424f6` |
| `becsy/simple_iter` | 15 | 169.343 ms | 167.251 ms | -1.24% | 46,415,872 / 46,432,256 B | 0 B | `8bdfe53cc561edd2d268b5ec61939ca46bd2140f98303471496b81ab7f7302b1` |
| `javelin-ecs/simple_iter` | 15 | 284.079 ms | 287.036 ms | +1.04% | 93,716,480 / 93,749,248 B | 0 B | `ec44c45d2a942a29877795c85b68ed218b13c7c0f0073892ad90437485b5cc46` |
| `piecs/simple_iter` | 1,500 | 736.326 ms | 751.261 ms | +2.03% | 16,171,008 / 16,203,776 B | 0 B | `cd1d310b8ad30a1f90e6dba95ca82dbe4cd8d344188a3868f9744168a7336b8a` |

Exact enabled/disabled executable sizes were respectively 14,949,648;
16,436,632; 26,191,360; and 14,966,232 bytes for the four rows.
32 changes: 32 additions & 0 deletions benchmarks/issue-8692/repro.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Reduced wolf-ecs kernel from https://github.com/PerryTS/perry/issues/8692.
// Compile with `PERRY_NO_AUTO_OPTIMIZE=1` for the ticket's stable A/B protocol.
class Query extends Array {
archetypes = this;
}

class Archetype extends Array {
entities = this;
}

const entityCount = 1_000;
const iterations = 2_000;
const query = new Query();
const archetype = new Archetype();
for (let i = 0; i < entityCount; i++) archetype.push(i);
query.push(archetype);

const components = new Uint32Array(entityCount);

function system(values) {
for (let i = 0, length = query.length; i < length; i++) {
const current = query[i];
for (let j = 0, length = current.length; j < length; j++) {
values[current[j]] += 1;
}
}
}

const start = performance.now();
for (let i = 0; i < iterations; i++) system(components);
const elapsedMs = performance.now() - start;
console.log(JSON.stringify({ elapsedMs, checksum: components[0] }));
7 changes: 7 additions & 0 deletions changelog.d/8692-guarded-uint32-rmw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Performance

- Fuse dynamic-index `Uint32Array` numeric read-modify-write expressions behind
explicit representation, backing-store, exact-index, and bounds guards. The
hot arm keeps the load/add/store in native SSA, while precondition failure and
post-RHS invalidation retain full JavaScript evaluation order and conversion
semantics through explicit generic fallbacks.
7 changes: 7 additions & 0 deletions changelog.d/8703-scalar-aggregate-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Performance

- Scalar-replace short arrays of non-escaping object literals passed to known,
bounded aggregate consumers. Their carrier arrays, descriptor objects,
property/index accesses, and write barriers are now eliminated after
conservative inlining and loop unrolling; identity-observing and otherwise
escaping uses continue to materialize normally.
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/expr/index_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,14 @@ pub(crate) fn lower(
index,
value,
} => {
if let Some(result) =
super::typed_array_rmw::try_lower_guarded_uint32_add(ctx, object, index, value)?
{
if value_discarded {
return Ok(double_literal(0.0));
}
return Ok(result);
}
// Issue #611: `globalThis[<key>] = value` writes to the
// persistent global-this singleton (see the matching IndexGet
// arm above for context).
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2197,6 +2197,7 @@ mod index_set_guarded;
mod index_set_typed_array;
mod instance_misc1;
mod member_update;
mod typed_array_rmw;
pub(crate) use instance_misc1::builtin_parent_reserved_class_id;
pub(crate) mod class_field_inline_guard;
pub(crate) mod element_shape_guard;
Expand Down
Loading
Loading