Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Perf: specialized builtin/method-descriptor call opcodes still box arguments through Vec+FuncArgs and re-dispatch per call #47

Copy link
Copy link

Description

@youknowone
Issue body actions

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:

  1. self.pop_multiple(nargs).collect::<Vec<PyObjectRef>>() — heap alloc;
  2. Vec::with_capacity(effective_nargs) + extend, to prepend self — second heap alloc;
  3. 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

  1. 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.
  2. 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.
  3. 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.

Reactions are currently unavailable

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

    Morty Proxy This is a proxified and sanitized view of the page, visit original site.