24 KiB
Serve Layer Architecture
Pantheon's serve layer implements an OpenAI-compatible HTTP inference server.
Entry point: src/serve/serve_command.c. The serve layer sits above the engine
(src/core/engine.c) and is reached via pantheon serve.
Overview
At startup pn_serve_main (serve_command.c:206) parses one or more --model
flags, loads each model into an mr_registry, binds a TCP socket, and enters an
infinite accept() loop. Every accepted connection is handed to a detached
pthread (spawn_connection, serve_command.c:185); if pthread_create fails
it falls back to handling the request inline so no connection is ever silently
dropped.
The connection thread calls handle_connection → dispatch. Dispatch is a
straight O(route-count) strcmp chain (serve_command.c:124):
| Method | Path | Handler |
|---|---|---|
OPTIONS |
any | CORS preflight: hs_send_json 200 {} |
GET |
/v1/models |
oai_handle_models |
POST |
/v1/chat/completions |
oai_handle_chat |
POST |
/admin/models/load |
ma_handle_load |
POST |
/admin/models/unload |
ma_handle_unload |
GET |
/admin/dies |
ma_handle_dies |
GET |
/ |
health: {"service":"pantheon","status":"ok"} |
GET /v1/models and admin routes never touch any generation lock; they can
respond concurrently with an in-flight generation on any model.
Request lifecycle (socket → SSE)
accept()
└─ spawn detached pthread
└─ hs_read_request() (http_server.c — reads headers + body)
└─ dispatch()
└─ oai_handle_chat()
├─ jr_parse(body)
├─ parse_chat_request() (extracts model, params, session_id)
├─ route_model() (mr_acquire → entry + refcount++)
│
├─ [batched path: QWEN35_BATCH_SLOTS=1]
│ ├─ bs_slot_acquire_or_wait() (blocks up to 30 s)
│ └─ sg_run_completion_batched() → SSE
│
└─ [single-user path: sched == NULL]
├─ pthread_mutex_trylock(&entry->gen_lock) → 429 if busy
└─ sg_run_completion() → SSE
└─ mr_release(reg, entry) (refcount--)
Model Registry and Routing
Files: src/serve/model_registry.h, src/serve/model_registry.c
Data structures
/* model_registry.h:54 */
typedef struct {
char name[MR_MAX_NAME]; /* served model id */
pn_model *model;
pn_state *state; /* single-user state (sched==NULL) */
pthread_mutex_t gen_lock; /* single-user generation serializer */
pn_batch_sched *sched; /* C3: non-NULL when batching is on */
pn_prefix_cache *prefix_cache; /* PC1: session prefix cache */
pthread_mutex_t pc_lock; /* serializes prefix-cache ops */
int dies[PN_MODEL_MAX_DIES];
int n_dies; /* 0 = CPU-resident */
uint64_t vram_bytes;
int refs; /* in-flight acquires */
bool unloading;
bool used;
} mr_entry;
/* model_registry.h:86 */
typedef struct {
mr_entry entries[MR_MAX_MODELS]; /* MR_MAX_MODELS = 16 */
int n;
pthread_mutex_t lock;
pthread_cond_t drained; /* signaled on every mr_release */
pthread_mutex_t upload_lock; /* A3: one GPU weight upload at a time */
} mr_registry;
Loading (mr_load, model_registry.c:90)
Load is a four-phase operation designed to keep the registry serviceable throughout:
- Slot reservation (under
reg->lock): a slot is markedused=truewith no model pointer yet — the name is taken but the entry is not yet acquirable (require_readyskips it infind_locked). - Placement + VRAM admission (lock-free): auto-placement via the die manager
(
pn_dm_place), or explicit dies from--dies.pn_admit_model_loadgates against the per-die budget using a conservative 8 k context guess (MR_ADMIT_CTX_GUESS = 8192,model_registry.c:36).pn_dm_bindcommits the reservation. - Weight upload (under
reg->upload_lock):pn_load_onruns while the registry stays fully serviceable.upload_lockensures at most one GPU upload is in flight at a time, preventing two concurrent 2.4 GB transients stacking on an 8 GB host (model_registry.c:213). - Publish (under
reg->lock): model, state, locks, and die info are written atomically andreg->nis incremented. After publish, the batch scheduler (bs_create) and prefix cache (pn_prefix_cache_new) are created and published under the lock. Both are fail-soft: failure leaves the model in single-user mode or full-prefill mode respectively.
Acquire / release refcounting
mr_entry *mr_acquire(mr_registry *reg, const char *name); /* model_registry.c:322 */
void mr_release(mr_registry *reg, mr_entry *entry); /* model_registry.c:358 */
mr_acquire locks reg->lock, scans entries, increments refs, and returns
the pointer. Every user of an entry MUST pair mr_acquire with mr_release.
mr_release decrements refs and broadcasts reg->drained; mr_unload waits
on that condvar until refs == 0 before freeing.
Multi-card load-balancing (model_registry.c:329)
An operator may place one model id on several cards (--dies 4,5 and --dies 6,7 both under --name my-model). mr_acquire finds all ready entries that
share the requested name and picks the one with the fewest in-flight refs —
round-robin load balance at the reference granularity:
for (int i = 0; i < MR_MAX_MODELS; i++) {
mr_entry *c = ®->entries[i];
...
if (!e || c->refs < e->refs)
e = c;
}
Empty/null name resolves to the first (lowest slot index) resident entry —
the back-compat single-model shortcut (model_registry.c:344).
Route model (openai_chat.c:499)
static mr_entry *route_model(int client_fd, mr_registry *reg, const char *name)
{
mr_entry *entry = (mr_count(reg) <= 1) ? mr_acquire(reg, NULL)
: mr_acquire(reg, name);
...
}
With exactly one resident model, every request routes to it regardless of the
model field — preserving single-model server behavior. With more than one,
an exact name match is required; an unknown name returns 404, no loaded model
returns 503.
/v1/models dedupe (openai_chat.c:266)
oai_handle_models calls mr_snapshot to get all resident entries, then
dedupes by name before serializing JSON — a model placed on two cards appears
as one logical id:
bool seen = false;
for (int j = 0; j < i; j++)
if (strcmp(info[j].name, info[i].name) == 0) { seen = true; break; }
if (seen) continue;
Unload (mr_unload, model_registry.c:369)
Marks unloading=true (blocking new acquires), decrements reg->n, then
blocks on reg->drained until refs == 0. Detaches all pointers under the
lock and frees them outside: prefix cache → batch scheduler → die binding →
state → model.
Batched Decode (batch_sched.c)
Feature gate: QWEN35_BATCH_SLOTS=1 (env var, CUDA build only).
bs_enabled() (batch_sched.c:44) caches the result.
When enabled, mr_load calls bs_create(model, BS_MAX_USERS) where
BS_MAX_USERS = 8 (batch_sched.h:173). The scheduler owns 8 pn_state*
instances (one per slot) and a background driver thread.
Slot lifecycle
IDLE -(bs_slot_acquire/acquire_or_wait)-> PREFILL
-(bs_slot_bind / bs_slot_bind_ex)-> BOUND
-(driver first step)--------------> DECODE
-(bs_slot_release)----------------> IDLE
Only the owner thread moves a slot back to IDLE. The driver marks finished=true
on EOS/budget but never calls bs_slot_release — it always signals the owner
to drain and release.
GPU-ownership turnstile (batch_sched.c:72)
The model's single-user forward machinery (C2 async phase machine, shared live
SSM buffers, shared GPU KV ring) supports exactly one in-flight GPU forward at
a time. Pre-C3 the per-model gen_lock enforced this. The batch scheduler uses
a FIFO ticket turnstile instead — gpu_ticket_next / gpu_ticket_serving /
cv_gpu — which prevents the free-running L3 driver from barging ahead of a
waiting prefiller:
static void bs_gpu_acquire_locked(pn_batch_sched *sched) { /* batch_sched.c:72 */
uint64_t my = sched->gpu_ticket_next++;
while (sched->gpu_ticket_serving != my)
pthread_cond_wait(&sched->cv_gpu, &sched->mu);
}
A turn owner never cond_wait (chunks and steps are bounded GPU work), so
the turnstile is deadlock-free by construction.
D1 overflow queue + D3 admission backpressure
bs_slot_acquire_or_wait (batch_sched.c:1475, called from openai_chat.c:577
with a 30 s timeout) replaces the immediate 429:
- Fast path: slot free AND
pressure_level < BS_PRESSURE_HOLD_LEVEL(3 = CRITICAL) → acquire immediately. - Slow path: enqueue (
n_queued++) and block oncv_idle.- Queue full + pressure < CRITICAL → 429 +
Retry-After: 5. - Queue full + CRITICAL → 503.
- Timeout after
wait_ms→ 429 +Retry-After: 2. - Shutdown → 503.
- Queue full + pressure < CRITICAL → 429 +
BS_QUEUE_DEPTH_DEFAULT = 32 (batch_sched.h:274). Override with BS_QUEUE_DEPTH.
bs_notify_pressure (batch_sched.c:1589) is called by the serve path before
every bs_slot_acquire_or_wait (openai_chat.c:569) to push the live
pn_mem_state into the scheduler, so the gate sees the current host state at
decision time.
D2 — continuous batching / dynamic-width step
The driver compacts the active (BOUND/DECODE) slots into GPU indices
[0, n_active) each step. Idle slots pay zero GPU compute. When active_set_changed
is set (join, leave, or finish), rebuild_gpu_map (batch_sched.c:237) runs
a greedy permutation algorithm that handles arbitrary reorderings — including
cycles that require a scratch slot (cap index, batch_sched.c:279). This
fixed the K≥5-concurrent-joiners bug where the old downward-only move refusal
caused all active users to emit empty content.
L3 — in-driver sampling (default on)
bs_slot_bind_ex (batch_sched.c:1294) passes the request's sampling params,
repeat-penalty window, and token budget to the driver. After each decode_slots
step the driver samples every slot itself — sequentially, deterministically —
and pushes the result to a per-slot output ring sized to the full budget:
uint32_t next = pn_sample_next_token(lo[g], sched->n_vocab,
&sl->sp, sl->recent, sl->n_recent);
sl->ring[sl->ring_w++] = next; /* batch_sched.c:545 */
Owners drain the ring with bs_slot_pop_token and do detokenize/emitter/SSE
entirely off the step's critical path. A slow or stalled client cannot stall
the rest of the batch.
The ring is never resized after bind (ring_cap == budget), so it can never
overflow and the driver never blocks on a slow consumer.
Stop-string detection stays owner-side (text level); a slot may over-generate past a stop string until its owner drains and releases — surplus ring tokens are discarded.
Disable with QWEN35_BS_DRIVER_SAMPLE=0 to restore legacy lockstep
logits/token exchange.
L2 — chunked prefill with yield windows
Prefill runs in chunks of QWEN35_BS_PREFILL_CHUNK tokens (default 128).
After each chunk (except the last) bs_prefill_yield (batch_sched.c:1040)
makes the slot's host mirror authoritative via gpu_state_to_host, releases
the GPU turn, and re-acquires it FIFO — giving the driver one decode step
between chunks. This bounds a joiner's stall on concurrent decoders to one
chunk instead of one full prompt.
Single-user fallback
When QWEN35_BATCH_SLOTS is not set (or the build has no CUDA), entry->sched
is NULL. The serve path (openai_chat.c:658) falls back to:
if (pthread_mutex_trylock(&entry->gen_lock) != 0) {
hs_send_error(client_fd, 429, "server_busy",
"a generation is already in progress on this model; "
"retry shortly");
...
}
A second concurrent request to the same model gets an immediate 429. Requests to different models run concurrently (each holds only its own entry's lock).
Generation and Streaming (serve_generation.c)
Prompt building
build_prompt (serve_generation.c:120) tokenizes the full messages[] array
into ChatML format using the model's tokenizer:
<|im_start|>role\ncontent<|im_end|>\n for each turn, with tool messages
wrapped in <tool_response>. Opens the assistant turn and prefills <think>
for reasoning models (PN_CHAT_CHATML_THINK).
Admission
Before any prefill, pn_admit checks live host + die memory and either passes,
caps the context (sliding-window oldest-token drop), or denies. A denied
admission returns SG_ADMIT_DENIED, which the serve path converts to a 503
(openai_chat.c:683). A capped admission sets result->context_capped = 1 and
disables prefix caching for that turn; the client sees "context_capped": true
on the response.
Single-user completion (sg_run_completion, serve_generation.c:525)
build_prompt → pn_admit → [prefix cache acquire] → prefill chunks
→ sample first token → decode loop (sg_forward_step per token)
→ [prefix cache commit] → return sg_result
sg_forward_step (serve_generation.c:411) uses an async non-blocking launch +
event-poll when the model supports it (pn_forward_step_begin/poll), sleeping
50 µs → 500 µs between polls. This allows up to 8 per-card connection threads
to run their cards simultaneously from a 2-core host without blocking each other.
Batched completion (sg_run_completion_batched, serve_generation.c:744)
Same flow as single-user but:
- Prefill takes the GPU turn via
bs_prefill_beginorbs_prefill_resume. - Chunked prefill with
bs_prefill_yieldwindows (L2). - After the last chunk,
bs_slot_bind_ex(L3) orbs_slot_bind(legacy) hands the slot to the driver. - L3 path: drain the ring with
bs_slot_pop_token→ detokenize → emitter → SSE, all off the critical path. bs_slot_finalize(if caching) thenbs_slot_release.
Streaming (SSE)
run_streaming / run_streaming_batched (openai_chat.c:434,464) call
hs_begin_sse and register stream_delta_cb as the delta_fn. The callback is
invoked by the emitter for each text fragment:
static void stream_delta_cb(const char *text, int is_reasoning, void *ud) {
/* openai_chat.c:397 */
jw_raw(&jw, is_reasoning ? "\"reasoning_content\":" : "\"content\":");
jw_string(&jw, text);
send_chunk(sc, jw.data, NULL); /* hs_send_sse */
}
A final chunk carries finish_reason; then hs_end_sse writes data: [DONE].
Reasoning / stop-sequence emitter
The emitter struct (serve_generation.c:179) handles three channels:
- in_think: text before
</think>→reasoning_content; switches to content channel on</think>. - content: after
</think>, before a stop string or<tool_call>. - in_tool: raw text after
<tool_call>→ captured intoolchannel for structured parsing bytool_calling.c.
A carry buffer holds the longest suffix that could start any marker or stop
string across a token boundary.
Prefix cache (src/core/prefix_cache.c, used via serve_generation.c:574)
Keyed by session_id (from body field session_id or header X-Session-Id).
Single-user path: pn_prefix_cache_acquire returns the session's pn_state at
the delta boundary; on commit, pn_prefix_cache_commit records the full token
history. The pc_lock is not needed on the single-user path (gen_lock already
serializes), so pcx.pc_lock = NULL (openai_chat.c:674).
Batched path (PC-batched): session affinity is slot-resident. bs_slot_acquire_biased
prefers the slot that already holds this session's warm state. bs_prefill_resume
checks if the slot's committed history (hist) is an exact prefix of the new
prompt and skips the already-committed tokens. bs_slot_finalize snapshots the
GPU state to host and publishes the driver-maintained ct (committed-token log)
as hist for the next turn.
Concurrency Model
| Lock | Scope | What it protects |
|---|---|---|
reg->lock |
mr_registry |
slot table (used/refs/unloading/n); brief critical sections only |
reg->drained |
condvar on reg->lock |
unload drain wait |
reg->upload_lock |
mr_registry |
one GPU weight upload in flight at a time |
entry->gen_lock |
mr_entry |
single-user generation serializer (trylock → 429) |
entry->pc_lock |
mr_entry |
prefix cache ops on the single-user path (unused — gen_lock covers it; kept for the batched concurrent-slot case) |
sched->mu |
pn_batch_sched |
ALL slot state: status words, token/logits flags, rings, turnstile counters, D1 queue depth, D3 pressure level |
sched->cv_gpu (turnstile) |
under sched->mu |
one GPU forward in flight at a time (prefill chunk, bind, or decode step) |
sched->cv_work |
under sched->mu |
driver wake (gate may have opened) |
sched->cv_idle |
under sched->mu |
D1 waiter wake (slot freed or pressure dropped) |
sl->cv |
per-slot, under sched->mu |
driver → owner wakeup (logits/ring/finished) |
Connection threads are otherwise independent: each owns its own
hs_request, chat_request, sg_result, and emitter. The sampler scratch
(pn_sample_next_token) is _Thread_local.
The accept() loop is single-threaded but non-blocking per iteration. Heavy
work (parsing, prefill, decode, SSE write) runs entirely in per-connection
threads, so cheap routes (/v1/models, /) are never queued behind an
in-flight generation.
Admin API (model_admin.c)
| Route | Body | Action |
|---|---|---|
POST /admin/models/load |
{"path":"..","name":"..","dies":[..],"quantize":false} |
mr_load — may block ~55 s for a GPU upload |
POST /admin/models/unload |
{"name":".."} |
mr_unload — drains in-flight refs then frees |
GET /admin/dies |
— | pn_dm_get snapshot of all dies (budget/used/model) |
Error codes from status_for_reason (model_admin.c:28): admission/table-full
→ 507, duplicate/unloading → 409, not-found → 404, other → 500.
A runtime /admin/models/load arriving while a startup upload is in flight
blocks on reg->upload_lock and is refused promptly by the slot scan once the
prior load finishes — never stacks two 2.4 GB transients.
Router Registration (serve_registry.c)
Gating: fully off unless PANTHEON_REGISTRY_URL is set. rc_enabled() is checked at
serve_registry_start entry; with it unset the function returns immediately and the
serve process is byte-for-byte unchanged (serve_command.c:350, serve_registry.c:78).
When enabled, at startup serve_registry_start (serve_registry.c:76) iterates every
loaded model (via mr_snapshot) and calls rc_register for each with:
model_id = info[i].name /* the shared served identity */
instance_id = host:port:model /* serve_registry.c:98 — unique per process */
endpoint = http://host:port
capacity = batch-slot count (1 if single-user)
A background heartbeat thread then re-acquires each entry every
RW_HEARTBEAT_SECONDS to read live load (bs_get_load) and POST it to the router.
What this means for in-process replicas
Two replicas of ornith on dies 2 and 3 in the same process share the same
host:port, so they produce the same instance_id = host:port:ornith and collapse
to one instance at the router. The router sees one ornith endpoint, not two.
This is correct and intentional: in-instance replica load-balancing is handled
entirely by mr_acquire (least-busy across same-name entries — see above). The router
is the multi-instance dispatch layer for tying separate pantheon serve processes
together. A single self-routing instance does not need it.
Cross-link: docs/README.md "Multi-Model Smart Routing" — "one instance is fully self-routing and needs nothing in front of it."
Environment Variables
| Variable | Default | Effect |
|---|---|---|
QWEN35_BATCH_SLOTS |
off | 1 enables multi-user batched decode (C3) |
QWEN35_BS_DRIVER_SAMPLE |
1 |
0 restores legacy owner-side lockstep sampling |
QWEN35_BS_PREFILL_BATCH |
1 |
0 disables batched chunk prefill (token-by-token) |
QWEN35_BS_PREFILL_CHUNK |
128 |
chunk size in tokens for L2 yield windows |
BS_QUEUE_DEPTH |
32 |
max D1 queue depth before 429 |
QWEN35_BS_STATS |
off | 1 logs per-step timing to stderr |
QWEN35_KV_Q4 |
1 |
0 disables Q4 KV quantization in admission math |
QWEN35_GPU_FULL_LOGITS |
off | Refused at serve startup — forces 248k-vocab D2H copy |
PN_ASYNC_POLL_US |
500 |
Max sleep between async forward-step event polls (µs) |
PANTHEON_REGISTRY_URL |
unset | Set to enable router registration (serve_registry.c:78); unset = no-op |
PANTHEON_NODE_ID |
node0 |
Node identifier sent in router registration (serve_registry.c:83) |
PANTHEON_REGISTRY_TRANSPORT |
local |
Transport hint sent to router on registration (serve_registry.c:84) |
Known Sharp Edges
-
mr_unloadblocks its caller until all in-flightrefsdrain (model_registry.c:389). A long generation on the model being unloaded will hold the admin response for its full duration. There is no timeout. -
Duplicate names are explicitly allowed (
model_registry.c:119). An operator placing the same model id on cards 4 and 5, then unloading by name, will unload whichever slotfind_lockedfinds first — the other remains. There is no unload-by-slot-index API; the operator must call unload twice. -
make_completion_idusesrand()without seeding (openai_chat.c:87).rand()is not seeded withsrand(), so id generation is not cryptographically random and starts from the same sequence on every process restart. Collision risk is low (hex8 = 4B permutations) but completions ids are not guaranteed unique across restarts. -
pc_lockis allocated but never taken on the single-user path (openai_chat.c:674,pcx.pc_lock = NULL). On the batched path each slot owns its ownhist/ctstructures guarded bysched->mu, sopc_lockis also unused there. The lock exists as a slot for a future concurrent-slot single-cache design. -
Stop-string detection fires owner-side after the driver has already stepped tokens into the ring (L3 path). The driver steps until EOS/budget; the owner drains until a stop string is detected in the decoded text. Over-generation is bounded by
max_tokensbut the surplus ring tokens are silently discarded (serve_generation.c:930-942). This meanscompletion_tokensin the usage field may be slightly lower than the number of steps the driver ran. -
GPU lazy init hangs if called too early (
batch_sched.c:714). GPU lane registration (bs_lazy_init_gpu) is deferred to the firstbs_slot_bindto avoid a CUDA context timing issue right after a long weight upload. If the context is still unsettled when the first request arrives, the bind will hang or fail. The scheduler is fail-soft here (bind failure → -1 → request gets a 500), but a burst of concurrent first requests could all fail before the context settles. -
The
reg->lockis brief but coarse duringmr_load's slot reservation phase (model_registry.c:123). A concurrent load + multiple request threads all briefly contend on the same mutex. In practice this is negligible (the critical sections are O(MR_MAX_MODELS) array scans at most), but worth noting for any future lock-free refactor. -
Cross-replica session affinity is not implemented.
mr_acquireselects the least-busy replica byrefswith no stickiness (model_registry.c:329-341). A multi-turn session withsession_idcan hop replicas between turns, missing its prefix cache on the new replica and paying a full reprefill. The single-die batched path has slot-resident affinity (bs_slot_acquire_biased), but that only applies within one replica. Cross-replica affinity is designed but not yet shipped. -
QWEN35_GPU_FULL_LOGITSis a hard startup guard (serve_command.c:279). Setting it causes the server to refuse to start with an explicit error. It is a debug knob that forces the full 248k-vocab logit tensor D2H per step and caps throughput on the K80 host link (~0.4 GB/s). The guard is intentional and correct; do not remove it.