Part II - Applications and libraries
Ray Serve: Online Inference
Lesson 10 finished a model: N worker actors trained it, checkpointed it, and handed you a set of weights on durable storage. That checkpoint is worthless until someone can call it. But serving is not training run backwards — training is a throughput job that may take hours and only owes you a final number, while serving is a stream of individual requests, each owed an answer inside a latency budget, arriving in unpredictable bursts. This lesson takes the actor primitive from lesson 03 and the autoscaling instinct from the whole track and turns it into an online service: a group of model-holding replicas behind an HTTP door, with the one knob that decides whether your GPU is busy or your users are waiting — dynamic request batching.
@serve.deployment with autoscaling_config, @serve.batch dynamic batching, and deployment composition via .bind() and DeploymentHandle.New capability: Stand up a latency-SLO-bound online service from a trained model, size its replicas and batch knobs against a p99 target, and compose several models into one API where each stage scales independently.
1 · Online inference is a latency contract
A batch job (lesson 09's Ray Data, lesson 10's training) is judged on total time: process 50 million rows by 6 a.m., and nobody cares whether row 12,000,000 took 3 ms or 30 ms. Online inference inverts every one of those properties.
The consequence is that you cannot serve a model by wrapping model(x) in a Flask route and calling it done. You need: a process that loads the weights once and stays warm (or every request pays the load cost), several such processes so one slow request does not block the next, a front door that spreads requests across them, and a policy that grows and shrinks that pool as traffic moves. Ray Serve is that machinery, built on the actor you already know.
2 · The Serve object model
Serve adds three nouns on top of Ray Core. Learn them as the runtime objects they are, not as config keys.
@serve.deployment; Serve manages how many replicas exist and routes requests across them.__init__ loads the model once into that process's memory; every request to that replica reuses the warm model. Replicas are where GPUs and RAM are actually consumed — count them carefully.DeploymentHandle — a ServeHandle — lets one deployment call another in plain Python (await handle.method.remote(x)), which is how composition works.The minimal deployment is one class with a request handler. Note that __init__ runs once per replica — this is the lesson-03 "load the expensive thing once" pattern, now managed by Serve:
from ray import serve
from starlette.requests import Request
@serve.deployment(num_replicas=2, ray_actor_options={"num_gpus": 1})
class Classifier:
def __init__(self):
self.model = load_model_from_checkpoint("s3://ckpt/best") # once per replica
async def __call__(self, request: Request):
payload = await request.json()
return {"label": self.model(payload["text"])}
app = Classifier.bind() # bind() builds the deployment graph node
serve.run(app) # start ingress + 2 GPU replicas
Two replicas, each pinned to one GPU, means two requests run truly in parallel and a third queues behind whichever frees first. That is already better than a single Flask process — but each replica still runs one request at a time through its model, and on a GPU that is almost always the wrong way to use the hardware. Fixing that is the next section.
3 · Dynamic request batching — the central knob
A GPU is a throughput device: it is built to apply the same matrix multiply to a wide batch in roughly the time it takes to do one row, because the cost is dominated by launching the kernel and moving weights, not by the per-row arithmetic. Feed it one request at a time and you might use 5% of it. Dynamic batching is the mechanism that fixes this for online traffic: when a request arrives, instead of running it immediately, the replica holds it briefly to see if more arrive, then runs them together as one batched forward pass.
In Serve this is a decorator on a coroutine that takes a list of inputs:
@serve.deployment(num_replicas=2, ray_actor_options={"num_gpus": 1})
class Classifier:
def __init__(self):
self.model = load_model_from_checkpoint("s3://ckpt/best")
async def __call__(self, request: Request):
payload = await request.json()
return await self.classify(payload["text"]) # one item in, one out
# Serve gathers concurrent calls into a list, runs ONE forward pass.
@serve.batch(max_batch_size=16, batch_wait_timeout_s=0.01)
async def classify(self, texts: list[str]) -> list[str]:
return self.model(texts) # vectorized over the batch
Two knobs govern it. max_batch_size caps how many requests go in one pass. batch_wait_timeout_s caps how long the first waiting request will tolerate the replica holding the door open for friends. The batch fires when either the batch is full or the timeout expires — whichever comes first.
The math, worked
Say a single forward pass on this GPU takes T(b) ≈ 8 + 1.5 × b ms for a batch of b (8 ms fixed kernel/launch overhead, 1.5 ms of marginal work per extra item — the classic "wide is cheap" curve). Requests arrive at a steady λ = 800 requests/second.
- No batching (b = 1). Each pass is 8 + 1.5 = 9.5 ms, so one replica does at most 1000 / 9.5 ≈ 105 req/s. To serve 800 req/s you need ⌈800 / 105⌉ = 8 GPU replicas, and the GPU spends 84% of its cycles on fixed overhead. Expensive and wasteful.
- Batch of 16. One pass is 8 + 1.5 × 16 = 32 ms and clears 16 requests, so throughput is 16 / 0.032 = 500 req/s per replica — almost 5× the per-replica rate. Now ⌈800 / 500⌉ = 2 replicas suffice. You went from 8 GPUs to 2.
- What the latency cost was. At 800 req/s a batch of 16 fills in 16 / 800 = 20 ms on average, so a typical request waits ~10 ms (it lands mid-fill) plus the 32 ms compute → p50 ≈ 42 ms. The unlucky first arrival in a batch waits the full 20 ms fill + 32 ms compute = 52 ms, and under burst the queue stacks, pushing p99 toward 80–120 ms. Compare the unbatched p50 of ~10 ms: batching quadrupled median latency to cut the GPU bill by 4×.
That is the trade in one breath: bigger batch → higher throughput and utilization, fewer replicas, lower cost — but higher p50 and a fatter p99 tail. The timeout is the safety valve: set batch_wait_timeout_s so that even when traffic is light (batches never fill), no request waits longer than your tail budget. The widget lets you feel both knobs at once.
4 · Composing models — and right-sizing each stage
A real API is rarely one model. A moderation endpoint tokenizes text (cheap, CPU), runs a small fast classifier (cheap, GPU or CPU), and only on a flagged result escalates to a large expensive model (heavy, GPU). Serve composes these as a deployment graph: separate deployments wired together with handles, where each deployment scales independently.
@serve.deployment(num_replicas=2) # cheap CPU stage
class Tokenizer:
def __call__(self, text): return tokenize(text)
@serve.deployment(autoscaling_config={"min_replicas": 2, "max_replicas": 8,
"target_ongoing_requests": 5},
ray_actor_options={"num_gpus": 1}) # the bottleneck stage
class Classifier:
@serve.batch(max_batch_size=16, batch_wait_timeout_s=0.01)
async def __call__(self, batch): return self.model(batch)
@serve.deployment # ingress: wires the graph
class Pipeline:
def __init__(self, tok, clf):
self.tok, self.clf = tok, clf # DeploymentHandles
async def __call__(self, request):
text = (await request.json())["text"]
toks = await self.tok.remote(text) # call stage 1
return await self.clf.remote(toks) # call stage 2
app = Pipeline.bind(Tokenizer.bind(), Classifier.bind()) # graph via .bind()
serve.run(app)
The reason each stage is its own deployment is that they have different resource profiles and different throughput. Collapsing them into one deployment forces every stage to scale together — you would buy a GPU for every CPU tokenizer you add. Separating them lets you give replicas only to the stage that needs them.
Tokenizer capacity per replica: 1000 / 2 = 500 req/s → you need ⌈1000 / 500⌉ = 2 CPU replicas.
Classifier capacity per replica (batched, from §3): ~500 req/s → you need ⌈1000 / 500⌉ = 2 GPU replicas.
Now suppose the classifier is the heavy fallback at 50 ms/request, and only 30% of traffic (300 req/s) reaches it. Its capacity per replica is 1000 ms ÷ 50 ms = 20 req/s, so the fallback alone needs ⌈300 / 20⌉ = 15 GPU replicas. The fallback, hit by less than a third of traffic, dominates the GPU bill — which is exactly the signal to route only genuinely-uncertain cases to it. Independent scaling makes this visible; a monolithic deployment would have hidden it.
Autoscaling per deployment is driven by target_ongoing_requests — the number of concurrent requests Serve aims to keep queued+running per replica — bounded by min_replicas and max_replicas. If each replica handles ~5 in-flight requests comfortably and 40 are in flight, Serve wants ⌈40 / 5⌉ = 8 replicas; the min keeps a warm floor so a burst does not hit a cold start, the max caps cost. The deep autoscaling story — how this rides on the cluster autoscaler and pod cold-start latency — is lesson 12.
5 · The three trade-offs you are actually choosing
| Trade-off | How to reason about it (with the lever) |
|---|---|
| Batch wait vs SLO vs utilization | Bigger batch / longer timeout → higher GPU utilization and throughput, fewer replicas, lower cost — but higher p50 and a fatter p99. Set batch_wait_timeout_s below your tail budget so even light traffic never waits too long; size max_batch_size to where the throughput curve flattens. |
| Replica count vs cost vs p99 | More replicas drain the queue faster (lower tail under burst) but each GPU costs money sitting partly idle between spikes. target_ongoing_requests with min/max picks the floor (warm capacity for bursts) and ceiling (cost cap). |
| Collocation vs isolation | Putting two models in one deployment/replica saves processes and lets them share warm GPU memory — but they now scale and fail together, and one's batch can starve the other. Isolate stages with different resource profiles or SLOs; collocate only cheap, co-scaling glue. |
| Serve vs a specialized server | Serve wins at Python-native composition and heterogeneous logic (preprocess + retrieval + rules + model). For a single hot LLM, a dedicated engine (vLLM, TensorRT-LLM, Triton) has better kernels and KV-cache batching — often run inside a Serve deployment. |
6 · Failure modes & checklist
Failure modes
- Batching cheap Python glue.
@serve.batchon a stage with no GPU and no vectorization just adds queueing latency for zero throughput gain. Batch only where one wide forward pass is genuinely cheaper than many narrow ones. - Timeout tuned in the abstract. A 50 ms timeout looks fine until traffic drops and every request waits the full 50 ms because batches never fill. The right timeout depends on the live arrival rate (see §3 widget).
- Replicas sized for throughput, not memory. Eight replicas × a 14 GB model is 112 GB — more than fits. Each replica loads the weights in its own actor; count GPU memory per replica, not just request rate.
- CPU bottleneck behind a GPU. Slow preprocessing hidden inside the model deployment makes you scale expensive GPUs to fix a cheap CPU problem. Split the stage out and scale it independently (§4).
- Ignoring the per-hop tail. In a graph, user latency is the sum of every deployment's latency, and each hop adds its own tail. A 3-stage graph at p99 each is not p99 end-to-end — it is worse. Trace the whole path.
Implementation checklist
- What is the SLO, stated as p50/p99/throughput and an error budget — not just "fast"?
- Does each replica's
__init__load the model exactly once, and does the model fit in that replica's GPU memory? - Is
max_batch_sizesized to where the throughput curve flattens, andbatch_wait_timeout_skept under the tail budget? - Is each stage with a distinct resource profile its own deployment, scaled by its own
target_ongoing_requests? - Which stage is the real bottleneck, and does it (not the cheapest stage) get the most replicas?
- Are
min_replicas(warm floor for bursts) andmax_replicas(cost cap) set deliberately? - Is the end-to-end request path traced so you can attribute tail latency to a hop?
Checkpoint exercise
max_batch_size, batch_wait_timeout_s) pair that meets the SLO, and compute how many replicas you need. Now the SLO tightens to p99 ≤ 60 ms — does your answer change, and in which direction does the GPU bill move? (The right answer flips: a tighter tail forces a smaller batch / shorter timeout, which lowers per-replica throughput, which forces more replicas — you buy your tail back with hardware.)Where this points next
This lesson assumed replicas could appear when target_ongoing_requests demanded them — but where do the GPUs come from, and how fast? A burst that needs four new replicas may wait on a new pod, a pulled image, and a model load before it serves a single request, and that scale-up latency is itself part of your tail. Lesson 12 takes the autoscaling instinct down a layer: the Ray autoscaler scaling on pending resource demand, KubeRay's RayService running this graph on Kubernetes, and the two-level autoscaling dance between Ray and the cluster — where online inference's "just add replicas" meets the physics of cold starts and idle cost.
@serve.batch with max_batch_size + batch_wait_timeout_s): because a GPU is a throughput device, batching fills it and multiplies throughput, but every batched request paid a queueing tax, so it strictly trades p50/p99 latency for utilization — tune the timeout against your live arrival rate, not in the abstract. Compose models as a deployment graph where each stage scales independently, and give the replicas to the bottleneck stage, not the cheapest one.Interview prompts
- How is online inference a different problem from a batch job? (§1 — request-level, SLO-bound, tail (p99) sensitive, and bursty; you owe each request an answer inside a budget, not an aggregate by a deadline.)
- What is a deployment vs a replica in Ray Serve? (§2 — a deployment is an autoscaling group serving one logical thing; a replica is one Ray actor running an instance of the deployment class, loading the model once in its
__init__.) - Why does batching help on a GPU, and what does it cost? (§3 — a GPU's cost is dominated by kernel launch + weight movement, so a wide batch is nearly as cheap as one row, multiplying throughput; the cost is the queueing tax each batched request pays, raising p50 and p99.)
- How do
max_batch_sizeandbatch_wait_timeout_sinteract? (§3 — the batch fires when it fills OR the timeout expires; at low arrival rates the timeout binds (small batches), at high rates the size binds. Tune the timeout against the live λ so light traffic never over-waits.) - You must sustain 1000 req/s through tokenize → classify → fallback. How do you size replicas? (§4 — capacity per replica per stage = 1000/latency; ceil(load/capacity) per stage. Replicas go to the real bottleneck — here the 50 ms fallback dominates the GPU bill even at 30% of traffic.)
- Why split a pipeline into separate deployments instead of one? (§4 — stages have different resource profiles and throughput; independent deployments scale (and fail) independently, so you don't buy a GPU per CPU tokenizer, and the bottleneck is visible.)
- When is Serve the wrong tool, or only half of it? (§5 — for a single hot LLM a specialized engine (vLLM, TensorRT-LLM, Triton) has better kernels and KV-cache batching; run it inside a Serve deployment and keep Serve for the Python composition around it.)