aiml
SearchMy learning
Menu

Lesson 5 of 20Module 2

Choose hardware from the workload bottleneck

Use a roofline estimate to distinguish compute and bandwidth limits. Compare memory fit and explain why hardware choice depends on workload.

Prerequisites

Python, basic causal attention, and the preceding inference lessons.

By the end, you can

  • Choose hardware from the workload bottleneck.
  • Explain the bottleneck, measurement boundary, and tradeoffs in this implementation.
  • Distinguish a roofline bound from a measured hardware recommendation.
Runtime
Python 3.12+
Dependencies
Standard library only

Lab code, tests and fixtures (ZIP, 107 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A team must choose between a cheaper accelerator and one with more memory bandwidth for a code assistant. The answer depends on prompt length, active batch size, context length, and the latency budget. Build a roofline estimate to decide what to benchmark next.

What you run. Generic roofline calculator. Device specifications and prices are hypothetical inputs; no accelerator ranking was measured.

This lesson builds on budget model weights, kv cache, and runtime memory.

Find the limiting resource

Arithmetic intensity is useful operations divided by bytes moved. A roofline model bounds work by the slower of compute capacity and memory transfer capacity. Prefill can reuse weights across many input tokens; a small decode batch has less weight reuse. Larger batches and long cache reads change that balance.

The calculator uses generic example devices. Its compute, bandwidth, memory, and hourly prices are explicit scenario inputs. The reported latency is a lower-bound estimate, not a measured device recommendation. Attention, communication, host work, and scheduling can dominate outside this simplified model.

Workload shapeCompute boundTransfer boundpath Apath BWorkload shapeCompute boundTransfer boundpath A
Compare two independent calculations on the same input. There is no process flow from one alternative into the other.

compute_prefill_metrics() and compute_decode_step_metrics() estimate different phases, then check whether the weight and cache payload fits. Inspect ModelWorkload before comparing example device outputs. The first lesson’s real prefill/decode runs show the distinction without pretending to test these devices.

Explore the roofline estimate

Download the lab from this page, extract it, and run these commands from its directory. The default command reads saved run records or runs the explicitly described local algorithm; it never downloads a model or calls a paid API.

Run the local analysis and checksTerminal · bash
python3 lab.py
python3 -m unittest -v test_lab.py
Read lab.py
lab.pySource · pythonDownload
"""GPU roofline model and hardware selector for prefill and decode workloads."""

from __future__ import annotations

from dataclasses import asdict, dataclass
import json


@dataclass(frozen=True)
class GpuSpec:
    name: str
    memory_gb: float
    bandwidth_gbps: float
    bf16_tflops: float
    hourly_cost_usd: float

    @property
    def ridge_point_flops_per_byte(self) -> float:
        """Minimum operational intensity (FLOPs/byte) required to reach peak TFLOPS."""
        return (self.bf16_tflops * 1e12) / (self.bandwidth_gbps * 1e9)


@dataclass(frozen=True)
class ModelWorkload:
    model_name: str
    active_params_b: float
    bytes_per_param: int
    kv_bytes_per_token: int
    prompt_tokens: int
    output_tokens: int
    batch_size: int
    activation_overhead_gb: float = 2.0

    def validate(self) -> None:
        if self.active_params_b <= 0 or self.bytes_per_param <= 0:
            raise ValueError(
                "Model parameter count and bytes per parameter must be positive"
            )
        if self.prompt_tokens <= 0 or self.output_tokens <= 0 or self.batch_size <= 0:
            raise ValueError(
                "Prompt tokens, output tokens, and batch size must be positive integers"
            )

    @property
    def weight_bytes(self) -> float:
        return self.active_params_b * 1e9 * self.bytes_per_param

    @property
    def total_kv_bytes(self) -> float:
        seq_len = self.prompt_tokens + self.output_tokens
        return float(self.batch_size * seq_len * self.kv_bytes_per_token)

    @property
    def required_memory_gb(self) -> float:
        return (
            self.weight_bytes + self.total_kv_bytes
        ) / 1e9 + self.activation_overhead_gb


@dataclass(frozen=True)
class WorkloadPhaseMetrics:
    phase: str
    flops: float
    hbm_bytes: float
    operational_intensity: float
    ridge_point: float
    regime: str
    roofline_tflops_bound: float
    latency_ms: float


GPU_SPECS: dict[str, GpuSpec] = {
    "example-small": GpuSpec(
        name="example-small",
        memory_gb=48.0,
        bandwidth_gbps=864.0,
        bf16_tflops=362.0,
        hourly_cost_usd=1.55,
    ),
    "example-fast": GpuSpec(
        name="example-fast",
        memory_gb=80.0,
        bandwidth_gbps=3350.0,
        bf16_tflops=989.0,
        hourly_cost_usd=3.20,
    ),
    "example-large-memory": GpuSpec(
        name="example-large-memory",
        memory_gb=141.0,
        bandwidth_gbps=4800.0,
        bf16_tflops=989.0,
        hourly_cost_usd=3.80,
    ),
    "example-wide-bandwidth": GpuSpec(
        name="example-wide-bandwidth",
        memory_gb=192.0,
        bandwidth_gbps=8000.0,
        bf16_tflops=2250.0,
        hourly_cost_usd=6.20,
    ),
}


def compute_prefill_metrics(
    gpu: GpuSpec, workload: ModelWorkload
) -> WorkloadPhaseMetrics:
    workload.validate()
    total_prefill_tokens = workload.batch_size * workload.prompt_tokens
    flops = 2.0 * (workload.active_params_b * 1e9) * total_prefill_tokens
    kv_write_bytes = float(total_prefill_tokens * workload.kv_bytes_per_token)
    hbm_bytes = workload.weight_bytes + kv_write_bytes
    intensity = flops / hbm_bytes
    ridge = gpu.ridge_point_flops_per_byte
    regime = "compute-bound" if intensity >= ridge else "bandwidth-bound"
    compute_seconds = flops / (gpu.bf16_tflops * 1e12)
    memory_seconds = hbm_bytes / (gpu.bandwidth_gbps * 1e9)
    latency_s = max(compute_seconds, memory_seconds)
    roofline_tflops_bound = (flops / latency_s) / 1e12
    return WorkloadPhaseMetrics(
        phase="prefill",
        flops=round(flops, 2),
        hbm_bytes=round(hbm_bytes, 2),
        operational_intensity=round(intensity, 2),
        ridge_point=round(ridge, 2),
        regime=regime,
        roofline_tflops_bound=round(roofline_tflops_bound, 2),
        latency_ms=round(latency_s * 1000.0, 3),
    )


def compute_decode_step_metrics(
    gpu: GpuSpec, workload: ModelWorkload
) -> WorkloadPhaseMetrics:
    workload.validate()
    flops = 2.0 * (workload.active_params_b * 1e9) * workload.batch_size
    avg_context_len = workload.prompt_tokens + (workload.output_tokens / 2.0)
    kv_read_bytes = float(
        workload.batch_size * avg_context_len * workload.kv_bytes_per_token
    )
    hbm_bytes = workload.weight_bytes + kv_read_bytes
    intensity = flops / hbm_bytes
    ridge = gpu.ridge_point_flops_per_byte
    regime = "compute-bound" if intensity >= ridge else "bandwidth-bound"
    compute_seconds = flops / (gpu.bf16_tflops * 1e12)
    memory_seconds = hbm_bytes / (gpu.bandwidth_gbps * 1e9)
    latency_s = max(compute_seconds, memory_seconds)
    roofline_tflops_bound = (flops / latency_s) / 1e12
    return WorkloadPhaseMetrics(
        phase="decode_step",
        flops=round(flops, 2),
        hbm_bytes=round(hbm_bytes, 2),
        operational_intensity=round(intensity, 2),
        ridge_point=round(ridge, 2),
        regime=regime,
        roofline_tflops_bound=round(roofline_tflops_bound, 2),
        latency_ms=round(latency_s * 1000.0, 3),
    )


def evaluate_gpu_for_workload(
    gpu: GpuSpec,
    workload: ModelWorkload,
    require_single_card_fit: bool = True,
) -> dict[str, object]:
    workload.validate()
    required_gb = workload.required_memory_gb
    fits = required_gb <= gpu.memory_gb
    if require_single_card_fit and not fits:
        raise ValueError(
            f"{gpu.name} has {gpu.memory_gb:.1f} GB VRAM, below required {required_gb:.2f} GB"
        )
    prefill = compute_prefill_metrics(gpu, workload)
    decode = compute_decode_step_metrics(gpu, workload)
    ttft_ms = prefill.latency_ms
    tpot_ms = decode.latency_ms
    total_latency_s = (ttft_ms + tpot_ms * (workload.output_tokens - 1)) / 1000.0
    decode_tokens_per_sec = workload.batch_size / (tpot_ms / 1000.0)
    end_to_end_tokens_per_sec = (
        workload.batch_size * workload.output_tokens
    ) / total_latency_s
    tokens_per_dollar = end_to_end_tokens_per_sec * 3600 / gpu.hourly_cost_usd
    return {
        "gpu": gpu.name,
        "memory_gb": gpu.memory_gb,
        "required_memory_gb": round(required_gb, 2),
        "fits_single_gpu": fits,
        "ridge_point_flops_per_byte": round(gpu.ridge_point_flops_per_byte, 2),
        "prefill": asdict(prefill),
        "decode": asdict(decode),
        "ttft_ms": round(ttft_ms, 2),
        "tpot_ms": round(tpot_ms, 3),
        "decode_tokens_per_sec": round(decode_tokens_per_sec, 2),
        "end_to_end_tokens_per_sec": round(end_to_end_tokens_per_sec, 2),
        "tokens_per_dollar": round(tokens_per_dollar, 2),
    }


def build_report() -> dict[str, object]:
    # 8B BF16 model (16 GB weights, 128 KB/token KV cache for 32 layers GQA)
    small_model_comparisons = []
    for name, gpu in GPU_SPECS.items():
        workload = ModelWorkload(
            model_name="Llama-3-8B-BF16",
            active_params_b=8.0,
            bytes_per_param=2,
            kv_bytes_per_token=131072,
            prompt_tokens=2048,
            output_tokens=256,
            batch_size=8,
        )
        small_model_comparisons.append(evaluate_gpu_for_workload(gpu, workload))

    # 70B BF16 model (140 GB weights? Or 32B BF16 = 64 GB weights + KV cache)
    # Let's evaluate a 32B BF16 model (64 GB weights) with long context that fits H200/example-wide-bandwidth
    # and overflows example-small/H100 when KV cache grows.
    large_workload = ModelWorkload(
        model_name="Qwen-32B-BF16-LongContext",
        active_params_b=32.0,
        bytes_per_param=2,
        kv_bytes_per_token=262144,
        prompt_tokens=4096,
        output_tokens=512,
        batch_size=16,
    )
    large_model_comparisons = [
        evaluate_gpu_for_workload(gpu, large_workload, require_single_card_fit=False)
        for gpu in GPU_SPECS.values()
    ]

    batch_sweep = []
    for batch_size in (1, 8, 32, 128, 256):
        w = ModelWorkload(
            model_name="Llama-3-8B-BF16",
            active_params_b=8.0,
            bytes_per_param=2,
            kv_bytes_per_token=131072,
            prompt_tokens=1024,
            output_tokens=128,
            batch_size=batch_size,
        )
        metrics = evaluate_gpu_for_workload(GPU_SPECS["example-fast"], w)
        batch_sweep.append(
            {
                "batch_size": batch_size,
                "prefill_intensity": metrics["prefill"]["operational_intensity"],
                "prefill_regime": metrics["prefill"]["regime"],
                "decode_intensity": metrics["decode"]["operational_intensity"],
                "decode_regime": metrics["decode"]["regime"],
                "decode_roofline_tflops_bound": metrics["decode"][
                    "roofline_tflops_bound"
                ],
                "tpot_ms": metrics["tpot_ms"],
                "decode_tokens_per_sec": metrics["decode_tokens_per_sec"],
            }
        )

    return {
        "gpu_catalog": [
            {
                **asdict(gpu),
                "ridge_point_flops_per_byte": round(gpu.ridge_point_flops_per_byte, 2),
            }
            for gpu in GPU_SPECS.values()
        ],
        "llama_8b_batch8": small_model_comparisons,
        "qwen_32b_long_context": large_model_comparisons,
        "h100_batch_sweep": batch_sweep,
    }


def main() -> None:
    print(
        json.dumps(
            {
                "execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
                **build_report(),
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

The example catalog is input data for a sensitivity calculation. Its prices and performance figures do not describe current products. Use the real local prefill/decode record to learn measurement boundaries, then benchmark hardware candidates on their own runtime.

QuantityRecorded value
Example device count4
Inspect the complete local result
Recorded local resultRecorded output · jsonDownload
{
  "execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
  "gpu_catalog": [
    {
      "name": "example-small",
      "memory_gb": 48.0,
      "bandwidth_gbps": 864.0,
      "bf16_tflops": 362.0,
      "hourly_cost_usd": 1.55,
      "ridge_point_flops_per_byte": 418.98
    },
    {
      "name": "example-fast",
      "memory_gb": 80.0,
      "bandwidth_gbps": 3350.0,
      "bf16_tflops": 989.0,
      "hourly_cost_usd": 3.2,
      "ridge_point_flops_per_byte": 295.22
    },
    {
      "name": "example-large-memory",
      "memory_gb": 141.0,
      "bandwidth_gbps": 4800.0,
      "bf16_tflops": 989.0,
      "hourly_cost_usd": 3.8,
      "ridge_point_flops_per_byte": 206.04
    },
    {
      "name": "example-wide-bandwidth",
      "memory_gb": 192.0,
      "bandwidth_gbps": 8000.0,
      "bf16_tflops": 2250.0,
      "hourly_cost_usd": 6.2,
      "ridge_point_flops_per_byte": 281.25
    }
  ],
  "llama_8b_batch8": [
    {
      "gpu": "example-small",
      "memory_gb": 48.0,
      "required_memory_gb": 20.42,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 418.98,
      "prefill": {
        "phase": "prefill",
        "flops": 262144000000000.0,
        "hbm_bytes": 18147483648.0,
        "operational_intensity": 14445.2,
        "ridge_point": 418.98,
        "regime": "compute-bound",
        "roofline_tflops_bound": 362.0,
        "latency_ms": 724.155
      },
      "decode": {
        "phase": "decode_step",
        "flops": 128000000000.0,
        "hbm_bytes": 18281701376.0,
        "operational_intensity": 7.0,
        "ridge_point": 418.98,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 6.05,
        "latency_ms": 21.159
      },
      "ttft_ms": 724.15,
      "tpot_ms": 21.159,
      "decode_tokens_per_sec": 378.09,
      "end_to_end_tokens_per_sec": 334.66,
      "tokens_per_dollar": 777267.7
    },
    {
      "gpu": "example-fast",
      "memory_gb": 80.0,
      "required_memory_gb": 20.42,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 295.22,
      "prefill": {
        "phase": "prefill",
        "flops": 262144000000000.0,
        "hbm_bytes": 18147483648.0,
        "operational_intensity": 14445.2,
        "ridge_point": 295.22,
        "regime": "compute-bound",
        "roofline_tflops_bound": 989.0,
        "latency_ms": 265.06
      },
      "decode": {
        "phase": "decode_step",
        "flops": 128000000000.0,
        "hbm_bytes": 18281701376.0,
        "operational_intensity": 7.0,
        "ridge_point": 295.22,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 23.46,
        "latency_ms": 5.457
      },
      "ttft_ms": 265.06,
      "tpot_ms": 5.457,
      "decode_tokens_per_sec": 1466.01,
      "end_to_end_tokens_per_sec": 1236.27,
      "tokens_per_dollar": 1390804.63
    },
    {
      "gpu": "example-large-memory",
      "memory_gb": 141.0,
      "required_memory_gb": 20.42,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 206.04,
      "prefill": {
        "phase": "prefill",
        "flops": 262144000000000.0,
        "hbm_bytes": 18147483648.0,
        "operational_intensity": 14445.2,
        "ridge_point": 206.04,
        "regime": "compute-bound",
        "roofline_tflops_bound": 989.0,
        "latency_ms": 265.06
      },
      "decode": {
        "phase": "decode_step",
        "flops": 128000000000.0,
        "hbm_bytes": 18281701376.0,
        "operational_intensity": 7.0,
        "ridge_point": 206.04,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 33.61,
        "latency_ms": 3.809
      },
      "ttft_ms": 265.06,
      "tpot_ms": 3.809,
      "decode_tokens_per_sec": 2100.29,
      "end_to_end_tokens_per_sec": 1656.48,
      "tokens_per_dollar": 1569298.89
    },
    {
      "gpu": "example-wide-bandwidth",
      "memory_gb": 192.0,
      "required_memory_gb": 20.42,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 281.25,
      "prefill": {
        "phase": "prefill",
        "flops": 262144000000000.0,
        "hbm_bytes": 18147483648.0,
        "operational_intensity": 14445.2,
        "ridge_point": 281.25,
        "regime": "compute-bound",
        "roofline_tflops_bound": 2250.0,
        "latency_ms": 116.508
      },
      "decode": {
        "phase": "decode_step",
        "flops": 128000000000.0,
        "hbm_bytes": 18281701376.0,
        "operational_intensity": 7.0,
        "ridge_point": 281.25,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 56.01,
        "latency_ms": 2.285
      },
      "ttft_ms": 116.51,
      "tpot_ms": 2.285,
      "decode_tokens_per_sec": 3501.09,
      "end_to_end_tokens_per_sec": 2929.13,
      "tokens_per_dollar": 1700786.9
    }
  ],
  "qwen_32b_long_context": [
    {
      "gpu": "example-small",
      "memory_gb": 48.0,
      "required_memory_gb": 85.33,
      "fits_single_gpu": false,
      "ridge_point_flops_per_byte": 418.98,
      "prefill": {
        "phase": "prefill",
        "flops": 4194304000000000.0,
        "hbm_bytes": 81179869184.0,
        "operational_intensity": 51666.8,
        "ridge_point": 418.98,
        "regime": "compute-bound",
        "roofline_tflops_bound": 362.0,
        "latency_ms": 11586.475
      },
      "decode": {
        "phase": "decode_step",
        "flops": 1024000000000.0,
        "hbm_bytes": 82253611008.0,
        "operational_intensity": 12.45,
        "ridge_point": 418.98,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 10.76,
        "latency_ms": 95.201
      },
      "ttft_ms": 11586.48,
      "tpot_ms": 95.201,
      "decode_tokens_per_sec": 168.07,
      "end_to_end_tokens_per_sec": 136.0,
      "tokens_per_dollar": 315876.78
    },
    {
      "gpu": "example-fast",
      "memory_gb": 80.0,
      "required_memory_gb": 85.33,
      "fits_single_gpu": false,
      "ridge_point_flops_per_byte": 295.22,
      "prefill": {
        "phase": "prefill",
        "flops": 4194304000000000.0,
        "hbm_bytes": 81179869184.0,
        "operational_intensity": 51666.8,
        "ridge_point": 295.22,
        "regime": "compute-bound",
        "roofline_tflops_bound": 989.0,
        "latency_ms": 4240.954
      },
      "decode": {
        "phase": "decode_step",
        "flops": 1024000000000.0,
        "hbm_bytes": 82253611008.0,
        "operational_intensity": 12.45,
        "ridge_point": 295.22,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 41.71,
        "latency_ms": 24.553
      },
      "ttft_ms": 4240.95,
      "tpot_ms": 24.553,
      "decode_tokens_per_sec": 651.65,
      "end_to_end_tokens_per_sec": 487.98,
      "tokens_per_dollar": 548978.69
    },
    {
      "gpu": "example-large-memory",
      "memory_gb": 141.0,
      "required_memory_gb": 85.33,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 206.04,
      "prefill": {
        "phase": "prefill",
        "flops": 4194304000000000.0,
        "hbm_bytes": 81179869184.0,
        "operational_intensity": 51666.8,
        "ridge_point": 206.04,
        "regime": "compute-bound",
        "roofline_tflops_bound": 989.0,
        "latency_ms": 4240.954
      },
      "decode": {
        "phase": "decode_step",
        "flops": 1024000000000.0,
        "hbm_bytes": 82253611008.0,
        "operational_intensity": 12.45,
        "ridge_point": 206.04,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 59.76,
        "latency_ms": 17.136
      },
      "ttft_ms": 4240.95,
      "tpot_ms": 17.136,
      "decode_tokens_per_sec": 933.71,
      "end_to_end_tokens_per_sec": 630.28,
      "tokens_per_dollar": 597104.98
    },
    {
      "gpu": "example-wide-bandwidth",
      "memory_gb": 192.0,
      "required_memory_gb": 85.33,
      "fits_single_gpu": true,
      "ridge_point_flops_per_byte": 281.25,
      "prefill": {
        "phase": "prefill",
        "flops": 4194304000000000.0,
        "hbm_bytes": 81179869184.0,
        "operational_intensity": 51666.8,
        "ridge_point": 281.25,
        "regime": "compute-bound",
        "roofline_tflops_bound": 2250.0,
        "latency_ms": 1864.135
      },
      "decode": {
        "phase": "decode_step",
        "flops": 1024000000000.0,
        "hbm_bytes": 82253611008.0,
        "operational_intensity": 12.45,
        "ridge_point": 281.25,
        "regime": "bandwidth-bound",
        "roofline_tflops_bound": 99.59,
        "latency_ms": 10.282
      },
      "ttft_ms": 1864.13,
      "tpot_ms": 10.282,
      "decode_tokens_per_sec": 1556.12,
      "end_to_end_tokens_per_sec": 1150.85,
      "tokens_per_dollar": 668233.6
    }
  ],
  "h100_batch_sweep": [
    {
      "batch_size": 1,
      "prefill_intensity": 1015.48,
      "prefill_regime": "compute-bound",
      "decode_intensity": 0.99,
      "decode_regime": "bandwidth-bound",
      "decode_roofline_tflops_bound": 3.32,
      "tpot_ms": 4.819,
      "decode_tokens_per_sec": 207.51
    },
    {
      "batch_size": 8,
      "prefill_intensity": 7676.82,
      "prefill_regime": "compute-bound",
      "decode_intensity": 7.47,
      "decode_regime": "bandwidth-bound",
      "decode_roofline_tflops_bound": 25.02,
      "tpot_ms": 5.117,
      "decode_tokens_per_sec": 1563.42
    },
    {
      "batch_size": 32,
      "prefill_intensity": 25833.4,
      "prefill_regime": "compute-bound",
      "decode_intensity": 24.9,
      "decode_regime": "bandwidth-bound",
      "decode_roofline_tflops_bound": 83.41,
      "tpot_ms": 6.138,
      "decode_tokens_per_sec": 5213.42
    },
    {
      "batch_size": 128,
      "prefill_intensity": 63205.55,
      "prefill_regime": "compute-bound",
      "decode_intensity": 59.79,
      "decode_regime": "bandwidth-bound",
      "decode_roofline_tflops_bound": 200.29,
      "tpot_ms": 10.225,
      "decode_tokens_per_sec": 12518.34
    },
    {
      "batch_size": 256,
      "prefill_intensity": 83286.85,
      "prefill_regime": "compute-bound",
      "decode_intensity": 78.01,
      "decode_regime": "bandwidth-bound",
      "decode_roofline_tflops_bound": 261.33,
      "tpot_ms": 15.674,
      "decode_tokens_per_sec": 16332.78
    }
  ]
}
Saved output report for choose hardware from the workload bottleneck; execution mode and values come from the linked raw result.

This image is an application-generated report of the saved program result, not a new inference run. Open the full-resolution report or read the raw result.

Compare capacity and latency costs

A faster arithmetic unit may do little for bandwidth-bound decode. More memory can increase concurrency without reducing single-request latency. Splitting weights across devices adds communication, so an aggregate bandwidth figure is not automatically usable by one request.

Compute capacity is not the limiter. Increasing arithmetic peak does not resolve a bandwidth-bound small decode batch in this cost model.

Weight and cache fit. If the payload exceeds the example device memory, the single-device configuration is rejected before its performance estimate is compared.

Read the executable checks
Executable checksSource · pythonDownload
"""Unit and failure-boundary tests for the GPU roofline and selector lab."""

from __future__ import annotations

import unittest

from lab import (
    GPU_SPECS,
    GpuSpec,
    ModelWorkload,
    build_report,
    compute_decode_step_metrics,
    compute_prefill_metrics,
    evaluate_gpu_for_workload,
)


class GpuRooflineTest(unittest.TestCase):
    def test_ridge_points_reflect_compute_to_bandwidth_ratio(self) -> None:
        self.assertAlmostEqual(
            GPU_SPECS["example-small"].ridge_point_flops_per_byte, 418.98, places=2
        )
        self.assertAlmostEqual(
            GPU_SPECS["example-fast"].ridge_point_flops_per_byte, 295.22, places=2
        )
        self.assertAlmostEqual(
            GPU_SPECS["example-large-memory"].ridge_point_flops_per_byte,
            206.04,
            places=2,
        )
        self.assertAlmostEqual(
            GPU_SPECS["example-wide-bandwidth"].ridge_point_flops_per_byte,
            281.25,
            places=2,
        )

    def test_prefill_is_compute_bound_while_small_batch_decode_is_bandwidth_bound(
        self,
    ) -> None:
        workload = ModelWorkload(
            model_name="Llama-3-8B-BF16",
            active_params_b=8.0,
            bytes_per_param=2,
            kv_bytes_per_token=131072,
            prompt_tokens=2048,
            output_tokens=256,
            batch_size=8,
        )
        h100 = GPU_SPECS["example-fast"]
        prefill = compute_prefill_metrics(h100, workload)
        decode = compute_decode_step_metrics(h100, workload)
        self.assertEqual(prefill.regime, "compute-bound")
        self.assertGreater(
            prefill.operational_intensity, h100.ridge_point_flops_per_byte
        )
        self.assertEqual(decode.regime, "bandwidth-bound")
        self.assertLess(decode.operational_intensity, 20.0)

    def test_buying_peak_tflops_for_small_batch_decode_wastes_budget(self) -> None:
        """Doubling peak TFLOPS without raising HBM bandwidth leaves small-batch decode TPOT unchanged."""
        workload = ModelWorkload(
            model_name="Llama-3-8B-BF16",
            active_params_b=8.0,
            bytes_per_param=2,
            kv_bytes_per_token=131072,
            prompt_tokens=2048,
            output_tokens=256,
            batch_size=4,
        )
        h100 = GPU_SPECS["example-fast"]
        # Hypothetical chip with 2x BF16 TFLOPS but identical 3,350 GB/s HBM bandwidth
        double_compute_same_bw = GpuSpec(
            name="Hypothetical-2x-TFLOPS",
            memory_gb=80.0,
            bandwidth_gbps=h100.bandwidth_gbps,
            bf16_tflops=h100.bf16_tflops * 2.0,
            hourly_cost_usd=h100.hourly_cost_usd * 1.5,
        )
        h100_eval = evaluate_gpu_for_workload(h100, workload)
        hyp_eval = evaluate_gpu_for_workload(double_compute_same_bw, workload)
        self.assertEqual(h100_eval["tpot_ms"], hyp_eval["tpot_ms"])
        self.assertGreater(
            GPU_SPECS["example-large-memory"].bandwidth_gbps,
            h100.bandwidth_gbps,
        )
        h200_eval = evaluate_gpu_for_workload(
            GPU_SPECS["example-large-memory"], workload
        )
        self.assertLess(h200_eval["tpot_ms"], h100_eval["tpot_ms"])

    def test_insufficient_vram_rejects_single_card_fit(self) -> None:
        """A workload whose weights + KV cache + activations exceed GPU VRAM raises ValueError."""
        large_workload = ModelWorkload(
            model_name="Qwen-32B-BF16-LongContext",
            active_params_b=32.0,
            bytes_per_param=2,
            kv_bytes_per_token=262144,
            prompt_tokens=4096,
            output_tokens=512,
            batch_size=16,
        )
        with self.assertRaisesRegex(ValueError, "below required"):
            evaluate_gpu_for_workload(
                GPU_SPECS["example-small"], large_workload, require_single_card_fit=True
            )
        with self.assertRaisesRegex(ValueError, "below required"):
            evaluate_gpu_for_workload(
                GPU_SPECS["example-fast"], large_workload, require_single_card_fit=True
            )
        h200_fit = evaluate_gpu_for_workload(
            GPU_SPECS["example-large-memory"],
            large_workload,
            require_single_card_fit=True,
        )
        self.assertTrue(h200_fit["fits_single_gpu"])

    def test_report_structure_is_deterministic(self) -> None:
        report = build_report()
        self.assertEqual(len(report["gpu_catalog"]), 4)
        self.assertEqual(len(report["llama_8b_batch8"]), 4)
        self.assertEqual(len(report["h100_batch_sweep"]), 5)


class RooflineUnitsTests(unittest.TestCase):
    def test_first_output_token_has_no_decode_interval(self):
        from lab import GpuSpec, ModelWorkload, evaluate_gpu_for_workload

        gpu = GpuSpec("test", 100, 100, 10, 2)
        work = ModelWorkload("test", 0.01, 2, 16, 8, 1, 1)
        result = evaluate_gpu_for_workload(gpu, work)
        expected = 1000 / result["prefill"]["latency_ms"]
        self.assertAlmostEqual(result["end_to_end_tokens_per_sec"], expected, delta=0.2)
        self.assertAlmostEqual(
            result["tokens_per_dollar"],
            result["end_to_end_tokens_per_sec"] * 1800,
            delta=10,
        )


if __name__ == "__main__":
    unittest.main()


class GuardBoundaryTests(unittest.TestCase):
    def test_rejects_invalid_boundary_inputs_with_specific_reason(self):
        import lab
        cases = [('ModelWorkload("test",0,2,2,1,1,1).validate()', 'Model parameter'), ('ModelWorkload("test",1,2,2,0,1,1).validate()', 'Prompt tokens')]
        for code, reason in cases:
            with self.subTest(code=code):
                with self.assertRaisesRegex((ValueError, MemoryError, KeyError, RuntimeError), reason):
                    exec(code, vars(lab).copy())

Try a change and explain the result

Increase the active batch and inspect how arithmetic intensity changes. Then increase context length and observe cache traffic. Identify the next real benchmark you would run before spending money on hardware.

A decode workload is limited by memory transfer. Which hardware comparison is most useful next?

Choose one answer, then check your reasoning.

Read the explanation

A roofline estimate identifies a candidate limiter; the actual workload must confirm it.

Choose the next hardware benchmark

Get current specifications for the exact device and precision from its vendor. Measure sustained bandwidth, model compatibility, and full serving latency. Use an actual provider quote for cost; example prices here are not market prices.

References

Sources checked October 24, 2026. Runtime-specific commands and results apply to the recorded versions; linked rolling documentation can change. The application scenarios are teaching examples, not claims about an author’s production incident.

Written by Arjun Prabhulal.

Module 2

Fit the model and KV cache in GPU memory