Skip to content

Real awaitables instead of polling loops - #95

Merged
birkholz-cubert merged 4 commits into
developfrom
poc/real-awaitable
Aug 20, 2026
Merged

Real awaitables instead of polling loops#95
birkholz-cubert merged 4 commits into
developfrom
poc/real-awaitable

Conversation

@birkholz-cubert

@birkholz-cubert birkholz-cubert commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What

  • cuvis/Async.py gains _wait_off_the_loop; both __await__ methods collapse into it and stop polling.
  • Worker.get_next_result_async runs the SDK's blocking wait in an executor. register_worker_callback loses its 1 ms spin.
  • Adds a private Worker._wait_for_result that reads the status directly and returns None on timeout.

Why

async/await already exists. Underneath, every path was a sleep loop: 10 ms in both Async awaitables, 100 ms in get_next_result_async, 1 ms in register_worker_callback. The interval sets a latency floor that no Python-side work can lift.

The SDK was never the limitation. It has blocking waits with timeouts and documents them. The blocker was that no wrapped function released the GIL, so a blocking wait froze the whole interpreter. That is what the companion PRs fixed.

_wait_for_result exists because SDKException.__init__ calls logging.exception unconditionally, so a wait that expects to come back empty would otherwise write a traceback to the log every second.

Measurements

Simulated camera, one frame per round, 12 rounds, runs back to back.

Windows:

before after
worker result, awaited 108.6 ms 28.0 ms
awaited / blocking floor 3.20x 1.21x
registered callback, idle 0.8 % of a core 0.0 %

Linux, cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04:

before after
worker result, blocking 21.6 ms 21.6 ms
worker result, awaited 101.5 ms 22.6 ms
awaited / blocking floor 4.70x 1.05x
registered callback, idle 2.9 % of a core 0.1 %

The ratio is the number to read; absolute timings drift with machine load. The blocking floor is identical across the Linux pair, which is what makes it trustworthy: only the awaited number moved. The 108 ms and 101 ms figures are not coincidences, they are the 100 ms poll interval, present whether or not the result was ready.

The GIL release turned out to be worth more than async

The same GIL release, measured on the synchronous cuvis-ai-dataloader cu3s read path with no async involved, goes from 14.9 to 45.2 fps at 4 threads and 70.2 fps at 20, with single-thread throughput unchanged. Details in cuvis.pyil/THREAD_SCALING_REPORT.md.

That does not change this diff, but it means the awaitable work was not the only thing gated on it.

Pitfalls

  • Behaviour change: get_next_result_async now raises SDKException on timeout. The polling version returned a WorkerResult built from handles the SDK never filled in, which was worse but was the observable behaviour.
  • Costs one pooled thread per outstanding wait, and a wait cannot be cancelled, because a thread blocked in C is not interruptible. reset_worker_callback therefore cannot take effect until the current 1000 ms window expires. That is a workaround, not a design.
  • Executor threads inherit the SDK's thread-safety limits. Investigating the GIL release surfaced three: the SDK error channel is process-wide, so an SDKException raised from one thread can carry another's message (0.9% of reads at 3 threads, 0.2% already before); sharing a single Measurement across threads is a hard crash; and Measurement._refresh ignores the status of cuvis_measurement_get_data_image and siblings. None is introduced by this diff and none is hit by it today, because each await holds its own objects. It becomes relevant the moment callers share cuvis objects across awaits.
  • The Changelog CI check fails, since this branch adds no entry. It must be fixed before merge.

What would finish this

The executor is a real improvement but the two costs above are the SDK's to remove. Fully event driven needs the SDK to hand out either a waitable OS primitive per async result, a Win32 event HANDLE or an eventfd, or a completion callback that can be routed through loop.call_soon_threadsafe.

Of those, the completion callback is the better ask: std::future has no OS handle to expose, so the handle route means the SDK inventing a new primitive per async result with two platform paths, whereas the callback only needs a hook where the promise is already fulfilled. It needs no C++23 continuations either, since the SDK owns the promise. The proposed signature, the registration-race contract, and the measurements above are in the removed write-up, archived locally at C:\dev\cuvis_sdk\.poc-archive\real-awaitable\. That proposal should become a cuvis.c ticket rather than living on one machine.

