diff --git a/FrOSCon26/FrOSCon.26.mm b/FrOSCon26/FrOSCon.26.mm index 4e7e14d..18948e4 100644 --- a/FrOSCon26/FrOSCon.26.mm +++ b/FrOSCon26/FrOSCon.26.mm @@ -901,6 +901,968 @@ + + + + + + + + + + + + + + + + + + + +

+ die Zuhörer sind keine Systemprogrammierer +

+ + +
+
+ + + + + + + + + + + + + + + + +

+ Der Desktop läuft mit z.B. 60fps, das video mit 25fps. Man sollte akzeptieren (und im Talk klar machen) daß das niemals aufgeht. Vielmehr sollte man regelmäßig die fertig geladenen Frames übergeben, wenn sie eben da sind. Für den Talk schlägt Claude vor, das Thema "tearing" mehr oder weniger zu ignorieren (hatte ich nicht gesagt, sehe ich aber genauso). Später, für die Prod-Version sei das ein nicht-trivialer Aspekt +

+ + +
+ + + +
+ + + + + + +

+ ...das sei zwar nicht notwendig (korrekt!), aber vermeide Komplexitäten. Ein OpenGL-Kontext ist an einen Thread gebunden, also müßte man nach jedem Thread-Wechsel umbinden. +

+

+ ⟹ sehr gut, damit hat sich eine Frage von selber geklärt, die ich noch stellen wollte +

+ + +
+
+ + + + + + + + + + + + + +

+ allerdings: der Worker wäre damit ein Proactor +

+ + +
+
+ + + + + + +

+ ....es wäre nämlich tatsächlich dann ganz ideomatisch (so hab ich die Doku verstanden, und Claude sagt das von sich aus auch). +

+

+ Zwar hatte ich die Idee, im Talk schon grundsätzlich etwas zu machen, was funktioniert wie in Lumiera geplant. Aber wenn ich mir das wirklich vom didaktischen Gesichtspunkt her nochmal überlege, ist es nicht so geschickt. +

+ + +
+
+
+
+ + + + + + + + + + + + +

+ ...es würde zur "toolkit, nicht framework"-Policy passen, und zudem wäre das Signal explizit von jedem Thread aus triggerbar +

+ + +
+
+ + + + + + + + + + + + + + + + + +
    +
  • + As an aside: do I assume correct that the OpenGL side will just redisplay the same data if nothing is sent for intermittent buffer flips (assuming that the desktop frame rate is higher than the video frame rate)? +
  • +
+

+ +

+

+ Your aside — yes, and here's the actual mechanism: with swap interval 0 and swaps only triggered by frameReady.emit(), you are not relying on OpenGL to "resend" anything between your swaps — OpenGL/EGL has no ongoing activity at all in between. What actually keeps the picture on screen is the desktop compositor: once eglSwapBuffers() makes a buffer the current front buffer, that's simply what's there until you swap again, and the compositor samples it at its own repaint cadence (its vsync, typically 60Hz+) regardless of whether your app does anything. So your assumption holds, but the credit belongs to "a swapped buffer is persistent state the compositor keeps re-showing," not to any active redisplay behavior on your side — a good one-sentence clarification to have ready if someone in the audience asks "wait, doesn't something have to keep pushing frames?" +

+ + +
+ + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + +

+ ich habe das Stichwort "Proactor" gebraucht. Claude bestätigt das +

+ + +
+ + + + + +

+ das heißt: die Lösung, auf die es nun für den Talk hinausläuft, ist daß ich nicht wirklich einen "Worker" hab (kein Vorgriff auf die Lumiera Engine), sondern einen Proactor. +

+ + +
+ + + + + +
+ + + + + + + +

+ Ich verwende das aktuelle "Sonnet" auf Stufe Medium. Das braucht erhebliche Rechenzeit für jede Antwort auf diesem Diskussionslevel (und zeigt auch jeweils expandierende, schlußfolgernde und kondensierende Phasen an) +

+ + +
+
+ + + + + + +

+ Proactor, confirmed. io_uring is the textbook proactor: you submit a description of the completed operation you want, the kernel does the work, and your thread's job is purely to consume results and act on them — as opposed to a reactor (epoll-style), where you're told "this fd is ready," and you still perform the (potentially blocking, now guaranteed-non-blocking) I/O yourself. Good instinct to name this explicitly in the talk; it's the single sentence that makes the rest of the design legible. +

+

+ On the "abstraction of state progression" claim — I think it's correct, but I'd collapse your four options into two families rather than four separate things: +

+
    +
  • + Implicit state, paid for in threads. Thread-per-operation blocking I/O doesn't avoid needing a state machine — it hides one inside the OS thread: the thread's stack and program counter are  the state ("I am here, at this line, holding these locals"). You get state-tracking for free, but you pay for it in thread/stack cost per concurrent operation, which is exactly what proactor-style async I/O exists to avoid. +
  • +
  • + Explicit state, paid for in bookkeeping. Everything else — hand-rolled flags, continuation chains, and coroutines — is the same  idea at different levels of automation. A coroutine is, mechanically, a compiler-synthesized state machine: co_await points become case labels, locals that need to survive a suspension become member fields of a generated struct. Continuation-passing is the same thing done by hand with closures instead of compiler codegen. So "coroutines" and "explicit state engine" aren't really two different answers to your question — a coroutine is an explicit state engine, just one you didn't have to write by hand. +
  • +
+

+ Given that framing: you're right that a flag-per-buffer is not a lesser or simplified version of "the real solution" — it's the same solution as coroutines, just written manually because your problem is small and linear enough that the compiler-generated version would be pure overhead (both in runtime terms and, more importantly here, in audience cognitive load — introducing C++20 coroutine machinery to explain a 3-state pipeline would be a bad trade for this talk). +

+ + +
+
+ + + + + + + +

+ std::atomic<SlotState> +

+ + +
+ + + + + + + Since each slot has exactly one producer (the worker) and one consumer (the GUI thread, via the dispatcher callback) and they never touch a slot at the same time by construction, + +

+ std::atomic<SlotState> with release-on-write / acquire-on-read is enough — no mutex needed. That pairing is worth a beat in the talk on its own: the atomic isn't there to prevent concurrent writes (there aren't any), it's there to guarantee the GUI thread sees the buffer contents the worker wrote, not just the state flag — a clean, concrete illustration of why acquire/release exists, tied directly to something the audience just watched you build. +

+ + +
+ + +
+
+
+ + + + + + + + +

+ The ring idea is correct, and it's the right way to lay out storage — but it solves a different problem than the one we flagged last time, and I think that's the thing worth pulling apart carefully, because the two compose nicely rather than one replacing the other. +

+

+ What the cyclic layout gives you: a deterministic mapping from frame number to physical buffer — slot = frameNumber % N — so you never need a free-list, an allocator, or any "find me an empty slot" search. You always know exactly where frame K's data belongs before you even submit the read. That's a clean, standard pattern and exactly right for a fixed pool of reusable buffers. +

+

+ What it does not give you for free: the display-order guarantee. The ring tells you where frame K's bytes live; it says nothing about when the read for frame K finishes relative to the read for frame K+1. Two SQEs submitted in order are not guaranteed to complete in order — for sequential reads against one local file on one machine it's very likely in practice, but "likely" is exactly the kind of unverified pragmatism you just said you don't accept. So you still need the mechanism from last time: track which frame is next expected for display, and only emit() once that specific slot says Ready  — regardless of what else has completed in the meantime. +

+ + +
+ + +
+ + + + + + + + + + + + +
    +
  • + Keep one cursor, nextExpected (monotonically increasing, starts at 0). On every completion, mark slots[frameNumber % N] as Ready. Separately — and this can literally be a small loop, since bursts of completions can land between two checks — while slots[nextExpected % N].state == Ready: emit the dispatcher for that slot, mark it Empty  again, ++nextExpected. +
  • +
  • + The same nextExpected-style counter also tells the submission  side what to do: whenever a slot frees up (goes back to Empty  after display), submit a new read for nextToSubmit, into slots[nextToSubmit % N], ++nextToSubmit. Priming at startup is just: submit frames 0..N-1 into slots 0..N-1 before the first wait_cqe. +
  • +
+ + +
+ + +
+ + + + + + + +

+ ...obschon man sie auf dem gleichen lokalen State aufbaut. Das Beispiel, das Claude nennt: man könnte das Quellfile loopen +

+ + +
+
+
+
+ + + + + + + + + + + +

+ struct io_uring ring; +

+

+ io_uring_queue_init(QUEUE_DEPTH, &ring, 0); +

+

+ +

+

+ // One reserved tag that can never collide with a real frame number: +

+

+ constexpr uint64_t TICK_TAG = UINT64_MAX; +

+

+ +

+

+ // The periodic beat — submitted ONCE, keeps firing on its own: +

+

+ struct __kernel_timespec ts = { .tv_sec = 0, .tv_nsec = 40'000'000 }; // 40ms ≈ 25fps +

+

+ struct io_uring_sqe* tsqe = io_uring_get_sqe(&ring); +

+

+ io_uring_prep_timeout(tsqe, &ts, 0, IORING_TIMEOUT_MULTISHOT); +

+

+ io_uring_sqe_set_data64(tsqe, TICK_TAG); +

+

+ +

+

+ // Prime the pipeline — one read per slot, tagged with its frame number: +

+

+ for (uint64_t f = 0; f < N; ++f) { +

+

+     struct io_uring_sqe* sqe = io_uring_get_sqe(&ring); +

+

+     io_uring_prep_read(sqe, fd, slots[f % N].buf, FRAME_BYTES, f * FRAME_BYTES); +

+

+     io_uring_sqe_set_data64(sqe, f); +

+

+ } +

+

+ io_uring_submit(&ring);   // one syscall flushes everything prepped above +

+ + +
+
+ + + + + + +

+ for (;;) { +

+

+     struct io_uring_cqe* cqe; +

+

+     io_uring_wait_cqe(&ring, &cqe);      // blocks — the worker's only wait point +

+

+ +

+

+     uint64_t tag = cqe->user_data; +

+

+     if (tag == TICK_TAG) +

+

+         onTick(); +

+

+     else +

+

+         onReadComplete(/*frame=*/tag, cqe->res); +

+

+ +

+

+     io_uring_cqe_seen(&ring, cqe);       // MUST always call this, or the CQ ring fills and stalls +

+

+ } +

