Throughput Optimization › Implementation: Measuring and Optimizing Throughput

Michael BrenndoerferFebruary 6, 20269 min read
Language AI HandbookAll chapters
Part 65 of 67Production Systems
Chapter 493Throughput Optimization › Implementation: Measuring and Optimizing Throughput
Reading section 8 of 11 · Implementation: Measuring and Optimizing Throughput · 9 minSection 8 of 11 · 9 min

Book contents

Section 8 of 11 · 9 min read

Throughput Optimization › Implementation: Measuring and Optimizing ThroughputOne section · Sections in this chapter8 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us build a practical throughput measurement and optimization toolkit. We will simulate a serving system, measure throughput under different conditions, and visualize the impact of key decisions.

Simulating a Request Workload

We start by modeling realistic request distributions. Production workloads typically show a mix of short and long requests with a roughly log-normal length distribution. A few very long requests coexist with a large volume of short requests, and the tail is heavier than a normal distribution would suggest.

In[15]:
Code
@dataclass
class Request:
    request_id: int
    prompt_tokens: int
    max_output_tokens: int
    arrival_time: float = 0.0
    completion_time: float = 0.0


def generate_workload(
    n_requests: int,
    mean_prompt_tokens: int = 256,
    mean_output_tokens: int = 128,
    rng_seed: int = 42,
) -> List[Request]:
    """Generate a synthetic workload with log-normal length distributions."""
    rng = np.random.default_rng(rng_seed)

    # Log-normal distributions typical of production LLM workloads
    prompt_lengths = (
        rng.lognormal(
            mean=np.log(mean_prompt_tokens), sigma=0.8, size=n_requests
        )
        .clip(10, 2048)
        .astype(int)
    )
    output_lengths = (
        rng.lognormal(
            mean=np.log(mean_output_tokens), sigma=1.0, size=n_requests
        )
        .clip(5, 512)
        .astype(int)
    )

    # Poisson arrival process
    inter_arrivals = rng.exponential(
        scale=1.0 / 10.0, size=n_requests
    )  # 10 req/s
    arrival_times = np.cumsum(inter_arrivals)

    return [
        Request(i, int(p), int(o), float(a))
        for i, (p, o, a) in enumerate(
            zip(prompt_lengths, output_lengths, arrival_times)
        )
    ]
Out[16]:
Console
Generated workload: 500 requests
Prompt lengths  -- mean: 339, median: 256, p95: 923
Output lengths  -- mean: 174, median: 128, p95: 512
Total tokens to generate: 87,172

The workload reflects the log-normal shape typical in production: most requests are short, but a long tail of complex requests generates the majority of total tokens. The P95 output length is substantially higher than the mean, which is precisely what makes continuous batching so valuable. Static batching at this workload would have most batch slots sitting idle while the long requests complete.

Simulating a Serving System

Next we build a simple serving simulator that can model both static and continuous batching. The simulator tracks wall-clock simulation time, prefill costs, and per-request completion times.

In[17]:
Code
@dataclass
class ServingStats:
    total_requests: int = 0
    total_tokens_generated: int = 0
    total_time_seconds: float = 0.0
    latencies_ms: List[float] = field(default_factory=list)

    @property
    def request_throughput(self) -> float:
        return (
            self.total_requests / self.total_time_seconds
            if self.total_time_seconds > 0
            else 0.0
        )

    @property
    def token_throughput(self) -> float:
        return (
            self.total_tokens_generated / self.total_time_seconds
            if self.total_time_seconds > 0
            else 0.0
        )

    @property
    def p50_latency_ms(self) -> float:
        return (
            float(np.percentile(self.latencies_ms, 50))
            if self.latencies_ms
            else 0.0
        )

    @property
    def p99_latency_ms(self) -> float:
        return (
            float(np.percentile(self.latencies_ms, 99))
            if self.latencies_ms
            else 0.0
        )


def simulate_serving(
    requests: List[Request],
    batch_size: int,
    ms_per_decode_step: float = 5.0,
    ms_per_prefill_token: float = 0.1,
    use_continuous_batching: bool = True,
) -> ServingStats:
    """
    Simulate LLM serving with either static or continuous batching.

    ms_per_decode_step: milliseconds per single generation step (all active requests advance one token)
    ms_per_prefill_token: milliseconds to prefill one token in the prompt
    """
    stats = ServingStats()
    queue = list(requests)
    queue.sort(key=lambda r: r.arrival_time)

    current_time_ms = 0.0
    active: List[Request] = []
    remaining_tokens: List[int] = []
    completed_requests: List[Request] = []

    queue_idx = 0

    while queue_idx < len(queue) or active:
        # Prefill: admit new requests up to batch_size
        while len(active) < batch_size and queue_idx < len(queue):
            req = queue[queue_idx]
            # Prefill cost
            prefill_cost = req.prompt_tokens * ms_per_prefill_token
            current_time_ms += prefill_cost
            active.append(req)
            remaining_tokens.append(req.max_output_tokens)
            queue_idx += 1

        if not active:
            # Idle: jump to next arrival
            if queue_idx < len(queue):
                current_time_ms = max(
                    current_time_ms, queue[queue_idx].arrival_time * 1000
                )
            continue

        if use_continuous_batching:
            # One decode step: advance all active requests by 1 token
            current_time_ms += ms_per_decode_step
            new_active = []
            new_remaining = []
            for req, rem in zip(active, remaining_tokens):
                if rem <= 1:
                    req.completion_time = current_time_ms
                    completed_requests.append(req)
                    stats.total_tokens_generated += req.max_output_tokens
                    latency = req.completion_time - req.arrival_time * 1000
                    stats.latencies_ms.append(latency)
                else:
                    new_active.append(req)
                    new_remaining.append(rem - 1)
            active = new_active
            remaining_tokens = new_remaining
        else:
            # Static batching: process entire batch to completion (max output tokens)
            max_steps = max(remaining_tokens)
            current_time_ms += max_steps * ms_per_decode_step
            for req, rem in zip(active, remaining_tokens):
                req.completion_time = current_time_ms
                completed_requests.append(req)
                stats.total_tokens_generated += req.max_output_tokens
                latency = req.completion_time - req.arrival_time * 1000
                stats.latencies_ms.append(latency)
            active = []
            remaining_tokens = []

    stats.total_requests = len(completed_requests)
    stats.total_time_seconds = current_time_ms / 1000.0
    return stats

Benchmarking Batch Size Impact

Now we measure how batch size affects throughput and latency across both batching strategies. This is the kind of sweep you would run before deploying a model to production.

In[18]:
Code
batch_sizes_to_test = [1, 2, 4, 8, 16, 32, 64]
continuous_results = []
static_results = []

small_workload = generate_workload(n_requests=200, rng_seed=0)

for bs in batch_sizes_to_test:
    cont_stats = simulate_serving(
        small_workload, batch_size=bs, use_continuous_batching=True
    )
    stat_stats = simulate_serving(
        small_workload, batch_size=bs, use_continuous_batching=False
    )
    continuous_results.append(cont_stats)
    static_results.append(stat_stats)
Out[19]:
Console
 Batch     Cont Throughput   Static Throughput   Cont P99 (ms)   Static P99 (ms)
--------------------------------------------------------------------------------
     1             192.1 t/s             192.1 t/s        150766 ms          150766 ms
     2             369.4 t/s             258.6 t/s         68961 ms          107377 ms
     4             672.8 t/s             384.9 t/s         28528 ms           66447 ms
     8            1139.3 t/s             552.7 t/s          9205 ms           40528 ms
    16            1749.7 t/s             879.7 t/s          2778 ms           18298 ms
    32            2419.4 t/s            1356.7 t/s          4182 ms            6174 ms
    64            2954.5 t/s            1937.4 t/s          6637 ms            4645 ms

The results show token throughput rising substantially with batch size for both strategies, with continuous batching consistently delivering higher throughput because it keeps batch slots occupied throughout generation. At small batch sizes, the two strategies perform similarly because there is less opportunity for idle slots in a small batch. At large batch sizes, the divergence grows because longer requests keep more slots idle under static batching.

Out[20]:
Visualization
Dual-panel line chart showing token throughput and P99 latency versus batch size for static and continuous batching strategies.
Effect of batch size on token throughput (left) and P99 latency (right) for static and continuous batching over a 200-request workload arriving at 10 requests per second. Batch size 1 cannot keep up with the offered load, so requests accumulate and P99 latency is extreme. Larger batches drain the queue, with continuous batching reaching the low-latency knee sooner and sustaining higher throughput.

The dual-panel view in @fig-batch-size-throughput-latency makes the tradeoff concrete. At batch size 1, the simulated server cannot keep up with the fixed offered load, so requests accumulate in the queue and P99 latency becomes extreme. Increasing the batch size raises throughput sharply and drains that backlog, reducing latency until the system reaches a low-latency knee. Beyond the knee, throughput gains flatten and queueing pressure begins to reappear. The useful operating point is therefore the smallest batch size that clears the offered load with enough throughput headroom.

Notice also that continuous batching reaches the low-latency region at a smaller batch size than static batching while delivering higher throughput throughout the sweep. This is because continuous batching eliminates the "wait for the slowest request" effect that causes static batching's occasional very long latencies. When the slowest request in a static batch runs to 500 tokens, every other request in that batch must wait those 500 steps. In continuous batching, short requests finish and leave the batch long before the long request completes. At the largest tested batches, both strategies have enough capacity to clear the offered load, so their P99 latencies converge.

A Worked Throughput Calculation

To make the concepts concrete, let us trace through a specific scenario. Suppose you are serving a 7B model on an A100 80GB GPU. Your target is to serve 50 concurrent users at a P99 latency under 3 seconds, and you expect an average output of 150 tokens at 5 ms per decode step.

Step 1: Calculate the expected time to generate a 150-token response at batch size BB.

time per response=150×5 ms=750 ms\text{time per response} = 150 \times 5\,\text{ms} = 750\,\text{ms}

At batch size 32, this time is shared across 32 concurrent requests. The GPU runs all 32 simultaneously, so the wall clock time is still roughly 750 ms (plus queuing if the batch is full).

Step 2: Calculate token throughput at batch size 32.

token throughput=32×150 tokens750 ms=48000.75 s=6400 tok/s\text{token throughput} = \frac{32 \times 150\,\text{tokens}}{750\,\text{ms}} = \frac{4800}{0.75\,\text{s}} = 6400\,\text{tok/s}

Step 3: Estimate memory usage. Model weights at 14 GB, KV cache at 1 MB per token. For 32 requests at 150 tokens average output plus 256 tokens average prompt, the working KV cache is roughly 32×406×1 MB≈13 GB32 \times 406 \times 1\,\text{MB} \approx 13\,\text{GB}. Total: 27 GB, well within the 80 GB limit.

Step 4: Check if the batch size matches the roofline. With the crossover around batch size 156 for this model, batch size 32 is well into the memory-bound regime. You are not saturating GPU compute. This tells you that if you can increase batch size to 64-128, you will get proportionally higher throughput with minimal additional latency cost (the decode step time barely changes in the memory-bound regime as batch size increases).

Step 5: Verify latency SLO. At batch size 32 with continuous batching, P99 latency is dominated by the long-tail output lengths. The P99 output length for a log-normal distribution with mean 150 is typically around 400-600 tokens. At 5 ms per step, P99 latency is roughly 600×5=3000 ms600 \times 5 = 3000\,\text{ms}, right at your 3-second SLO.

This calculation suggests that batch size 32 barely meets the SLO. Going to batch size 64 would improve throughput but might push P99 latency over 3 seconds due to longer queuing. The right answer is to run the simulation and profile at sustained load, using the batch size that keeps P99 under 3 seconds with a 10-20% margin.

Key Parameters

The key parameters for throughput optimization are:

  • max_batch_size: The maximum number of requests processed simultaneously. Controls the peak throughput and memory budget. Set based on available GPU memory minus model weight overhead.
  • max_wait_time_ms: Maximum time a request waits before being dispatched even with an incomplete batch. Lower values reduce latency at the cost of smaller, less efficient batches.
  • continuous_batching: Whether to use iteration-level scheduling (recommended for production). Most modern serving frameworks (vLLM, TGI, TensorRT-LLM) enable this by default.
  • tensor_parallel_degree: Number of GPUs to split model layers across within a node. Increases memory capacity and throughput at the cost of all-reduce communication overhead.
  • pipeline_parallel_degree: Number of nodes to pipeline across. Enables very large models but introduces bubble overhead that requires large micro-batch sizes to amortize.