Search Tech Journey

Find topics, journeys and posts

back to blog
mladvanced 120m read

DL S073 · Latency vs Throughput — Little's Law, TTFT, ITL, and Load Testing

The four numbers every LLM serving team lives and dies by (TTFT, ITL, aggregate TPS, p99 latency). How to measure them, how to trade them, and how to load-test with k6.

🧠SoftwareM11 · Efficient inference + serving· Session 073 of 130 120 min

🎯 Set SLOs on the four numbers (TTFT, ITL, aggregate throughput, p99 end-to-end), and load-test your service to prove they hold.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 73 / 80 · Module M11 · ~2 hours

The story

You have an API (S072). It works on your laptop. Does it work under load? What is "load"?

Every LLM serving team obsesses over four numbers:

  • TTFT (Time to First Token): from user sending prompt to first token appearing. This is what makes the product feel responsive.
  • ITL / TPOT (Inter-Token Latency / Time Per Output Token): steady-state generation speed for one stream. This is what makes the product feel smooth.
  • Aggregate throughput (tokens/sec across all users): what determines your GPU bill.
  • End-to-end p99 latency: the worst 1% experience. What determines whether users churn.

The awkward truth: these four numbers trade against each other. Higher batch size → higher throughput and worse per-stream ITL. Lower concurrency → better ITL and worse throughput. Priority-decode → smooth streams but bad TTFT for newcomers. Every serving decision is a trade among these four.

