--- title: "Hello World: Single-Node Roofline" subtitle: "Predict model performance on hardware before writing a single CUDA kernel." --- ::: {.callout-note} ## Prerequisites Complete the [Getting Started](../getting-started.qmd) guide before this tutorial. It introduces the `Engine.solve` API and the MLSys Zoo. ::: In this tutorial, you will model the performance of **ResNet-50** on an **NVIDIA A100** GPU using the analytical roofline model. By the end, you will understand: - What it means for a model to be **memory-bound** vs. **compute-bound** - How changing **batch size** shifts the bottleneck - Why the A100's memory bandwidth matters as much as its peak TFLOP/s ::: {.callout-note} ## Background: ResNet-50 and the A100 **ResNet-50** is a 50-layer convolutional neural network (CNN) commonly used for image classification. It has roughly 25 million parameters and requires about 8 billion floating-point operations (8 GFLOP) per inference. It is a standard benchmark workload because its size is well-characterized and widely published. The **NVIDIA A100** is a datacenter GPU designed for ML training and inference. Its key specifications: 312 TFLOP/s peak compute (FP16 Tensor Core), 2.0 TB/s HBM2e (High Bandwidth Memory) bandwidth, and 80 GB of memory. These two numbers (compute speed and memory speed) are what the roofline model uses to predict performance. See the [Glossary](../glossary.qmd) for definitions of terms like FLOP/s, HBM, and Tensor Core. ::: ::: {.callout-tip} ## What is the roofline model? Every GPU has two speed limits: how fast it can compute (FLOP/s) and how fast it can load data from memory (bytes/s). Your model's actual throughput is determined by whichever limit you hit first. The roofline model tells you exactly which one, and by how much. ::: --- ## 1. Setup ```{python} #| echo: false #| output: false # Build-system path setup — hidden from students import sys, os, importlib.util current_dir = os.getcwd() root_path = os.path.abspath(os.path.join(current_dir, "../../../")) if not os.path.exists(os.path.join(root_path, "mlsysim")): root_path = os.path.abspath("../../") package_path = os.path.join(root_path, "mlsysim") init_file = os.path.join(package_path, "__init__.py") spec = importlib.util.spec_from_file_location("mlsysim", init_file) mlsysim = importlib.util.module_from_spec(spec) sys.modules["mlsysim"] = mlsysim spec.loader.exec_module(mlsysim) Engine = mlsysim.Engine ``` After `pip install mlsysim`, the import is simple: ```python import mlsysim from mlsysim import Engine ``` --- ## 2. Select Workload and Hardware Pull vetted specifications directly from the **MLSys Zoo**—no need to look up datasheets. ```{python} # Load ResNet-50 from the Model Zoo model = mlsysim.Models.ResNet50 # Load NVIDIA A100 from the Silicon Zoo hardware = mlsysim.Hardware.Cloud.A100 print(f"Model: {model.name} ({model.architecture})") print(f"Hardware: {hardware.name} ({hardware.release_year})") print(f"") print(f"Model FLOPs (inference): {model.inference_flops}") print(f"Hardware Peak TFLOP/s: {hardware.compute.peak_flops.to('TFLOPs/s'):.0f}") print(f"Hardware Memory BW: {hardware.memory.bandwidth.to('TB/s'):.1f}") ``` --- ## 3. Solve the Performance Profile The `Engine.solve` method applies the **Iron Law of ML Systems**—it calculates which of the two hardware speed limits (compute or memory) you hit first, and returns your latency from there. ```{python} profile = Engine.solve( model=model, hardware=hardware, batch_size=1, precision="fp16" ) print(f"Bottleneck: {profile.bottleneck}") print(f"Latency: {profile.latency.to('ms'):.3f} ms per inference") print(f"Throughput: {profile.throughput:.0f} images/sec") ``` ::: {.callout-note} ## Why "Memory Bound"? At batch size 1, ResNet-50 performs ~8 GFLOPs of computation but loads ~50 MB of weights (25.6M parameters at fp16). Its **arithmetic intensity** (FLOPs/Byte) is far below the A100's roofline ridge point. The A100's memory bandwidth (2 TB/s) becomes the bottleneck, not its 312 TFLOP/s compute. ::: --- ## 4. Sweep Batch Sizes The bottleneck changes as batch size grows. Run the sweep and see when compute takes over: ```{python} print(f"{'Batch':>6} {'Bottleneck':<16} {'Throughput':>12} {'Latency':>10}") print("-" * 52) for batch in [1, 4, 16, 32, 64, 128, 256]: p = Engine.solve( model=model, hardware=hardware, batch_size=batch, precision="fp16" ) print( f"{batch:>6} {p.bottleneck:<16} " f"{p.throughput:>10.0f}/s " f"{p.latency.to('ms'):>8.2f} ms" ) ``` ::: {.callout-tip} ## The crossover point Watch where the output switches from `Memory Bound` to `Compute Bound`. That is the **ridge point** of the roofline—the batch size at which you've saturated both resources equally. Beyond that point, adding more compute (or a bigger GPU) pays off. Below it, more memory bandwidth is what matters. ::: --- ## 5. Visualizing the Roofline MLSYSIM includes built-in visualization tools. The roofline chart plots the hardware's two ceilings and shows where your workloads sit relative to them: ```python import matplotlib.pyplot as plt fig, ax = mlsysim.plot_roofline(hardware, workloads=[model]) ax.set_title(f"Roofline: {model.name} on {hardware.name}") plt.show() ``` ![Roofline plot for ResNet-50 on the NVIDIA A100. The rising diagonal is the memory bandwidth ceiling; the flat line is the compute ceiling. ResNet-50 at batch=1 lands on the memory-bound slope, well below the ridge point.](images/roofline_hello_world.png){#fig-roofline-hello} --- ## Your Turn ::: {.callout-caution} ## Exercises **Exercise 1: Predict before you compute.** Before running the code, predict: Will ResNet-50 at batch_size=64 be memory-bound or compute-bound on the A100? Write down your prediction, then verify with `Engine.solve(...)`. Were you right? Why or why not? **Exercise 2: Hardware comparison.** Before running: which GPU do you predict will have the highest ridge point -- the V100, A100, or H100? (Hint: compare their compute-to-bandwidth ratios.) Then run the same ResNet-50 analysis on `mlsysim.Hardware.Cloud.H100` and `mlsysim.Hardware.Cloud.V100`. Which gives the lowest latency at batch_size=1? At batch_size=256? What explains the difference? **Exercise 3: Precision effect.** Before running: will switching from `precision="fp16"` to `precision="int8"` change the bottleneck classification for ResNet-50 on the A100 at batch_size=1? Write your prediction and reasoning, then compare both. How does quantization change the arithmetic intensity? **Self-check:** If a model's arithmetic intensity is 50 FLOP/byte and the hardware's ridge point is 156 FLOP/byte, is the model compute-bound or memory-bound? ::: --- ## What You Learned - **Roofline model**: Performance is bounded by $\max\left(\frac{\text{FLOPs}}{\text{Peak}},\ \frac{\text{Bytes}}{\text{BW}}\right)$ (whichever takes longer, computing or loading data, determines your runtime) - **Batch size matters**: Small batches are memory-bound; large batches become compute-bound - **The ridge point**: The crossover batch size where memory and compute are equally saturated - **Practical implication**: If you are memory-bound, reducing data movement (quantization, larger batches) helps more than a faster GPU --- ## Next Steps - **[Sustainability Lab](sustainability.qmd)**: Calculate the carbon footprint of training across different grid regions - **[LLM Serving Lab](llm_serving.qmd)**: Model the two phases of LLM inference and discover the KV-cache memory wall - **[Math Foundations](../math.qmd)**: The complete set of equations used by all solvers - **[Silicon Zoo](../zoo/hardware.qmd)**: Browse all vetted hardware specs and compare alternatives