all_lessons/ray/11lesson 12 / 17

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.

Book source
Chapter 8, "Online Inference with Ray Serve" - online inference characteristics, Serve architecture, endpoints, scaling, batching, multimodel graphs, and an NLP API example. Calibrated to current Ray: @serve.deployment with autoscaling_config, @serve.batch dynamic batching, and deployment composition via .bind() and DeploymentHandle.
Linear position
Prerequisite: Lesson 03 (Actors) — a stateful worker process that loads an expensive object once and answers method calls — and lesson 10 (Ray Train), which produced the checkpoint we now serve.
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.
The plan
Five moves. (1) Name what makes online inference a different problem from a batch job — the latency contract and the tail. (2) Build the Serve object model: deployment, replica, ingress, and the handle that lets deployments call each other. (3) Make dynamic batching quantitative — the central throughput/latency trade-off, with a draggable widget for batch size vs timeout vs p99. (4) Compose deployments into a graph and right-size replicas per stage so the bottleneck, not the cheapest stage, gets the actors. (5) Failure modes, a checklist, and the hand-off to clusters and autoscaling.

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.

request-levelThe unit of work is one request from one caller waiting on the other end of a socket — not a partition of a dataset. You owe each request an answer, not an aggregate.
SLO-boundAn SLO (service-level objective) is a latency budget you promise to meet, e.g. "p99 ≤ 200 ms." It is a contract: cross it and an upstream timeout fires, a user bounces, or a pager goes off.
tail mattersp99 (the 99th-percentile latency — 99% of requests are faster, 1% slower) is what bites you. A 10 ms median with a 2 s p99 means 1 in 100 users has a terrible experience, and that fans out: a page making 100 calls almost always hits one slow tail.
bursty trafficLoad is not a smooth dataset of known size; it is a stream with spikes. You provision for the burst you must survive, not the average, so capacity sits partly idle between spikes — which is what autoscaling exists to claw back.

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.

Deployment
A deployment is an autoscaling group of identical replicas that together serve one logical thing (one model, one preprocessing stage). You write it as a class decorated with @serve.deployment; Serve manages how many replicas exist and routes requests across them.
Replica
A replica is one Ray actor (lesson 03) running an instance of your deployment class. Its __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.
Ingress & handle
Requests enter through an HTTP/gRPC ingress deployment (an HTTP proxy on each node fronts the cluster). Internally, a 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 real contract: batching buys throughput with latency
Every request that gets batched paid a queueing tax — it waited for the batch to assemble before any compute started. So batching strictly raises the latency of an individual request to raise the throughput (and utilization) of the GPU. The whole game is: take just enough queueing latency to fill the GPU, and not one millisecond more than your p99 SLO allows.

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.

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.

Dynamic batching — throughput vs p99 as you drag the knobs
A stream of requests arrives at rate λ. Each waits until the batch hits max_batch_size or the batch_wait_timeout expires, then runs one forward pass of 8 + 1.5×b ms. The bars show, for the chosen knobs, the realized throughput (req/s a single replica sustains) and the latency distribution (p50 / p99) — green while p99 is under the SLO, red when it blows past. Drag and watch a bigger batch trade tail latency for throughput.
Throughput / replica
p50 latency
p99 latency
Effective batch / GPU util
Show the core JS
// time to fill a batch from empty at rate lambda (s^-1):
fill_ms = 1000 * max_batch_size / lambda;          // could exceed the timeout
wait_ms = Math.min(fill_ms, timeout_ms);           // batch fires at whichever is first
eff_b   = Math.min(max_batch_size, lambda * wait_ms / 1000); // items actually gathered
compute = 8 + 1.5 * eff_b;                          // one forward pass, ms
throughput = 1000 * eff_b / compute;                // req/s ONE replica sustains
replicas = ceil(lambda / throughput);               // right-size the deployment
rho = lambda / (replicas * throughput);             // per-replica load, in [0,1)
// a request waits a uniform share of the fill window, then computes:
p50 = 0.5 * wait_ms + compute;
p99 = wait_ms + compute + compute * rho;            // + ~one burst pass; grows with batch
Worked number — reading the widget's defaults
At the defaults (max_batch_size 16, timeout 10 ms, λ = 800/s), a batch of 16 would take 20 ms to fill but the 10 ms timeout fires first, so the replica gathers only 800 × 0.010 = 8 items per pass. Compute is 8 + 1.5 × 8 = 20 ms, throughput is 8 / 0.020 = 400 req/s, and p99 sits comfortably under a 200 ms SLO. Now drag the timeout to 30 ms: batches fill to the full 16 (fill time 20 ms < 30 ms timeout), compute climbs to 32 ms, throughput jumps to 500 req/s — but p99 rises too. The timeout, not the batch size, was the binding constraint at 800/s. That is the lesson: tune the timeout against your actual arrival rate, not in the abstract.

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.

Worked number — put the replicas where the bottleneck is
Suppose tokenization takes 2 ms/request and the classifier takes 20 ms/request (batched), and you must sustain 1000 req/s.
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-offHow to reason about it (with the lever)
Batch wait vs SLO vs utilizationBigger 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 p99More 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 isolationPutting 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 serverServe 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.batch on 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_size sized to where the throughput curve flattens, and batch_wait_timeout_s kept 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) and max_replicas (cost cap) set deliberately?
  • Is the end-to-end request path traced so you can attribute tail latency to a hop?

Checkpoint exercise

Try it
You serve a 7B classifier on one GPU. A forward pass costs 10 + 2×b ms for batch b; your SLO is p99 ≤ 150 ms; traffic is 600 req/s. Find a (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.

Takeaway
Online inference is a latency contract, not a batch job: each request is owed an answer inside an SLO, the p99 tail is what bites, and traffic is bursty. Ray Serve serves it as a deployment — an autoscaling group of replica actors, each loading the model once — behind HTTP ingress, callable internally through a ServeHandle. The central engineering knob is dynamic batching (@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