LLM inference optimization

From first principles to interview depth. Every number is either computed with a script, measured on our stg RTX PRO 6000 Blackwell, or explicitly marked as a vendor spec.

How to read this You already know serving, caching, latency and SLAs. This page spends its words on the layer underneath: what the GPU is doing per token, and why. Labs were run on the stg lab pod (nsight-lab: RTX PRO 6000 Blackwell, 97887 MiB, PyTorch 2.10, Triton 3.6). Source scripts are saved in inf_labs/. Each section ends with a quiz; section 13 has interview questions with model answers.
Provenance legend Measured = ran on the lab pod, output pasted verbatim. Computed = arithmetic done in a Python script. Spec = vendor/datasheet number, not measured here. From memory = model-architecture or framework facts I believe but did not verify here; check before quoting in an interview.

1. Serving metrics & SLO vocabulary

TermDefinitionWhat mostly drives it
TTFT (time to first token)Request arrival to first output token.Queueing + tokenization + prefill (compute-bound, scales with prompt length).
TPOT / ITLTime per output token (average) / inter-token latency (per-gap, so it has a tail).Decode step time: weight + KV reads (memory-bound), plus batch interference.
E2E latencyTTFT + (N_out - 1) x TPOT.Both of the above and output length.
ThroughputTokens/s (or requests/s) across all users.Batch size, utilization, kernel efficiency.
GoodputThroughput of requests that met their SLO (e.g. TTFT < 500 ms and TPOT < 50 ms).Scheduling policy. Raw throughput can rise while goodput collapses.
p50 / p99Percentiles of the latency distribution.p50 is the pipeline; p99 is queueing, GC/CPU stalls, long prompts, preemptions, noisy neighbours.
$ per 1M tokensGPU $/hr / (tokens/s x 3600) x 1e6.Throughput at SLO (goodput), not peak throughput.

The central tension

Bigger batches raise throughput (weights are read once and amortized over more tokens) but raise per-request latency. Every serving decision is a move along that curve. The job is to find the highest-batch operating point that still meets the p99 SLO, which is why the right cost metric is cost at SLO.

# computed
e2e_ms  = 300 + (200 - 1) * 25                 # TTFT 300 ms, TPOT 25 ms, 200 tokens -> 5275 ms
usd_per_M = 3.0 / (5000 * 3600) * 1e6           # $3/hr GPU, 5000 tok/s -> $0.167 per 1M tokens
p_any_slow = 1 - 0.99 ** 100                    # 100 independent calls, each p99 -> 63.4% see a slow one
Tail-latency fan-out (relevant to scoring many candidates) If one user-visible response waits on N independent sub-calls, the response's latency is the max of N draws. With N=100 and a per-call p99 of X, about 63% of responses see at least one call slower than X. This is why fan-out systems must manage the per-call p99.9, or batch the 100 into one call so there is one tail to manage instead of 100.

2. Roofline for LLMs: prefill vs decode

Arithmetic intensity (AI) = FLOPs performed per byte moved from memory. A GPU has a compute peak P (FLOP/s) and a bandwidth B (bytes/s). Attainable speed = min(P, B x AI). The ridge point P/B is the AI where you switch from memory-bound to compute-bound.

decode, small batch (AI ~ batch) prefill, long prompt arithmetic intensity (FLOP/byte)FLOP/s ridge = peak FLOPs / bandwidthslope = memory bandwidth

Why decode is memory-bound

Generating one token for one sequence multiplies a [1 x d] activation by every weight matrix. Each weight is read once from HBM/GDDR and used for ~2 FLOPs (one multiply, one add). AI is about 1-2 FLOP/byte, far below any ridge point (hundreds). Adding more sequences to the batch reuses the same weight read: a batch of B sequences does B times the FLOPs for the same bytes, so for an fp16 weight GEMM AI is about B FLOP/byte.

Prefill processes the whole prompt as a [T x d] matrix, so AI is about T and it sits at the compute roof.

The floor formula

time per decode step >= bytes_read_per_step / memory_bandwidth. For a single sequence the bytes are essentially the weight bytes (plus KV bytes). It is a floor: real systems land at 60-85% of peak bandwidth, plus launch and sampling overhead.

Model / precisionWeightsFloor on RTX PRO 6000 (1.8 TB/s, spec)Floor on L4 (300 GB/s, spec)
8B fp1616 GB8.9 ms (112 tok/s)53.3 ms (18.8 tok/s)
8B fp88 GB4.4 ms26.7 ms
8B INT4~4 GB2.2 ms13.3 ms
70B fp870 GB38.9 ms (fits in 96 GB, little KV room)does not fit in 24 GB
70B INT4~35 GB19.4 msdoes not fit
1B INT4 (~0.5 GB)0.5 GB0.28 ms1.67 ms

All table values are computed from the spec bandwidths. The L4 is 6x slower on every row because its bandwidth is 6x lower, which matches the earlier L4-vs-RTX 6000 result for being memory-bound.

Lab 2a: decode-like GEMM, time vs batch (measured)

Weight matrix 8192 x 8192 fp16 (134 MB, rotated through 4 copies so L2 cannot serve repeats). x @ W.T with x of shape [B, 8192]. This is exactly one linear layer of a decode step at batch B.

import torch
def bench(f, n=50):
    for _ in range(5): f()
    torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record()
    for _ in range(n): f()
    e.record(); torch.cuda.synchronize()
    return s.elapsed_time(e) / n * 1e3  # us
torch.manual_seed(0)
N = K = 8192
# 4 distinct weight copies cycled so L2 (~100+ MB class) cannot serve repeats
Ws = [torch.randn(N, K, device="cuda", dtype=torch.float16) for _ in range(4)]
print(f"weight bytes per matrix: {N*K*2/1e6:.1f} MB (fp16)")
print(f"{'B':>5} {'time_us':>9} {'GB/s':>8} {'TFLOPS':>8}")
for B in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]:
    x = torch.randn(B, K, device="cuda", dtype=torch.float16)
    i = [0]
    def f():
        i[0] = (i[0] + 1) % 4
        return x @ Ws[i[0]].T
    t = bench(f)
    print(f"{B:5d} {t:9.1f} {N*K*2/t/1e3:8.0f} {2*B*N*K/t/1e6:8.1f}")

measured on RTX PRO 6000 Blackwell, stg

weight bytes per matrix: 134.2 MB (fp16)
    B   time_us     GB/s   TFLOPS
    1      97.9     1371      1.4
    2     100.3     1339      2.7
    4     100.2     1339      5.4
    8      99.7     1346     10.8
   16      99.5     1349     21.6
   32     103.3     1300     41.6
   64      97.8     1372     87.8
  128     114.3     1174    150.3
  256     123.7     1085    277.7
  512     206.4      650    332.9
 1024     359.2      374    382.6
 2048     694.4      193    395.8
 4096    1437.3       93    382.5
