Skip to content

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
mainfrom
duppy-domain-pool
Open

scheduler: run duppy on a pool of domains, retire its monad, and stop rescanning the event loop#5359
toots wants to merge 20 commits into
mainfrom
duppy-domain-pool

Conversation

@toots

@toots toots commented Aug 25, 2026

Copy link
Copy Markdown
Member

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 — epoll on Linux, kqueue on BSD, select where 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_queues and non_blocking_queues are removed — they counted threads that no longer exist — and settings.scheduler.blocking_tasks replaces 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:

before after
32 CPU-bound tasks, 1 → 6 domains 3.04s → 0.449s (6.8x)
16 CPU-bound tasks, 1 → 8 queues 3.58s → 5.11s

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:

busy tasks before after
2 p50 53.9ms p50 0.09ms
4 p50 100ms p50 0.07ms
7 p50 177ms p50 0.06ms

Waiting for descriptors. poll rescans everything submitted to it; a kernel-held set does not:

descriptors watched poll Pollset.wait
500 37.1 µs 0.5 µs
2000 107.8 µs 0.3 µs
4000 233.7 µs 0.3 µs

Cost 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:

waiting tasks before after
0 10.4 µs ~40 µs
500 43.4 µs ~40 µs
1000 79.5 µs ~40 µs
2000 168.9 µs ~40 µs

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 Immediate that 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.ml 1141 → 800 lines, duppy.mli 512 → 255, and duppy_stubs.c gone entirely: its only function backed ba_write, which the sole harbor transport implemented as failwith "Not implemented!" and nothing called. Duppy.Monad.Mutex and Condition went with Server.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.writei and a JACK semaphore, where no continuation can be captured, and the event loop's timer is coarser than the clock_nanosleep they use today.

Tests

src/modules/duppy/test/test_duppy.ml covers 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.ml covers 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=select forces the fallback, so the backend Windows runs is exercised on Linux too — both it and epoll pass 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.

kqueue compiles 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.set silently doing nothing once anything had read the local time.

@toots toots changed the title scheduler: run duppy tasks on a pool of domains scheduler: run duppy on a pool of domains and retire its monad Aug 25, 2026
@toots
toots force-pushed the duppy-domain-pool branch from d25fd57 to 32ae4fc Compare August 25, 2026 13:51
toots pushed a commit to savonet/ocaml-mm that referenced this pull request Aug 25, 2026
toots pushed a commit to savonet/ocaml-mm that referenced this pull request Aug 25, 2026
@toots toots changed the title scheduler: run duppy on a pool of domains and retire its monad scheduler: run duppy on a pool of domains, retire its monad, and stop rescanning the event loop Aug 25, 2026
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
toots force-pushed the duppy-domain-pool branch from 43eb5e3 to d32c483 Compare August 25, 2026 18:30
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
toots force-pushed the duppy-domain-pool branch from d32c483 to 7219eef Compare August 25, 2026 18:55
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
toots force-pushed the duppy-domain-pool branch from 7219eef to 64516f2 Compare August 25, 2026 20:11
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
toots force-pushed the duppy-domain-pool branch from 64516f2 to c10020d Compare August 25, 2026 20:20
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
toots force-pushed the duppy-domain-pool branch from a295c1e to d33ccaf Compare August 25, 2026 22:23
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
toots force-pushed the duppy-domain-pool branch from d33ccaf to 8fb215c Compare August 26, 2026 00:00
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
toots added 18 commits August 26, 2026 08:55
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
toots force-pushed the duppy-domain-pool branch from 08d7a62 to 3cc4a64 Compare August 26, 2026 13:56
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
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