Problem
Calling a builtin function or a builtin method runs 5–15x slower than CPython, even though the CALL specialization family (CallBuiltinO, CallBuiltinFast, CallMethodDescriptor*, …) exists and engages. The specialized handlers still box arguments through multiple heap-allocated Vecs and re-dispatch through the generic call machinery on every call.
This is distinct from #40: that issue covers Python→Python calls (frame lifecycle, eval-loop recursion). This one covers calls into native (Rust) callables, where no frame is involved at all — the entire cost is argument boxing and dispatch.
Measurements
Apple M4 (10 cores), macOS Darwin 25.5.0. RustPython typelock @ 8d7d9d9, cargo build --release; CPython 3.14.2 (Homebrew). 2026-07-23. 54-bench family suite, 3 alternating full-suite passes per interpreter, min-of-3. Per-op = time/iterations; incremental = per-op minus the same run's for _ in range(n): acc += 1 loop baseline (RustPython 75.5 ns/iter, CPython 19.1 ns/iter — 4.0x; anything above 4.0x is family-specific cost).
| benchmark |
ratio |
incremental RustPython |
incremental CPython |
abs(x) loop |
15.2x |
+157 ns/call |
~0 ns |
min(i, 5) loop |
5.9x |
+93 ns |
+9.5 ns |
s.upper() loop |
6.4x |
+68 ns |
+3.1 ns |
s.startswith("hello") loop |
6.5x |
+53 ns |
+0.8 ns |
d.get("a") loop |
5.8x |
+54 ns |
+3.3 ns |
sorted([10 ints]) loop |
6.0x |
— |
— |
len(s) loop (has CallLen) |
4.7x |
+36 ns |
+5.0 ns |
control: isinstance(x, int) (has CallIsinstance) |
4.4x |
+2 ns |
~0 ns |
control: str(i) (has CallStr1) |
2.9x |
|
|
The controls are the point: builtins whose specialization computes the result inline (CallIsinstance, CallStr1) already sit at the interpreter baseline. Everything that goes through the actual native-call path pays ~50–160 ns/call where CPython pays ~1–10 ns. Same structural story as the LOAD_ATTR series (#41/#42): the opcode exists, but the handler still does the expensive general thing.
Mechanism
1. Three heap alloc/free pairs per call
CallBuiltinO (crates/vm/src/frame.rs:5200), CallBuiltinFast (:5234), CallBuiltinFastWithKeywords (:5656), CallBuiltinClass (:5531) and the generic fallback execute_call_vectorcall (:7205) each do:
self.pop_multiple(nargs).collect::<Vec<PyObjectRef>>() — heap alloc;
Vec::with_capacity(effective_nargs) + extend, to prepend self — second heap alloc;
- inside
vectorcall_native_function, FuncArgs::from_vectorcall runs args[..nargs].to_vec() (crates/vm/src/function/argument.rs:151) — third heap alloc, plus one atomic incref per argument for the clone while the original owned Vec is dropped right after (matching decrefs + free).
Each Vec is freed within the same call. pop_multiple's stackref promotion additionally increfs each borrowed argument.
2. vectorcall_native_function clones owned args instead of moving them
crates/vm/src/builtins/builtin_func.rs:230-256 receives args: Vec<PyObjectRef> by value but calls the borrowing FuncArgs::from_vectorcall(&args, …). The owned variant FuncArgs::from_vectorcall_owned (argument.rs:176) already exists and is used by the type constructors (list.rs:793, dict.rs:1564, …). Pure waste of one alloc + 2n atomic RMWs per builtin call, on the hottest path of every CallBuiltin* hit and every generic CALL of a native function.
3. Triple type resolution per call
The specialized handlers prove the callable is an exact PyNativeFunction via downcast_ref_if_exact and check its flags — then discard that knowledge and call the generic PyObject::vectorcall, which re-resolves PyCallable::new (class deref + two atomic slot loads, protocol/callable.rs:49,66-75), checks tracing, and dispatches to vectorcall_native_function, which downcasts the same object again (builtin_func.rs:237) and re-tests the STATIC flag.
CallMethodDescriptorNoargs (frame.rs:5417-5431) already demonstrates the direct pattern — let func = descr.method.func; … func(vm, args) — but even it builds two Vecs to pass a single self argument to a NOARGS method like s.upper().
What CPython does
CALL_BUILTIN_O / CALL_BUILTIN_FAST (Python/bytecodes.c:4250,4285) call the C function pointer directly on the stack argument span — STACKREFS_TO_PYOBJECTS is a pointer cast in the default build — zero heap allocation, zero per-argument refcount traffic (builtins are immortal), and the result steals into the stack slot.
Suggested direction
- Small, immediate: switch
vectorcall_native_function to FuncArgs::from_vectorcall_owned (both branches: move args in; in the needs-self branch build all_args and move that in). Removes one alloc/free and 2n atomic refcount ops per call.
- Handler rewrite: after the guard succeeds, invoke
(native.value.func)(vm, func_args) directly like CallMethodDescriptorNoargs does — skipping PyObject::vectorcall, PyCallable::new, and the second downcast — and build a single Vec (reserve effective_nargs, read self before draining args) so a call costs one allocation. Keep a tracing check in the handler for sys.setprofile c_call events.
- Capstone (CPython parity): a native-call convention that passes a borrowed slice of the evaluation stack (
&[PyObjectRef] + nargs) into the native function, with FuncArgs::bind consuming from a slice cursor — eliminating per-call Vecs entirely. The inline-8 CallArgBuffer pattern already used by CallPyExactArgs (frame.rs:1842) is a template.
Per-argument atomic refcount cost beyond this belongs to #44 (biased/immortal refcounting); this issue is about allocations and redundant dispatch.
Part of #38.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
Problem
Calling a builtin function or a builtin method runs 5–15x slower than CPython, even though the CALL specialization family (
CallBuiltinO,CallBuiltinFast,CallMethodDescriptor*, …) exists and engages. The specialized handlers still box arguments through multiple heap-allocatedVecs and re-dispatch through the generic call machinery on every call.This is distinct from #40: that issue covers Python→Python calls (frame lifecycle, eval-loop recursion). This one covers calls into native (Rust) callables, where no frame is involved at all — the entire cost is argument boxing and dispatch.
Measurements
Apple M4 (10 cores), macOS Darwin 25.5.0. RustPython
typelock@ 8d7d9d9,cargo build --release; CPython 3.14.2 (Homebrew). 2026-07-23. 54-bench family suite, 3 alternating full-suite passes per interpreter, min-of-3. Per-op = time/iterations; incremental = per-op minus the same run'sfor _ in range(n): acc += 1loop baseline (RustPython 75.5 ns/iter, CPython 19.1 ns/iter — 4.0x; anything above 4.0x is family-specific cost).abs(x)loopmin(i, 5)loops.upper()loops.startswith("hello")loopd.get("a")loopsorted([10 ints])looplen(s)loop (hasCallLen)isinstance(x, int)(hasCallIsinstance)str(i)(hasCallStr1)The controls are the point: builtins whose specialization computes the result inline (
CallIsinstance,CallStr1) already sit at the interpreter baseline. Everything that goes through the actual native-call path pays ~50–160 ns/call where CPython pays ~1–10 ns. Same structural story as the LOAD_ATTR series (#41/#42): the opcode exists, but the handler still does the expensive general thing.Mechanism
1. Three heap alloc/free pairs per call
CallBuiltinO(crates/vm/src/frame.rs:5200),CallBuiltinFast(:5234),CallBuiltinFastWithKeywords(:5656),CallBuiltinClass(:5531) and the generic fallbackexecute_call_vectorcall(:7205) each do:self.pop_multiple(nargs).collect::<Vec<PyObjectRef>>()— heap alloc;Vec::with_capacity(effective_nargs)+ extend, to prepend self — second heap alloc;vectorcall_native_function,FuncArgs::from_vectorcallrunsargs[..nargs].to_vec()(crates/vm/src/function/argument.rs:151) — third heap alloc, plus one atomic incref per argument for the clone while the original ownedVecis dropped right after (matching decrefs + free).Each
Vecis freed within the same call.pop_multiple's stackref promotion additionally increfs each borrowed argument.2.
vectorcall_native_functionclones owned args instead of moving themcrates/vm/src/builtins/builtin_func.rs:230-256receivesargs: Vec<PyObjectRef>by value but calls the borrowingFuncArgs::from_vectorcall(&args, …). The owned variantFuncArgs::from_vectorcall_owned(argument.rs:176) already exists and is used by the type constructors (list.rs:793,dict.rs:1564, …). Pure waste of one alloc + 2n atomic RMWs per builtin call, on the hottest path of everyCallBuiltin*hit and every generic CALL of a native function.3. Triple type resolution per call
The specialized handlers prove the callable is an exact
PyNativeFunctionviadowncast_ref_if_exactand check its flags — then discard that knowledge and call the genericPyObject::vectorcall, which re-resolvesPyCallable::new(class deref + two atomic slot loads,protocol/callable.rs:49,66-75), checks tracing, and dispatches tovectorcall_native_function, which downcasts the same object again (builtin_func.rs:237) and re-tests the STATIC flag.CallMethodDescriptorNoargs(frame.rs:5417-5431) already demonstrates the direct pattern —let func = descr.method.func; … func(vm, args)— but even it builds twoVecs to pass a singleselfargument to a NOARGS method likes.upper().What CPython does
CALL_BUILTIN_O/CALL_BUILTIN_FAST(Python/bytecodes.c:4250,4285) call the C function pointer directly on the stack argument span —STACKREFS_TO_PYOBJECTSis a pointer cast in the default build — zero heap allocation, zero per-argument refcount traffic (builtins are immortal), and the result steals into the stack slot.Suggested direction
vectorcall_native_functiontoFuncArgs::from_vectorcall_owned(both branches: moveargsin; in the needs-self branch buildall_argsand move that in). Removes one alloc/free and 2n atomic refcount ops per call.(native.value.func)(vm, func_args)directly likeCallMethodDescriptorNoargsdoes — skippingPyObject::vectorcall,PyCallable::new, and the second downcast — and build a singleVec(reserveeffective_nargs, read self before draining args) so a call costs one allocation. Keep a tracing check in the handler forsys.setprofilec_call events.&[PyObjectRef]+ nargs) into the native function, withFuncArgs::bindconsuming from a slice cursor — eliminating per-callVecs entirely. The inline-8CallArgBufferpattern already used byCallPyExactArgs(frame.rs:1842) is a template.Per-argument atomic refcount cost beyond this belongs to #44 (biased/immortal refcounting); this issue is about allocations and redundant dispatch.
Part of #38.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.