Read it like an interviewer wants (1) Time is flat (~98-103 us) from B=1 to B=64 while TFLOPS grows 60x: memory-bound, the weights dominate. (2) Achieved ~1.34-1.37 TB/s, i.e. about 75% of the 1.8 TB/s spec (computed from the table: 134.2 MB / 97.9 us). That gap is normal; use ~75% when turning the floor formula into a realistic estimate. (3) Time starts rising at B=128 and is linear in B past B=512: compute-bound, plateauing at ~380-396 TFLOPS fp16 dense (measured here). (4) Naive ridge = 396 TFLOPS / 1.37 TB/s = ~290 FLOP/byte, i.e. B about 290. The measured knee arrives somewhat earlier (B=128-256) because a real kernel never hits peak bandwidth and peak FLOPs at the same moment. The knee is a band, not a point.
Not verified here The L4 numbers (300 GB/s, ~121 TFLOPS fp16 dense) are vendor specs from memory; I had no L4 to measure. The 1.8 TB/s and 96 GB for the RTX PRO 6000 are the spec figures you supplied; the measured 1.37 TB/s is a GEMM-achievable number, not a pure-copy benchmark (lab 10b measures ~1.46 TB/s for a copy).

Prefill TTFT estimate

FLOPs for a forward pass of T tokens is about 2 x params x T (ignoring attention). 8B model, 2000-token prompt: 2 x 8e9 x 2000 = 3.2e13 FLOP; at 400 TFLOPS = 80 ms, at a more realistic 200 TFLOPS = 160 ms (computed). For a 70B model the same prompt is 2.8e14 FLOP, i.e. 700 ms at 400 TFLOPS on one GPU. That single fact drives much of section 13's design question.

3. KV cache

Attention for the new token needs the keys and values of every previous token. Recomputing them each step would be quadratic work, so we store them: the KV cache.

bytes_per_token = 2 * n_layers * n_kv_heads * head_dim * bytes_per_elem   # 2 = K and V
total_bytes     = bytes_per_token * tokens_in_cache   # summed over all live sequences
Model (architecture values from memory)layerskv headshead_dimfp16 bytes/token
Llama-3-8B-like (GQA)328128131,072 = 128 KiB
same shape, full MHA (32 kv heads)3232128524,288 = 512 KiB (4x)
Llama-3-70B-like (GQA)808128327,680 = 320 KiB

One 8k-token sequence on the 70B-like model = 2.5 GiB of KV; on the 8B-like model, 1.0 GiB (computed).

Lab 3: the formula vs the allocator (measured)

import torch
layers, kvh, hd, bytes_, seqs, toks = 32, 8, 128, 2, 4, 4096
per_tok = 2 * layers * kvh * hd * bytes_
pred = per_tok * seqs * toks
torch.cuda.synchronize(); base = torch.cuda.memory_allocated()
kv = torch.empty(layers, 2, seqs, toks, kvh, hd, dtype=torch.float16, device="cuda")
meas = torch.cuda.memory_allocated() - base
print(f"formula: 2*{layers}*{kvh}*{hd}*{bytes_} = {per_tok} B/token = {per_tok/1024:.0f} KiB/token")
print(f"predicted for {seqs} seqs x {toks} tok: {pred} B = {pred/2**30:.3f} GiB")
print(f"measured memory_allocated delta   : {meas} B = {meas/2**30:.3f} GiB  match={pred==meas}")

measured on RTX PRO 6000 Blackwell, stg

formula: 2*32*8*128*2 = 131072 B/token = 128 KiB/token
predicted for 4 seqs x 4096 tok: 2147483648 B = 2.000 GiB
measured memory_allocated delta   : 2147483648 B = 2.000 GiB  match=True

GQA / MQA

Multi-head attention keeps K,V per query head. MQA shares one K/V head across all query heads; GQA shares one per group (e.g. 32 query heads over 8 KV heads). Quality loss is small, and KV size and decode KV bandwidth drop by the group factor (4x in the table above). Since decode reads the whole KV cache every step, GQA speeds up decode as well as shrinking memory.

Why vLLM preallocates

PagedAttention

Naively each sequence reserves a contiguous slab for its maximum length: most is unused (internal fragmentation) and slabs of different sizes leave holes (external fragmentation). The vLLM paper reported large waste in earlier systems (I recall 60-80%; treat as from memory). PagedAttention splits the cache into fixed blocks (typically 16 tokens) and gives each sequence a block table mapping logical position to physical block, like virtual memory pages. The attention kernel indexes through the table. Consequences: waste is at most one partial block per sequence; blocks can be shared (prefix caching, beam search, parallel sampling) with copy-on-write; sequences can grow without moving.

Worked: how many sequences fit? 8B-like fp16 on the 97887 MiB card, 90% usable, 16 GB weights, 2 GB activations/overhead: free = 74.4 GB = ~567k tokens = ~138 sequences of 4096 tokens (computed). With FP8 KV, ~1.13M tokens. This ignores block rounding and CUDA-graph memory.

4. Continuous batching, chunked prefill, scheduling

Static batching: collect N requests, run them together until the longest finishes. Short requests wait and the GPU idles on finished rows. Continuous (in-flight, iteration-level) batching (Orca): the scheduler runs every step; finished sequences leave and waiting ones join at the next iteration boundary. Throughput rises several-fold and queueing delay falls, because there is no head-of-line blocking behind the longest sequence.

The prefill/decode interference problem

A step that includes a 8000-token prefill is compute-heavy and long (hundreds of ms). Every decoding user in that batch sees a stalled token: an ITL spike that wrecks p99 TPOT.

Chunked prefill: cap the tokens per step (max_num_batched_tokens), fill the budget first with all decode tokens (1 each), then with a chunk of pending prefill. A long prompt is split across several steps, each with bounded length. Trade-off: it raises that request's TTFT slightly (more steps, each also carrying decode work) in exchange for flat ITL for everyone else. A bigger budget favours TTFT/throughput; a smaller one favours ITL. Recent vLLM versions enable it by default (from memory; check your version's defaults).

Example (computed): budget 2048, 100 running decodes leaves 1948 tokens/step for prefill, so an 8000-token prompt needs ceil(8000/1948) = 5 steps.

Scheduling policy and preemption

Check on your version Scheduler defaults (chunked prefill on/off, preemption mode, max_num_seqs) changed across vLLM releases. The lab pod has vLLM 0.13; read its EngineArgs help before quoting any default.

