Real awaitables instead of polling loops - #95
Conversation
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.
|
Verified on Linux, in
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 One caveat, and it is not this branch. That image pairing segfaults in |
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.
What
cuvis/Async.pygains_wait_off_the_loop; both__await__methods collapse into it and stop polling.Worker.get_next_result_asyncruns the SDK's blocking wait in an executor.register_worker_callbackloses its 1 ms spin.Worker._wait_for_resultthat reads the status directly and returnsNoneon timeout.Why
async/awaitalready exists. Underneath, every path was a sleep loop: 10 ms in bothAsyncawaitables, 100 ms inget_next_result_async, 1 ms inregister_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_resultexists becauseSDKException.__init__callslogging.exceptionunconditionally, 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:
Linux,
cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04: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-dataloadercu3s 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 incuvis.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
get_next_result_asyncnow raisesSDKExceptionon timeout. The polling version returned aWorkerResultbuilt from handles the SDK never filled in, which was worse but was the observable behaviour.reset_worker_callbacktherefore cannot take effect until the current 1000 ms window expires. That is a workaround, not a design.SDKExceptionraised from one thread can carry another's message (0.9% of reads at 3 threads, 0.2% already before); sharing a singleMeasurementacross threads is a hard crash; andMeasurement._refreshignores the status ofcuvis_measurement_get_data_imageand 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.ChangelogCI 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
HANDLEor an eventfd, or a completion callback that can be routed throughloop.call_soon_threadsafe.Of those, the completion callback is the better ask:
std::futurehas 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 atC:\dev\cuvis_sdk\.poc-archive\real-awaitable\. That proposal should become a cuvis.c ticket rather than living on one machine.Verification
cuvis.pyillanded and again after the file removal.develop;ruff check,ruff format --checkand the pyupgrade rules all clean.