Repository navigation
perf(codegen): class-field array push misses direct lowering; typed local cuts cyclic workload CPU 10.64 → 4.90 s #11743
Description
Problem and demonstrated opportunity
A declared DocNode[] class field loses the benefit of existing direct array-call lowering: node.children.push(makeTree(...)) uses js_typed_feedback_native_call_method_by_id, while explicitly binding the same array to a typed local unlocks direct lowering.
Measured 2026-10-01 on macOS arm64, Perry main d40ed1a47019bcae547819ebb5756bcd96ba1057 (v0.5.1655), with matching compiler and runtime archives. This snapshot already includes #11645. These are diagnostic probes; no compiler/runtime fix was applied and the original comparison remains unchanged.
class DocNode {
children: DocNode[] = [];
}
// Original recursive construction:
node.children.push(makeTree(depth - 1, seed * 4 + i + 1, node));
// Diagnostic probe: bind once after constructing node, before the loop:
const children: DocNode[] = node.children;
children.push(makeTree(depth - 1, seed * 4 + i + 1, node));| Arm | Median CPU seconds | Median peak RSS MiB |
|---|---|---|
| Original/default | 10.64 | 131.63 |
| Typed array local/default | 4.90 | 131.55 |
Three sequential repeats per arm; CPU = child user + system time from wait4, RSS = peak resident memory. Every run matched the original full stdout and exit status, including parent identity and closure/cache checks. Default, nursery-8, nursery-4 and typed-local arms rotated order across three rounds; the combined arm ran afterward. Same constants, node count and 1 GiB limit.
Three 8-second external stack captures at 1 ms intervals (19,648 main-thread observations) put 55.70% of samples through js_typed_feedback_native_call_method_by_id; all sampled calls originate in makeTree child-array pushes. Nested js_native_call_method accounts for 50.89%. These are inclusive stack observations, including actual push and triggered GC, not isolated dispatch overhead. Original/typed-probe disassembly confirms the lowering change.
Scope
Related to #10505 (generic dispatch overhead) and #5497 (type-directed specialization), but this issue is the compiler-side opportunity: preserve array receiver information through class-field method calls or provide an equivalent guarded fast path. The exact compiler pass losing the proof has not been traced. Inspect HIR/class-field typing, codegen type_analysis/predicates.rs, and the native-call bridge before assigning a cause.
The typed-local probe also removes repeated field reads. It demonstrates an opportunity, not a proof that the entire time difference is dispatch alone or that arbitrary property reads can be hoisted.
Acceptance
- The original class-field spelling reaches the appropriate existing guarded array fast path, with checked outputs and a repeatable CPU improvement on this workload; a typed local is no longer needed to unlock it.
- Preserve receiver and method evaluation order, getters, mutations during argument evaluation, own/prototype overrides, array subclasses, and moving-GC roots/write barriers. Existing related correctness work: arr.push(f()) / arr.push(...g()) evaluate the receiver AFTER the argument, so a reassigning argument pushes onto the wrong array #7634, An array that takes an own named property records nothing in its header that the inline push tier can test — GC_ARRAY_NAMED_PROPS is only set when a reserve is created, OBJ_FLAG_ARRAY_DESCRIPTORS gates the fallback table, so
const a = [1]; a.push = fnarms neither #11021, Patched built-in prototype methods are ignored: Array.prototype.push / Map.prototype.get / Function.prototype.bind = f never calls f #11394. - Include emitted-IR coverage for the original spelling and semantic negative cases, plus allocating recursive arguments under moving-GC stress. Report CPU and RSS separately; this probe does not lower RSS materially.
Reproducer
Compile/run this unchanged workload and compare against the one-local rewrite above, with the same compiler/runtime artifacts. There are no explicit GC calls in the original workload.
Complete cyclic workload
const CYCLIC = true;
const EPOCHS = 96;
const BATCH = 128;
const DEPTH = 5;
const WINDOW = 8;
// Reference nodes: child-to-parent cycles are observed during every traversal.
const TEXT = 'A collaborative document revision contains paragraphs, comments, edits, and history. '.repeat(3);
class DocNode {
id: number;
text: string;
children: DocNode[];
parent: DocNode | null;
constructor(seed: number, parent: DocNode | null) {
this.id = seed % 100003;
this.text = TEXT + seed;
this.children = [];
this.parent = CYCLIC ? parent : null;
}
}
function makeTree(depth: number, seed: number, parent: DocNode | null): DocNode {
const node = new DocNode(seed, parent);
if (depth > 0) {
for (let i = 0; i < 4; i++) {
node.children.push(makeTree(depth - 1, seed * 4 + i + 1, node));
}
}
return node;
}
function scan(node: DocNode): number {
let value = node.id + node.text.length + node.text.charCodeAt(node.text.length - 1);
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (CYCLIC && child.parent !== node) throw new Error('parent identity mismatch');
if (!CYCLIC && child.parent !== null) throw new Error('unexpected parent');
value += scan(child);
}
return value;
}
function capture(root: DocNode): () => number {
return () => scan(root);
}
// A bounded revision cache keeps old graphs and closures alive across churn.
const roots: DocNode[] = [];
let latestRoot: DocNode | null = null;
let readLatest: () => number = () => 0;
let checksum = 0;
let treesBuilt = 0;
let nodesBuilt = 0;
function nodeCount(depth: number): number {
let count = 1;
for (let i = 0; i < depth; i++) count = count * 4 + 1;
return count;
}
function build(depth: number, seed: number, parent: DocNode | null): DocNode {
treesBuilt++;
nodesBuilt += nodeCount(depth);
return makeTree(depth, seed, parent);
}
function epoch(index: number, retain: boolean): void {
for (let i = 0; i < BATCH; i++) {
const root = build(DEPTH, (index * BATCH + i) % 4000, null);
checksum += scan(root);
if (retain && i === 0) {
const slot = index % WINDOW;
if (roots.length < WINDOW) roots.push(root);
else roots[slot] = root;
latestRoot = root;
readLatest = capture(root);
}
}
for (let i = 0; i < roots.length; i++) {
// An older cached object now points at newly allocated children.
roots[i].children[0] = build(DEPTH - 1, index * 13 + i, roots[i]);
checksum += scan(roots[i]);
}
}
function verifyCapture(): void {
if (latestRoot !== null && scan(latestRoot) !== readLatest()) {
throw new Error('closure/cache identity mismatch');
}
checksum += readLatest();
}
// Fill, churn, release, then reuse. No forced GC or runtime-specific API.
function checkpoint(phase: string, index: number): void {
verifyCapture();
console.log('phase=' + phase + ' epoch=' + index + ' checksum=' + checksum
+ ' roots=' + roots.length + ' trees=' + treesBuilt + ' nodes=' + nodesBuilt);
}
for (let i = 0; i < WINDOW; i++) {
epoch(i, true);
checkpoint('fill', i);
}
for (let i = WINDOW; i < WINDOW + EPOCHS; i++) {
epoch(i, true);
checkpoint('churn', i);
}
roots.length = 0;
latestRoot = null;
readLatest = () => 0;
checkpoint('released', WINDOW + EPOCHS);
for (let i = WINDOW + EPOCHS; i < WINDOW + EPOCHS * 2; i++) {
epoch(i, true);
checkpoint('reuse', i);
}
roots.length = 0;
latestRoot = null;
readLatest = () => 0;
checkpoint('released-again', WINDOW + EPOCHS * 2);
for (let i = WINDOW + EPOCHS * 2; i < WINDOW + EPOCHS * 2 + WINDOW; i++) {
epoch(i, false);
checkpoint('drain', i);
}
console.log('done checksum=' + checksum + ' nodes=' + nodesBuilt);Evidence in the project-comparison workspace: demo/results/stress/perry-profile/summary.md, profile-manifest.json, cpu-summary.json, experiment-summary.json, experiments.json, gc-diagnostics.stderr, nursery-4-gc-diagnostics.stderr, census.jsonl, vmmap-*.txt, and the original/typed-receiver makeTree disassemblies. These paths are local artifacts, not publicly hosted links.
Activity