Skip to content

perf(codegen): class-field array push misses direct lowering; typed local cuts cyclic workload CPU 10.64 → 4.90 s #11743

Description

@proggeramlug

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions