⚡ RUNTIME: mini-infer v0.2.4 🛡️ SIGSTORE: Cryptographically Attested 🧪 TESTS: 174/174 Pass (100% CI)
← Return to Engineering Notes

Roofline Benchmarking & Auto-Tuning LLM Serving: How We Pushed Pure PyTorch to 27 tok/s

Most engineers treat LLM serving as an opaque black box—guessing thread counts, setting arbitrary batch sizes, and hoping VRAM doesn't crash. Here is how empirical roofline modeling and batched paged KV-cache gather eliminated DRAM memory bandwidth waste.

c=1 throughput: 13.6 tok/s c=4 throughput: 27.0 tok/s (+130.8%) energy: 2.04 Tok/Joule dram power: 13.2 W gpu: RTX 3050 6GB Laptop

1. The Fundamental Memory-Bandwidth Bottleneck

Autoregressive LLM generation is overwhelmingly memory-bandwidth bound. During single-token decoding, every forward pass reads the model's entire weight matrix from GPU DRAM into on-chip cache just to emit a single token:

$$\text{Arithmetic Intensity} \approx \frac{2 \times P \text{ FLOPs}}{2 \times P \text{ Bytes}} \approx 1 \text{ FLOP / Byte}$$

On an entry-level consumer laptop GPU like the NVIDIA GeForce RTX 3050 (192 GB/s memory bandwidth), theoretical peak decode speed for a 0.5B model in bfloat16 (1 GB of weights) is strictly capped by how fast weights stream from DRAM:

Theoretical Max Decode = 192 GB/s / 1.0 GB ≈ 192 tokens / second (unbatched upper limit)

However, in naive Python serving runtimes, request concurrency often tanks generation speed rather than accelerating it. Why?

2. The Serial Iteration Flaw vs. Unified Batched Decoding

In naive continuous batching architectures, active sequences are stepped in a serial loop:

# Naive unbatched loop: Reads weights from DRAM 4 times per step!
for seq in active_sequences:
    token = model.forward(seq.current_token, seq.kv_cache)

When 4 requests run concurrently, the GPU reads the entire 1 GB model weights from DRAM 4 separate times every single iteration step.

In mini-infer v0.2.1, we introduced zero-copy physical block gathering:

# mini-infer v0.2.1: Unified [B, 1] forward pass
k_all, v_all, mask = block_pool.gather_batched_paged_kv(
    layer_idx=layer, block_tables=tables, context_lens=lens, device=device
)
# Weights are read from DRAM exactly ONCE per iteration across all 4 sequences!
logits = model(tokens_batch, k_all, v_all, mask)

3. Empirical Load Testing with servebench

We load-tested the engine using servebench 1.1.2 on an RTX 3050 6GB Laptop GPU running Qwen2.5-0.5B-Instruct across concurrency levels c = 1, 2, 4:

Concurrency OK / Fail Throughput (v0.1) Throughput (v0.2.1) Throughput Gain TTFT p50 TPOT p50 Power Avg Tok/Joule
c = 1 12 / 0 9.6 tok/s 13.6 tok/s +41.6% 556 ms 70 ms 13.4 W 1.01
c = 2 12 / 0 12.0 tok/s 18.6 tok/s +55.0% 1,162 ms 98 ms 13.1 W 1.41
c = 4 12 / 0 11.7 tok/s 27.0 tok/s +130.8% (2.3×) 2,456 ms 126 ms 13.2 W 2.04

4. Key Systems Takeaways

  • Unified Batched Gather Doubled Concurrency Speed: Concurrency 4 throughput leaped from 11.7 tok/s to 27.0 tok/s (+130.8%) because weights are streamed from DRAM once per step.
  • Extreme Energy Scaling (2.04 Tokens / Joule): Generating 27 tok/s while drawing only 13.2 Watts makes consumer laptop inference over 2× more energy-efficient than idle cloud instances.
  • Automated Auto-Tuning: Using servebench tune, developers can detect saturation knees automatically and prevent latency degradation before deploying to production.