5. Attention kernels: FlashAttention, FlashDecoding

Naive attention: S = QK^T (N x N), P = softmax(S), O = PV. The N x N matrix is written to HBM, read back for softmax, written, read again for PV. Cost is dominated by memory traffic, not FLOPs, and memory footprint is O(N^2).

FlashAttention (IO-aware): split Q into row blocks and K,V into column blocks that fit in on-chip SRAM/shared memory. For each Q block, loop over K/V blocks, compute the score tile on-chip, update the output, and never write S to HBM. Same FLOPs (it is exact attention, not an approximation), far fewer HBM bytes, O(N) extra memory.

The trick that makes tiling possible is online softmax. Keep per-row running max m and running sum l. For a new tile with scores s:

m_new = max(m, rowmax(s))
p     = exp(s - m_new)
l     = l * exp(m - m_new) + rowsum(p)
O     = O * exp(m - m_new) + p @ V_tile
m     = m_new
# at the end: O / l   (identical to softmax(S) @ V)

Lab 5: naive vs online-softmax tiling vs fused SDPA (measured)

import torch, math, torch.nn.functional as F
torch.manual_seed(0)
H, N, D = 16, 8192, 128
q, k, v = (torch.randn(1, H, N, D, device="cuda", dtype=torch.float16) for _ in range(3))

def naive(q, k, v):  # materializes the full N x N score matrix per head in HBM
    s = (q @ k.transpose(-1, -2)) / math.sqrt(D)
    return torch.softmax(s.float(), -1).half() @ v

def tiled(q, k, v, BK=1024):  # online softmax over K/V tiles; never builds N x N
    scale = 1 / math.sqrt(D)
    m = torch.full(q.shape[:-1] + (1,), -float("inf"), device="cuda", dtype=torch.float32)
    l = torch.zeros_like(m); o = torch.zeros(q.shape, device="cuda", dtype=torch.float32)
    for j in range(0, N, BK):
        s = (q @ k[..., j:j+BK, :].transpose(-1, -2)).float() * scale
        m_new = torch.maximum(m, s.amax(-1, keepdim=True))
        p = torch.exp(s - m_new); corr = torch.exp(m - m_new)
        l = l * corr + p.sum(-1, keepdim=True)
        o = o * corr + p.half().float() @ v[..., j:j+BK, :].float()
        m = m_new
    return (o / l).half()

def run(f):
    torch.cuda.reset_peak_memory_stats(); torch.cuda.synchronize()
    base = torch.cuda.memory_allocated()
    for _ in range(2): f()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record()
    for _ in range(5): out = f()
    e.record(); torch.cuda.synchronize()
    return out, s.elapsed_time(e) / 5, (torch.cuda.max_memory_allocated() - base) / 2**20

ref, t_sdpa, m_sdpa = run(lambda: F.scaled_dot_product_attention(q, k, v))
o1, t_n, m_n = run(lambda: naive(q, k, v))
o2, t_t, m_t = run(lambda: tiled(q, k, v))
print(f"H={H} N={N} D={D} fp16 (prefill-shaped, non-causal)")
print(f"{'impl':28s} {'ms':>8} {'peak extra MiB':>15} {'max|err| vs SDPA':>18}")
print(f"{'SDPA (fused flash kernel)':28s} {t_sdpa:8.2f} {m_sdpa:15.0f} {'-':>18}")
print(f"{'naive (N x N in HBM)':28s} {t_n:8.2f} {m_n:15.0f} {(o1-ref).abs().max().item():18.4f}")
print(f"{'python tiled online-softmax':28s} {t_t:8.2f} {m_t:15.0f} {(o2-ref).abs().max().item():18.4f}")
print(f"score matrix alone would be {H*N*N*2/2**20:.0f} MiB in fp16")

measured on RTX PRO 6000 Blackwell, stg

H=16 N=8192 D=128 fp16 (prefill-shaped, non-causal)
impl                               ms  peak extra MiB   max|err| vs SDPA
SDPA (fused flash kernel)        1.82              65                  -
naive (N x N in HBM)            20.62           10280             0.0003
python tiled online-softmax     44.90            2146             0.0002
score matrix alone would be 2048 MiB in fp16
What to take away The tiled Python version matches SDPA to ~2e-4 (so the online-softmax math is right) and cuts peak memory from ~10 GiB to ~2 GiB. But it is slower than naive, because every tile still round-trips through HBM across separate PyTorch kernels. The speed comes only when tiling and softmax are fused into one kernel that keeps tiles in SRAM: SDPA does it in 1.82 ms and 65 MiB. 4 x N^2 x D x H = 5.5e11 FLOP / 1.82 ms = ~302 TFLOPS (computed), i.e. attention here runs near the compute roof. Which backend SDPA picked (flash vs another fused kernel) was not inspected; I only observed the result.

FlashDecoding

FlashAttention parallelizes over (batch, head, Q blocks). In decode there is one query per sequence, so batch x heads may be only 8-64 blocks on a GPU with ~100+ SMs, while the KV length is thousands of tokens. FlashDecoding additionally splits the KV sequence across thread blocks; each computes a partial (m, l, O), and a small reduction combines them using the same rescaling identity. It fills the GPU for long-context, small-batch decode. (Modern libraries like FlashInfer and vLLM's paged-attention kernels use split-KV in this spirit; implementation details differ and I have not read the current ones.)

6. Quantization

SchemeWhat shrinksMath done inWins atRisk
Weight-only INT4 (AWQ, GPTQ) "W4A16"Weights to 4 bit with per-group scales (group 128 typical)fp16: weights dequantized inside the kernel (e.g. Marlin-style kernels)Decode, small batch: bytes drop ~3.5-4x, so memory-bound steps get nearly that much faster. Also fits bigger models.Dequant ALU cost makes it little or no help (even slower) once compute-bound (prefill, B > ~100s). Accuracy loss largest on small models and reasoning/long-tail tasks.
FP8 W8A8Weights and activations to 8 bit floatFP8 tensor cores (2x fp16 throughput on supporting GPUs)Both: half the bytes and 2x compute.Needs scales (per-tensor/per-token/block). Activation outliers; calibrate. Usually the safest first step.
INT8 W8A8 (SmoothQuant)Same bytes as FP8INT8 tensor coresSame as FP8 on GPUs with INT8 coresInteger grid handles outliers worse; needs smoothing that migrates difficulty to weights.
KV-cache quant (FP8/INT8)Cache entriesDequant inside attention kernelCapacity (2x tokens/sequences) and decode KV bandwidth at long context.Errors accumulate over long context; check long-context evals, not just short benchmarks.

Lab 6: 8-bit vs fp16 on the same GEMM shape (measured)

Same 8192 x 8192 weight, 4 rotating copies. int8 via torch._int_mm, fp8 (e4m3) via torch._scaled_mm, random data (timing only, not accuracy). _int_mm requires B > 16, hence the error at B=16.

import torch
def bench(f, n=50):
    for _ in range(5): f()
    torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record()
    for _ in range(n): f()
    e.record(); torch.cuda.synchronize()
    return s.elapsed_time(e) / n * 1e3
N = K = 8192
for B in [16, 64, 256, 2048]:
    x16 = torch.randn(B, K, device="cuda", dtype=torch.float16)
    W16 = [torch.randn(N, K, device="cuda", dtype=torch.float16) for _ in range(4)]
    c = [0]
    def f16():
        c[0] = (c[0]+1) % 4; return x16 @ W16[c[0]].T
    t16 = bench(f16)
    row = f"B={B:5d} fp16 {t16:8.1f}us"
    try:
        x8 = torch.randint(-127, 127, (B, K), device="cuda", dtype=torch.int8)
        W8 = [torch.randint(-127, 127, (N, K), device="cuda", dtype=torch.int8) for _ in range(4)]
        def f8():
            c[0] = (c[0]+1) % 4; return torch._int_mm(x8, W8[c[0]].T)
        t8 = bench(f8); row += f" | int8 W8A8 {t8:8.1f}us ({t16/t8:.2f}x)"
    except Exception as ex: row += f" | int8 err {str(ex)[:60]}"
    try:
        xf = x8.float().to(torch.float8_e4m3fn)
        Wf = [W.float().to(torch.float8_e4m3fn) for W in W8]
        sa = torch.tensor(1.0, device="cuda"); sb = torch.tensor(1.0, device="cuda")
        def ff():
            c[0] = (c[0]+1) % 4; return torch._scaled_mm(xf, Wf[c[0]].T, scale_a=sa, scale_b=sb, out_dtype=torch.float16)
        tf = bench(ff); row += f" | fp8 W8A8 {tf:8.1f}us ({t16/tf:.2f}x)"
    except Exception as ex: row += f" | fp8 err {str(ex)[:80]}"
    print(row)
    del W16

measured on RTX PRO 6000 Blackwell, stg

B=   16 fp16     99.9us | int8 err self.size(0) needs to be greater than 16, but got 16 | fp8 W8A8     47.8us (2.09x)
B=   64 fp16     97.9us | int8 W8A8     47.5us (2.06x) | fp8 W8A8     48.8us (2.00x)
B=  256 fp16    123.5us | int8 W8A8     66.3us (1.86x) | fp8 W8A8     66.6us (1.86x)
B= 2048 fp16    727.5us | int8 W8A8    375.6us (1.94x) | fp8 W8A8    363.2us (2.00x)
Interpretation At B=16-64 (memory-bound) 8-bit is 2.0-2.1x faster: exactly the byte ratio. At B=2048 (compute-bound) it is still ~1.9-2.0x because 8-bit tensor cores have 2x the rate. A W4A16 kernel would show the first effect (up to ~4x at small B) but not the second: its math is still fp16. I did not benchmark a real INT4 kernel here.
Unverified Blackwell also has FP4 tensor-core formats (NVFP4/MXFP4). I did not test them, and whether vLLM 0.13 on this pod has working FP4 kernels for the RTX PRO 6000 (compute capability 12.x) is something I did not check.

Rules of thumb

7. Speculative decoding

Decode is memory-bound, so verifying k extra tokens costs almost the same as generating one (the weights are read once either way; lab 2a shows time flat up to B=64). Idea: a cheap draft proposes k tokens; the big target model scores all k+1 positions in one forward pass; accept the longest prefix that matches what the target would have sampled (rejection sampling keeps the output distribution exactly the target's). Each step yields at least 1 token and up to k+1.

Variants

Acceptance math

If each drafted token is accepted independently with probability alpha, expected tokens per target step is (1 - alpha^(k+1)) / (1 - alpha). With draft cost c (relative to one target step), speedup is about E / (1 + k c) in the memory-bound regime.

import random
random.seed(1)
def sim(a, k, trials=200000):
    tot = 0
    for _ in range(trials):
        n = 0
        while n < k and random.random() < a: n += 1
        tot += n + 1  # +1: the target's own token (correction or bonus)
    return tot / trials
print(f"{'alpha':>5} {'k':>2} {'simulated':>10} {'formula (1-a^(k+1))/(1-a)':>27}")
for a in [0.5, 0.7, 0.8, 0.9]:
    for k in [3, 5]:
        print(f"{a:5.1f} {k:2d} {sim(a,k):10.3f} {(1-a**(k+1))/(1-a):27.3f}")
print()
c = 0.1  # draft step cost relative to one target step
print("speedup = E[tokens] / (1 + k*c), c=0.1, memory-bound regime (verify cost ~ one decode step)")
for a in [0.5, 0.7, 0.8, 0.9]:
    for k in [3, 5]:
        e = (1-a**(k+1))/(1-a); print(f"alpha={a} k={k}: {e/(1+k*c):.2f}x")

Monte-Carlo simulation, run locally (pure Python; not GPU)

alpha  k  simulated   formula (1-a^(k+1))/(1-a)
  0.5  3      1.873                       1.875
  0.5  5      1.966                       1.969
  0.7  3      2.534                       2.533
  0.7  5      2.948                       2.941
  0.8  3      2.949                       2.952
  0.8  5      3.692                       3.689
  0.9  3      3.436                       3.439
  0.9  5      4.691                       4.686

speedup = E[tokens] / (1 + k*c), c=0.1, memory-bound regime (verify cost ~ one decode step)
alpha=0.5 k=3: 1.44x
alpha=0.5 k=5: 1.31x
alpha=0.7 k=3: 1.95x
alpha=0.7 k=5: 1.96x
alpha=0.8 k=3: 2.27x
alpha=0.8 k=5: 2.46x
alpha=0.9 k=3: 2.65x
alpha=0.9 k=5: 3.12x

Simulation confirms the closed form. Note the diminishing returns: at alpha=0.5, going from k=3 to k=5 lowers the speedup (1.44x to 1.31x) because the extra draft cost buys almost no extra accepted tokens.

When it hurts (1) Large batch: the target is already compute-bound, so verifying k+1 positions per sequence costs real FLOPs, not free bandwidth; gain shrinks and can go negative. (2) Low acceptance (creative/high-temperature sampling, weak draft, domain mismatch). (3) Draft overhead: a slow draft (CPU-bound, Python) erases the gain. (4) Memory: draft weights and KV reduce the batch you can hold. Measure on your real traffic mix.

8. Parallelism & communication

KindWhat is splitCommunicationUse when
Tensor parallel (TP)Each weight matrix across GPUs (column/row split)2 all-reduces per transformer layer, every token stepModel does not fit on one GPU, or you want lower per-token latency (each GPU reads 1/TP of weights). Needs fast interconnect.
Pipeline parallel (PP)Layers into stagesActivations point-to-point between stages (small)Across nodes / slow links. Adds pipeline bubbles; does not reduce single-request latency.
Expert parallel (EP)MoE experts across GPUs2 all-to-alls per MoE layer (dispatch, combine)MoE models. Load imbalance (hot experts) is the pain.
Data parallel (DP)Whole model replicated, requests splitNone at inference (in MoE/attention-DP setups, some coordination)Scale throughput once the model fits. Simplest, highest efficiency.

Order of preference: fit on one GPU (quantize!) and replicate; else TP within a node; PP/EP across nodes. TP buys latency and memory at the cost of communication; DP buys throughput for free.

Communication cost, with numbers

An 80-layer model with TP does 2 x 80 = 160 all-reduces per token step. At batch 32 and hidden size 8192 in fp16 each message is only 32 x 8192 x 2 = 512 KiB (computed). Small messages are latency-bound, not bandwidth-bound: 160 collectives times a per-collective latency of tens of microseconds adds milliseconds per token, comparable to the entire compute floor of 2-5 ms. That is why TP wants NVLink-class links (high bandwidth and low latency, with in-network/one-shot all-reduce tricks) and degrades on PCIe, where bandwidth and latency are both worse and traffic may traverse the CPU root complex.

Not verified here The lab pod has one GPU, so I measured no collective. I believe the RTX PRO 6000 Blackwell Server Edition has no NVLink (PCIe Gen5 only), unlike SXM H100/H200/B200 parts, but confirm on the datasheet. This matters: on such cards TP across 2-4 GPUs pays PCIe all-reduce latency, so quantizing to fit one 96 GB GPU and replicating is usually preferable. Measured host-to-device pinned PCIe bandwidth on our card is ~55 GB/s (section 11), consistent with Gen5 x16 (a measured H2D copy, not a GPU-to-GPU number).

MoE serving

9. Prefix caching & disaggregated prefill/decode

Prefix caching

Many requests share a long prefix (system prompt, few-shot examples, a document, chat history). KV for identical prefix tokens is identical, so keep it. vLLM hashes each full block of tokens (chained with the previous block's hash) to find reusable physical blocks; unreferenced blocks are evicted LRU. Savings: prefill compute for the prefix (= TTFT and GPU time).

Disaggregated prefill/decode

Prefill is compute-bound and bursty; decode is memory-bound and steady. Co-located, they interfere (chunked prefill mitigates but does not remove it) and you must pick one parallelism and one hardware type for both. Disaggregation runs prefill on one pool and decode on another and ships the KV cache between them.

10. Kernels & compilers

10a. CUDA graphs: launch overhead (measured)

Each kernel launch costs the CPU several microseconds. Decode runs hundreds of small kernels per step, so launch overhead can exceed GPU time. A CUDA graph records a kernel sequence once and replays it with a single launch.

import torch
x = torch.randn(1024, device="cuda")
M = 300
def body(x):
    for _ in range(M): x = x * 1.0001 + 0.0001
    return x
def bench(f, n=20):
    for _ in range(3): f()
    torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record()
    for _ in range(n): f()
    e.record(); torch.cuda.synchronize()
    return s.elapsed_time(e) / n
te = bench(lambda: body(x))
g = torch.cuda.CUDAGraph()
st = torch.cuda.Stream()
with torch.cuda.stream(st):
    for _ in range(3): body(x)
torch.cuda.synchronize()
with torch.cuda.graph(g): y = body(x)
tg = bench(lambda: g.replay())
print(f"{2*M} tiny kernels ({M} iterations of mul+add on 1024 floats)")
print(f"eager      : {te:.3f} ms per call = {te/(2*M)*1e3:.2f} us per kernel")
print(f"cuda graph : {tg:.3f} ms per call = {tg/(2*M)*1e3:.2f} us per kernel")
print(f"speedup    : {te/tg:.1f}x")

measured on RTX PRO 6000 Blackwell, stg

600 tiny kernels (300 iterations of mul+add on 1024 floats)
eager      : 3.633 ms per call = 6.05 us per kernel
cuda graph : 0.612 ms per call = 1.02 us per kernel
speedup    : 5.9x

These are deliberately tiny kernels (1024 floats), so this is the worst case for launch overhead: ~6 us per kernel eager is CPU-launch-bound, ~1 us per kernel in the graph is close to the GPU's own per-kernel floor. Real decode kernels are larger, so the benefit is smaller but still large for small models, which is exactly where the 1B-class models live. Constraints: static shapes and static addresses, so vLLM captures graphs for a set of batch sizes and pads up; prefill (variable shapes) typically runs eagerly. Nsight Systems shows the symptom as gaps between kernels on the GPU timeline.

10b. Fusion: Triton and torch.compile (measured)

y = x*2 + 1 in eager PyTorch is two kernels: each reads and writes the full tensor, so 4 passes over memory instead of 2. A fused kernel does one read, one write.

import torch, triton, triton.language as tl
@triton.jit
def k(x_ptr, y_ptr, n, a, b, BLOCK: tl.constexpr):
    offs = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
    m = offs < n
    x = tl.load(x_ptr + offs, mask=m)
    tl.store(y_ptr + offs, x * a + b, mask=m)
def tri(x, y):
    n = x.numel(); k[(triton.cdiv(n, 1024),)](x, y, n, 2.0, 1.0, BLOCK=1024)
def bench(f, n=100):
    for _ in range(5): f()
    torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record()
    for _ in range(n): f()
    e.record(); torch.cuda.synchronize()
    return s.elapsed_time(e) / n * 1e3
n = 1 << 28  # 1 GiB fp32
x = torch.randn(n, device="cuda"); y = torch.empty_like(x)
tri(x, y); assert torch.allclose(y, x * 2 + 1)
byt = 2 * n * 4
t1 = bench(lambda: tri(x, y))
t2 = bench(lambda: x * 2.0 + 1.0)
cf = torch.compile(lambda x: x * 2.0 + 1.0)
cf(x); t4 = bench(lambda: cf(x))
t5 = bench(lambda: y.copy_(x))
for name, t, b in [("triton fused", t1, byt), ("torch eager x*2+1 (2 kernels)", t2, 4*n*4), ("torch.compile fused", t4, byt), ("plain copy (reference)", t5, byt)]:
    print(f"{name:32s} {t:8.0f} us  {b/t/1e3:7.0f} GB/s moved")

measured on RTX PRO 6000 Blackwell, stg (1 GiB fp32 tensor)

triton fused                         1470 us     1461 GB/s moved
torch eager x*2+1 (2 kernels)        2939 us     1461 GB/s moved
torch.compile fused                  1469 us     1462 GB/s moved
plain copy (reference)               1474 us     1457 GB/s moved
Read A 15-line Triton kernel and torch.compile (whose Inductor backend generates Triton code) both hit the same 1.46 TB/s as a plain copy, which is this card's practical memory-bandwidth ceiling in my test. Eager takes twice as long because it moves twice the bytes. The "GB/s moved" column for eager counts all four passes, so it looks equal to the others: the lesson is in the time, not that column.

The tool landscape

ToolWhat it isUse it for
CUDA C++Full control: threads, warps, shared memory, async copiesMax performance, new hardware features; highest cost
Triton (language)Python DSL; you write per-block programs on tiles, compiler handles thread mapping, shared memory, coalescingCustom fused ops (norms, quant/dequant, attention variants). Typically fast to write, within some distance of hand-tuned on the newest features.
CUTLASS / CuTeC++ templates for GEMM/attention building blocks incl. tensor-core and TMA featuresProduction GEMMs, grouped GEMM for MoE, FP8/FP4 epilogues
torch.compileDynamo captures graph, Inductor fuses and emits Triton/C++Easy elementwise/norm fusion; vLLM uses it for parts of the model; compile time and dynamic-shape recompiles are the costs
cuBLAS / cuDNN / FlashInfer / FlashAttentionVendor and community tuned librariesDefault for GEMM and attention; beat them only with a specific reason

TensorRT-LLM vs vLLM vs SGLang (honest view)

StrengthsWeaknesses
TensorRT-LLMNVIDIA-tuned kernels, early access to new hardware features (FP8/FP4), engine ahead-of-time compile, strong peak numbers on NVIDIA hardwareBuild/compile step per model+config+GPU, less flexible, slower to support new architectures, NVIDIA-only
vLLMBroad model coverage, fast to adopt new models, large community, PagedAttention, prefix caching, many quant formats, easy Python extension; you already run it under TritonPython scheduler overhead can show at very small models/high QPS; peak kernel performance sometimes behind TRT-LLM on a given model
SGLangRadixAttention (tree-structured prefix cache), efficient scheduler, strong at structured output, multi-turn and agentic prefix-heavy workloadsSmaller ecosystem than vLLM
Not sure These frameworks change monthly. The strengths/weaknesses above are my general understanding, not a benchmark. Which is fastest depends on model, GPU, batch and version. For an interview, answer with the method (benchmark all three on your traffic shape and length distribution at SLO), and cite what you personally measured. The earlier benchmark scripts in your workspace (bench_sglang_vllm.py) are the right kind of evidence to quote; I did not re-run them.
Profiling workflow nsys first (timeline: gaps, launch overhead, sync, memcpy, overlap), then ncu per kernel (achieved bandwidth, occupancy, tensor-core use, stall reasons). On this pod ncu counters are blocked, so the roofline numbers on this page come from timing, not counters. Know the ncu "speed of light" section and what each stall reason means, since you cannot demonstrate it here.

11. Multi-tenancy, LoRA, autoscaling & cold starts

Scope This section describes how serverless GPU inference platforms are generally built. I do not know the internals of Modal, Baseten, Fireworks or any specific company and make no claims about them.

LoRA multi-tenant serving

A LoRA adapter adds low-rank matrices A (r x d_in) and B (d_out x r) to selected linears: y = Wx + B(Ax). One base model in memory serves many tenants' adapters. Rank 16 on all linears of a Llama-3-8B-shaped model is 41.9M parameters, ~84 MB in fp16 (computed), 0.5% of the base, so thousands fit in CPU memory and dozens on GPU.

Autoscaling LLM replicas

Cold start anatomy

  1. Provision the GPU node (cloud API, minutes; mitigated with warm node pools).
  2. Pull the container image (multi-GB; mitigated by lazy/streamed image layers and local caches).
  3. Load weights: storage/network to host memory to GPU.
  4. Initialize: CUDA context, allocate KV pool, torch.compile, capture CUDA graphs, warm kernels (often tens of seconds if not cached).

Lab 11: host to GPU copy (measured)

import torch, time
n = 2 * 2**30
for name, pin in [("pageable", False), ("pinned", True)]:
    h = torch.empty(n, dtype=torch.uint8, pin_memory=pin); h.fill_(1)
    d = torch.empty(n, dtype=torch.uint8, device="cuda")
    d.copy_(h, non_blocking=pin); torch.cuda.synchronize()
    t = time.time()
    for _ in range(3): d.copy_(h, non_blocking=pin)
    torch.cuda.synchronize(); dt = (time.time() - t) / 3
    print(f"{name:9s} H2D 2 GiB: {dt*1e3:7.1f} ms = {n/dt/1e9:5.1f} GB/s")

measured on RTX PRO 6000 Blackwell, stg

pageable  H2D 2 GiB:   206.2 ms =  10.4 GB/s
pinned    H2D 2 GiB:    38.7 ms =  55.5 GB/s

Last-hop PCIe is not the bottleneck: 16 GB over 55 GB/s is 0.29 s, versus 16 s if the source delivers 1 GB/s, and 35 s for 70 GB at 2 GB/s (computed). Pageable (non-pinned) memory is 5x slower, so loaders stage through pinned buffers. Cold-start work is therefore about the earlier hops, which is where the standard techniques act:

12. Capacity planning & benchmarking methodology

Open vs closed loop

Method checklist

  1. Realistic length distributions: sample prompt and output lengths from production logs, not a fixed 128/128. Long-tail prompts drive p99 TTFT; output length drives KV occupancy.
  2. Realistic content for caching: shared-prefix fraction determines prefix-cache hit rate; random prompts understate it, a single repeated prompt overstates it.
  3. Warmup: discard the first requests (CUDA graph capture, compile, lazy loading, empty caches); measure steady state, long enough to include GC pauses and cache eviction (minutes, not seconds).
  4. Sweep offered load (QPS) to produce a latency-vs-throughput curve; capacity = highest QPS where p99 meets SLO. Report goodput.
  5. Look at percentiles, not means; collect enough samples (p99 needs hundreds+, p99.9 thousands). Report per-request-class percentiles (short vs long prompts).
  6. Keep the load generator off the critical path: a saturated client (Python, tokenizer, TLS) makes the server look slow. Check client CPU.
  7. Change one variable at a time; repeat runs; record versions, flags, GPU clocks.
  8. Accuracy gate every perf experiment that touches numerics (quantization, spec decode, kernels).

Tied to your world

Your candidate-scoring workload (illustrative arithmetic, plug in your real numbers) A request carrying 100 candidates is a prefill-shaped job: lots of tokens processed once, few tokens generated. That moves it toward the compute side of the roofline, unlike chat decode. FLOPs ~ 2 x params x total tokens. Hypothetical: 1B-class model (take 2e9 FLOP/token as an upper bound; embedding parameters do no matmul work, so true FLOPs/token is lower), 20 tokens per candidate, 100 candidates = 2000 tokens = 4e12 FLOP = 20 ms at 200 TFLOPS effective, 10 ms at 400 (computed; token counts are made up). The prefix shared by all 100 candidates (the query/context) is a prime prefix-caching target, if the engine and the batch layout allow sharing.
Tokenizer as the CPU bottleneck If tokenization is 4 ms of serial CPU per request and the GPU needs 2 ms per request, one tokenizer thread caps you at 250 req/s while the GPU could do 500; four threads lift the cap to 1000, so the GPU (500) becomes the limit (computed). Diagnosis in Nsight Systems or any tracer: GPU idle gaps between batches while one CPU core is pinned at 100%. Fixes: more tokenizer workers or processes (GIL), fast Rust tokenizers with batch encode, pre-tokenize/cache candidate tokens (candidates repeat), move tokenization off the request path.
Your INT4 gemma-3-1b on RTX PRO 6000 Using spec bandwidth, 0.5 GB of INT4 weights has a decode floor of 0.28 ms/token, and the lab's 75% realistic factor gives ~0.37 ms. You told me production P50/P99 is ~ / . A gap that large means end-to-end latency is not dominated by reading weights; it is the sum of everything else (queueing, tokenization, prefill of many candidates, CPU scheduling, network/serialization). I cannot say from here which term dominates; a trace of one request through the pipeline (nsys on the model-runtime plus timestamps at each hop) would settle it. Also from memory and unverified: gemma-3-1b has 26 layers, 1 KV head, head_dim 256 and mixed local/global attention, giving at most ~26 KiB of fp16 KV per token (if every layer cached the full context; local-window layers need less). Read the config.json before quoting.
Not measured I did not benchmark vLLM end to end on the pod (no real model loaded), so no tokens/s or p99 figures for any real model are claimed on this page. The labs measure the hardware primitives the models are built on.

13. Interview prep

Open each answer only after you have said yours out loud. Answers lean on the numbers measured above.

Q1. Why is decode memory-bound?

Each decode step multiplies a tiny activation (one token per sequence) by every weight matrix. Each weight byte is read once and used for ~2 FLOPs, so arithmetic intensity is ~batch FLOP/byte, far below the GPU ridge point (~290 on our card: 396 TFLOPS / 1.37 TB/s). So step time ~ bytes / bandwidth, and it is nearly flat in batch until the knee. I measured exactly that: 98 us from B=1 to B=64 on an 8192x8192 fp16 layer, then compute takes over around B=128-512. Consequences: quantize weights, batch more, speculate, and GQA/KV quant for the KV portion.

Q2. Estimate the max concurrent sequences for an 8B model on a 96 GB GPU.

Weights 16 GB fp16. Usable ~90% of 97,887 MiB = 92.4 GB (decimal). Subtract weights and ~2 GB activations/graphs: ~74 GB for KV. KV per token (32 layers, 8 KV heads, 128 dim, 2 bytes, x2 for K and V) = 131,072 B. 74.4e9 / 131,072 = ~567k tokens, i.e. ~138 sequences of 4096 tokens or ~69 of 8192. FP8 KV doubles it. Caveats: block rounding, CUDA graph memory, and that decode at 138 sequences x 4k reads ~75 GB of KV per step, so step time becomes KV-bound (~40 ms at 1.8 TB/s spec), which is why max concurrency is not the same as good latency.

Q3. Design a serving stack for a 70B model with p99 TTFT < 500 ms.

Start with the numbers. Prefill FLOPs for a 2000-token prompt = 2 x 70e9 x 2000 = 2.8e14. At ~400 TFLOPS per GPU (what I measured fp16 dense on a Blackwell workstation-class card; datacenter parts differ) that is 700 ms on one GPU, so single-GPU fp16 cannot meet 500 ms even with zero queueing. Levers: (1) tensor parallel across 4-8 GPUs with NVLink-class interconnect to cut prefill to ~100-200 ms; (2) FP8 weights and compute (about 2x math rate, half the memory so fewer GPUs); (3) prefix caching with prefix-aware routing so the shared system prompt is not recomputed; (4) chunked prefill with a token budget so decode traffic does not hold up new prefills, and a priority for short prompts; (5) disaggregation if long prompts dominate and the KV transfer fits the budget; (6) admission control and load shedding to stay below ~70% utilization, since queueing eats the p99 budget (M/M/1 intuition: at 90% load, latency is 10x service time); (7) separate pools by prompt length class. Decode: TPOT floor = weight bytes per GPU / bandwidth, TP divides it. Validate with an open-loop load test using production length distributions, report goodput at the SLO, and gate accuracy for FP8. I would state which numbers are assumptions.

Q4. p99 latency spikes appeared. How do you debug?

Separate "which stage" from "which cause". (1) Break the latency into queue, tokenize, prefill, decode, network using per-request timestamps; see which component the p99 lives in. (2) Correlate spikes with request features: prompt length, output length, tenant, adapter, batch size at admission. Long prompts without chunked prefill produce ITL spikes for everyone. (3) Engine metrics: waiting-queue depth, KV utilization, preemption count, cache hit rate over time. (4) Periodic spikes at fixed intervals suggest GC, logging flush, autoscaler/health checks, or compile/graph-capture for a new batch shape (shapes outside the captured set fall back to eager). (5) GPU side: nsys timeline for gaps (CPU stall), clock throttling (power/thermal), noisy neighbours (MIG/MPS/time-slicing). (6) Load generator or client-side artifact (coordinated omission) can fake spikes. (7) Fix by cause: chunking, admission control, more KV room, pre-warming shapes, pinning CPU/threads, bigger tokenizer pool. Verify with an open-loop replay.

Q5. Explain PagedAttention and why it increases throughput.

KV is stored in fixed-size blocks drawn from a preallocated pool; each sequence has a block table mapping logical to physical blocks, as in OS paging. This eliminates reservation for max length and external fragmentation, so the fraction of memory holding real KV approaches 100%. Higher memory efficiency means more concurrent sequences, so larger decode batches, so more tokens per weight read. It also enables block sharing (prefix caching, parallel sampling) and cheap preemption. Cost: indirection in the attention kernel and block-granularity effects (partial last block, prefix reuse only on full blocks).

Q6. Explain FlashAttention, including online softmax.

Attention is IO-bound, not FLOP-bound, because the N x N score matrix is moved through HBM several times. FlashAttention tiles Q, K, V into SRAM, and for each Q tile streams over K/V tiles keeping running row max m and row sum l, rescaling the partial output by exp(m_old - m_new) as the max changes. The result equals exact softmax attention without ever materializing S in HBM: O(N) memory and much less traffic. My lab: tiled math matched SDPA to 2e-4 and cut peak memory 10 GiB to 2 GiB, but only the fused kernel is fast (1.82 ms, ~302 TFLOPS), because tile data must stay on-chip across the whole loop. Backward recomputes S instead of storing it.

Q7. When does speculative decoding hurt?

When the target is not memory-bound (large batch, so verification costs real FLOPs), when acceptance is low (speedup = E/(1+kc) with E = (1-a^(k+1))/(1-a); at a=0.5, k=5, c=0.1 it is 1.31x and lower than k=3), when the draft is slow relative to the target, or when the draft's memory shrinks your batch. Under load, it can trade throughput for latency. Use it for latency-sensitive, low-batch, predictable-output workloads (code, structured output, extractive), and make it adaptive (disable it above a batch threshold).

Q8. INT4 weight-only vs FP8: how do you choose?

Bytes versus math. W4A16 cuts bytes ~4x, so it wins in memory-bound decode at small batch, and lets a model fit a smaller or cheaper GPU, but compute stays fp16 with dequant overhead, so it does little for prefill or high batch. FP8 W8A8 halves bytes and doubles tensor-core rate (I measured 2.0-2.1x at B=16-64 and 1.9-2.0x at B=2048 on identical shapes), so it helps everywhere and is the safe default for throughput serving. Choose by regime and by accuracy: run the task's eval, with extra scrutiny for small models and long-context. KV quant is a separate decision aimed at capacity.

Q9. TP vs PP vs EP vs DP, and why does the interconnect matter?

If it fits on one GPU: replicate (DP), no communication. If not: TP inside a node for latency (each GPU reads 1/TP of weights) at the cost of 2 all-reduces per layer per step (160 for an 80-layer model, ~512 KiB each at batch 32), latency-bound, so NVLink-class interconnect matters and PCIe hurts. PP across nodes moves small activations but adds bubbles and no latency gain. EP for MoE: all-to-all per layer and imbalance risk. I would first try quantizing to fit one GPU and replicating.

Q10. What does chunked prefill trade off?

It bounds step duration by capping tokens per step, so decoders see consistent ITL instead of multi-hundred-ms stalls from long prefills. It lengthens the long prompt's own TTFT slightly (more steps) and adds scheduler complexity. The token budget is the knob: larger favours throughput and TTFT, smaller favours ITL. Example: budget 2048, 100 running decodes leaves 1948, so an 8000-token prompt needs 5 steps.

Q11. Why do CUDA graphs help decode, and what are the constraints?

Decode has many tiny kernels, so CPU launch cost (~6 us each in my test) can exceed GPU execution (~1 us each in the graph): 3.63 ms vs 0.61 ms for 600 tiny kernels, 5.9x. A graph replays the entire sequence with one launch. Constraints: static shapes and pointers, so capture for several batch sizes and pad; no CPU-side control flow; extra memory; capture time at startup. Prefill and unusual shapes run eagerly.

Q12. How would you reduce cost per million tokens?

Cost = $/hr / (goodput tokens/s x 3600) x 1e6 (e.g. $3/hr at 5000 tok/s = $0.167/M). Raise goodput per GPU: higher batch via more KV room (KV quant, GQA, paging, prefix caching), cheaper bytes (FP8/INT4), better kernels, speculation at low batch, disaggregation; pick hardware by $ per unit of the bottleneck resource (bandwidth for decode, FLOPs for prefill); raise utilization with autoscaling and mixed workloads. Always at fixed SLO and accuracy.

Q13. How do you decide between GPU types (your L4 to RTX PRO 6000 experience)?

Compute the roofline floor for the model on each (bytes / bandwidth for decode, FLOPs / peak for prefill), then validate with a measured load test at the SLO. For decode-heavy small models the 6x bandwidth ratio (1.8 TB/s vs 300 GB/s spec) predicts the order-of-magnitude difference you observed (L4 at several hundred ms, memory-bound). The decision metric is cost at SLO, and the L4 can still be right for small encoders with short sequences.

Q14. Walk me through profiling a slow kernel.

nsys first: is the GPU even busy? Look for gaps (launch/CPU bound), synchronizations, memcpys, serialization across streams. Then pick the dominant kernels by total time. Use ncu on those: compare achieved DRAM bandwidth and tensor-core utilization to the roofline to classify memory- or compute-bound; check occupancy, coalescing, bank conflicts, warp stall reasons. Fix by fusing, changing tiling/vector width, or switching libraries. Re-measure end to end. (I know the workflow; in our stg pod ncu counters are blocked, so I have practiced the nsys half hands-on and the ncu half from reading.)

Q15. Prefix caching: what makes the hit rate bad in production?

Variable content early in the prompt (timestamps, user ids before the shared part), block granularity (only full blocks hit), load balancing that scatters same-prefix requests across replicas, eviction under memory pressure, and tenant isolation separating cache keys. Fix with prompt layout, prefix-aware routing, and sizing cache vs batch capacity.

How to talk about your experience

Frame: strengths first, then the gap you closed Strengths to lead with: you have run LLM serving in production on Triton + vLLM on Kubernetes; you made a real hardware decision (L4 rejected at several hundred ms because decode is memory-bound; moved to RTX PRO 6000 for INT4 AWQ gemma-3-1b) and can explain it with the roofline; you certify models against latency SLAs and know that p99 and goodput, not means, are the metric; you have worked on caching and the CPU tokenizer bottleneck, which are the same problems as prefix caching and host-side overhead.

The gap, honestly and concretely: "I came from the systems and platform side. To go deeper on the kernel level I studied the roofline from first principles and verified it myself: I measured a decode-like GEMM staying flat to batch 64 and crossing over to compute-bound near 128-512, got 2x from 8-bit on identical shapes, measured CUDA graph launch overhead, wrote a Triton kernel that matches torch.compile at the bandwidth ceiling, and implemented online-softmax attention to see exactly why FlashAttention is IO-bound." Then name what you are still learning (hand-written CUDA/CUTLASS kernels, ncu-level analysis) rather than hiding it. Avoid: claiming kernel-authoring experience you do not have, quoting framework benchmarks you did not run, or asserting details of employers' internals.