scheduler: run duppy on a pool of domains, retire its monad, and stop rescanning the event loop - #5359
Open
toots wants to merge 20 commits into
Open
scheduler: run duppy on a pool of domains, retire its monad, and stop rescanning the event loop#5359toots wants to merge 20 commits into
toots wants to merge 20 commits into
Conversation
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 13:51
d25fd57 to
32ae4fc
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains and retire its monad
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains and retire its monad
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 18:30
43eb5e3 to
d32c483
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 18:55
d32c483 to
7219eef
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 20:11
7219eef to
64516f2
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 20:20
64516f2 to
c10020d
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 25, 2026 22:23
a295c1e to
d33ccaf
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 25, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
force-pushed
the
duppy-domain-pool
branch
from
August 26, 2026 00:00
d33ccaf to
8fb215c
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
This was referenced Aug 26, 2026
This was referenced Aug 26, 2026
Duppy queues were systhreads, and on OCaml 5 those all share domain 0's runtime lock, so they gave concurrency and no parallelism. Duppy now spawns one dispatch domain per core plus one for the event loop, batching non-blocking tasks onto a domain and handing blocking ones an auxiliary thread inside it, so parking in a syscall frees the domain. This removes settings.scheduler.generic_queues, fast_queues and non_blocking_queues, which counted threads that no longer exist, and adds settings.scheduler.blocking_tasks to bound blocking tasks. It also fixes the logging thread returning without a final flush, which dropped everything logged during shutdown.
Server.condition was their only consumer and nothing ever called it, so Server_wait, its handler branch and both modules were dead along with their socket pair and finaliser. The parallelism test was passing by luck: at startup no worker has parked yet, so the first one awake can take blocking tasks up to its cap, and it now gives each worker one slot and holds every task until all are running.
A computation under run can await an event and carry on where it left off, so code that waits on a socket no longer has to be written as a chain of tasks. Parking registers an ordinary task whose handler resumes the continuation, which leaves the task type alone and keeps plain Task.add free of the fiber a handler would cost. Only run installs the handler, so awaiting anywhere else raises Effect.Unhandled rather than failing quietly.
Harbor, the telnet server and harbor output read and write their sockets as ordinary sequential code, raising their reply rather than threading it through a bind. Duppy.Io parks on an effect instead of returning a task, which halves the read loop and lets it compile a Split marker once per read rather than once per chunk. Transport_t loses its bigarray member, whose only implementation was a failwith and whose only caller was the bundled example, and with it the last C stub duppy had.
Taking every ready immediate task on every round left blocking work unreachable whenever the ready list refilled as fast as it drained: with one domain and a flood of self-rearming immediate tasks, 2.6 million of them ran in two seconds while a waiting blocking task never did. A worker that just took a batch now takes a blocking task next when it has one and is under its cap, which bounds the wait to one batch per worker and leaves batching otherwise untouched. The benchmark also reports its loaded cases on machines with fewer cores than the load it applies, where it had been skipping them entirely.
Script code registers its callbacks through an effect whose handler is installed per thread, and tasks stopped running on the threads that installed it, so every registration from a task raised Effect.Unhandled. Duppy takes a wrapper for task bodies rather than learning what a liquidsoap callback is, and the scheduler passes the one that collects them. Rendering a type went through Format.str_formatter, which is per-domain and shared with whatever else formats a value there, so a type printed from a task came out empty; it gets its own buffer, and the boxes around it are opened on the formatter being printed to rather than on stdout.
Lazy is documented as unsafe to force from several fibers, systhreads or domains, which liquidsoap has always done and now does in parallel, so every suspension goes through Lazy.Mutexed: a concurrent forcer blocks rather than leaving the outcome unspecified. Image.Bitmap.Font becomes a functor over what it needs of a suspension, so mm keeps building its character map with the stdlib's Lazy while liquidsoap instantiates it with the mutexed one. The win32 and asan CI images are still built against 5.4 and their jobs fail until they are rebuilt.
Nothing otherwise stops a new call site reaching for the unsafe suspension again, and the mutexed one is only a convention until something checks it.
Setting TZ is ignored by the C library once it has parsed the variable, which it does the first time anything reads the local time — in a running instance, the first log line. The builtin now re-reads it, and owns both halves rather than leaving the environment variable to the standard library.
poll rescans every descriptor submitted to it, so an event loop watching quiet sockets pays for all of them: 234us per call at four thousand, against 0.3us for a set epoll already holds. epoll and kqueue sit behind one interface, with select where neither exists, so a caller need not know which it got. Readiness is level-triggered everywhere, since select cannot be anything else and differing by platform is what the interface exists to prevent. LIQ_POLLSET_BACKEND=select forces the fallback, which is otherwise reachable only on the platform that has nothing else. Nothing uses this yet.
A turn of the event loop cost what was waiting on it rather than what had happened, so a harbor with two thousand quiet clients spent 169us before doing any work. It is now flat in the number of waiting tasks. A task carries the events it waits on and when it expires, so it can be found by descriptor and by deadline, which is what a set the kernel keeps requires: waking returns descriptors, and something has to say whose they are. The interest registered for a descriptor is the union of what the tasks waiting on it want, and it is dropped as they are dispatched, which is what keeps level-triggered readiness from reporting the same descriptor every turn. The bundled telnet example was still written against the monad, which nothing but the citest alias builds.
Joining a domain waits for the threads created inside it, and a blocking task runs on one of those, so a task that does not return kept stop from ever returning. Shutdown hung wherever a source held a socket read open, which the srt and osc tests do. Stop now waits for the workers to go quiescent for a bounded time and joins only those that got there, leaving a worker that still holds a task to die with the process. The wait moves here from the dispatch loop, which had the same rule and no bound.
A flag guarding work that must happen only once was read and set as two steps, so two threads could both find it unset and both do the work. The stdlib registers its playlist parsers behind such a flag, and registering one twice is fatal: whichever test lost the race died. References are already backed by an atomic, so the operation that was missing is exchange. Setting and reporting what was there is enough to claim a flag, and it needs no equality on values, which a compare and set would have. A reference built from a pair of functions cannot offer this, and only its own test used ref.make and ref.map, so both are gone. The two remaining callers that build a reference over foreign state keep the fallback, which is as divisible as their set already is.
With no timer to wait for, the loop asked the kernel for an unbounded wait and relied entirely on a byte written to a socket to come back. That byte is dropped when the socket's buffer is full, and the writer ignores the failure, so the loop could stop looking at whether it had been asked to stop. Shutdown then hung wherever a source had nothing pending, which the srt and osc tests reach. One wait is now capped, and stopping joins the loop's domain only once it has left, so a wedged loop costs the wait rather than the process.
toots
force-pushed
the
duppy-domain-pool
branch
from
August 26, 2026 13:56
08d7a62 to
3cc4a64
Compare
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
A domain terminates once every thread created inside it has finished, and a task may start one that outlives it: srt logs from a thread of its own, created wherever the task that set the source up happened to run, and that thread never returns. The domain it pinned could then never be joined, so stopping hung after announcing itself. The tests that use srt passed and then timed out on the way out. Reaping a domain is handed to a thread of our own, which cannot hold up stopping whatever the domain is waiting for. Closing the event loop's set is left to the case where the loop has actually left it, freeing what it waits on from under it being worse than losing a descriptor.
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
The osc handler registered global roots on two of its own locals and removed them once it was done, which an unhandled argument type and any exception out of the callback both skip. What is left is a root pointing into a frame that has gone, and the collector walks it later. Local roots leave with the frame however it is left. Escaping without taking the runtime back also left it held, so the same exception could wedge liblo's thread.
toots
pushed a commit
to savonet/ocaml-lo
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
toots
pushed a commit
to savonet/ocaml-mm
that referenced
this pull request
Aug 26, 2026
…domains, retire its monad, and stop rescanning the event loop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Duppy queues were systhreads, and on OCaml 5 those all share domain 0's runtime lock, so they gave concurrency and no parallelism — adding queues subtracted throughput.
Duppy now spawns one dispatch domain per core plus one for the event loop, batching non-blocking tasks onto a domain and handing blocking ones an auxiliary thread inside it, so parking in a syscall frees the domain.
With domains in place the CPS monad OCaml 4 required is no longer needed: computations park on an effect and resume where they left off, so harbor, the telnet server and harbor output read and write their sockets as ordinary sequential code.
The event loop no longer rescans what it is waiting on. Descriptors are watched through a set the kernel keeps —
epollon Linux,kqueueon BSD,selectwhere neither exists — and tasks are found by descriptor and by deadline instead of by folding over all of them.Breaking:
settings.scheduler.generic_queues,fast_queuesandnon_blocking_queuesare removed — they counted threads that no longer exist — andsettings.scheduler.blocking_tasksreplaces them to bound blocking tasks; scripts setting the old ones must drop those lines. Requires OCaml 5.5.Measurements
8 cores, OCaml 5.5.0.
Parallelism. Scheduled work runs on every core instead of taking turns on one:
Streaming latency. A 20ms periodic thread — a clock's shape — measured against CPU-bound scheduler work. The old medians are one 50ms systhread tick per contender:
Waiting for descriptors.
pollrescans everything submitted to it; a kernel-held set does not:pollPollset.waitCost of one loop turn, against the number of tasks waiting on quiet sockets — the shape of a harbor with that many connected-but-silent clients:
Flat rather than linear, which is the point. The after column is one figure because it no longer varies; it is noisy between 31 and 50 µs because cross-domain dispatch now dominates the loop entirely, and it is worse than the old empty case for the same reason — the two are not measuring the same path once the loop stops being the bottleneck.
Degraded cases
A flood of non-blocking tasks on one domain used to starve everything else. Taking every ready immediate task on every round left blocking work unreachable whenever the ready list refilled as fast as it drained: 200 self-rearming tasks on one domain ran 2.6 million times in two seconds while a waiting blocking task never ran once. At four domains it was invisible, because the worker taking the batch hands the leftover blocking work to another worker and with one worker there is no other worker. A worker that just took a batch now takes a blocking task next when it has one and is under its cap, which bounds the wait to one batch per worker.
Batch length is still unbounded by design, so a task classified
Immediatethat does not return immediately is a latency bug the scheduler will not rescue.Clocks are unaffected by scheduler load, including on one core. They stay on domain 0 rather than joining the pool, so they never queue for a dispatch slot and compete only for CPU — arbitrated by the OS across domains instead of by OCaml's 50ms tick within one. Pinned to a single core, the periodic thread holds p50 0.03–0.08ms with up to 7 busy tasks.
Deletions
duppy.ml1141 → 800 lines,duppy.mli512 → 255, andduppy_stubs.cgone entirely: its only function backedba_write, which the sole harbor transport implemented asfailwith "Not implemented!"and nothing called.Duppy.Monad.MutexandConditionwent withServer.condition, which was exported but had no callers anywhere.Not here
Clocks keep their own threads and their own high-precision wait — they park inside
Pcm.writeiand a JACK semaphore, where no continuation can be captured, and the event loop's timer is coarser than theclock_nanosleepthey use today.Tests
src/modules/duppy/test/test_duppy.mlcovers cross-domain dispatch, batching, socket events, the blocking cap, draining on stop, effect resumption landing on a different domain than it started on, and the starvation case above.src/modules/stdlib-utils/test/test_pollset.mlcovers registration, readiness, level-triggering, timeouts, peer close, and the property the kernel-held set exists for: with 500 watched and one ready, a wait returns exactly one.LIQ_POLLSET_BACKEND=selectforces the fallback, so the backend Windows runs is exercised on Linux too — both it andepollpass the same tests, and duppy's own suite passes on both.Each check was falsified by reverting the fix and watching it go red. Harbor 5/5, language and regression tests pass, and the suite runs clean pinned to one core and under full CPU load. The telnet server, which has no automated test, was driven by hand.
kqueuecompiles nowhere reachable from here and is CI-only.Also fixes the logging thread dropping everything logged during shutdown, type names coming out empty in errors raised from a callback, and
time.zone.setsilently doing nothing once anything had read the local time.