Core

Model Serving and Inference APIs: Turning a Model File Into a Service

0 of 10 complete

0%

Contents

Back|CoreModel Serving and Inference APIs: Turning a Model File Into a Service
1/10
54 min left
  1. Home
  2. AI Engineering: Foundation
  3. Core Concepts
  4. Model Serving and Inference APIs: Turning a Model File Into a Service
Prerequisites
Model Packaging and Containerization: Killing 'Works On My Machine' for MLrequired
Related Topics
Fine-Tuning vs RAG vs Prompting: Choosing Your ApproachLLM and GenAI OpsParameter-Efficient Fine-Tuning: LoRA and QLoRALLM and GenAI OpsEvaluating LLMs in Production: Grading Answers That Have No Right AnswerLLM and GenAI OpsPrompt Management and Versioning: Treat Prompts as Production CodeLLM and GenAI OpsVector Databases and Approximate Nearest Neighbor SearchLLM and GenAI Ops
Previous lessonModel Packaging and Containerization: Killing 'Works On My Machine' for MLNext lesson
1 of 10
Training Pipelines and Orchestration: Retraining That Runs Itself

System Design

  • Foundation
  • Intermediate
  • Advanced
  • Capstone

AI Engineering

  • Foundation
  • Data, RAG and Agents
  • Evaluation, LLM Ops and Security

systemdesign.academy

  • Home
  • Glossary
  • Interview prep
  • Reviews
  • About
  • Privacy
  • Terms

Where this shows up in interviews

3 system design questions lean on this idea. Each walks through the full answer.

  • →Design a Fraud Detection System
  • →Design a Recommendation System
  • →Design an LLM Inference Platform

The Model Predicts in 8 Milliseconds. The Endpoint Falls Over at 200 Requests.

A team ships a recommendation model. In the notebook, model.predict(x) returns in 8 milliseconds and everyone is thrilled. They wrap it in a quick Flask app, put it behind a URL, and point the app at it. It works in the demo. It works for the first ten users.

Then a marketing push sends real traffic, and at a couple hundred requests per second the endpoint starts timing out. The GPU sits at 12 percent utilization while requests pile up in a queue. that was 8 milliseconds in the notebook is now 4 seconds and climbing. Nothing is technically broken. The model is fine. The serving is not.

This is the gap between a model that predicts and a model that serves. Predicting is a function call. Serving is a service that has to answer thousands of callers at once, hold a latency budget, use an expensive GPU efficiently, survive a replica crashing, roll out new versions safely, and scale when a spike hits. That is a real distributed systems problem, and it is the subject of this lesson.

A model file answers one caller. A serving system answers ten thousand callers at once, fast, without wasting the GPU, and keeps answering when a machine dies.

By the end you will understand the modes of serving, the exact path a request takes, how to pick REST or , why dynamic batching is the trick that makes GPU serving affordable, how autoscaling and cold starts interact, why the p99 latency is the number that decides if your service is good, and how to ship a new model version without betting all your traffic on it.

Three Ways to Serve, and the One Question That Decides

Before any code, get the biggest decision right. Serving splits by one question: is a user or a live flow waiting for this specific prediction right now?

If yes, you need online serving (also called real-time serving). A request comes in, and something is blocked until you answer. A fraud check on a card swipe. A search ranking. The recommendation row that has to render before the app feels ready. Here, is everything. An answer that arrives a second late is worthless even if it is correct.

If no, you need batch serving (also called offline serving). A scheduled job scores an enormous set of rows in one run, and no single row is on a clock. Precomputing recommendations for every user overnight. Scoring all accounts for churn once a week. a whole document corpus. Here, per-row latency does not matter at all. What matters is total throughput: finishing the whole run before the deadline as cheaply as possible.

There is a third shape that sits between them. Streaming serving scores an unbounded, never-ending flow of events, one window at a time, and the goal is to keep up with the arrival rate forever rather than to finish a fixed set. Live anomaly detection on a metrics firehose and scoring a clickstream as it lands are streaming problems.

Three-lane comparison of online, batch, and streaming serving. Online serves one blocked caller and optimizes p99 latency with a 10 to 200 millisecond budget, paying for warm idle capacity, used for fraud checks and feed ranking. Batch scores millions of rows on a schedule with nobody waiting, optimizes total throughput to finish by a deadline, and pays only for the run, used for nightly churn scoring and embedding a corpus. Streaming scores a continuous event window, optimizes a sustained rate in a seconds budget, and pays for an always-on stream, used for live anomaly detection and clickstream scoring

The modes have opposite economics. Online serving keeps replicas warm and idle-ready, so you pay for availability. Batch serving spins compute up, saturates it, and tears it down, so you pay only for the run. Streaming holds a long-lived job open and pays to stay caught up. Many real systems use all three: a recommender precomputes candidate items overnight in batch, re-ranks the top few hundred online in a handful of milliseconds when you open the app, and refreshes features from a live click stream in between. Picking the wrong mode is an expensive and common mistake. A nightly report does not belong on a real-time endpoint, and a live user does not belong waiting on a batch job. The rest of this lesson lives inside online serving, because that is where the hard engineering is.

The Request Path: What Actually Happens Between Click and Answer

Zoom into online serving. A prediction request does not go straight to the model. It travels through a small system, and every hop earns its place.

The online serving stack drawn left to right. A client with a latency budget and API key calls, step one, an API gateway and load balancer running NGINX that terminates TLS, checks the API key, applies rate limits, and routes across many identical model server replicas on Kubernetes, each replica holding a batcher and one GPU. A Redis prediction cache sits beside the replicas so step two checks it first and step three returns a hit in under a millisecond, skipping the whole path below. Only a cache miss reaches step four, the GPU, which preprocesses features into a tensor and runs the forward pass

The client never touches the model server directly. In front sits an (often NGINX or a cloud ) that terminates TLS, checks the API key, applies rate limits, and spreads traffic across many identical model server replicas. A prediction cache (usually ) sits alongside so a repeated input can skip the model entirely. The model servers run on , which adds and removes replicas as load moves and reschedules any replica that crashes. The model itself runs its forward pass on a GPU. That indirection is not overhead. It is exactly what lets you add replicas, cache repeats, terminate TLS once, rate-limit abusers, and reschedule a crashed pod, all without the client knowing or the model changing.

Now walk a single request through, arrow by arrow, and watch where the time actually goes.

Request lifecycle sequence diagram across five lifelines: client, gateway, cache, model server, and GPU. The request travels in over 8 milliseconds of network, the gateway spends 3 milliseconds on TLS, auth, and rate limit before a 1 millisecond cache lookup, the cache misses and forwards to a replica, the model server holds the request 5 milliseconds for the batch window and spends 4 milliseconds preprocessing, the batched forward pass takes 28 milliseconds on the GPU, scores return with 3 milliseconds of postprocessing, and the response streams back over 8 milliseconds of network. A dashed green region marks that a cache hit would skip the entire lower path and return in under a millisecond

Notice the surprise: the network hops are cheap. The real time is spent in two places. First, the server deliberately holds the request for a few milliseconds to gather a batch (more on that soon). Second, the GPU forward pass. And notice the escape hatch: if the cache check had been a hit, the whole path from the model server down would have been skipped and the answer would have come back in under a millisecond. A good serving layer answers as many requests as possible without ever waking the GPU.

How the Client Talks to the Model: Protocol and Timing

The client and the model server need a shared language, and they need to agree on how long the caller can wait. Two decisions, both about who is calling and how hard.

Start with the wire protocol. REST with JSON over is the default. You can curl it, read it in a browser, and debug it in Postman. JSON is text, though, so numbers become strings, keys repeat on every row, and a large feature vector or an image balloons on the wire and burns CPU to parse on both ends. with Protobuf over HTTP/2 is the high-performance option. The payload is compact binary defined by a typed .proto schema that both sides compile, a float is 4 bytes instead of a string, and many calls multiplex over one connection.

REST versus gRPC comparison for one 512-float feature vector. REST with JSON over HTTP/1.1 is the readable default: text and verbose payload, loose documented schema, awkward streaming, excellent debugging with curl and browser and Postman, and the vector serializes to about 6.5 kilobytes because numbers become strings. gRPC with Protobuf over HTTP/2 is the high-performance path: binary and compact payload, strict proto contract, native bidirectional streaming, needs tooling to debug, and the same vector packs to about 2.0 kilobytes at 4 bytes per float with no repeated keys

DimensionREST / JSONgRPC / Protobuf
PayloadText, verboseBinary, compact
TransportHTTP/1.1

Dynamic Batching: The Trick That Makes GPUs Affordable

Here is the single most important idea in GPU serving. A GPU running a batch of 32 inputs takes almost the same wall-clock time as running a single input, because it does the math in parallel. So if you feed the GPU one request at a time, it sits nearly idle between requests and you pay a fortune per prediction. If you feed it full batches, you get many predictions for roughly the cost of one.

But in online serving, requests arrive one at a time from different users. You cannot ask users to show up in neat groups of 32. Dynamic batching solves this: the server holds each incoming request for a tiny, bounded window (say up to 5 milliseconds), gathers whatever other requests arrive during that window, and runs them all as one batch.

Dynamic batching window timeline. Six requests trickle in from different users across a batch window of up to 5 milliseconds, drawn as blue request markers arriving at different times inside a dashed blue window box. At 5 milliseconds the window closes and fires one forward pass over the whole batch of six, taking about 28 milliseconds on the GPU. The window has two racing stop conditions: the batch hits its max size of 32, or the timer hits its max wait of 5 milliseconds, whichever comes first

The window has two stop conditions racing each other: the batch hits its max size, or the timer hits its max wait. Whichever comes first ends the window and fires the forward pass. This is a direct trade: you spend a few milliseconds of latency to buy several times the throughput. Tune it wrong in either direction and you lose. Too short a window and the GPU starves on tiny batches. Too long and you blow your p99 latency budget waiting for a batch that never fills. The goal is to set max batch size and max wait so the batch fills right as the timer expires under normal load. Triton and vLLM push this further with , where new requests join a running batch instead of waiting for the next window, which is a big deal for LLMs where one generation can take seconds.

Batching is not a free lever, though. It sets the position on a curve that every serving engineer has to understand: as you push more concurrent requests through one replica, total throughput climbs, but only until the GPU saturates, after which throughput flattens while explodes.

Latency versus throughput tradeoff on one replica. As concurrent requests in flight rise from 1 to 64, throughput in queries per second climbs steeply and then plateaus near 2500 QPS once the GPU saturates, while p99 latency stays low at first and then shoots up from about 15 milliseconds to 300 milliseconds. A dashed marker labels the knee, the load where you capture most of the throughput while p99 still fits the budget. Past the knee, more concurrency buys almost no extra throughput and only trades away latency

A Real Serving Endpoint, and a Batching Simulator You Can Run

Enough theory. Here is a real online serving endpoint. BentoML is a serving framework that wraps your model in a service, handles the layer, and gives you dynamic batching with a single decorator argument. This is a recommendation ranking service that scores candidate items for a user.

import bentoml
import numpy as np
import torch


@bentoml.service(
    resources={"gpu": 1},          # this service needs one GPU
    traffic={"timeout": 10},        # reject any request still open after 10s
    workers=1,                      # one worker owns the GPU; scale with replicas
)
class RecommenderService:
    def __init__(self) -> None:
        # Loaded ONCE at startup, then kept warm in GPU memory.
        # This is the cold start: heavy now, so every request stays cheap.
        self.model = torch.jit.load("ranking_model.pt")
        self.model.eval()
        self.model.to("cuda")

    @bentoml.api(
        batchable=True,             # let BentoML batch concurrent calls
        batch_dim=0,                # stack requests along axis 0
        max_batch_size=32,          # never build a batch bigger than 32
        max_latency_ms=5,           # ...and never wait longer than 5ms to fill it
    )
    def rank(self, features: np.ndarray) -> np.ndarray:
        # `features` arrives already batched: shape (N, feature_dim),
        # where N is however many requests BentoML grouped this window.
        with torch.no_grad():
            x = torch.from_numpy(features).float().to("cuda")
            scores = self.model(x)                 # one forward pass, whole batch
            return scores.cpu().numpy()            # shape (N,), one score per request

Read what each part buys you. The model loads once in __init__, so the expensive weight load happens at startup, not on every request. The batchable=True plus max_batch_size and max_latency_ms give you exactly the dynamic batching from the last slide, declared in three lines. The rank method receives an already-batched array and returns one score per row, so you write the model logic once and the framework handles the fan-in and fan-out. You start this with bentoml serve, containerize it into an OCI image with , and ship that image to .

Autoscaling, Cold Starts, and Why p99 Is the Number That Matters

Traffic is never flat. It spikes at lunch, drops at 3am, and jumps when marketing sends an email. So the serving layer has to autoscale, and inference autoscaling has a nasty twist that web autoscaling does not: the cold start.

Autoscaling and cold start timeline over a lunchtime traffic spike. A red demand curve in queries per second rises sharply, and a blue step line tracks capacity as replicas are added, one replica at about 600 QPS each, stepping from 1 to 2 to 3 to 4 replicas. Because a fresh replica needs about 45 seconds to pull its image, start, and load weights, capacity lags demand during scale-up, and a hatched red region marks the overload window during cold start where demand exceeds capacity

When load rises, Kubernetes adds a replica. But a fresh model server pod is not instantly useful. It has to pull a multi-gigabyte container image, start the runtime, and load model weights onto the GPU. For a large model that is 30 to 90 seconds during which the new replica serves nothing while your existing replicas stay overloaded. That overload window is the hatched region in the figure, and it is you pay for by reacting too late. Two rules follow. First, scale on latency headroom, not on saturation, so you add capacity before p99 breaches, not after. Second, keep a warm floor: for latency-critical models you never scale to zero, because a scale-to-zero service greets its next user with a cold start. You trade a little idle GPU cost for protection against tail latency.

That word tail is the whole point. Serving quality is not measured by the average latency, it is measured by the tail: p50, p95, p99. Here is why the average lies.

Tail latency distribution histogram of request latency in milliseconds. Most requests cluster on the left near a p50 of 18 milliseconds, but the distribution has a long right tail. Dashed markers show p50 at 18 milliseconds in green, p95 at 52 milliseconds in amber, and p99 at 95 milliseconds in red, so the p99 is more than five times the median. A note explains that a page making five model calls has about a 5 percent chance at least one lands on the p99 slow path

Metric

Shipping a New Version Without Betting All Your Traffic

The last piece of serving is not about a single request, it is about change. A model that predicts well on your offline test set can still fail in production: the live feature distribution has drifted, a dependency added , an edge case crashes the new code path. You never cut all traffic from the old model to a new one in a single switch. Two patterns let live traffic vet a candidate before it owns the request.

Canary and shadow deployment comparison. Canary sends a small slice, 5 percent, of real traffic to the new v2 while 95 percent stays on the stable v1, both serving real users. You watch error rate, p99, and the business KPI, cap the blast radius at the slice, and roll back by routing the 5 percent back to v1, ramping 5 to 25 to 100 as metrics hold. Shadow mirrors 100 percent of traffic: v1 is the only answer users see, while a copy is sent to v2 whose output is discarded. Zero users are affected by v2, you watch latency and crashes and the v1 versus v2 diff, and the cost is running the model twice

Canary routes a small slice of real traffic, say 5 percent, to the new version while the rest stays on the stable one. Real users get v2 answers, so you can measure not just whether it stays up but whether it actually improves the metric you care about. You watch error rate, p99, and the business KPI on the canary slice, and if anything degrades you route the slice back to the stable version. The blast radius is capped at the slice, and you ramp 5 to 25 to 100 percent as the metrics hold.

Shadow (also called dark launch) mirrors real production traffic to the new version but throws its answers away. Users only ever see the stable version's output. The shadow copy runs against real inputs at real scale, so you catch crashes, latency regressions, and behavior differences with zero user exposure. The cost is running the model twice for the shadowed traffic.

Shadow proves it is safe; canary proves it is better. Shadow catches crashes and latency regressions under real load with no user risk, but because its answers never reach anyone, it cannot tell you whether v2 improves the business metric. Canary can, at the price of exposing a small slice. The mature rollout runs shadow first to catch the obvious failures, then a slow canary ramp with automated rollback wired to a metric breach. This is also why the cache key and every logged prediction carry the model version: when two versions run at once, you need to know which one produced any given answer.

How the Giants Serve at Scale

None of this is hypothetical. Every company running ML at scale hit these exact problems and built exactly this kind of serving layer.

DoorDash serves ranking and recommendation models on every home screen and search. To hold at high request rates, they moved heavy prediction traffic onto a dedicated prediction service and leaned hard on feature caching and request batching so a GPU is never scored one order at a time. The pattern is the one in this lesson: gateway, cache, batched model servers, autoscaled behind a latency budget.

Netflix ranks the rows you see with models that must respond in tens of milliseconds while a page assembles. They precompute heavy candidate generation offline in batch, then do the fast, personalized re-ranking online, which is the batch-then-online split from the second slide made real.

Spotify built its serving on top of and standard model servers so that hundreds of models, from Discover Weekly to the home feed, share one autoscaling, monitored serving substrate instead of each team hand-rolling a Flask app. That shared substrate is also what makes canary and shadow rollouts a standard, safe operation rather than a bespoke risk for every team.

For large language models, the shape shifts but the ideas hold. vLLM and NVIDIA Triton are the workhorses, and their headline feature is , the same latency-for-throughput trade you saw, tuned for generation where each request produces tokens over seconds. Companies serving LLM features route requests through vLLM precisely so one expensive GPU serves many concurrent conversations at once, and they stream tokens back over async connections because a full generation is far too slow for a synchronous call. Same lesson, bigger model: turning a model file into a service that survives real traffic is its own engineering discipline, and now you know its moving parts.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

A nightly job scores every user for churn risk and writes the results to a table. No user is waiting on any single prediction. Which serving mode fits?

Q2

Why does dynamic batching improve GPU serving?

Q3

Why do serving teams obsess over p99 latency instead of the average?

Q4

You want to ship model v2, but you are not sure it stays up under real load. Which rollout catches crashes and latency regressions with zero user exposure?

There is one more detail most notebook demos skip. Real models rarely receive a ready tensor. They receive an id, and the server has to assemble the feature vector before it can predict. Two lookups sit in front of the model, and both have to fit inside the same budget.

Caching and feature-fetch data flow. A request carries a user id or item id, not a full feature vector. It first hits a Redis prediction cache keyed on the id plus the model version. On a cache hit the request returns in under a millisecond with no features fetched and no GPU touched. On a cache miss it fetches features from the online feature store, runs the model, and writes the result back to the cache. The feature store must serve the same features that training used or you get training-serving skew, and it is often the second slowest hop after the GPU

Two things to internalize here. First, the cache key must include the model version, or a new deployment silently serves stale predictions computed by the old model. Second, the online must serve exactly the features training used. If serving reads a feature computed differently from how training computed it, you get training-serving skew, and the model quietly degrades in production while every offline metric still looks fine. The feature store, not the cache, is where that skew hides.

HTTP/2, multiplexed
SchemaLoose, documentedStrict .proto contract
StreamingAwkwardNative, bidirectional
DebuggabilityExcellent (curl, browser)Needs tooling
Best forPublic and browser APIsHigh-QPS internal traffic, large tensors, LLM streaming

You do not have to choose only one. Plenty of production servers expose both: REST on one port for external and browser callers, gRPC on another for internal service-to-service inference where every millisecond and every byte counts. NVIDIA Triton and TensorFlow Serving both expose gRPC for the hot internal path, and gRPC streaming is the natural fit for token-by-token LLM output.

The second decision is timing. Even inside online serving there is a fork. If a prediction returns in tens of milliseconds, answer it synchronously: the caller holds the connection open and gets the result on the same response. But if inference takes seconds or longer, a large generation, a video model, a big document, holding the socket open ties up a worker and eventually times out. Then you serve it asynchronously: accept the job, return a ticket immediately, and deliver the result later by polling or a webhook.

Synchronous versus asynchronous serving comparison. Synchronous means the caller blocks and gets the result on the same response, best under about one second, with tens of milliseconds latency, a held-open connection, a simple one-call client, and the risk that a worker is tied up until timeout, used for fraud scoring and ranking. Asynchronous means accept now and deliver later, for slow or bursty inference, with seconds to minutes of latency, a 202 plus job id then released connection, a client that polls status or gets a callback, and more moving parts, used for long LLM generation and video and bulk scoring

The async queue is also a shock absorber. It decouples arrival rate from processing rate, so a traffic burst piles up in the queue instead of knocking replicas over. You trade immediate delivery and client simplicity for the ability to smooth spikes and run expensive inference without holding a socket hostage. Chat interfaces often blend both: an async job that streams partial tokens back over a kept-open connection, which is why gRPC streaming and server-sent events show up together in LLM products.

The job is to run each replica near the knee of that curve: the load where you capture most of the while p99 still fits your budget. Past the knee, more concurrency is not more capacity. You are only trading latency for a sliver of throughput you will not keep. The right move once a replica is at its knee is horizontal, not vertical: hold each replica near the knee and add more replicas, so throughput scales with the fleet while every request stays inside the latency budget.

bentoml build

Numbers on a slide are easy to nod at and hard to feel. So run the batching trade for yourself. The simulator below plays one busy second of an online endpoint where requests arrive faster than a single GPU pass can clear them, and it serves them two ways: one at a time, and with dynamic batching. Run it and watch what batching does to the p99 and to GPU utilization.

The unbatched run cannot keep up: a single GPU that needs 25 milliseconds per pass clears only about 40 requests per second, so at 800 arrivals the queue explodes and p99 runs into the seconds while the GPU still shows low utilization because it is stalled waiting on one-item passes. The batched run fills each pass with dozens of requests, keeps the GPU busy, and holds p99 near the batch window plus one forward pass. Same model, same hardware. The only thing that changed is how requests are grouped, and that is the whole ballgame.

You do not build any of this machinery by hand. You pick a serving runtime that ships it, and the four common choices overlap heavily.

Comparison matrix of four model servers across framework support, dynamic batching, protocols, GPU multi-model, best fit, and when to reach for each. TF Serving from Google serves TensorFlow SavedModels with dynamic batching and REST plus gRPC, best for TensorFlow shops at scale. TorchServe is PyTorch native with TorchScript, dynamic batching, and REST plus gRPC, best for PyTorch shops. NVIDIA Triton is multi-framework across TensorFlow, PyTorch, ONNX, and TensorRT, with advanced dynamic batching, REST plus gRPC, and many models per GPU, best for maximum GPU performance on a mixed stack. BentoML wraps any Python model with one-decorator dynamic batching and REST plus gRPC via config, best for shipping fast with custom logic and FastAPI ergonomics

Triton wins on raw performance and multi-framework flexibility; BentoML wins on speed to production and custom Python logic. If you are standardizing one framework at massive scale on NVIDIA hardware, Triton earns its complexity. If a small team needs to wrap a model with custom pre and post logic and ship it this week, BentoML gives you dynamic batching and an OCI image with a decorator and a build command. Most teams pick by the framework they already train in.

Meaning
Why it matters
p50 (median)Half of requests are faster than thisThe typical experience, but it hides the pain
p9595 percent are faster; 1 in 20 is slowerWhere slowness starts to be felt
p9999 percent are faster; 1 in 100 is slowerThe number that defines your worst common case

One in a hundred sounds rare until you count. A page that loads recommendations, ranking, and fraud scoring might make several model calls, and a user browsing makes dozens of page views. At p99, a heavy user hits the slow path many times per session. Worse, a single slow model call inside a page that fans out to many services drags the whole page down to its slowest dependency. With five calls per page, the chance at least one lands on the p99 slow path is about 1 minus 0.99 to the fifth, roughly 5 percent of page loads. This is tail latency amplification, and it is why teams obsess over p99 and p99.9 while barely glancing at the average. A model with a great average and an ugly tail is a bad production model.

The disciplined way to manage the tail is to treat the SLO as a budget and spend it hop by hop. Write the target at the top, subtract every stage, and see what headroom is left.

SLO latency budget breakdown as a stacked horizontal bar against a 120 millisecond p99 ceiling. The used time is 68 milliseconds, split into network in 8, TLS plus auth plus rate limit 4, cache lookup 1, batch wait 5, preprocess 6, GPU forward pass 28, postprocess 4, and serialize plus network out 12 milliseconds, leaving 52 milliseconds of headroom before the ceiling. The GPU forward pass is the single largest line at 41 percent of used time, and network round trip is about 20 milliseconds in and out

Budget first, optimize second. Assign every hop a number, sum them against the SLO, and attack the largest line that has slack. The GPU forward pass is usually the biggest single item, so cutting inference (a smaller model, , a better batch fill) moves the budget most. A team that tunes the network while a 28 millisecond GPU pass dominates is polishing the wrong hop. When the stages already sum past the ceiling, no single tweak saves you, and the honest answers are a smaller or quantized model, a warmer cache, or a looser SLO.