Scheduling and CPU shares
Weight CPU between workloads with proportional-share scheduling, and catch a fiber that hogs a core -- both opt-in, both inspired by Glommio.
---- The gap: FIFO splits CPU evenly
- Proportional share: weighting CPU between classes
- The stall watchdog: who hogged the core?
- When to reach for this
- Optional lane policies and continuous batching
- Async offload, cross-loop submission, and placement
- Demonstrated in
- See also
By default libxtc’s run queue is a plain FIFO plus a work-stealing deque: equally busy fibers get roughly equal CPU, and there is no way to say “the flush workload should get 3x the query workload” or “this class must be scheduled within 500us”. This chapter covers the two opt-in scheduler features that add exactly that – proportional-share scheduling and the over-budget stall watchdog – both off by default with zero overhead until you use them.
Both are directly inspired by Glommio (Glauber Costa / ScyllaDB), the thread-per-core io_uring runtime whose executor gives each task queue shares and a latency class and picks the next queue by a CFS-style virtual-runtime heap, and whose stall detector reports which task monopolized a core. libxtc’s north star is the same multi-tenant / mixed-workload database use, so these were the one capability worth adopting outright.
The gap: FIFO splits CPU evenly
Put two groups of equally busy, well-behaved worker fibers on one loop
– call them class A and class B, each doing equal compute chunks with a
cooperative xtc_yield between them. Under the plain FIFO run queue the
CPU split is about 1:1, no matter what weighting you want. There is
no knob. That is the gap.
Proportional share: weighting CPU between classes
Create a scheduling class with xtc_exec_class_create, give it
shares in 1..1000, and place a process’s tasks in it with
xtc_proc_opts_t.sched_class at spawn (or xtc_proc_set_class from
inside the process). A loop that carries more than one class picks the
minimum-virtual-runtime class on each dispatch, so each class gets a CPU
fraction proportional to its shares:
#include <stdio.h>
#include <string.h>
#include <stdatomic.h>
#include "xtc.h"
#include "xtc_loop.h"
#include "xtc_exec.h"
#include "xtc_proc.h"
#define BUDGET 900
static atomic_int g_total; /* shared work budget across both classes */
/* Workers of BOTH classes contend for ONE shared budget, each round taken
* by whichever class the scheduler picks next. That is what makes this a
* proportional-share demonstration: the classes are competing for a
* scarce resource, so the higher-shares class gets more of it.
*
* (An earlier version gave every worker a fixed private round count.
* That is deterministic but measures nothing: equal work in means equal
* runs out, and it reported 1.00:1 every time. Shares weight vruntime
* ACCRUAL -- they decide who is picked next when work is contended, not
* how much total work a task is allowed to do.) */
static void
worker(void *arg)
{
(void)arg;
while (atomic_fetch_add_explicit(&g_total, 1, memory_order_relaxed)
< BUDGET)
xtc_yield();
}
struct ctx {
xtc_loop_t *loop;
xtc_exec_class_t a;
xtc_exec_class_t b;
};
static void
spawner(void *arg)
{
struct ctx *c = arg;
xtc_proc_opts_t oa, ob;
int i;
memset(&oa, 0, sizeof oa);
memset(&ob, 0, sizeof ob);
oa.sched_class = c->a; /* place these tasks in class A */
ob.sched_class = c->b; /* and these in class B */
for (i = 0; i < 3; i++) {
(void)xtc_proc_spawn(c->loop, worker, NULL, &oa, NULL);
(void)xtc_proc_spawn(c->loop, worker, NULL, &ob, NULL);
}
}
int
main(void)
{
xtc_loop_t *loop;
xtc_exec_class_t a = NULL, b = NULL;
struct ctx c;
double ratio;
if (xtc_loop_init(&loop) != XTC_OK)
return 1;
/* Class A: 3 shares. Class B: 1 share. Optional latency bound 0
* (none). With no class created the loop would be a plain FIFO. */
if (xtc_exec_class_create(loop, 3, 0, &a) != XTC_OK ||
xtc_exec_class_create(loop, 1, 0, &b) != XTC_OK)
return 1;
/* L3: report any run that hogs the loop for more than 50 ms. Off
* by default; this arms it (a NULL callback logs + backtraces). */
xtc_loop_set_stall_budget(loop, 50 * 1000 * 1000LL);
c.loop = loop;
c.a = a;
c.b = b;
if (xtc_proc_spawn(loop, spawner, &c, NULL, NULL) != XTC_OK)
return 1;
(void)xtc_loop_run(loop);
/* Class A has 3 shares to class B's 1, so A is picked more often.
* Report the observed counts rather than asserting a ratio -- see the
* note below on why an exact ratio is not a real-hardware property. */
ratio = xtc_exec_class_runs(b)
? (double)xtc_exec_class_runs(a) / (double)xtc_exec_class_runs(b)
: 0.0;
{
unsigned long long stalls = xtc_loop_stall_count(loop);
printf("class A runs=%llu, class B runs=%llu, ratio=%.2f:1 "
"(shares 3:1), stalls=%llu\n",
(unsigned long long)xtc_exec_class_runs(a),
(unsigned long long)xtc_exec_class_runs(b),
ratio, stalls);
(void)xtc_loop_fini(loop);
/* The well-behaved workers never trip the stall watchdog.
*
* NOTE what this does and does not assert. It does NOT assert
* a run ratio, because on real hardware it cannot: vruntime
* accrues by MEASURED run time floored to a minimum quantum,
* and a yield-only worker's real elapsed time is dominated by
* scheduling jitter -- so the accrual, and with it the pick
* order, is noisy. Asserting `ratio >= 2.0` here made this a
* flaky release gate: observed 0.10:1 to 16.65:1 across runs on
* an unchanged tree.
*
* There is also a genuine counter-effect worth understanding:
* the favoured class drains the shared budget FASTER and so
* finishes and stops running sooner, after which only the
* slower class is left to accumulate runs. A raw run count at
* the end of a fixed budget is therefore not a clean CPU-share
* signal on real hardware.
*
* Strict proportionality IS proven -- under the deterministic
* simulator, where the virtual clock makes every run cost
* exactly the floor, so the run ratio equals the share ratio
* exactly and reproducibly: test/sim/test_sim_sched_shares.c.
* That is the right place for the numeric claim; this snippet's
* job is to show the API and print what it observed. */
return stalls == 0 ? 0 : 1;
}
}
Tested source: docs/_includes/snippets/10_sched_shares.c
Class A has 3 shares and class B has 1, so A is picked more often. The
virtual-runtime accrual is Glommio’s exact formula:
vruntime += (cost_ns * reciprocal) >> 12, where
reciprocal = (1<<22)/shares, so a higher-shares class accrues virtual
time more slowly and is chosen more often.
Be precise about what the snippet above prints, because the honest claim is narrower than “3:1 shares gives 3:1 run counts” on real hardware:
- Accrual is weighted by measured run time, floored to a minimum quantum. For a yield-only worker the real elapsed time is dominated by scheduling jitter, so the observed run ratio is noisy – it is not a stable 3:1.
- A shared work budget adds a counter-effect: the favoured class drains it faster, finishes sooner, and then stops running, after which only the slower class accumulates runs. A raw end-of-budget run count is therefore not a clean CPU-share signal.
Strict proportionality – run ratio equal to share ratio, exactly and
reproducibly – is proven under the deterministic simulator, where the
virtual clock makes every run cost exactly the floor:
test/sim/test_sim_sched_shares.c. That is where the numeric guarantee
lives. On real hardware, treat shares as a weighting that holds in
aggregate over a sustained contended workload, not as an exact ratio you
can assert over a few hundred dispatches.
Untagged work (any task with no class) races an implicit default lane in the same pick, so background work is never starved by always-ready class work – it simply gets a default weight.
Latency classes
Pass a non-zero latency_ns to xtc_exec_class_create and the loop’s
cooperative yield interval shrinks to that bound (like Glommio’s
reevaluate_preempt_timer), so a latency-sensitive class is serviced
promptly instead of waiting behind a throughput backlog. This is how you
say “compaction gets 20% of the CPU, but queries must be scheduled
within 1ms” on a single core.
Zero overhead when unused
A loop with no class created runs the exact plain-FIFO + work-stealing path, byte-for-byte; the virtual-runtime accounting activates only once a class exists. You pay nothing until you opt in.
The stall watchdog: who hogged the core?
libxtc is cooperatively scheduled – a fiber that never yields
monopolizes its loop. The over-budget stall watchdog turns that from
a silent tail-latency mystery into an alertable signal: arm a per-loop
budget with xtc_loop_set_stall_budget (or every loop of an executor at
once with xtc_exec_set_stall_budget), and when a single run exceeds it
the runtime reports which task did it.
xtc_loop_set_stall_budget(loop, 50 * 1000 * 1000LL); /* 50 ms */
xtc_loop_set_stall_cb(loop, my_report_fn, ctx); /* or NULL to log */
With no callback the runtime logs a warning and emits a backtrace of the
loop to stderr. xtc_loop_stall_count returns how many overruns fired.
The check is a cheap wall-clock comparison at the run-end boundary of
the loop step – no watcher thread, no signal – so it is a single
branch on a disabled flag when off. This is inspired by Glommio’s stall
detector.
When to reach for this
- Mixed workloads on one core (compaction vs queries, flush vs read path): give each a class and weight the shares.
- A latency SLA on one workload while another does bulk throughput: a latency class.
- Diagnosing “something occasionally pins a core for 200ms”: arm the stall budget and let it name the culprit.
If your workload is a single kind of task, or you are happy with even sharing, leave both off – the default FIFO + work stealing is simpler and faster.
Optional lane policies and continuous batching
Configure with --enable-sched-policy (Meson: -Dsched_policy=true) to
get two more interfaces. A default build still exports every function;
each returns XTC_E_NOSYS, and the scheduler hot path is unchanged. An
enabled build with no policy installed keeps the ordinary picker, queue
order, and weighted accounting.
xtc_sched.h– a lane policy that chooses which ready lane runs next.xtc_batch.h– a batch engine that calls one step function per iteration with the vector of ready requests, and refills that vector between iterations.
The two are independent. Batching works under the ordinary picker, and a policy works without any batch group.
Runtime-owned queues
A lane is a queue the loop already has: lane -1 is the default
FIFO/deque, lanes 0..15 are the class queues created by
xtc_exec_class_create. A policy’s select callback receives an
immutable snapshot of the ready lanes, ordered by lane ID, with each
lane’s shares, runs, bypass count, and advisory batch counts. It returns
a lane ID, or XTC_SCHED_DEFAULT to use the ordinary picker. The
snapshot holds no task or queue pointers. The runtime still removes the
task, dispatches it, owns its lifetime, and handles wakes and stealing.
An invalid choice falls back to the ordinary picker and is counted by
xtc_sched_get_stats.
Installation is setup-only. xtc_sched_policy_set and
xtc_sched_continuous_set must run before the loop’s first run or step,
even an empty one; afterwards they return XTC_E_AGAIN. Callbacks must
be bounded, nonblocking, allocation-free, and deterministic under
simulation.
Bounded bypass
Every policy carries a positive max_bypass. For each ready lane the
runtime counts consecutive selection opportunities in which that lane was
not served. When a continuously ready lane reaches max_bypass, the
runtime overrides the policy and serves the oldest such lane (ties go to
the lower lane ID). With L ready lanes, every ready lane is served within
max_bypass + L opportunities, provided callbacks and dispatched tasks
return.
The bound counts opportunities, not time. A task that runs for 50 ms without yielding stretches it by 50 ms. It is not preemption, and it is not per-task fairness inside the default LIFO deque. A custom policy may skew weighted shares; it does not promise exact proportions.
The continuous policy
xtc_sched_continuous_set(loop, max_bypass) installs a built-in policy
with loop-owned state and no allocation. Among registered batch classes
that are ready and report ready members, it prefers the greatest bypass
age and rotates equal ages by class ID. With no such class it returns
XTC_SCHED_DEFAULT. A group whose members are all BLOCKED gets no
preference. The policy picks a class, not a group member, and the
bypass bound still covers ordinary tasks and the default lane.
Each explicit class holds at most one batch group; ordinary tasks may share the class. A group created with a NULL class works but gets no preference from this policy.
Batch engine lifecycle
A group owns a pinned driver process on its loop. Requests are borrowed
pointers with a fixed resource charge. Admission to the active set is
first-come, first-served against resource_capacity; a full or closed
group returns XTC_E_AGAIN and the caller keeps the request.
The step callback receives the whole ready vector and writes one result
per member: CONTINUE, DONE, BLOCKED, or ERROR. The complete
callback runs exactly once per accepted request:
/* One call per iteration, with the whole ready vector. */
static int
step(const xtc_batch_item_t *items, xtc_batch_result_t *results,
size_t count, void *user)
{
struct demo *d = user;
uint64_t h;
size_t i;
/* Callbacks cannot mutate their own group. */
if (xtc_batch_submit(d->group, NULL, 1, &h) == XTC_E_DEADLK)
d->deadlk++;
for (i = 0; i < count; i++) {
struct req *r = items[i].request;
r->rounds++;
results[i].state = r->rounds == r->length ?
XTC_BATCH_DONE : XTC_BATCH_CONTINUE;
}
return XTC_OK;
}
/* Exactly once per accepted request. */
static void
complete(uint64_t handle, void *request, int outcome, void *user)
{
struct demo *d = user;
struct req *r = request;
(void)handle;
r->outcome = outcome;
r->order = ++d->finished;
}
Tested source: docs/_includes/snippets/15_continuous_batching.c
Create the group and submit work before the loop’s first run. The continuous policy is optional; the snippet installs it to show the call:
opts = (xtc_batch_opts_t){ .size = sizeof(opts),
.version = XTC_BATCH_VERSION,
.capacity = NREQ, /* waiting + active slots */
.max_active = 2, /* members per vector, at most */
.resource_capacity = 2, /* units shared by active members */
.sched_class = cls,
.step = step, .complete = complete, .user = &d };
/* Before the loop's first run. */
rc = xtc_batch_create(loop, &opts, &d.group);
if (rc == XTC_E_NOSYS) {
puts("batching not built (configure --enable-sched-policy)");
(void)xtc_loop_fini(loop);
return 0;
}
if (rc != XTC_OK ||
xtc_sched_continuous_set(loop, 3) != XTC_OK) /* optional */
return 1;
for (i = 0; i < NREQ; i++) {
d.reqs[i].length = lengths[i];
if (xtc_batch_submit(d.group, &d.reqs[i], 1,
&d.reqs[i].handle) != XTC_OK)
return 1;
}
Tested source: docs/_includes/snippets/15_continuous_batching.c
Shutdown has a fixed order:
/* Close, run until the driver ends, destroy, then fini. */
if (xtc_batch_close(d.group, XTC_BATCH_DRAIN) != XTC_OK ||
xtc_loop_run(loop) != XTC_OK ||
xtc_batch_get_stats(d.group, &st) != XTC_OK ||
xtc_batch_destroy(d.group) != XTC_OK ||
xtc_loop_fini(loop) != XTC_OK)
return 1;
Tested source: docs/_includes/snippets/15_continuous_batching.c
The four requests need 5, 1, 2, and 1 rounds, with at most two active. Each finished member frees a slot that the next waiting request takes at the following iteration boundary, so the program runs 9 member-rounds in 5 iterations and all three short requests finish before the long one. A static batch of two would hold requests 2 and 3 until request 0 finished. In a default build the snippet prints a note and exits 0.
The rules the snippet follows:
- Create pre-run.
xtc_batch_createafter the first run or step returnsXTC_E_AGAIN. - Owner-only mutation. After the loop starts, submit, cancel,
resume, close, and destroy are valid only on the owner loop; another
loop gets
XTC_E_INVAL. Route cross-loop work to an owner process with messaging. - Callbacks cannot mutate. While a step or complete callback is in
flight, every mutator on that group returns
XTC_E_DEADLK, including destroy and including peer processes that run while the callback is suspended. The snippet’s step callback checks this. Return a result state instead, and mutate from an owner process after the callback returns. Statistics stay readable. - Close, run, destroy, fini.
XTC_BATCH_DRAINwaits for accepted work (BLOCKED members need resume or cancel);XTC_BATCH_CANCELcancels it. The driver exits only when the loop runs, even for an empty group closed before the first run.xtc_loop_stopis not a drain: destroy returnsXTC_E_AGAINwhile the driver exists. Destroy the group beforextc_loop_fini.
Alternatives not chosen
- Policy-owned queues. A vtable that owns enqueue, dequeue, stealing, and placement would duplicate task lifecycle and wake handling, and widen what has to be proven. The policy only chooses among lanes the runtime owns.
- Orca’s GPU results. The engine adopts the iteration-level admission idea from Orca (OSDI ‘22). It runs CPU fibers, makes no claim to Orca’s GPU throughput, and its lane rotation is not Orca’s scheduler.
- Timely dataflow. There are no timestamps, frontiers, progress tracking, or differential updates. A group is bounded admission plus a vector step. Build pipelines from processes and channels with explicit backpressure.
- Wall-time fairness. A time-based bound would put clock reads in the picker and would not replay under deterministic simulation.
When not to use it
- The requests share no per-iteration work, so a vector call saves nothing. One process per request is simpler.
- Requests are equal length and arrive together. Submitting a cohort and draining it gives the same result without refill.
- You need a latency bound in time. Use a latency class and the stall watchdog; the bypass bound counts opportunities.
- Many threads submit directly, and you do not want a thread-safe
admission path. The synchronous
xtc_batch_submitrequires an owner process to receive the work; if front-end loops or plain threads must feed a group, use the async cross-loop admission below instead.
Async offload, cross-loop submission, and placement
The synchronous engine above runs one step callback per iteration on the
driver fiber: the step must finish on the CPU before the driver yields.
An LLM-style inference server wants more – a decode step offloaded to a
GPU/NPU and awaited while new requests join, requests fed in from
front-end loops, and groups placed near their device. Three additive
features (all behind --enable-sched-policy, all XTC_E_NOSYS in a
default build, no existing public struct grown) close that gap. They are
described in full in xtc_batch(3);
the summary:
- Async offload.
xtc_batch_create_asyncreplaces the single step with asubmit/collectpair.submitlaunches one device operation over the ready vector and returns a completion descriptor (xtc_batch_exec_t); the driver awaits it without stalling the loop;collectreads the outputs and decides per-member CONTINUE/DONE at the next boundary. The exec kind is FENCE (an fd – native only, since the simulation backend has no fd-readiness hook), FUTURE (anxtc_future_t, sim-deterministic), BLOCKING (a closure on the blocking pool), or SYNC. An optionalexec_timeout_nsbounds how long the engine waits for a FENCE or FUTURE op (BLOCKING ignores it: a pool thread cannot be interrupted). It does not stop the device: after a timeout, an abort or a negative (could-not-await) exec status the op may still be running, so the engine never resubmits those members over the same buffers – they end terminal – and the application must keep the op’s buffers until its own runtime says the op is done. A cancel of an in-flight member firescancel_hintas soon as it lands (so the application can abort its kernel) and is consumed at the completion boundary –collectstill runs and the member ends ABORTED exactly once.close(CANCEL)and a kill of the driver end a FENCE or FUTURE wait promptly. - Cross-loop / cross-thread admission.
xtc_batch_submit_mt,_cancel_mtand_resume_mtare callable from any loop or a plain OS thread. They touch only a bounded lock-light ring + record pool + the group’s monotonic handle counter, never the group’s slots; the owner loop drains the ring at each boundary. AnXTC_OKfromsubmit_mtalways leads to exactly one completion: a hand-in that finds the group full waits (in submission order) for a slot, and a closed group refusessubmit_mtwithXTC_E_AGAINbefore issuing a handle. A full ring or record pool also returnsXTC_E_AGAIN; the caller retries (yield or sleep between attempts – there is no blocking variant). Acancel_mtfrom any producer for a handle it was given is always applied; one for a handle that already completed is discarded. - Placement.
xtc_batch_loop_for_devicemaps a probed accelerator to the executor loop whose carrier is on the device’s NUMA node (best-effort, loop 0 fallback). Carriers record their node only when they start, so before the executor has run – the only time a group can be created – it currently always returns loop 0; andxtc_batch_router_create/_route/_destroyshard one logical model across N caller-created groups by a stable key. These are advisory glue, not a scheduler: there is no automatic migration of a live group.
Two examples drive real accelerators through this API with a FAKE
backend fallback that needs no device and runs in make check:
examples/13_gpu_batching– continuous-batching Mandelbrot over OpenCL, demonstrated on two AMD GPUs at once (one group per device, each on its own carrier loop).examples/14_hetero_batching– a heterogeneous capstone driving an Intel Arc GPU (OpenCL) and an Intel AI Boost NPU (Level Zero) concurrently, with GPU and NPU operations in overlapping in-flight windows.
Demonstrated in
examples/03_supervised_app.c– the minimal case: two supervised children share one loop as two classes (a latency-bounded foreground counter with 3x shares vs a best-effort background stats printer), plus the stall watchdog.examples/06_sqlxtc– the real case: the SQL server enables the over-budget stall watchdog so a runaway query or structure-modification that hogs a worker loop is logged with a backtrace instead of silently stalling every other connection on that loop.
See also
xtc_exec(3)– the class and stall-watchdog API in full.xtc_proc(3)–xtc_proc_set_classandxtc_proc_opts_t.sched_class.xtc_sched(3)– lane policies, the bypass bound, and the continuous policy.xtc_batch(3)– the batch engine, callbacks, cancellation, and shutdown.