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
- Separate the executing interpreter frame from the Python-visible frame object.
- Keep the executing frame on the thread datastack and materialize a frame object lazily on escape.
- Borrow immutable function metadata where lifetime rules permit, avoiding common-path reference churn.
- Flatten Python call and return into one eval loop.
- Preserve slow paths for tracing, monitoring, custom eval-frame hooks, generators/coroutines, and escaped frames.
Part of #38.
Problem
A specialized exact-args Python-to-Python call still has a disproportionate cost relative to RustPython's general interpreter overhead.
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:
6ccc0fe1429711b73df943404f272712e6be7933caller - inlinedifferenceacc += f(1)acc += 1fib(28)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:
CallArgBufferfor up to eight arguments instead of allocating aVecper call.A current sampling profile did not show
libsystem_mallocon-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
Frameobject shell throughFrame::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::newandPyPayload::into_refFrame::clearanddefault_dealloc<Frame>release_datastack_frameThe 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_framestill 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_frameperforms 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:
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:
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
Part of #38.