Skip to content

perf(ffi): resume a Python wake on the loop's own thread without the socket pair - #564

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

kixelated merged 6 commits into
mainfrom
quest/m3/ffi-python-wake

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A future the exporter's Python awaits resumed through the queue's socket pair even when Rust woke it on the event loop's own thread, as a task's write_frame wakes a parked read_frame: a write, a select wakeup, and reads, about 5 µs of a 21 µs woken read (#407).

Approach

  • At the outermost call into the C ABI on a thread where a loop has polled or taken, the glue reads asyncio._get_running_loop() into a thread-local. A thread where none runs stops looking until a loop polls or takes there again, so calls outside a loop cost what they did.
  • A wake whose queue belongs to that loop, during that call, queues its token and notes the queue on the thread, writing nothing. The loop can't run until the call returns.
  • When the call returns, the glue calls the runtime's _resume, which schedules the loop's dispatch with call_soon. Dispatch still runs at a step boundary, as the socket's reader did, so a token can never reach the loop before its parked future exists. If that loop isn't the one running (which shouldn't happen), or _resume raises, the queue's socket signals it instead. A KeyboardInterrupt raised in _resume is re-armed with PyErr_SetInterrupt.
  • A wake from any other thread, or outside a call, writes the byte as before. Queue.take(read) skips the socket read on the direct path.

Tests in tests/api.py cover these cases, and the existing wake-latch and cancel tests still pass:

  • a same-thread wake writes no byte and never resumes inside the call;
  • a cross-thread wake writes one;
  • a wake before the first await;
  • a wake before and after a cancel in the same step, which loses no payload;
  • two same-thread wakes of one Waker call it once;
  • 20 rounds of 64 receives woken half from the loop's thread and half from another thread at once, each resuming once with its payload.

just ci and just ffi moq pass.

Measurements

moq/bench/export.py (the Python leg of just ffi bench) on main and on this branch, interleaved with BoltFFI 0.31.1's Python harness from moq@0f986d9 in the same run, rotating order. The host is shared, with load average 5 to 12 on 32 threads, CPython 3.14.8. Medians of 9 rounds, min to max in parentheses:

main this PR BoltFFI
frame written and read after a wake 20,721 ns (18,834 to 30,033) 15,684 ns (14,863 to 22,080) yield_once 28,711 ns (26,603 to 40,181)
handle call, outside a loop 106 ns (103 to 120) 103 ns (100 to 215) sequence 152 ns (145 to 231)
u64 getter, outside a loop 110 ns (105 to 146) 109 ns (104 to 210)

A second run of 12 rounds put the woken read at 20,681 (19,321 to 28,668) on main against 17,558 (15,012 to 25,073) here.

The cost is that each call inside a running loop pays for the lookup. Over 6 rounds, a handle call in a running loop went from 104 to 118 ns (medians). A frame written and read without suspending went from about 1,000 to about 1,150 ns, since it makes 3 or 4 calls. Each woken read saves about 5 µs.

BoltFFI's yield_once measured 28.7 µs on this host, against the 10 µs bench/ffi/README.md records, so this host runs about 2.9 times slower than that one. Scaled the same way, this PR's woken read (15.7 µs) would be about 5.5 µs there.

Impact

  • Public API: none. The generated module's private _n.Queue now takes its loop, and the runtime reserves _resume.

Alternatives

  • A per-thread mirror of the loop's thread, set at poll and take, with no lookup per call. It goes stale once a loop stops and later runs on another thread. A blocking call on the old thread would then hold a wake back until it returns, and deadlock if it waits on that loop.
  • Dispatching synchronously when the call returns. It saves one loop iteration, but a token could then reach the loop before __await__ stores its parked future, and the wake would be lost.
  • Taking the GIL inside the wake. It breaks the rule that a wake never takes the GIL, and risks a deadlock with a thread that holds it.

Follow-ups

None.

Review

  • Reviewer: quest review, Codex gpt-6.1-sol at medium effort, round 1 at head 2ac3985 and round 2 at head 2022479. No findings.

Checks run locally on 2022479: just ci (ffi lint and the 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 kixelated changed the title Python's wake on the loop's own thread perf(ffi): resume a Python wake on the loop's own thread without the socket pair Nov 4, 2026
@kixelated
kixelated marked this pull request as ready for review November 4, 2026 04:52
@pullfrog

pullfrog Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

This run was cancelled before it finished.

Pullfrog  | View workflow run | via Pullfrog

@kixelated
kixelated enabled auto-merge November 4, 2026 04:52
@kixelated
kixelated added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit eb8389e Nov 4, 2026
9 of 11 checks passed
@kixelated
kixelated deleted the quest/m3/ffi-python-wake branch November 4, 2026 04: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