Skip to content

Perf: Python→Python call overhead is ~15x CPython (eager frame lifecycle + recursive eval loop) #40

Description

@youknowone

Problem

A specialized exact-args Python-to-Python call still has a disproportionate cost relative to RustPython's general interpreter overhead.

def f(x):
    return x

def caller(n):
    acc = 0
    for _ in range(n):
        acc += f(1)
    return acc

def inline(n):
    acc = 0
    for _ in range(n):
        acc += 1
    return acc

Current baseline

Measured on 2026-07-23 after the existing exact-args staging, frame-freelist, untracked-call-frame, and direct-datastack-release work.

Environment and method:

  • Apple M5 Max, 18 CPU cores, 128 GiB RAM, AC power
  • RustPython release build at 6ccc0fe1429711b73df943404f272712e6be7933
  • CPython 3.14.6
  • GC disabled
  • 1,000,000-iteration warmup
  • 5,000,000 iterations per sample
  • 11 sequential samples with alternating call/inline order
  • Values below are medians; incremental overhead is the median paired caller - inline difference
Measurement RustPython CPython RustPython / CPython
acc += f(1) 150.2 ns/iter 18.6 ns/iter 8.1x
acc += 1 51.6 ns/iter 11.9 ns/iter 4.3x
Incremental Python-call overhead 98.6 ns/call 6.7 ns/call 14.8x
Recursive fib(28) 182.1 ms 17.3 ms 10.6x

The no-call loop already has a 4.34x baseline interpreter gap versus CPython, but the incremental call cost is 14.8x. Python calls therefore remain uniquely expensive even after accounting for RustPython's general bytecode-execution gap.

Current implementation state

The following previously identified costs have already been mitigated on the specialized small exact-args path:

  • Arguments are staged in an inline CallArgBuffer for up to eight arguments instead of allocating a Vec per call.
  • Ordinary call frames are created untracked and only join the GC if they escape.
  • Dead frame-object allocations are reused through a per-thread freelist.
  • A non-escaping frame drops localsplus directly on the thread datastack without copying it to the heap.

A current sampling profile did not show libsystem_malloc on-stack in the hot loop. Allocator and GC tracking are no longer the primary explanation for the remaining 98.6 ns.

Remaining costs

1. Eager frame-object lifecycle

Every ordinary call still constructs a full Python-visible Frame object shell through Frame::new(...).into_ref(...). The freelist usually avoids a system allocation after warmup, but each call still initializes, publishes, clears, decrefs, and recycles the object.

The current profile has substantial samples in:

  • Frame::new and PyPayload::into_ref
  • Frame::clear and default_dealloc<Frame>
  • frame freelist push/pop
  • release_datastack_frame

The executing-frame representation should be a plain datastack-resident structure. A Python-visible frame object should be materialized only when observed by sys._getframe, tracing, traceback creation, f_back, or another escape.

2. Per-call reference setup and teardown

prepare_exact_args_frame still takes owned references to the function's code, globals, builtins, and function object, including several atomic reference-count operations. Frame creation also initializes synchronization and bookkeeping fields that are unnecessary for an unobserved leaf call.

3. Frame-chain and recursion bookkeeping

with_frame performs recursion checks, owner transitions, current-frame publication, frame-chain linking, TLS access, atomics, and scopeguard cleanup for every call. Some of this is required for observability, but the common unobserved path should not require a full Python object transition.

4. Recursive Rust eval-loop entry

The specialized path remains:

CallPyExactArgs
  -> invoke_exact_args_slots
    -> run_frame
      -> with_frame
        -> ExecutingFrame::run

Each Python call therefore enters another Rust evaluation loop and consumes native stack. Returning unwinds through Rust calls before the caller's eval loop resumes.

The evaluator should instead push a lightweight interpreter frame, continue in the same eval loop, and pop back to the caller on return. This also decouples Python recursion depth from native Rust stack depth.

Resolution target

This issue is resolved when the call-specific gap is approximately no worse than RustPython's general interpreter gap:

  • Primary close criterion: median incremental Python-call overhead <= 30 ns/call on the M5 Max reference measurement.
  • Normalized close criterion: incremental Python-call overhead <= 4.5x CPython on the same machine and run.
  • Intermediate milestone: <= 60 ns/call, expected after removing most of the eager frame-object lifecycle.
  • Supporting regression checks: the complete small-helper loop is <= 5x CPython, and recursive fib(28) is <= 7x CPython.

For the current run, 6.7 ns * 4.5 ~= 30 ns. With a 30 ns incremental cost, the complete RustPython loop would be about 82 ns/iteration, roughly the same 4.4x gap as the no-call loop. At that point Python calls would no longer carry a disproportionate interpreter penalty.

Suggested implementation sequence

  1. Separate the executing interpreter frame from the Python-visible frame object.
  2. Keep the executing frame on the thread datastack and materialize a frame object lazily on escape.
  3. Borrow immutable function metadata where lifetime rules permit, avoiding common-path reference churn.
  4. Flatten Python call and return into one eval loop.
  5. Preserve slow paths for tracing, monitoring, custom eval-frame hooks, generators/coroutines, and escaped frames.

Part of #38.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions