为什么 server 用“一份权重 + 多 slot + 连续批处理”,而不是“多进程、每进程一份权重”?请从显存占用和吞吐两个角度说明,并谈谈这套设计的代价(比如延迟、公平、实现复杂度)。
Last lesson's llama-cli wrapped the shared engine in a "command-line shell"; this lesson's llama-server fits the same engine with an "HTTP shell" - turning inference into a network service: many users and many requests connect at once, and it speaks the OpenAI API, so an existing SDK just needs a new address.
Since cli and server share one engine (revealed in L27), this lesson looks head-on at how that engine runs when it must serve many requests at once. Its most brilliant and most memorable move is continuous batching: one forward pass advances many requests together.
This lesson is an architecture overview only - walk the main road a request takes from door to door, and make the two core concepts, slots and continuous batching, clear. The deeper scheduling trade-offs (how prefill and decode interleave, batch capacity, preemption and fairness) are beyond this overview.
🌍 Big picture
server's skill boils down to one line: turn "one engine" into "a service that serves many people at once". The hard part is not "send and receive HTTP" - that is off the shelf; the hard part is "one GPU, one copy of the model weights, serving a dozen requests at once without any of them crawling". The answer is continuous batching: rather than queue requests and let the GPU compute one at a time, pack each request's "current step" into the same forward pass, compute once, and hand each its share. Grasp this and you have server's essential difference from cli - cli serves only you, while server must do "concurrency" well on the very same engine. It does not copy the engine many times; it teaches one engine to "split itself" across many conversations. Look one level deeper and this is possible thanks to KV-cache (L19) isolation: each request has its own slice of KV, uncontaminated, so they can safely share the same forward computation without "crossing wires". So server's concurrency is essentially "shared compute, isolated state" - one forward spreads the compute across everyone, while each conversation's history is stored apart, invisible to the others. Neither half can be dropped: share without isolation and it descends into chaos; isolate without sharing and you are back to the clumsy one-at-a-time queue. Hold this pairing and every later detail about slots, batches, and scheduling is just elaboration on these two principles; that is also why server's real hard part is never "how to receive HTTP requests", but "how to let one GPU, while strictly isolating each request's state, still advance everyone together in one forward".
🔌 Analogy
Think of server as a restaurant kitchen: each table (slot) seats one party (one request), and there is only one kitchen (the engine). The clumsy way is table by table - finish table one before greeting table two, everyone else waiting. Continuous batching is like a chef who can cook several woks at once: he throws "the next ingredient every table needs right now" into one pan, stir-fries once, and splits it out to each table. The stove (one GPU forward pass) lights once and advances several tables' dishes by a step. The number of tables (--parallel N) sets how many parties the kitchen serves at once; and that multi-wok chef is update_slots. The analogy hides a point easy to miss: cooking many woks pays off only if each table's "next dish" can go into the pan at the same moment - that is, each request is right on the beat of "compute the next token". In a real server, some tables are still on appetizers (prefilling a long prompt) and some already plating mains token by token (generating), and the chef must weave these different stages into one pan. That is the weight of the word "continuous": it does not gather all requests before lighting the fire, but every round re-decides "whose ingredients go in this pan" - clearing a table that finished and seating a newcomer, the stove turning without pause. This lesson tastes the sweetness of "many woks"; how the chef actually schedules and weighs "more guests" against "every table fast" is beyond this lesson.
Overall architecture: a request's journey
First walk a request's main road from door to door. An incoming HTTP request is wrapped into a server_task (a to-do) and dropped into the server_queue (a task queue); the scheduler assigns it to an idle server_slot; then the update_slots continuous-batching loop keeps advancing it, producing a server_task_result per token (streamable in pieces); finally the HTTP layer assembles the response back to the client.
1
HTTP layer (server-http)
Take the request, parse JSON; at the end write the result (streamable) back to the client.
2
Task queue (server-queue)
The request becomes a server_task in server_queue; post() submits, recv() takes out, scheduled to an idle slot.
3
Engine + slots (server-context)
server_context holds a set of server_slot; update_slots advances all active slots by continuous batching.
4
Result (server_task_result)
Each step yields a result, streamed back; server-chat handles OpenAI-compatible format conversion.
This modularity is why server reads well: server-http minds only the network, server-queue only the queue, server-context only inference, server-chat only OpenAI compatibility. Each to its own, untangled - want to see "how requests queue" open queue, want "how it generates" open context, no needle-in-a-haystack in one giant file. This split also echoes L27: what cli reuses is exactly that middle server_context engine. When you read server's source, this module map is your navigation: lost, come back and glance at it, first locate "is what I care about now the network, the queue, inference, or compatibility", then dive into the matching file, rather than gnawing through the whole server front to back in one go.
What a slot is
A slot is the first cornerstone for understanding server. At startup --parallel N opens N slots (called n_parallel in the source), and each slot is an independent parallel sequence: with its own seq_id, its own slice of KV (echoing L19's KV cache), and a small state machine. The total context is divided among slots, and each one's share is n_ctx_slot.
IDLE
free, can take a request
->
STARTED
assigned a task
->
PROCESSING_PROMPT
eat prompt (prefill)
->
GENERATING
emit token by token
->
IDLE
done, back to pool
A slot's life is this circle: idle (IDLE) on standby; on a task it enters STARTED and begins eating the prompt (PROCESSING_PROMPT, that is prefill); once the prompt is eaten (DONE_PROMPT) it turns to GENERATING and emits tokens one by one; when generation finishes (an end token or the length cap) it returns to IDLE and waits for the next request. The N slots each turn this circle independently, without interfering - that is the basis for server "running many conversations at once". Each slot has its own KV, so one conversation's context never bleeds into another's. One more thing worth noting: because the slot count is fixed, server carves out these N KV regions once at startup and no longer keeps asking VRAM for memory and freeing it at runtime - fast and stable, but it also means once N is set, how many can run at once is set too, and anything beyond must queue.
Continuous batching (the core)
Now to server's most brilliant move. Picture 3 requests running at once: some slots are in prefill (eating the prompt), some in decode (emitting words). The clumsy way takes them one at a time, the GPU serving only one slot while the rest wait. Continuous batching does the opposite: it packs "the tokens to compute this step" from all currently active slots into the samellama_batch, and one llama_decode advances all their sequences by one step together.
// the heart of continuous batching (condensed from update_slots in server-context.cpp)common_batch_clear(batch);
for (slot : slots) { // iterate all active slotsif (slot.state == GENERATING || slot.state == PROCESSING_PROMPT)
common_batch_add(batch, slot.token, slot.pos, { slot.id }); // tag with seq_id=slot.id
}
llama_decode(ctx, batch); // one forward, advance all active sequencesfor (slot : slots)
slot.next = common_sampler_sample(slot.smpl, ctx, slot.i_logits); // each reads its own row
The key is that line common_batch_add(batch, token, pos, { slot.id }): it tags each token with "which slot I belong to" as a seq_id. So one batch holds tokens from several slots, llama_decode uses the attention mask to let each sequence see only its own history, and afterward each slot reads its own row of logits to sample. The diagram below freezes "one step": how 3 slots' tokens squeeze into one batch, and how after one decode each gets its own next token. When you read it, look closely at the middle "merged batch" box: it is not split into three request-segments, but truly mixes the three slots' tokens cell by cell, telling them apart only by the seq tag on each cell - it is exactly this "mix into one pan, recognize by tag" that lets one forward feed in the work of three requests at once.
Tracing one continuous-batch step: slot0/slot2 are generating, slot1 is prefilling; their tokens pack into one batch, and after one llama_decode each slot gets its next token (values are illustrative).
OpenAI compatibility
server has one more feature that makes it especially handy: it grows a set of endpoints identical to the OpenAI API, such as /v1/chat/completions. This means any client, SDK, or front-end written for OpenAI only needs to point its request URL (base URL) at your llama-server to talk to a local model, with almost no code change. The layer doing this "translation" is server-chat: it converts back and forth between OpenAI's JSON schema (messages, tools, and so on) and the engine's internal representation, tool calls included. This translation layer looks humble but is the key to server fitting into the existing ecosystem - your existing tool chains, monitoring dashboards, and client code can almost all be reused without changing a line, cutting the cost of "switching to a local model" to a minimum.
# start a service, then call it like OpenAI
llama-server -m model.gguf --port 8080 # opens N slots by default (--parallel)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello"}]}'
💡 Hands-on
The step that best conveys server's value: llama-server -m model.gguf --port 8080 to start the service, then open a few terminals and curl /v1/chat/completions at the same time - you will see them all respond concurrently, not one at a time in a queue; that is continuous batching cooking several woks behind the scenes. Then turn --parallel up or down and watch "how many requests it serves at once" change. Because the endpoints are OpenAI-compatible, you can even take the off-the-shelf OpenAI Python SDK, point base_url at http://localhost:8080/v1, and use it directly - a local model with the feel of a cloud API. To see continuous batching's power directly, run a comparison: first start with --parallel 1 (one slot) and send 4 requests at once - they basically queue, emitting one after another; then start with --parallel 4 and send 4 again - this time they begin together and spill out words together. Same machine, same model, yet just opening a few slots and letting the engine "cook many woks" makes the overall feel night and day. This small experiment best turns "batching for throughput" from a slogan into a fact seen with your own eyes; go further, watch the throughput logs while you load-test, and you can find by hand the knee where "more slots no longer means faster" - that is this card's compute ceiling.
Why not just run many processes?
By here a natural question may surface: to serve many requests at once, why not simply run many llama processes, one process per request - is that not simpler? The answer hides in VRAM. Model weights run from a few GB to tens of GB, and each independent process must load another copy of those weights into VRAM - run 8 processes and the same weights are paid for 8 times, which an ordinary GPU cannot bear.
server does the opposite: one set of weights, many sessions. The model loads once (echoing L25's read-only, shareable llama_model), all slots share these same weights, and each only takes one extra small slice of KV (L19). So VRAM cost goes from "weights x process count" to "weights x 1 + KV x slot count" - and one KV slice is far smaller than a full set of weights. That is why "one engine, many slots" fits far more concurrency on a single card than "many processes".
Besides, many processes each run their own forward pass and miss continuous batching's "one forward, serve many" throughput dividend. So the combo of "one engine + many slots + continuous batching" is not for elegance, but the optimum forced by two hard constraints, VRAM and throughput: save VRAM (shared weights) and high throughput (shared forward), two wins at once. Many processes do have merits - cleaner isolation (one crash spares the rest), simpler deployment; but under the most common goal of "serve as many requests as possible on one card", one engine with many slots almost always wins, which is why mainstream inference servers (not just llama.cpp) nearly all take this road.
Deep dive: queueing and throughput
Two final folds answering two questions that cut to the point: what happens when slots run out, and why continuous batching makes throughput high.
1 Slots are full - what happens to a new request? click to expand
The number of slots is fixed (--parallel N). If all N slots are busy and a new request arrives, it is not dropped but queued to wait: it stays in the server_queue (the implementation has a "deferred tasks" queue) until some slot finishes generating and returns to IDLE, then the scheduler takes it out and assigns it. So --parallel is a trade-off: larger means more concurrent requests, but each slot's KV context (n_ctx_slot) and compute are thinner; smaller is the opposite. How to weigh it depends on your VRAM and load - exactly a deeper layer of scheduling trade-off. A handy rule of thumb: estimate from "how much context one request needs" how much KV each slot must reserve, then divide remaining VRAM by it, and that is roughly the ceiling for N - beyond it you either blow VRAM or squeeze each one's context too thin.
2 Why is continuous batching faster than "per request"? click to expand
The key is a property of the GPU: the cost of one big matrix op (one forward pass) barely grows with "how many sequences computed at once" - one sequence versus eight is nowhere near 8x the time. So merging many requests' tokens into one llama_decode spreads the cost of that single forward over several requests, and the total tokens produced per unit time (throughput) rises sharply. The price is more complex code (managing seq_ids, the attention mask, each slot's progress), plus some fairness and latency trade-offs - but the core gain of "one forward, serve many" is enough to make it standard for modern inference services. The deeper trade-offs are beyond this lesson. By the way, this also explains why "latency" and "throughput" are often at odds: continuous batching lifts overall throughput, yet may make a single request slightly slower for having to share one forward with others - which way you lean depends on whether you want one person to get an answer as fast as possible, or a whole batch of people to each wait not too long on average.
✅ Key points
llama-server fits the shared engine with an "HTTP shell", turning inference into a network service: concurrent requests + OpenAI-compatible endpoints.
slot: --parallel N opens N parallel sequences, each with a seq_id + KV + state machine (IDLE -> STARTED -> PROCESSING_PROMPT -> GENERATING -> IDLE).
Continuous batching (core): common_batch_add(..., {slot.id}) packs many slots' tokens into one batch, one llama_decode advances all active sequences - one forward, serve many.
OpenAI compatibility: server-chat converts /v1/chat/completions and other endpoints; an existing OpenAI client just changes the base URL.
💡 Design insight
the essence of this server lesson is the plain yet profound engineering idea of "batching for throughput". For a single request, continuous batching does not make it faster (latency is unchanged); but zoom out to "how many tokens the whole server produces per unit time" and it is an order-of-magnitude gain. Behind it is a deep deference to the hardware - the GPU excels at "computing one big batch at once", so gather requests into batches and feed it, instead of forcing it to compute one by one. From cli to server you see two uses of the same engine: cli chases "smooth for one person", server chases "efficient for a crowd". And the key to serving "a crowd" well is never to copy the engine many times, but to teach one engine to "cook many woks". Hold this idea, and when you dig into finer scheduling, you will find every trade-off revolves around it. Place it in a bigger picture: from L25's C API, L26's common, L27's cli, to this lesson's server, Part 5 keeps telling one story - how to use "one stable core" in ever more complex settings. cli makes it smooth for one person, server makes it affordable for a crowd, and underneath it all is the engine honed across the first four parts. Next time you face "how to make a system serve more users", first ask: what is my "one forward pass"? Can I merge many requests' version of it into one shot? Its inverse is worth heeding too - if a system inherently cannot batch and each request must hog resources to the end, its scalability is capped from the very birth of the architecture. Whether you can batch often sets the ceiling from the start.
🧪 Self-test - think about the design
1. What is the core mechanism of continuous batching?
pack many active slots' tokens into one batch via common_batch_add(..., {slot.id}), and one llama_decode advances all sequences at once
spawn an independent process per request, each loading its own weights and running in parallel
copy the model weights N times into VRAM, one copy per request
queue the requests and have the GPU process them strictly one after another
Show answer & explanation click to expand
Answer: A. Continuous batching tags each active slot's token with its seq_id via common_batch_add and packs them into one batch; one llama_decode then advances all active sequences using the attention mask - one forward, serve many, the heart of server throughput. Copying weights / many processes is exactly what it avoids.
2. Which parameter sets server's number of slots?
--n-predict / -n
--threads / -t
--ctx-size / -c
--parallel N (n_parallel in the source)
Show answer & explanation click to expand
Answer: D. --parallel N opens N slots (n_parallel), each an independent parallel sequence with its own seq_id + KV + state machine. -c sets the total context (divided among slots as n_ctx_slot), -t is thread count, -n is generation length - none set the slot count.
3. How does llama-server stay compatible with OpenAI clients?
server-chat converts between OpenAI's schema and the engine's internal representation, exposing endpoints like /v1/chat/completions
it forwards requests to OpenAI's cloud servers
it requires clients to switch to a llama.cpp proprietary protocol
the llama.h C API speaks HTTP directly
Show answer & explanation click to expand
Answer: A. server-chat converts between OpenAI's JSON schema (messages, tools, ...) and the engine's internal representation and exposes OpenAI-identical endpoints like /v1/chat/completions; an existing OpenAI client/SDK just points its base URL at llama-server, with no protocol change and no detour through OpenAI's cloud.
💭 Open questions (no single right answer - just think or try)
Why does server use 'one set of weights + many slots + continuous batching' instead of 'many processes, one set of weights each'? Argue from VRAM usage and throughput, and discuss the costs of this design (e.g. latency, fairness, implementation complexity).