Verification

  • 134 passed against the merged binding on Python 3.12, re-run after cuvis.pyil landed and again after the file removal.
  • 134 passed against the stock wheel on 3.11, so the branch does not require the new binding to be green, only to be fast.
  • Rebased onto develop; ruff check, ruff format --check and the pyupgrade rules all clean.
  • Linux: 134 passed in the 3.5.3 image.

Not a merge candidate. cuvis.python exposes async/await, but nothing
underneath waits on an event: Async.__await__ polls at 10 ms,
Worker.get_next_result_async at 100 ms, register_worker_callback spins
at 1 ms. The SDK is not the limit; it has blocking waits with timeouts.
The limit was that no wrapped function released the GIL, so a blocking
wait froze the interpreter for its whole timeout.

Async.py grows one _wait_off_the_loop helper and both __await__ methods
collapse into it. Worker runs the SDK's blocking wait in an executor,
and the 1 ms spin is gone. A private _wait_for_result reads the status
directly, because SDKException logs unconditionally and an idle wait
would otherwise write a traceback every second.

Paired runs: awaited worker results 108.6 ms to 28.0 ms, 3.20x the
blocking floor down to 1.21x, idle callback 0.8 % of a core to 0.0 %.

POC_REAL_AWAITABLE.md carries the write-up, including why
%module(threads="1") is the wrong instrument, and what the C SDK would
need for this to be finished without a thread per outstanding wait.
Built and run in cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04. Needs no
CMake change; SWIG emits the GIL release at 311 call sites there.

Awaited worker results 101.5 ms to 22.6 ms, 4.70x the blocking floor
down to 1.05x, idle callback 2.9 % of a core to 0.1 %. The blocking
floor is identical across both runs, so only the awaited number moved.
134 tests pass.

The 3.4.1 image segfaults in cuvis_proc_cont_create_from_session_file,
but a control binding with %exception restored to its develop form
crashes at the same line with the same exit code, so that is the SDK
version mismatch and not this change.
@birkholz-cubert

Copy link
Copy Markdown
Collaborator Author

Verified on Linux, in cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 on Python 3.12 against the SDK the image ships. No CMake change needed; SWIG emits the GIL release at 311 call sites there, against 325 on Windows.

before after
worker result, blocking 21.6 ms 21.6 ms
worker result, awaited 101.5 ms 22.6 ms
awaited / blocking 4.70x 1.05x
registered callback, idle 2.9 % of a core 0.1 % of a core

The blocking floor is identical across the two runs, which is what makes the pair trustworthy: only the awaited number moved. Linux ends up closer to the floor than Windows (1.05x against 1.21x), and its idle cost was the higher of the two to begin with. Full suite: 134 passed.

Mismatch behaviour matches Windows. A binding built in the 3.5.3 image and run against cubertgmbh/cuvis_pyil:3.4.1-ubuntu24.04 reports the three functions 3.4.1 does not export, warns once, and turns a call to one into a RuntimeError rather than a crash, with the GIL released throughout.

One caveat, and it is not this branch. That image pairing segfaults in cuvis_proc_cont_create_from_session_file, a plain synchronous constructor with no await involved. I built a control binding from the same tree with %exception restored to its develop form: it crashes at the same line with the same exit code. So it is the SDK version mismatch itself, present before any of this work, and it deserves its own ticket.

@birkholz-cubert birkholz-cubert changed the title POC: real awaitables instead of polling loops Real awaitables instead of polling loops Aug 20, 2026
They were review material, not library code. Kept locally at
C:\dev\cuvis_sdk\.poc-archive\real-awaitable, which is outside any git
repo. The PR now carries only the Async.py and Worker.py change.
@birkholz-cubert
birkholz-cubert marked this pull request as ready for review August 20, 2026 07:47
@birkholz-cubert
birkholz-cubert merged commit 735739a into develop Aug 20, 2026
4 checks passed
@birkholz-cubert
birkholz-cubert deleted the poc/real-awaitable branch August 20, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant