Skip to content

perf(codegen): explore a shared runtime callable dispatcher #552

Description

@nahime0

Context

Work on #455 exposed a code-size and compile-time scaling problem around dynamic PHP callables.

A callback stored as mixed can resolve at runtime to a function-name string, closure/first-class descriptor, invokable object, instance method array, or static method array. The current backend therefore emits selection code that inspects the runtime shape and routes the call to a matching descriptor.

The optimization developed alongside #455 shares module-wide callable wrappers, invokers, and immutable descriptor templates. This removes the largest duplicated bodies. However, each dynamic call site still emits its own:

  • runtime tag switch;
  • per-candidate string-name comparison loop;
  • class-ID selection loops for invokable objects and method arrays;
  • argument-container setup and descriptor invocation glue.

For N equivalent dynamic call sites and M callable candidates, the large wrapper/invoker bodies are now approximately O(M), but meaningful selector assembly remains approximately O(N × M).

This is especially visible in compatibility adapters such as the legacy callable overload of session_set_save_handler(), but it is a global call_user_func() / dynamic-call concern rather than a web-only problem.

Goal

Explore a module-wide or runtime-wide callable dispatcher so equivalent dynamic call sites can invoke one shared selector instead of embedding the full candidate-selection loop at every source location.

The common static/direct-call paths must remain direct and should not pay for dynamic dispatch.

Design questions

Potential approaches to evaluate:

  1. Generated module-level dispatcher per argument/signature shape

    • Emit the tag switch and candidate loops once per compatible ABI shape.
    • Call sites only materialize the callback/arguments and call the shared dispatcher.
  2. Table-driven runtime lookup

    • Publish module callable metadata in a compact table.
    • Resolve function names through a hash/index instead of a linear emitted comparison chain.
    • Keep class/method and receiver-capture metadata target-independent where possible.
  3. Hybrid

    • Generated per-shape entry points backed by shared runtime lookup helpers.
    • Preserve specialized handling where by-reference parameters, receiver captures, or return shapes require it.

Questions that need explicit answers:

  • What is the right cache/dispatcher key: visible argument shape, full FunctionSig, boxed argument container, or another ABI-level representation?
  • How should by-reference parameters and writebacks be represented?
  • How do named/spread argument semantics interact with a shared runtime entry?
  • Which descriptor pieces can remain immutable and shared when an instance receiver must be captured per call?
  • How should undefined-function and non-callable diagnostics retain the correct PHP-visible operation name?
  • Is a hash table worthwhile for small candidate sets, or should lookup strategy vary by table size?
  • How do we avoid linking optional callable/runtime support into programs that never use dynamic callables?

Required semantics

The design must preserve all supported PHP callable forms:

  • function-name strings;
  • closures and first-class callables;
  • invokable objects;
  • [$object, 'method'];
  • ['Class', 'method'] and 'Class::method';
  • malformed/non-callable fatal behavior;
  • argument evaluation order, return representation, ownership, and by-reference writebacks.

It must support every target in the supported matrix: macOS AArch64, Linux AArch64, and Linux x86_64.

Suggested benchmark

Add a focused generator/fixture with 1, 10, 100, and 1,000 equivalent dynamic call sites, measured separately for:

  • compile time;
  • codegen time;
  • assembly bytes and line count;
  • number of callable selector/invoker labels;
  • final binary size;
  • runtime throughput for repeated invocation.

Include at least:

  • runtime function-name strings;
  • boxed mixed callbacks;
  • invokable objects;
  • instance/static method arrays;
  • more than one argument/signature shape.

The benchmark must distinguish one call site executed 1,000 times from 1,000 distinct source call sites.

Acceptance criteria

  • Document the chosen dispatcher/table design and ABI ownership rules.
  • Equivalent dynamic call sites no longer emit independent per-candidate selection loops.
  • Statically resolved callable calls retain their current direct/specialized path.
  • All supported callable shapes and fatal paths have regression coverage.
  • Focused tests pass on all supported targets.
  • Scaling measurements demonstrate near-linear small call-site growth rather than repeated candidate-body growth.
  • Programs without dynamic callable use do not emit or link the shared dispatcher.
  • Assembly-comment and ownership/GC policies remain satisfied.

Non-goals

  • Removing call_user_func().
  • Dropping the deprecated-but-supported procedural session_set_save_handler() overload.
  • Replacing direct calls or statically resolved first-class callables with runtime dispatch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:codegenTouches target-aware assembly or backend lowering.area:runtimeTouches runtime helpers, GC, ownership, or bridge runtimes.topic:closuresConcerns closures, callables, or anonymous functions.topic:performancePrimarily concerns performance, latency, or resource usage.type:refactorRestructures code without intended behavior changes.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions