Skip to content

perf(codegen): specialize stable imported object-literal methods #8775

Description

@proggeramlug

Summary

Perry can now specialize imported class prototype methods that capture this (#8693/#8737), but stable methods on imported object-literal adapters still traverse generic method lookup and closure dispatch on every call. Public ECS adapters use exactly this pattern, so the outer calls remain generic and prevent profitable inlining across the adapter boundary.

Add guarded direct specialization/inlining for stable methods on imported object literals (including default exports), with an explicit generic fallback when the receiver shape or own method identity changes.

Self-contained reproduction

adapter.js:

class Store {
  constructor() {
    this.entities = [];
  }

  create(id) {
    const entity = { id, value: 0 };
    this.entities.push(entity);
    return entity;
  }

  add(entity) {
    entity.value += 1;
  }

  destroy(entity) {
    const index = this.entities.indexOf(entity);
    if (index !== -1) this.entities.splice(index, 1);
    return entity.value;
  }
}

export default {
  store: null,
  setup() {
    this.store = new Store();
  },
  createEntity(id) {
    return this.store.create(id);
  },
  addComponent(entity) {
    this.store.add(entity);
  },
  destroyEntity(entity) {
    return this.store.destroy(entity);
  },
};

main.js:

import adapter from "./adapter.js";

adapter.setup();
let checksum = 0;
const iterations = 200_000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
  const entity = adapter.createEntity(i);
  adapter.addComponent(entity);
  adapter.addComponent(entity);
  checksum += adapter.destroyEntity(entity);
}
console.log(JSON.stringify({
  elapsedMs: performance.now() - start,
  checksum,
  remaining: adapter.store.entities.length,
}));

Expected result: checksum: 400000, remaining: 0.

Build with the same release/runtime artifacts and retain --trace llvm --opt-report=json --explain-lowering --no-cache output. The public integration case is ddmills/js-ecs-benchmarks perform-ecs@0.7.8, suite Destroy: the suite and library contexts are imported object literals, and the timed method calls ctx.createEntity, ctx.addPositionComponent, ctx.addVelocityComponent, and ctx.destroyEntity.

Current evidence

On Perry 20a388974d7333ccaec9da9175fbcee54ad49902, the corrected public workload measures 13.609234 us/op versus Node 1.025681 us/op on the quiet Apple M1 protocol: 13.268x slower with exact component-ID and zero-retained-view parity.

A symbolized profile synchronized to the timed loop collected 701 main-thread samples. The imported adapter branches were:

  • createEntity: 317 samples (45.2% inclusive);
  • addPositionComponent: 103 (14.7%);
  • addVelocityComponent: 95 (13.6%);
  • destroyEntity: 42 (6.0%).

Every branch enters js_typed_feedback_native_call_method_by_id / js_native_call_method, handle dispatch, js_native_call_value, and an adapter closure before reaching already-specialized class methods below it. The fresh lowering report selects 10 typed clones but still records 10 generic-method fallbacks, 14 runtime dynamic boundaries, and 232 typed-clone rejections.

#8693 is not this case: it publishes producer-authoritative this capabilities for imported class prototype methods. These receivers are stable imported ordinary object literals whose methods are own function-valued properties.

Proposed direction

  • Publish producer-authoritative shape and own-method identity metadata for eligible imported object literals and their aliases/re-exports.
  • At a proven receiver site, emit a guard for exact receiver/shape, own property descriptor, function identity, and relevant invalidation generation, then call a clone with the correct this binding.
  • Inline small/medium adapter methods after direct-call selection so downstream imported-class specialization, field facts, and aggregate scalar replacement can see through the boundary.
  • Keep generic lookup/call as a cold fallback and explain both selection and rejection in lowering artifacts.

An initial slice can require a module-initialized const/default-export object, static string method names, ordinary data-method properties, and no proxy/accessor in the proven chain.

Semantic constraints

  • Preserve this for concise methods and function-valued properties; distinguish arrows, extracted/rebound methods, call/apply, and bound functions.
  • Invalidate on own-method replacement/deletion/redefinition, descriptor/enumerability changes, receiver/prototype mutation, own shadowing, proxy insertion, module reevaluation, and alias writes.
  • Preserve getters/setters, proxies, exceptions, inheritance/prototype lookup, and cross-module initialization order.
  • Keep receiver/function values properly rooted and reload after collecting calls.

Acceptance criteria

  • Register the two-file reproduction; Node and Perry print checksum: 400000 and remaining: 0, normally and under forced-moving GC.
  • Stable fast arms for createEntity, addComponent, and destroyEntity call direct clones (or inline them) without js_native_call_method_by_id, js_typed_feedback_native_call_method_by_id, js_native_call_value, or generic closure/vtable dispatch.
  • Explicit generic fallback remains and is exercised by method replacement, deletion/recreation, receiver/prototype mutation, extraction/rebinding, accessor, and proxy tests.
  • --explain-lowering identifies the imported object-literal provenance, selected method identity, guards, and fallback.
  • Re-run perform-ecs/destroy and the complete perform-ecs adapter set with exact state parity. On the quiet M1 alternating protocol, require at least a 10% median improvement and 9/11 wins; report generic-dispatch samples, executable size, and RSS.

Related work

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions