Deep Learning · Module 26 of 26 · 8 concepts · about 19 min

Deep Learning Deployment

Packaging a reproducible model service with safe rollout and post-release observability.

At work: Serving models to real users reliably: fast responses, safe updates, and knowing when something breaks.

Inference contracts, versioning and monitoring

What it is

Deployment freezes a model artifact, tokenizer or preprocessing, runtime dependencies, and input/output schema into one versioned inference contract. A serving system validates requests, bounds size and rate, batches compatible work when useful, applies timeouts, records versions, and supports rollback to a known-good release. Monitoring covers latency, errors, resource use, input drift, output behaviour, safety signals, and delayed quality labels; each measure needs an owner and a response threshold. Offline evaluation, shadow traffic, canary rollout, and rollback are successive ways to limit the blast radius of a bad release.

Why it matters

A model that is accurate offline can fail in production from preprocessing skew, changed inputs, resource exhaustion, or a silent version mismatch. The surrounding system is part of model correctness because it determines the actual tensors the model sees and the conditions under which users receive its result. A reproducible artifact and a monitored feedback loop turn deployment from a one-time upload into an operating discipline.

In the real world: Clear inputs, outputs and versions, so apps that use a model don't break when it changes.

Visual example

Deep learning

The deployed model includes every transformation needed to reproduce its training-time inputs and outputs.

Inference contract

  1. request validationschema, size, and safety checks
  2. versioned preprocessingtokenizer, normalization, label map
  3. model inferencebatched prediction under resource limits
  4. responses + monitoringoutput, latency, errors, drift signals

Version the whole contract, not weights alone.

ARTIFACT = {
    'model_version': 'ranker-2026.09.4',
    'weights': 'ranker-2026.09.4.safetensors',
    'tokenizer_hash': 'a1b2c3d4e5f6',
    'preprocessing': 'normalise_v3',
    'input_schema': {'text': 'str', 'locale': 'str'},
    'output_schema': {'score': 'float', 'model_version': 'str'},
    'torch_version': '2.5.1',
}

def predict(request):
    validate(request, ARTIFACT['input_schema'])
    return {'score': float(model(encode(request))), 'model_version': ARTIFACT['model_version']}

Saving only weights. Without the exact tokenizer, normalization statistics, label mapping, and model configuration, the same weights may make different—and wrong—predictions in production.

What is different about serving a deep network

What it is

The Machine Learning syllabus's production discipline applies unchanged: a versioned artifact, a validated schema, canary rollout, rollback, monitored drift. Four things are genuinely different. The artifact is gigabytes rather than megabytes, so loading is a meaningful part of startup and storage is a real cost. Inference may need a GPU, which is expensive, scarce, and cannot be autoscaled as casually as CPU. A single request's compute is large enough to matter individually. And throughput depends on *batching*, which introduces a latency-throughput tradeoff that a gradient-boosted tree simply does not have.

Why it matters

These four differences change the shape of the operational problem rather than adding detail to it. A scikit-learn service scales by adding cheap stateless replicas; a GPU service cannot, because a replica costs an order of magnitude more and one may hold the only copy of a 14 GB model that takes 90 seconds to load. So capacity planning becomes a real engineering activity, cold starts become a product concern, and the question 'CPU or GPU?' has to be answered per model with measurements rather than by default. Everything else you learned about production ML still holds — this is the delta, not a replacement.

In the real world: Big models need GPUs, batching and careful memory use to answer quickly and affordably.

Visual example

The delta

Everything from the ML syllabus still applies. These four rows are what deep learning adds.

Scale-to-zero is off the table

(and rolling deploys need overlap)

The new tradeoff

(batching buys throughput with latency)

Classical ML service vs deep learning service
gradient-boosted treedeep network
artifact size~5 MB0.5-70 GB
hardwareCPU, cheap, elasticGPU, expensive, scarce
cold start< 1 s30-120 s
throughputlinear in replicasdepends on batch size

Same discipline, four new constraints

Why this matters

Naming the delta precisely stops this module from re-teaching production ML, and stops a team from assuming their existing serving playbook transfers without change.

# The startup cost that classical ML does not have.
import time
import torch

start = time.perf_counter()
state = torch.load('model-14gb.safetensors', map_location='cpu')
model.load_state_dict(state)
model = model.to('cuda').eval()
load_s = time.perf_counter() - start        # ~90 s for a 14 GB model

# Which means:
#  - a cold start is 90 s, so scale-to-zero is not viable
#  - a rolling deploy needs the new replica warm BEFORE draining the old
#  - a health check must not report ready until the first forward pass has run
with torch.no_grad():
    model(warmup_batch)                     # CUDA kernels compile on first use

Reporting a replica healthy as soon as the process starts. The weights are still loading and CUDA kernels have not been compiled, so the load balancer routes real traffic into 90 seconds of timeouts — and the deploy looks like a model regression.

Batch inference versus real-time inference

What it is

Batch inference scores a large set of inputs on a schedule and stores the results for later lookup. Real-time inference scores one request when it arrives, inside a user-facing latency budget. The choice is not about the model; it is about whether the input exists before the request does. A daily recommendation refresh can be batch because the users and items are known in advance; a search ranker cannot, because the query is created by the request itself.

Why it matters

Batch is dramatically cheaper and simpler when it is possible, and the possibility is worth checking before building a real-time service. A batch job can use large batch sizes for near-perfect GPU utilisation, tolerate a failure by retrying, run on spot instances, and has no latency budget at all — so a model far too slow to serve online may be entirely practical offline. The cost of batch is staleness: a prediction computed at 03:00 is wrong by the time a user's behaviour changes at 14:00. That is the actual decision — how stale is acceptable — and many products that built a GPU-backed real-time service would have been better served by a nightly job and a key-value store.

In the real world: Tagging all new product photos overnight, versus answering each chatbot message within a second.

import torch

# Batch: maximise utilisation, no latency budget.
@torch.no_grad()
def nightly_scoring(model, all_users, batch_size=1024):
    model.eval()
    out = {}
    for i in range(0, len(all_users), batch_size):
        chunk = all_users[i:i + batch_size]
        scores = model(encode(chunk))              # GPU near 100% busy
        out.update(zip(chunk, scores.tolist()))
    write_to_kv_store(out)                         # served later as a lookup

# Real-time: minimise latency, accept poor utilisation.
@torch.no_grad()
def serve(model, request, budget_ms=50):
    model.eval()
    return model(encode([request]))[0]             # batch of 1, GPU mostly idle

Choosing real-time because the model is 'live'. If the input is knowable in advance, batch scoring plus a cache gives the same answer at a fraction of the cost — and removes the GPU from the request path entirely.

Exporting a model for serving

What it is

A trained nn.Module is a Python object that depends on your source tree, your package versions and your import paths. Serving it directly means shipping and running that whole environment. Exporting converts the model into a self-contained, framework-level graph instead: TorchScript serialises it into a form the PyTorch runtime can execute without your Python classes, and ONNX serialises it into a vendor-neutral graph that other runtimes — ONNX Runtime, TensorRT, mobile and browser engines — can load and optimise.

Why it matters

Two reasons, and the second is the bigger one. Portability: an exported graph decouples the artifact from your repository, so a refactor cannot break a deployed model and the serving image does not need your training code. Optimisation: an export is a *graph*, which a runtime can rewrite — fusing a convolution with its batch-norm and activation into one kernel, folding constants, selecting hardware-specific implementations. Those rewrites need the whole computation visible ahead of time, which eager Python execution cannot provide. That is where a large part of inference speedup comes from, and it is unavailable to a model served as a live Python object.

In the real world: Converting a trained model into a format a fast serving system or a mobile app can run.

Visual example

Export

Why a raw Python object is not what gets deployed: the bottom two rows are only available to a graph.

The coupling you are removing

(a class rename breaks production)

Where the speed comes from

(conv+bn+relu becomes one kernel)

torch.jit.trace

fast, but bakes in data-dependent branches

torch.onnx.export

for TensorRT, mobile, browser, or any non-PyTorch runtime

Serving an nn.Module vs an exported graph
eager nn.ModuleTorchScriptONNX
needs your source treeyesnono
survives a refactornoyesyes
kernel fusionlimitedyesyes
non-PyTorch runtimesnonoyes

An export is a graph, and only a graph can be rewritten by the runtime

Common misconception