Today: precise definitions, the math (Little's Law comes back), how to measure with k6 / locust / a custom async client, and how to set SLOs you can actually hold.

An analogy: coffee shop latency

Imagine a café with one barista.

  • TTFT = time from placing your order to the barista starting on it. If there's a long queue, this is dominated by the queue, not the espresso pull.
  • ITL = the pace at which the drink is prepared once started (shots pulled, milk steamed).
  • End-to-end latency = when you actually get the coffee.
  • Throughput = drinks per hour across all customers.

A smart café owner watches two knobs: queue length (visible from the door — too long, customers walk out) and barista utilization (too low, they're paying for idle time). At high traffic, queue grows and TTFT explodes even though the barista is still just as fast per drink. This is exactly what happens to LLMs at high concurrency: ITL stays flat, TTFT blows up, and the p99 user experience craters.

The fix isn't a faster barista (bigger model → no, that makes it worse). The fix is more baristas (more replicas) or a smaller menu (shorter prompts, smaller model, cheaper decoding).

Two clocks: order-to-first-sip vs sip-to-sip
🌍 Real world
💻 Code world
You will be able to
  • Define TTFT, ITL, and end-to-end latency precisely and know which client wall-clock corresponds to each.
  • State Little's Law and use it to convert between concurrency, throughput, and latency.
  • Read a latency histogram and identify head-of-line blocking, prefill starvation, or GPU saturation.
  • Write a k6 script that generates realistic streaming load with variable prompt lengths.
  • Explain the throughput/latency knee curve and where to operate on it.
  • Set SLOs (e.g., TTFT p99 ≤ 500ms, ITL p50 ≤ 40ms) and prove they hold under target QPS.

Prerequisites



1 · Precise definitions

Set up a chat completion with a 500-token prompt and a 300-token completion streamed to the client:

t=0.000s Client sends POST /v1/chat/completionst=0.150s Server receives, admits to schedulert=0.480s Prefill of 500 tokens finishest=0.505s First token arrives at client TTFT = 505mst=0.530s Second token arrives ITL[1] = 25mst=0.555s Third token arrives ITL[2] = 25ms...t=8.030s 300th (last) token arrives end-to-end = 8.030s
  • TTFT = 505ms. Dominated by network + queueing + prefill compute.
  • ITL = mean or p50 of inter-arrival gaps in the stream ≈ 25ms. Dominated by decode step latency at current batch size.
  • Per-stream throughput = 1/ITL ≈ 40 tok/s.
  • End-to-end latency = 8.030s. Approximately TTFT + (n_out − 1) × ITL.
  • Aggregate throughput = sum of tokens/sec across all concurrent streams.
Try itInstrument a real client and split end-to-end into TTFT + n×ITL yourself.

Start from your S072 client script. Add three timers: t_send, t_first_token, t_last_token. Log ttft = t_first_token - t_send, total = t_last_token - t_send, itl = (total - ttft) / (n_output_tokens - 1). Run 20 concurrent requests via asyncio.gather and dump per-request TTFT and ITL. Sort and look at p50/p95/p99. The gap between p50 and p99 TTFT is your "is my scheduler starving new requests?" signal.

💡 Hint · Use `time.perf_counter()` before the request, after the first `data:`, and after the terminal `[DONE]`.

2 · Little's Law, again, applied

L=λWL = \lambda \cdot W

For LLM decode:

concurrency=QPS×avg end-to-end latency\text{concurrency} = \text{QPS} \times \text{avg end-to-end latency}

Or in tokens:

aggregate tok/s=concurrency×per-stream tok/s\text{aggregate tok/s} = \text{concurrency} \times \text{per-stream tok/s}

If you have per-stream 40 tok/s and 32 concurrent streams, aggregate is 1280 tok/s. If you push concurrency to 64, per-stream drops (compute is now shared) — maybe to 25 tok/s. Aggregate rises to 1600 tok/s. Keep pushing to 128 — per-stream drops to 15 tok/s, aggregate rises to 1920 tok/s. Keep pushing to 256 — KV cache saturates, requests get preempted, aggregate falls and tail latency explodes.

Somewhere in that curve is your knee — the point past which more concurrency stops adding throughput. Operate at ~80% of the knee: you get near-max throughput with headroom for burst.


3 · The knee curve

Sketch (typical Llama-3-8B AWQ on A10G with vLLM):

aggregate tok/s
     ^
2000 |                    _____________
     |                  /              \
1600 |               / knee here
1200 |             /
 800 |          /
 400 |       /
     |    /
     +----+----+----+----+----+----+----> concurrency
     1    8   16   32   48   64   96

Per-stream tok/s falls monotonically:

tok/s per stream
     ^
  80 |*
     |   *
  60 |     *
     |         *
  40 |             *
     |                 *
  20 |                       *  *  *
     +----+----+----+----+----+----+----> concurrency

Where the knee sits depends on: model size, quantization, GPU, KV budget, prompt length distribution. You must measure it for your workload. Nobody's benchmark transfers exactly.


4 · SLOs, honestly

Sensible defaults for a chat product:

  • TTFT p50 ≤ 200ms, p99 ≤ 800ms. Below 300ms feels instant. Above 1s users notice.
  • ITL p50 ≤ 40ms (25 tok/s). Above 80ms feels sluggish.
  • End-to-end p99 — derive from TTFT + output length × ITL. For a 500-token response: 800ms + 499 × 80ms ≈ 40s. If that's unacceptable, cap max_tokens or push down ITL SLO.
  • Aggregate throughput — sized for peak QPS × 1.3 headroom.

For batch / async workloads (e.g., overnight document processing) you can throw the TTFT/ITL SLOs out and just maximize aggregate throughput — crank concurrency to the knee.

The stealth SLO

    5 · Load testing with k6

    k6 is a Go-based load tester with JS scripting. Handles streaming SSE well enough for our purposes.

    Install: brew install k6 or docker run -i grafana/k6 run - <script.js.

    // llm_load.js
    import http from 'k6/http';
    import { check } from 'k6';
    import { Trend, Counter } from 'k6/metrics';
     
    const ttft = new Trend('ttft_ms', true);
    const itl  = new Trend('itl_ms',  true);
    const e2e  = new Trend('e2e_ms',  true);
    const tokensOut = new Counter('tokens_out');
     
    export const options = {
      scenarios: {
        steady: {
          executor: 'constant-arrival-rate',
          rate: 10,                  // 10 requests/sec
          timeUnit: '1s',
          duration: '5m',
          preAllocatedVUs: 100,
          maxVUs: 500,
        },
      },
      thresholds: {
        'ttft_ms': ['p(99)<800'],    // fail run if TTFT p99 > 800ms
        'itl_ms':  ['p(50)<40'],
      },
    };
     
    const PROMPTS = [
      'Explain quantum entanglement in one paragraph.',
      'Write a Python function to reverse a linked list.',
      'What is the derivative of sin(x)/x?',
      // ... in real life, sample from your production distribution
    ];
     
    export default function () {
      const body = JSON.stringify({
        model: 'meta-llama/Llama-3-8B-Instruct',
        messages: [{ role: 'user', content: PROMPTS[Math.floor(Math.random() * PROMPTS.length)] }],
        stream: true,
        max_tokens: 200,
      });
      const params = {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer sk-dev-key',
          'Accept': 'text/event-stream',
        },
        timeout: '60s',
      };
      const t0 = Date.now();
      const res = http.post('https://your-app.modal.run/v1/chat/completions', body, params);
      // k6 buffers the whole response body — for real streaming metrics, use xk6-sse.
      // Simple proxy: count tokens in body, use response.timings.
      const nTokens = (res.body.match(/data: /g) || []).length;
      const total = Date.now() - t0;
      e2e.add(total);
      ttft.add(res.timings.waiting);   // approximation: time-to-first-byte
      if (nTokens > 1) {
        itl.add((total - res.timings.waiting) / (nTokens - 1));
      }
      tokensOut.add(nTokens);
      check(res, { 'status 200': (r) => r.status === 200 });
    }

    Run: k6 run llm_load.js. Output includes p50/p95/p99 for each metric and pass/fail on thresholds.

    For real SSE token-by-token timing use xk6-sse or write a custom Python client with httpx.AsyncClient.stream. Example async client:

    import asyncio, time, httpx, statistics
     
    async def one_request(client, prompt):
        t0 = time.perf_counter()
        ttft = None; itls = []; last = None
        async with client.stream("POST", URL, json={
            "model": MODEL, "messages": [{"role":"user","content":prompt}], "stream": True
        }, headers={"Authorization": f"Bearer {KEY}"}) as r:
            async for line in r.aiter_lines():
                if line.startswith("data: ") and "content" in line:
                    now = time.perf_counter()
                    if ttft is None:
                        ttft = now - t0
                    else:
                        itls.append(now - last)
                    last = now
        return ttft, itls, time.perf_counter() - t0
     
    async def run(qps=10, duration_s=60):
        async with httpx.AsyncClient(timeout=None) as client:
            tasks = []
            end = time.time() + duration_s
            while time.time() < end:
                tasks.append(asyncio.create_task(one_request(client, "hi")))
                await asyncio.sleep(1.0 / qps)
            results = await asyncio.gather(*tasks)
        ttfts = [r[0] * 1000 for r in results if r[0]]
        itls  = [x * 1000 for r in results for x in r[1]]
        print(f"TTFT p50 {statistics.median(ttfts):.0f}ms p99 {sorted(ttfts)[int(0.99*len(ttfts))]:.0f}ms")
        print(f"ITL  p50 {statistics.median(itls):.0f}ms p99 {sorted(itls)[int(0.99*len(itls))]:.0f}ms")
     
    asyncio.run(run(qps=10, duration_s=60))

    Use this Python version when you need true token-arrival timestamps.


    6 · Reading a latency histogram

    Common shapes and what they mean:

    • Bimodal TTFT distribution — some fast (~100ms), some slow (~2s). Almost always: cache hits vs misses on prefix caching, or prompts split between "short admitted immediately" and "long, wait for prefill window".
    • ITL with periodic spikes every ~50ms — decode iteration stall from admitting new requests without chunked prefill. Enable --enable-chunked-prefill.
    • Long tail on end-to-end — head-of-line blocking. A single very long request is holding batch slots. Use max_tokens cap + priority scheduling.
    • Everything degrades together as concurrency rises — you're past the knee. Reduce max_num_seqs or add replicas.
    • ITL fine, TTFT rising with QPS — waiting queue growing. Not enough capacity for admission; add replicas.

    7 · Capacity planning worked example

    Scenario: SaaS chat product, target 5000 requests/min at peak = 83 QPS. Prompts avg 800 tokens, completions avg 200 tokens. SLO: TTFT p99 ≤ 500ms, ITL p50 ≤ 40ms.

    Load-test your Llama-3-8B AWQ on A10G with vLLM:

    • Measured: at concurrency 32, TTFT p99 = 400ms, ITL p50 = 30ms, aggregate = 850 tok/s.
    • At concurrency 64, TTFT p99 = 700ms (SLO fail), ITL p50 = 55ms (fail), aggregate = 1100 tok/s.

    Pick concurrency 32 per replica.

    Little's Law: concurrency = QPS × avg end-to-end. Avg end-to-end ≈ 400ms + 200 × 30ms = 6.4s. So one replica at concurrency 32 handles 32/6.4 = 5 QPS.

    Replicas needed: ceil(83/5) × 1.3 (headroom) = 22 replicas.

    Cost: 22 × A10G × 1.10/hr= 1.10/hr = ~24/hr. If peak is 4 hrs/day: ~$3000/mo.

    Now try Llama-3-8B on H100 with fp8 KV: measured single-replica capacity 30 QPS at SLO. Need ceil(83/30) × 1.3 = 4 replicas × H100 × 4/hr= 4/hr = ~16/hr. Cheaper and faster. Always benchmark the "expensive" GPU — it's often better per token.


    8 · Pitfalls

    War story Benchmarking with all-identical prompts

    Real traffic has variance. Testing with 100 copies of "hi" gives a wildly optimistic picture — every prefix cache hits, no prefill contention. Always sample from a realistic prompt-length distribution (log-normal is a decent starting point).

    War story Ignoring warm-up

    First 30 seconds of a load test have inflated latencies (cold caches, JIT compile, model warm-up). Discard them or use stages with a ramp-up in k6. Report metrics only from steady state.

    War story Client-side bottleneck disguised as server-side

    You run k6 from your laptop, hit 30% CPU on the k6 process. Your "server p99 latency" is really the k6 event loop bottleneck. Run k6 on a beefy machine (or distributed) and always compare against a server-side latency metric to sanity-check.

    War story Averaging p99 across regions

    p99 is not additive. Averaging p99s from 3 regions doesn't give you p99. Compute p99 over the union of samples. This bites every metrics dashboard.


    9 · Mermaid: the knee curve decision


    Recall

    1. Define TTFT and ITL in one sentence each, measured from the client. TTFT: client wall-clock from POST to first token appearing. ITL: mean inter-arrival gap between consecutive tokens in the stream (after the first).

    2. Little's Law applied to LLM decode? concurrency = QPS × avg end-to-end latency. Or: aggregate tok/s = concurrency × per-stream tok/s.

    3. What does a "knee" in the throughput vs concurrency curve mean? The point past which adding concurrency stops increasing aggregate throughput (KV cache saturates, preemption starts). Operate at ~80% of the knee.

    4. TTFT failing but ITL fine — likely cause and fix? Prefill starvation: new requests waiting for a big prompt to prefill. Enable --enable-chunked-prefill so prefill interleaves with decode.

    5. Why can't you average p99 across regions? Percentiles aren't linear. Compute p99 over the union of all samples, not the average of per-region p99s.

    Stretch: write a k6 script that runs a 5-minute ramp from 1 to 100 concurrent VUs against your Modal deployment, records TTFT and ITL per request, and outputs the concurrency-vs-throughput knee curve. Where is your knee?

    In your own words: the four numbers of LLM serving are __________________________, and their fundamental tension is __________________________.

    Spaced review: S066 KV cache (why the knee exists), S070 continuous batching (Little's Law), S072 deploying FastAPI (the target of load tests).

    Next session (S074): we leave M11 behind and enter M12 — multimodal. CLIP, LLaVA, and how to bolt vision onto a text model.

    Bring back tomorrow:

    • TTFT vs ITL vs aggregate vs p99 — the four numbers.
    • Little's Law: L = λW.
    • The knee: measure it, operate at 80% of it.

    The 2024–2025 SLO benchmark reality

    A useful calibration: here are numbers real 2025 serving teams hold, on a single 8×H100 node running Llama-3-70B fp8 via vLLM v1.

    WorkloadTTFT p50TTFT p99ITL p50Aggregate tok/s
    Chat, 500-tok prompts, concurrency 32180 ms450 ms22 ms~4,500
    Chat, 500-tok prompts, concurrency 128320 ms900 ms45 ms~11,000
    Code, 8k-tok prompts, concurrency 32950 ms2.1 s28 ms~3,800
    Code, 8k-tok prompts, chunked prefill on380 ms720 ms34 ms~3,500
    RAG, 32k-tok context, concurrency 82.4 s4.1 s25 ms~1,200

    Takeaways:

    • Chunked prefill trades ~10% throughput for 3× better TTFT tail on long-prompt workloads. Almost always worth it for interactive UX.
    • Concurrency 32 → 128 quadruples aggregate throughput but doubles p99 TTFT. Pick your point on the curve based on whether the product is latency- or cost-sensitive.
    • 32k RAG contexts cost 5× the per-request GPU time of 500-tok chat. Charge accordingly, or cache prefixes aggressively.

    Anthropic's public 2024 disclosure

    At QCon 2024, Anthropic engineers shared that their Claude serving stack targets p99 TTFT ≤ 700 ms for chat, p50 ITL ≤ 30 ms, and rolls out any change that regresses either by more than 5% only behind a flag. That's roughly the bar to match if you're building a consumer chat product in 2025.

    The knee migrates over time

    Every vLLM release (roughly monthly) shifts the knee to the right by 5–20%. If you set concurrency limits in early 2024 and never revisited them, you're leaving throughput on the table. Re-benchmark every quarter minimum and after every backend upgrade. Automate the benchmark; you will not do it by hand.


    Try it yourself

    1. Point k6 at your Modal deployment. Ramp 1→200 VUs over 10 minutes. Plot concurrency vs (a) aggregate tok/s (b) p50 TTFT (c) p99 TTFT.
    2. Identify the knee. Set your production concurrency limit at 80% of that.
    3. Turn --enable-chunked-prefill on and off. Re-run. Confirm the TTFT/throughput trade matches the table above.
    4. Instrument your gateway to log {ttft_ms, itl_ms_mean, itl_ms_p99, prompt_tokens, completion_tokens} per request. Ship to Postgres. Build a Grafana dashboard.
    5. Set alerts: TTFT p99 > 1s for 5 minutes, ITL p99 > 100ms for 5 minutes, num_waiting > 5 for 2 minutes. Page yourself.

    Further reading:


    Module M11 closeout

    You just finished the "make LLM inference cheap and fast" module. You should now be able to answer:

    • How big is the KV cache and why does batch size hit a wall? (S066)
    • Which quantization method for my target? (S067)
    • Distill or prune? (S068)
    • Speculative decoding — when to reach for it? (S069)
    • Continuous batching — what changed vs the BERT era? (S070)
    • What is PagedAttention actually doing? (S071)
    • Ship it behind a real API. (S072)
    • Prove it holds SLO under load. (S073)

    That's the entire "inference my own model efficiently" promise from the series intro. Sessions 074+ pivot to multimodal, agents, RAG, and production ML systems.

    Common misconception
    ✗ What most people think

    "Latency and throughput are two views of the same performance. If I make the system faster, both improve — so I'll optimise for tokens per second and latency will follow."

    ✓ What is actually true

    Beyond a certain point they are in direct opposition, and the mechanism is batching. Every sequence added to a batch increases total tokens per second while making each individual sequence's tokens arrive more slowly, because they now share the same step. Throughput and per-request latency trace out a Pareto frontier, and your job is to choose a point on it, not to move along it hoping for both.

    Why the myth is so sticky

    The belief is right for a large and formative class of systems: single-threaded code, a slow database query, an unoptimised loop. There, work reduction genuinely improves both numbers, and you correctly learned that performance is one dimension. It stops being true the moment the system has a concurrency knob that trades queueing for amortisation — which every batched inference server has. The trap is that the two regimes look identical from a single benchmark run: at low load, raising batch size improves throughput at almost no latency cost, so the first several experiments confirm the myth. The opposition only appears past the knee, which is exactly where production runs and where your benchmark did not.

    Prove it to yourself

    Sweep the knob and plot both axes rather than reporting one number:

    # pseudo-harness: fix the prompt, sweep concurrency, record BOTH
    for c in [1, 2, 4, 8, 16, 32, 64, 128]:
        r = run_load(concurrency=c, duration_s=60)
        print(c,
              'tok/s', round(r.total_output_tokens / r.wall_s),
              'ttft_p50', round(r.ttft_p50_ms),
              'itl_p95', round(r.itl_p95_ms))

    Plot tokens/s against p95 inter-token latency. The curve bends: throughput keeps climbing for a while after latency has started to degrade. Everything you need to decide lives at that bend, and no single-number benchmark can show it to you.

    From first principles
    Start with the question

    Why does raising batch size increase throughput almost for free at first, and then start costing latency sharply? Both effects come from the same change, so why are they not proportional?

    1. 1
      A decode step reads the full model weights from HBM regardless of how many sequences are in the batch, then does a small amount of arithmetic per sequence.
      forced by · the weight tensor is shared across the batch dimension, so its transfer cost is fixed while the compute scales with batch
    2. 2
      At batch 1 the step is limited by that weight transfer, and the tensor cores are almost entirely idle while it happens.
      forced by · arithmetic intensity for a single-row matmul is a small constant, far below the hardware's break-even ratio
    3. 3
      Adding sequences therefore adds work that fits inside time you were already spending, so step duration barely moves while tokens produced per step rises linearly — throughput improves nearly for free.
      forced by · you are consuming idle compute capacity, which costs nothing you were not already paying
    4. 4
      That slack is finite. Once arithmetic intensity crosses the hardware's break-even ratio, the step becomes compute-bound and every further sequence lengthens the step.
      forced by · past break-even the tensor cores, not the memory system, set the clock, and their work does scale with batch
    5. 5
      Past that point per-token latency for every sequence rises with batch size, while throughput gains flatten because you are now paying proportionally for what you get.
      forced by · the amortisation argument is exhausted — there is no idle capacity left to fill
    6. 6
      Separately, the KV cache grows with batch and context, so the memory constraint can bind before the compute one does, producing queueing rather than slower steps.
      forced by · capacity exhaustion converts latency degradation into admission delay, which shows up in TTFT rather than in inter-token latency
    ⇒ Therefore

    Therefore the free region and the costly region are separated by a specific, measurable crossover, and its position is a property of your model and your GPU rather than a universal constant.

    And note the prediction. First: the knee should move with model size and hardware — a model whose weights are larger relative to per-token compute has more slack, so it should tolerate a higher batch size before latency degrades. Compare the sweep for two model sizes on the same GPU and expect different knees. Second: because the mechanism is weight amortisation, anything that reduces weight bytes should shift the knee — a quantised model should become compute-bound at a lower batch size, since you removed the transfer time that was providing the slack. Third: prefill is already compute-bound, so batching should help TTFT far less than it helps decode throughput, and past the knee it should actively hurt TTFT as prefills queue behind each other.

    Mental modelTwo clocks and a frontier

    Every request carries two clocks. The first runs once: how long until the first token appears, set by queueing plus prefill, and it is what makes the interface feel alive or broken. The second runs continuously: the gap between tokens, set by decode step time, and it is what makes the output feel smooth or laboured. They have different causes and different fixes, and averaging them into "latency" destroys the information you need.

    Behind them sits a frontier. Turning the concurrency knob moves you along a curve where throughput and per-request latency trade. There is no operating point that is best — only a point that matches your SLO, and the frontier itself moves only when you change the model, the hardware, or the algorithm.

    • TTFT is prefill plus queueing; inter-token latency is decode step time. Different causes, different fixes — never report them as one number.
    • Batch size moves you along the frontier. Quantisation, better kernels, and speculation move the frontier itself. Only the second kind is free.
    • Report percentiles, not means. Generation length is heavy-tailed, so a mean is dominated by the wrong requests and hides the ones users complain about.
    • Every benchmark number is meaningless without its input length, output length, and concurrency. Those three are the experiment; the number is just the result.
    • Load-test at the concurrency you actually expect. Performance at concurrency 1 tells you nothing about behaviour past the knee, which is where production lives.
    🔔 Fires when you see

    Fire this the moment you see: a tokens-per-second figure quoted with no latency alongside it · a p50 latency SLO with no p95 or p99 · benchmarks run at concurrency 1 used to size a deployment · "the model got slower" without saying which clock · a latency regression that coincides with a traffic increase (you moved along the frontier, nothing broke) · a comparison between two serving stacks at different batch sizes.

    The tradeoff

    You are missing your latency SLO under load. Do you reduce batch size, add hardware, or make the model cheaper?

    Reduce concurrency — cap batch size or admit less aggressively
    + you gain immediate, reversible, and costs nothing to try; it is a configuration change you can make in minutes and undo just as fast, and it targets the exact mechanism causing the degradation
    − you pay you are moving along the frontier, not improving it — throughput drops by the same mechanism that latency improves, so the same traffic now needs more replicas and your cost per token rises
    pick when the SLO breach is recent and correlates with a traffic increase, or you need relief today — always the first move, because it is the only one that is free to test
    Add replicas or bigger GPUs
    + you gain preserves the per-replica operating point entirely, so latency characteristics stay exactly as tested; it is the most predictable option and the only one with no quality or correctness risk at all
    − you pay cost scales linearly and forever, and it does nothing for a latency floor that is inherent to the model on that hardware — if a single request is too slow at concurrency 1, no amount of replicas fixes it
    pick when the bottleneck is queueing rather than step time, which you can confirm by checking whether TTFT degraded while inter-token latency held steady — that pattern means you need capacity, not optimisation
    Make each step cheaper — quantise, use better kernels, or speculate
    + you gain genuinely moves the frontier, so you get better latency and better throughput at the same cost; this is the only category that improves your cost per token rather than trading one metric for another
    − you pay engineering time and an evaluation burden, since quantisation and distillation carry quality risk that generic benchmarks will not surface; speculation only helps at low concurrency, so it may do nothing for the load you are actually failing at
    pick when the degradation is in inter-token latency rather than TTFT, meaning steps themselves are slow — and you have an evaluation suite good enough to detect quality regressions on your own workload
    What a senior engineer actually does

    Diagnose which clock broke before touching anything. TTFT degradation under load means queueing, and the answer is capacity or admission policy. Inter-token degradation means step cost, and the answer is a cheaper step. Applying the wrong fix is common and expensive — adding replicas to a slow-step problem multiplies your bill without moving the number you care about.

    The discipline that prevents most of this is defining the SLO before optimising, and defining it per metric: a TTFT target, an inter-token target, and a percentile for each, stated for a specific input and output length. Without that you cannot tell whether you are done, and every performance conversation becomes a debate about which number to look at. With it, the concurrency sweep tells you your maximum sustainable load directly, and capacity planning becomes arithmetic instead of guesswork.


    Quick recall · click to reveal
    ★ = stretch question

    Previous: ← DL S072 · Next: DL S074 →