Skip to content

perf(ffi): hold a Python call's handles inline - #560

Merged
kixelated merged 5 commits into
mainfrom
quest/m3/ffi-python-calls
Nov 4, 2026
Merged

kixelated merged 5 commits into
mainfrom
quest/m3/ffi-python-calls

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m3/ffi-python-calls.md.

Problem

#407 measured a call on an object through the exporter's Python at about 125 ns, against 63 ns for BoltFFI's group.sequence() in bench/ffi/README.md.

Approach

Profiled track.is_closed() and group.demand().sequence() with perf on this host. About a quarter of the native time went to the call's Scope:

  • Every held handle was pushed to a heap Vec, so each call on an object did a malloc and a free.
  • Dropping the scope drained seven Vecs, mostly empty, through out-of-line Drain calls.

The fix, in runtime/python.rs:

  • held is now a SmallVec<[(Obj, bool); 4]>. The flag records whether the call moves the value out, which replaces the separate moved list.
  • The scope pops rather than drains.
  • The generated crate gains smallvec under its python feature. moq's lockfile already has it.

moq/bench/export.py now also times a u64 getter the way the BoltFFI comparison timed group.sequence(): a bound method called in a loop. Its comment explains the remaining gap.

Measured

The host is an AMD Ryzen 9 5900XT (32 threads) under load average 13 to 36, so these are medians of five interleaved runs, with min to max. BoltFFI 0.31.1's Python was rebuilt from moq-dev/moq@0f986d9 and timed in the same runs.

u64 getter, ns per call median spread
BoltFFI group.sequence() 151 148 to 159
export, before 124 122 to 134
export, after 104 103 to 111

As a measurement only, the base build with its GIL release removed ran at a median of 81 ns (72 to 96). The base build ran at 122 ns (114 to 164) in the same runs.

On the full export.py, three interleaved runs, before and after:

  • Handle call: 134 → 107 ns.
  • Record return: 206 → 187 ns.
  • Stamped frame: 724 → 640 ns.

The quest's 63 ns came from a different CPU, an Intel Core Ultra 7 270K Plus. On this host, in the same run, BoltFFI's call is the slower one. BoltFFI's glue builds the attribute name "_handle" and hashes it on every call, through PyObject_GetAttrString.

The export's remaining cost over a GIL-holding call is the GIL release, about 40 ns here. #407 kept the release because the glue can't prove that a call never blocks on a lock an upcall holds. BoltFFI doesn't release it.

Impact

  • Public API: none. The generated crate's manifest adds an optional smallvec dependency under python.

Alternatives

  • Hold the GIL for calls: saves about 40 ns here. Rejected, as in feat(ffi): generate Python bindings as a CPython extension #407: a call that blocks on a lock held by a Rust thread calling Python back would deadlock.
  • Native methods on the classes (PyType_FromSpec method tables): would drop the Python wrapper frame, about 25 ns. Dropped: it changes how the generated classes are built, and BoltFFI keeps a Python wrapper too.
  • Moving Scope's rarely used lists behind a lazy Box: worth about 5 ns more by the profile. Dropped as not worth the extra code.

Follow-ups

None.

Review

  • Reviewer: quest review, Codex gpt-6.1-sol at medium effort, round 1, at head 95c0739. No findings.
  • The head being landed, 257f0ed, adds only conflict-free merges of origin/main.

Checks run locally: just ci on the merged head (ffi lint, and 21 fixture tests driving C, C++, Kotlin, Python, Swift, Go, and Dart), and just ffi moq on every language. Not run: just ffi android, which needs a device.

🤖 Generated with Claude Code

kixelated and others added 4 commits November 3, 2026 16:24
A call's Scope pushed each handle it held to a heap Vec and drained seven
mostly empty Vecs on drop. Hold handles in a SmallVec with whether the call
moves each, and pop rather than drain. bench/export.py times a u64 getter
as the BoltFFI comparison did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Python's call cost perf(ffi): hold a Python call's handles inline Nov 4, 2026
@kixelated
kixelated marked this pull request as ready for review November 4, 2026 00:50
@kixelated
kixelated enabled auto-merge November 4, 2026 00:51
@coderabbitai

coderabbitai Bot commented Nov 4, 2026

Copy link
Copy Markdown

Review in Change Stack →

Finishing Touches
Simplify code
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@kixelated
kixelated added this pull request to the merge queue Nov 4, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Inline the call's held handles (ffi/runtime/python.rs): Scope.held is now a SmallVec<[(Obj, bool); 4]>, with the bool replacing the separate moved vec, so a typical call does no allocation and the scope drains no lists on drop.
  • smallvec under python (ffi/src/glue.rs): the generated crate's manifest adds an optional smallvec dependency gated into the python feature; ffi/Cargo.lock needed no change as smallvec 1.16.4 is already resolved there.
  • A u64 getter in the bench (ffi/moq/bench/export.py): times a bound sequence method in a loop the way the BoltFFI comparison timed group.sequence(), and the footer reports it.
  • Quest bookkeeping: quest/m3/ffi-python-calls.md is done and deleted, and its line removed from quest/m3/README.md.

I traced the handle lifecycle for equivalence with the old two-vec scheme: hold pushes (Obj::borrowed, moved) and bumps holds; done() closes moved handles in the same relative insertion order as the old moved.drain(..), idempotently, and can't free early because holds is at least 1; the async glue runs done() before rt::start, whose take_held() carries the pairs into the future, which future_close releases exactly as before. The pop-over-drain changes in done()/Drop reverse iteration order, but the popped wakers, lents, and gift objects are independent of each other, so nothing ordering-dependent exists. (Written by GLM)

Merge-queue checks were still skipping at review time, expected for a freshly ready PR; the PR body records the local just ci and just ffi moq runs.

Pullfrog  | View workflow run | Using glm-5.3-flash | 𝕏

Merged via the queue into main with commit a947a3a Nov 4, 2026
6 of 7 checks passed
@kixelated
kixelated deleted the quest/m3/ffi-python-calls branch November 4, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant