+ 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 +
+ + ++ +
+
+ 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: +
+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> +
+ + +
+ 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.
+
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.
+ 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 +
++ } +
+ + +
+ 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.
+
+ #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 +
+ + +
+ 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) +
+ + +
+ 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.
+
+ 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
+
+ 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.
+
+ 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 +
+ + ++ 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:
+
gdk_x11_window_get_xid() gives you a raw Window XID directly usable as the native window type.
+ 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) +
+ + +