Wrong idea: exporting is a packaging formality.

Correct idea: it changes what the runtime is allowed to do. Eager execution sees one operation at a time; a graph can be fused and reordered, which is where much of the inference speedup lives.

import torch

model.eval()
example = torch.randn(1, 3, 224, 224)

# TorchScript: trace the graph, then run it without your Python classes.
traced = torch.jit.trace(model, example)
traced = torch.jit.optimize_for_inference(traced)
traced.save('model.pt')

# ONNX: a vendor-neutral graph other runtimes can consume and optimise.
torch.onnx.export(
    model, example, 'model.onnx',
    input_names=['pixel_values'], output_names=['logits'],
    dynamic_axes={'pixel_values': {0: 'batch'}, 'logits': {0: 'batch'}},
    opset_version=17,
)

# Always verify the export agrees with the original.
with torch.no_grad():
    assert torch.allclose(model(example), traced(example), atol=1e-4)

Tracing a model whose forward contains data-dependent control flow. torch.jit.trace records the path taken by the example input only, so an if on a tensor value is baked in — the export runs, and takes the wrong branch forever. Use torch.jit.script or torch.export for those models.

Server-side request batching

What it is

A GPU running one request at a time is almost idle — the arithmetic is a fraction of what the hardware can do, and most of the step is fixed overhead. Dynamic batching puts a short queue in front of the model: requests arriving within a small window are collected, run as one batch, and the results are split apart again. Throughput rises steeply because the fixed cost is amortised over many requests, and the marginal cost of one more item in a batch is small.

Why it matters

The tradeoff it creates is the core operational decision of GPU serving. Every request now waits for the batching window before its compute even starts, so p50 latency rises, and the request that arrives first waits longest. Throughput and latency move in opposite directions and you have to choose a point: a 5 ms window might double throughput for 5 ms of added latency, while a 50 ms window might quadruple it at a cost no interactive product can absorb. The right window is derived from the latency budget, not tuned for maximum throughput — pick the largest window whose p99 still fits the SLA, and no larger.

In the real world: Grouping many users' requests together so one GPU serves thousands per second.

Visual example

Latency vs throughput

Throughput and p99 latency move in opposite directions. The SLA picks the point, not the throughput curve.

throughput (req/s)p99 latency (ms)
inside the SLAthroughput you cannot use01020304050020406080100120batching window (ms)normalisedp99 SLA = 50 ms3.4x throughput, p99 still 47 ms+5% throughput, SLA broken

Reading the operating point

no batching: 24 req/s, p99 22 ms. The GPU is ~15% utilised

12 ms window: 82 req/s, p99 47 ms. 3.4x the throughput, still inside budget

50 ms window: 113 req/s, p99 128 ms. Faster in aggregate, unusable per request

The right answer is the largest window whose p99 fits - here, about 12 ms

Why this matters

This is the tradeoff that classical ML serving does not have, and it is the one most often tuned in the wrong direction — toward the throughput number, which always looks better.

Common misconception

Wrong idea: batching makes inference faster.

Correct idea: it makes the GPU more productive and every individual request slower. You are buying throughput with latency, and the exchange rate gets worse as the window grows.

The window is chosen where the red line meets the SLA, not where the green line flattens.

import asyncio
import torch

class DynamicBatcher:
    def __init__(self, model, max_batch=32, window_ms=5):
        self.model, self.max_batch, self.window = model, max_batch, window_ms / 1000
        self.queue: list = []

    async def predict(self, item):
        future = asyncio.get_running_loop().create_future()
        self.queue.append((item, future))
        if len(self.queue) == 1:
            asyncio.create_task(self._flush_after_window())
        elif len(self.queue) >= self.max_batch:
            self._flush()
        return await future

    async def _flush_after_window(self):
        await asyncio.sleep(self.window)     # the latency you are spending
        self._flush()

    @torch.no_grad()
    def _flush(self):
        batch, self.queue = self.queue, []
        if not batch:
            return
        outputs = self.model(torch.stack([i for i, _ in batch]))
        for (_, future), out in zip(batch, outputs):
            if not future.done():
                future.set_result(out)

Tuning the batching window for maximum throughput. Throughput keeps improving as the window grows, so the optimum is unbounded — the window has to be chosen against the p99 latency budget instead, which is a different objective entirely.

Versioning and rollback at model scale

What it is

Rollback requires that the previous artifact still exists, still loads, and can be made live quickly. For a deep network each of those has a cost the classical case does not. The artifact is gigabytes, so keeping ten versions is terabytes of storage. Loading takes a minute or more, so rollback is not instant unless the previous version is kept warm. And the artifact is more than weights: the tokenizer, preprocessing, and export must roll back together or you have recreated the version-mismatch failure from module 19.

Why it matters

The practical consequence is that a rollback plan for a deep learning model has to be designed, not assumed. The pattern that works is a retention policy with tiers — keep the last few versions immediately loadable, a longer history in cheaper storage, and every version's manifest forever, since a manifest is bytes and is what lets you reconstruct which model produced a given logged prediction. The other half is keeping the previous replica set alive during a rollout rather than replacing it, so a rollback is a traffic-weight change taking seconds instead of a cold load taking minutes. That costs double capacity during the rollout window, and it is worth it.

In the real world: Switching back to the previous model within minutes if a new release misbehaves.

Visual example

Retention

Rollback speed is a storage tier decision made before the incident, not during it.

What an incident needs

(and it costs double capacity to have)

Keep these forever

(which model produced which logged prediction)

weights + tokenizer + export

all three roll back together, or none

A 14 GB artifact, tiered
versions keptstoragetime to roll back
hot (loaded)2GPU memory x2~10 s, a traffic shift
warm (local disk)570 GB~90 s, a cold load
cold (object store)30420 GB5-15 min, plus egress
manifest onlyallkilobytesnot loadable, but auditable

A rollback you have not paid for in advance is an outage, not a rollback

Why this matters

The classical ML instinct — keep every version, they are small — does not survive gigabyte artifacts. Tiering is how you keep fast rollback affordable.

# Storage cost is a real constraint, so retention is tiered.
RETENTION = {
    'hot': 2,        # loaded and serving traffic: instant rollback
    'warm': 5,       # on local disk: ~90 s to restore
    'cold': 30,      # object storage: minutes, plus egress
    'manifest': None,  # kept forever - it is a few kilobytes
}

def rollout(new_version, weights=(0.01, 0.1, 0.5, 1.0)):
    deploy_alongside(new_version)              # do NOT drain the old replicas
    wait_until_warm(new_version)               # weights loaded AND warmed up
    for w in weights:
        set_traffic(new_version, w)
        if not guardrails_ok(window_minutes=15):
            set_traffic(new_version, 0.0)      # seconds, because old is still warm
            raise RuntimeError(f'rolled back at {w:.0%}')
    retire_after(previous_version, hours=24)   # only now

Draining the old replicas as soon as the new version takes 100% of traffic. Rollback then requires a cold load of a multi-gigabyte artifact, turning a 10-second traffic shift into a 5-minute outage during the exact window you are most likely to need it.

Monitoring a deployed deep network

What it is

The Machine Learning syllabus established the signals: input drift, output distribution shift, latency, errors, and delayed quality labels reconciled as cohorts mature. All of them apply. Deep learning adds one that classical ML rarely needs — GPU cost and utilisation as a first-class monitored metric, tracked per request and per model version, with its own threshold and its own owner.

Why it matters

It is first-class because it is large enough to change decisions. A GPU-backed service can cost an order of magnitude more per request than a CPU one, so a 30% utilisation regression is a budget event, and a latency regression is simultaneously a user-experience problem and a cost problem — slower requests hold the GPU longer, so the same traffic needs more replicas. It is also a *leading* indicator in a way the quality metrics are not: a change in input length distribution shows up as rising GPU time per request days before it shows up in any accuracy number, because more tokens cost more compute immediately and only degrade quality once they exceed what the model handles well. Watching cost per request is watching the input distribution with a much shorter lag.

In the real world: Watching response times, errors and input changes, like photos from a new phone camera.

import time
import torch

GPU_USD_PER_HOUR = 2.50

def monitored_predict(request, model, version):
    start = time.perf_counter()
    tokens = encode(request)
    with torch.no_grad():
        output = model(tokens)
    elapsed = time.perf_counter() - start

    emit({
        'model_version': version,
        'latency_ms': elapsed * 1000,
        'input_tokens': int(tokens.numel()),        # the leading indicator
        'output_tokens': int(output.shape[-1]),
        'gpu_usd': elapsed / 3600 * GPU_USD_PER_HOUR,
        'gpu_util_pct': torch.cuda.utilization(),
        'output_mean': float(output.mean()),        # distribution drift
    })
    return output

# Alert on cost per request, not just on latency: they diverge when
# batching efficiency changes without the model getting slower.

Monitoring only p50 latency and accuracy. Cost per request and input-length distribution move first, and a service can stay inside its latency SLA while its GPU bill doubles because batch efficiency collapsed.

Closing the loop

What it is

A deployed deep learning model is a cycle, not a delivery. Serve; log every request with its inputs, its outputs, its latency, its GPU cost and its model version; watch the label-free signals continuously; reconcile against true quality as each cohort matures; and when a threshold is crossed, go back — to the data, to the compression settings, to the batching window, or to the architecture. The drifted segment or the regressed slice is already identified, which is a far better starting point than the original project had.

Why it matters

It is the shape of the whole syllabus. The loop reuses everything: the tokenizer contract from module 19, the fine-tuning discipline from 21, the checkpoint hygiene from 22, the memory budget from 23, the triage method from 24, and the per-slice evaluation gate from 25. And it reuses the Machine Learning syllabus's monitoring loop wholesale, with one metric added — GPU cost per request — that turns out to move first. Twenty-six modules exist so that when the dashed line on the closing chart starts rising, you know which of them to open.

In the real world: Collecting real-world mistakes and user feedback to retrain and improve the model.

Visual example

The loop, running

The last picture in the syllabus: p99 latency over time, an SLA line, a regression window, and a fix — with GPU cost per request leading it by nine days.

p99 latencyGPU cost per 1k requests (scaled)
input lengths drifting upwardquantize + retune batching010203040506020304050607080Days since deploymentp99 latency (ms)p99 SLA = 50 mscost rises first, day 15p99 crosses the SLA, day 26back inside budget

The full cycle, day by day

day 15: GPU cost per 1k requests up 16%. Latency unchanged. A ticket, not a page

day 21: input token p50 up from 340 to 520 - a new client is sending longer documents

day 26: p99 crosses the 50 ms SLA, exactly as the cost curve predicted 11 days earlier

day 44: int8 export re-gated per slice (module 25): -0.3 pt overall, no slice past tolerance

day 47: quantized model canaried at 1/10/50/100%, batching window retuned 12 ms -> 8 ms

day 48: p99 back to 46 ms, cost per 1k below launch. The old replicas stayed warm throughout

The cost signal led the latency signal by eleven days. That lead time is the whole point of monitoring it

Why this matters

This is what all twenty-six modules were for: a model in production whose degradation is visible before users feel it, attributable to a named cause, and repairable by a loop that has been rehearsed. Shipping was where it started turning.

Common misconception

Wrong idea: deployment is the finish line, and monitoring is a dashboard somebody checks.

Correct idea: deployment is where the loop starts turning. The dashed cost line moves first, the solid latency line follows, and the gap between them is the time you have to act.

def operating_cycle(today):
    health = {
        'p99_ms': latency_percentile(99, window_hours=24),
        'gpu_usd_per_1k': gpu_cost_per_1000_requests(window_hours=24),
        'input_tokens_p50': input_length_percentile(50, window_hours=24),
        'output_drift_psi': psi(baseline_outputs, recent_outputs),
        'mature_quality': cohort_quality(today, maturity_days=14),
    }

    for signal, threshold, action in RULES:
        if health[signal] > threshold:
            dispatch(action, signal, health)

    # Each action lands back in an earlier module:
    #   p99 breach        -> batching window (26) or compression (25)
    #   cost regression   -> batch efficiency (26) or quantization (25)
    #   output drift      -> retrain, fine-tune (21), re-gate slices (25)
    #   quality decay     -> back to the data, and the loop runs again
    return health

Logging the prediction without the inputs, the model version, the input token count and the timestamp together. When the matured quality labels finally arrive there is nothing to join them to — no per-version comparison, no per-slice attribution, no cost regression analysis. Log first; none of it can be reconstructed afterwards.