Repository navigation
perf(ffi): hold a Python call's handles inline - #560
Conversation
Quest-Claim: 30c79a4195d94a04
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>
Finishing Touches
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. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Inline the call's held handles (
ffi/runtime/python.rs):Scope.heldis now aSmallVec<[(Obj, bool); 4]>, with the bool replacing the separatemovedvec, so a typical call does no allocation and the scope drains no lists on drop. smallvecunderpython(ffi/src/glue.rs): the generated crate's manifest adds an optionalsmallvecdependency gated into thepythonfeature;ffi/Cargo.lockneeded no change as smallvec 1.16.4 is already resolved there.- A u64 getter in the bench (
ffi/moq/bench/export.py): times a boundsequencemethod in a loop the way the BoltFFI comparison timedgroup.sequence(), and the footer reports it. - Quest bookkeeping:
quest/m3/ffi-python-calls.mdis done and deleted, and its line removed fromquest/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.
glm-5.3-flash | 𝕏

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()andgroup.demand().sequence()withperfon this host. About a quarter of the native time went to the call'sScope:Vec, so each call on an object did a malloc and a free.Draincalls.The fix, in
runtime/python.rs:heldis now aSmallVec<[(Obj, bool); 4]>. The flag records whether the call moves the value out, which replaces the separatemovedlist.smallvecunder itspythonfeature. moq's lockfile already has it.moq/bench/export.pynow also times a u64 getter the way the BoltFFI comparison timedgroup.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.
group.sequence()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: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, throughPyObject_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
smallvecdependency underpython.Alternatives
PyType_FromSpecmethod 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.Scope's rarely used lists behind a lazyBox: worth about 5 ns more by the profile. Dropped as not worth the extra code.Follow-ups
None.
Review
quest review, Codex gpt-6.1-sol at medium effort, round 1, at head 95c0739. No findings.Checks run locally:
just cion the merged head (ffi lint, and 21 fixture tests driving C, C++, Kotlin, Python, Swift, Go, and Dart), andjust ffi moqon every language. Not run:just ffi android, which needs a device.🤖 Generated with Claude Code