+ + +
+
+ + + + + + + void onTick() { if (slots[nextExpected % N].state == Ready) { frameReady.emit(); // GUI thread will draw slots[nextExpected % N] ++nextExpected; // GUI marks the slot Empty once it's done drawing } // else: do nothing — this frame slips one tick later. Not dropped, just delayed. while (slots[nextToSubmit % N].state == Empty) { submitRead(nextToSubmit); // io_uring_prep_read + set_data64(nextToSubmit) ++nextToSubmit; } io_uring_submit(&ring); } + + + + + + + + + +

+ The reason top-up belongs in onTick rather than in onReadComplete:
a slot only becomes reusable once the GUI thread has finished drawing it and flips it back to Empty  — and the worker, sitting blocked in io_uring_wait_cqe, has no way to know that happened except by waking up for something. The tick is that guaranteed periodic wake-up, so it doubles as "check if there's new capacity to fill," with a worst-case latency of one tick period (~40ms) before a freed slot gets reused — fine at this cadence, and a nice illustration that the timer isn't just pacing display, it's also the thing that keeps the whole state machine alive even when nothing else is happening. +

+ + +
+
+
+
+
+ + + + + + + + + +
    +
  • + can you write me a short inline function that takes the "40 millisecods" as a std::chrono spec ("40ms") and returns the struct __kernel_timespec +
  • +
+

+ #include <chrono> +

+

+ #include <linux/time_types.h>   // struct __kernel_timespec (also pulled in via liburing.h) +

+

+ +

+

+ template<class Rep, class Period> +

+

+ constexpr struct __kernel_timespec +

+

+ toKernelTimespec(std::chrono::duration<Rep, Period> d) +

+

+ { +

+

+     using namespace std::chrono; +

+

+     auto secs  = duration_cast<seconds>(d); +

+

+     auto nsecs = duration_cast<nanoseconds>(d - secs); +

+

+     return __kernel_timespec{ static_cast<long long>(secs.count()), +

+

+                                static_cast<long long>(nsecs.count()) }; +

+

+ } +

+ auto ts = toKernelTimespec(40ms); + + +
+ + +
+ + + + + + + + +

+ er hat dazu keine weiteren Fragen +

+ + +
+
+ + + + + + +

+ bestätigt meine Analyse, ohne Diskrepanz +

+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + As far as I could see from looking at the liburing sources, there is a fundamental read barrier before retrieving/updating the rings, and a write barrier at the other side (when done with a completion or submitting). Now you also pointed out the conclusion that submitting the next IO task could itself be a scheduled activity. Combining both, if we ensure that interaction with io_uring happens only in the "mutual exclusive" phase (i.e. the worker holds the GroomingToken) -- then that would ensure that, at any given time, only one thread at max interacts with the urings. And Liburing inserts the necessary barriers, so that the next task to enter that zone would see the changes. Does that reasoning hold? What difficulties do you see? + + + + + + + + + + + + + +

+ Ja, weiß ich. Deshalb hab ich's so programmiert. Freut mich daß Claude da unabhängig zum gleichen Schluß kommt (denn ich hab mich oft gefragt, ob ich mich da in Spitzfindigkeiten verrenne).
Also nochmal explizit: memory_order_relaxed reicht hier nicht
+

+ + +
+ +
+ + + + + + +

+ Einmal geht es um shared-data zwischen Kernel und Userspace. Wohingegen das Grooming-Token die Sichtbarkeit über die Worker hinweg steuert. +

+

+ +

+

+ Diese Unterscheidung ist konzeptionell (sehe ich so, Claude deutet es auch so). Beide Ansätze stören sich nicht, man sollte sie aber auseinanderhalten. Ich denke, das ist ein generischer guter Rat beim Design solcher Systeme, man sollte nicht danach streben, solche verschiedenen Belange zusammenzufassen, selbst wenn es im konkreten Fall möglich wäre (wie hier der Fall, aber nur weil keine data-dependenciy im memory-ordering includiert ist) +

+ + +
+ +
+ + + + + + + That's a much bigger problem than in the FrOSCon demo, where blocking on + +

+ wait_cqe was the worker's entire job. Here it would reintroduce exactly the kind of global stall your pull architecture is designed to avoid. The fix is straightforward but needs to be deliberate: only ever use the non-blocking io_uring_peek_cqe()  inside grooming — check if anything's ready, harvest what's there, submit what's ready to submit, and move on regardless of whether anything was found. That keeps the ring interaction cheap and bounded, consistent with a critical section that's already supposed to be short. +

+ + +
+ + + + + + + + + + + + + + + CQ overflow: if grooming genuinely doesn't run for a while under heavy CPU load, does a full completion queue drop completions, or buffer them? Recent kernels buffer overflow internally rather than dropping (subject to + +

+ IORING_FEATURE_NODROP-era behavior), but it's worth confirming for whatever kernel baseline you commit to, rather than assuming. +

+ + +
+ + +
+
+ + + + + + +

+ Deliberately avoid IORING_SETUP_SQPOLL +

+ + +
+ + + + + + + + Deliberately avoid + +

+ IORING_SETUP_SQPOLL. It's tempting since it removes the need to call into the kernel for submission most of the time, but it works by adding a kernel-side polling thread that reads the SQ ring on its own schedule — an actor outside your GroomingToken discipline and outside your capacity self-calibration entirely. That's exactly the kind of uncontrolled independent actor your architecture is built to avoid. +

+ + +
+ + + +
+
+
+ + + + + + + Net: I think folding I/O into the same single-threaded grooming discipline you already have is the + +

+ consistent choice, not a compromise — you've already accepted one global serialization point for scheduling decisions, and a small, non-blocking peek/submit pair is cheap enough to ride along in that same section without becoming a new bottleneck category. The real design work is less "is this safe" (I think it is, modulo the ordering-semantics check and the non-blocking discipline) and more "how do we know, empirically, that grooming-section latency stays small enough once I/O bookkeeping is added" — which sounds like exactly the kind of thing you'd want numbers for before committing, rather than reasoning about further in the abstract. +

+ + +
+ + + + + + + + +
+ + + + + + + + + + + +

+ Current 2 — thread-per-core, shared-nothing, and it's picking up real steam. This is the more consequential trend to be aware of, because it's a genuinely different answer to the same problem you're solving, and it's currently the fashionable one in high-performance I/O-heavy systems: pin one thread per core, partition data and work by that core (typically via hashing), eliminate cross-thread shared state entirely, and communicate exclusively via message passing rather than shared queues or locks. This is the architecture behind ScyllaDB and Redpanda via the Seastar framework, and it's spreading into the Rust ecosystem too (Glommio, compio, and others). A recent writeup from the Apache Iggy project frames the shift precisely as moving from work stealing to "work steering" — i.e., instead of idle workers reaching into a shared structure (what you have), work is routed to the specific core that already owns the relevant data, and nobody has to contend for anything. Interestingly, the standard advice against a single shared io_uring instance per application is exactly the contention concern we discussed for your ring — each thread in Glommio gets its own private set of rings instead, which is the thread-per-core answer to the same "who owns the ring" question you and I just spent a while on. Apache + 2 +

+ + +
+ + +
+ + + + + + + + + + + + + + Even more directly relevant to your capacity-calibration idea: Glommio ships built-in "controllers" that automatically adjust per-task-queue scheduler shares to control throughput against a user-specified criterion — that's essentially your self-calibrating capacity-weights idea, independently arrived at by a different team solving an adjacent problem. Worth knowing that's not an idiosyncratic idea of yours — it's something the field has landed on more than once, which is generally a good sign for an architectural instinct. + +

+ Rust +

+

+ +

+

+ Where I'd place your design relative to these two currents: your GroomingToken-serialized shared-scheduler model sits closer to "classic" worker-pool architecture than to shared-nothing thread-per-core — and I think that's the right call for Lumiera specifically, not a lag behind the state of the art. The strongest argument for thread-per-core is when per-request overhead is so small (network request handling, KV-store lookups — sub-microsecond territory) that even brief lock contention or cache-line bouncing dominates the cost. Your own numbers — single-digit milliseconds up into seconds per frame task — are three to six orders of magnitude coarser than that regime. A brief critical section for grooming and ring bookkeeping is genuinely negligible next to task costs measured in milliseconds; it would not be negligible next to costs measured in tens of nanoseconds, which is the world Seastar/ScyllaDB actually live in. So I wouldn't read "thread-per-core is trendy" as pressure to abandon your model — I'd read it as: the technique exists and is well-proven if your task granularity ever drops by several orders of magnitude (which, for a video render engine, seems unlikely — your bottleneck is decode/filter/encode work, not tiny message dispatch). +

+

+ One trend worth just keeping an eye on, not acting on: core counts keep climbing — high core-count workstation and consumer chips are increasingly normal — and any single serialization point gets proportionally more expensive as contention scales with thread count, even if each individual crossing is cheap. Not a problem today, and your task granularity buys you a lot of headroom, but it's the kind of thing worth having empirical data on (exactly the "more empirics needed" conclusion you already reached) if Lumiera is ever deployed on genuinely large core counts rather than typical desktop/workstation counts. +

+ + +
+ + + + + + + + +

+ Lumiera könnte ja schon auch auf eine high-end-Workstation laufen +

+ + +
+
+ + + +
+
+
+ + + + + + + + +

+ von Claude erwähnt im Zusammenhang mit Locking visibility. Er weist auch darauf hin daß io_uring keine multiple thread submissions unterstützt. Zu dem Schluß war ich ja auch gekommen +

+ + +
+ + + + + + + + + +

+ der Kernel kann nicht zaubern; stattdessen hat er dann einfach einen poll-Worker. Keine gute Idee in einer Architektur die selber alle Resourcen steuern möchte +

+ + +
+ +
+
+ + + + + + + + + + + + + +

+ Wayland/X11 +

+

+ Your instinct is basically right, but I'd sharpen "not too deep" a bit: the EGL calls that matter for your loop — eglCreateContext, eglMakeCurrent, eglSwapBuffers — are identical either way. What differs is only the step just before eglCreateWindowSurface, i.e. how you obtain the native window handle from GDK: +

+
    +
  • + X11: you already have this — gdk_x11_window_get_xid() gives you a raw Window XID directly usable as the native window type. +
  • +
  • + Wayland: GDK gives you a wl_surface* (gdk_wayland_window_get_wl_surface()), but EGL on Wayland doesn't accept that directly — you have to wrap it with wl_egl_window_create() (from libwayland-egl) to get something usable as EGLNativeWindowType. +
  • +
+

+ So it's genuinely a small, well-isolated patch of platform glue — a handful of lines, gated behind a runtime check of which GDK backend is active (GDK_IS_X11_DISPLAY / GDK_IS_WAYLAND_DISPLAY) — everything downstream of surface creation is untouched. +

+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +

+ aber man sollte konzeptionell zwischen der visibility mit dem Kernel und der Visibility zwischen den Workern unterscheiden; für ersteres gibt es die Barrieren in Liburing +

+ + +
+
+ + + + + + + + + +

+ der Umstand daß neue IO-Aktivitäten gescheduled werden und Completions entnommen werden müssen, garantiert nur eine passive Backpressure (nicht entnommene Completions gehen nicht verloren, und der Platz in den URings ist begrenzt). Sehr wahrscheinlich ist mehr notwendig, damit die Engine rund läuft. +

+ + +
+
+ + + + + + + +

+ Es ist eine etablierte und bewährte Methode, und wird nicht weggehen. Was wir geschaffen haben konvergiert gut mit den allgemeinen Einsichten im Feld +

+ + +
+
+ + + + + + +

+ das ist ein Gegentrend, der aber auf einer anderen Größenordnung arbeitet (im Nanosekunden-Bereich) +

+ + +
+
+ + + +
+
+
+