Anatomy of GPU Performance

August 2026Vladislav Kruglikov

GPUs are expensive, so optimizing their performance has a direct financial impact: models train faster, inference throughput increases, fewer GPU hours are required, and overall compute costs decrease. There is also a less obvious strategic advantage: reducing GPU hours shortens development cycles, helping you publish research or release new models ahead of your competitors.

Optimizing random parts of a system and hoping for better efficiency is gambling and usually a waste of time. You may get lucky and solve the problem quickly, but you will not understand why the change worked. More often, you will repeat experiments without making progress.

I prefer a data and knowledge driven approach where metrics show what is happening, while fundamentals help understand why. With that, sort of, radar, performance problem is solvable. It becomes a question of execution time and implementation effort. Even when progress is slow, every step moves toward the goal. There is no random try this or that.

Table of Contents

FLOP

Floating point operation. Can be one of floating point addition, subtraction, multiplication or division operation.

PyTorch allows to compute FLOPS
import torch
import torch.utils.flop_counter


n = 1024
device = 0
a = torch.rand(n, n, dtype=torch.float16, device=device)
b = torch.rand(n, n, dtype=torch.float16, device=device)

flop_counter = torch.utils.flop_counter.FlopCounterMode(display=False)
with flop_counter:
    a @ b

analytical_flops = 2 * n ** 3
empirical_flops = flop_counter.get_total_flops()

assert analytical_flops == empirical_flops

FMA

Fused multiply add computes a×b+ca \times b + c as one instruction. 1 FMA equals to 2 FLOPS.

FLOPS/s

Floating point operations per second is how many floating point operations were performed in 1 second.

Compute FLOPS/s in PyTorch
import torch


n = 1024
device = 0
warmup_steps = 4
measure_steps = 8

for _ in range(warmup_steps):
    a = torch.rand(n, n, dtype=torch.float16, device=device)
    b = torch.rand(n, n, dtype=torch.float16, device=device)
    a @ b

starter = torch.cuda.Event(enable_timing=True)
ender = torch.cuda.Event(enable_timing=True)

latencies = []
for _ in range(measure_steps):
    a = torch.rand(n, n, dtype=torch.float16, device=device)
    b = torch.rand(n, n, dtype=torch.float16, device=device)
    torch.cuda.synchronize()
    starter.record()
    a @ b
    ender.record()
    torch.cuda.synchronize()
    latency = starter.elapsed_time(ender) / 1e3
    latencies.append(latency)

analytical_flops = 2 * n ** 3
average_latency = sum(latencies) / len(latencies)
flops = analytical_flops / average_latency
tflops = flops / 10 ** 12
print(tflops) # 11.440554750942827

TFLOPS/s observed will be less then reported in datasheets because matrix multiplication also requires memory movemenets that takes some time

Compute FLOPS/s from NVIDIA's datasheets

First, NVIDIA often reports FLOPS/s figures that assume sparsity, typically disclosed only in a small footnote at the bottom of the page.

The formula is:

FLOPS/s=(SM count)(cores per SM)(FMA per core per clock)(clock frequency in Hz)(FLOPS per FMA)\begin{aligned} \text{FLOPS/s} ={}& (\text{SM count}) \cdot (\text{cores per SM}) \cdot (\text{FMA per core per clock}) \\ &\cdot (\text{clock frequency in Hz}) \cdot (\text{FLOPS per FMA}) \end{aligned}

For different accelerators for FP16 tensor cores:

MetricV100
SXM
T4A100
SXM
H100
SXM5
H200
SXM
SM count8040108132132
Tensor cores per SM88444
Tensor core FMAs per clock6464256512512
Base frequency (MHz)1380585109513501500
Boost frequency (MHz)15301590141019801980
GPU memory bandwidth (GB/s)900320155533504800
TDP (W)30070400700700
FLOPS per tensor FMA22222
FP16 tensor TFLOPS (base)113.024.0242.2729.9811.0
FP16 tensor TFLOPS (boosted)125.365.1311.91070.51070.5
FP16 tensor TFLOPS (reported)12565312989989

For Hopper the 1980 MHz peak frequency is typically for the standard CUDA cores. For heavy tensor core operations the GPU typically operates at an application clock of roughly 1830 MHz. Uaing 1830 MHz in the formula produces reported values. NVIDIA does this to maximize performance while keeping the H100 within its 700 W power and thermal limits. Running every SM and tensor core at the absolute peak frequency simultaneously would exceed this power envelope leading to immediate thermal throttling to protect the silicon from permanent degradation.

Why maximize observed FLOP/s?

For a fixed algorithm, the useful FLOP count is fixed. If one implementation achieves a higher useful FLOP/s, it completes the same work in less time and therefore has lower latency. Maximizing observed FLOP/s means minimizing memory and system overhead so that the algorithm's unavoidable computation becomes the critical path.

HFU

Hardware FLOPS utilization measures the achieved FLOPS/s as a percentage of the theoretical peak FLOPS/s of the hardware:

HFU=Achieved FLOP/sTheoretical peak FLOP/s×100%\small \mathrm{HFU} = \frac{\text{Achieved FLOP/s}} {\text{Theoretical peak FLOP/s}} \times 100\%

Suppose we use activation checkpointing and, in the extreme case, store no intermediate activations. During the backward pass, every required activation must be recomputed. This introduces many additional operations, but HFU does not distinguish recomputation from the original forward and backward work. Matrix multiplication is usually highly optimized, so these additional operations can still use the GPU efficiently and produce a high HFU.

Suppose the original forward pass requires 50 TFLOPs, recomputing the activations wastes another 450 TFLOPs, and the backward pass requires 300 TFLOPs. Divide the total by the 10-second step time to get the achieved TFLOP/s. Then divide that throughput by the theoretical peak of 100 TFLOP/s and multiply by 100 to express HFU as a percentage:

HFU=(50+450+300) TFLOPs10 s100 TFLOP/s×100%=80%\small \mathrm{HFU} = \frac{(50+450+300)\ \text{TFLOPs}} {10\ \text{s}\cdot100\ \text{TFLOP/s}} \times100\% =80\%

The distinction goes further. Consider a matrix multiplication:

CM,N=AM,KBK,NC_{M,N}=A_{M,K}B_{K,N}

Using the common convention that counts one multiplication and one addition for every reduction element, the algorithmic work is:

2MNK FLOPs2MNK\ \text{FLOPs}

Algorithmic FLOPs describe the useful mathematical operation, but the hardware may execute more FLOPs. The operations actually issued by the device are called hardware FLOPs. Dividing this count by execution time gives achieved hardware FLOP/s, which is the numerator of HFU.

For a simplified example, suppose the selected Tensor Core kernel processes matrices in 128×128×128128\times128\times128 tiles. Tensor Core instructions use smaller fixed MMA shapes internally, but the larger tile is a useful way to model how a kernel partitions the matrix. Now suppose we multiply two 130×130130\times130 matrices. Since 130 is not divisible by 128, the kernel needs two tiles along each dimension. Conceptually, the matrices are padded with zeros from 130 to 256 elements in each dimension.

The useful algorithmic work is:

21303=4,394,000 FLOPs2\cdot130^3=4{,}394{,}000\ \text{FLOPs}

In this simplified tiled implementation, the hardware work can be as large as:

22563=33,554,432 FLOPs2\cdot256^3=33{,}554{,}432\ \text{FLOPs}

The zero padding does not change the result, but the hardware still performs FMAs for padded elements. Because matrix multiplication kernels are usually highly optimized, this extra work can keep the compute units busy and produce a high HFU even while making the workload less efficient. HFU therefore measures utilization, not usefulness.

Interpreting HFU

The exact thresholds depend on the hardware and workload, but an HFU above roughly 70% is generally high. It usually indicates that the compute units are busy most of the time and that instruction issue is efficient. This is common for compute-bound workloads such as large matrix multiplications and models using large batch sizes.

High HFU does not mean that the algorithm or end-to-end latency is optimal. Redundant computation, padding, activation recomputation, or an inefficient algorithm can inflate the number of executed FLOPs while still keeping the hardware busy. A workload can therefore have high HFU and poor useful performance.

An HFU below roughly 30% is generally low. It means that the compute units are often idle because something else limits performance. Common causes include memory-bound kernels, small matrices or batch sizes, kernel-launch overhead, insufficient fusion, synchronization barriers, and control-flow-heavy operations.

Low HFU is not necessarily a problem. Embedding lookups, layer normalization, I/O-bound pipelines, and latency-optimized inference may naturally have low arithmetic utilization. A memory-bound kernel can have low HFU while saturating memory bandwidth and operating close to its roofline limit. In that case, adding more compute throughput would not make it faster. The low HFU is expected for that workload.

MFU

Model FLOPS utilization measures how much of the theoretical peak FLOPS/s is spent on useful model work:

MFU=Useful model FLOP/sTheoretical peak FLOP/s×100%\small \mathrm{MFU} = \frac{\text{Useful model FLOP/s}} {\text{Theoretical peak FLOP/s}} \times100\%

MFU uses an implementation-independent definition of useful work. Its numerator is determined by the mathematical operations required by the model, rather than by how a particular implementation executes them. The measured MFU can still change between implementations because a faster implementation reduces the step time.

Consider the activation-checkpointing example again. The original forward pass requires 50 TFLOPs and the backward pass requires 300 TFLOPs, so the useful model work is 350 TFLOPs. The additional 450 TFLOPs spent recomputing activations are excluded. With a 10-second step time and theoretical peak performance of 100 TFLOP/s, MFU is:

MFU=(50+300) TFLOPs10 s100 TFLOP/s×100%=35%\small \mathrm{MFU} = \frac{(50+300)\ \text{TFLOPs}} {10\ \text{s}\cdot100\ \text{TFLOP/s}} \times100\% =35\%

The same execution has an HFU of 80% because the hardware executes 800 TFLOPs in total. The 45-percentage-point difference is caused by recomputation that keeps the GPU busy without performing additional useful model work.

Now consider the simplified padded matrix multiplication. Multiplying the two 130×130130\times130 matrices requires 4,394,0004{,}394{,}000 useful FLOPs. Because the Tensor Core kernel works with fixed 128×128×128128\times128\times128 tiles, it pads the dimensions to 256 and may execute as many as 33,554,43233{,}554{,}432 hardware FLOPs for the resulting 256×256×256256\times256\times256 tiled computation. Since MFU and HFU use the same execution time and theoretical hardware peak, they are related by:

MFU=HFUUseful model FLOPsExecuted hardware FLOPs\small \mathrm{MFU} = \mathrm{HFU} \cdot \frac{\text{Useful model FLOPs}} {\text{Executed hardware FLOPs}}

If the padded matrix multiplication reaches 80% HFU, its useful-work utilization is:

MFU=80%4,394,00033,554,43210.5%\small \mathrm{MFU} = 80\% \cdot \frac{4{,}394{,}000}{33{,}554{,}432} \approx10.5\%

The GPU is highly utilized, but only a small fraction of its theoretical peak performance contributes to the requested 130×130130\times130 multiplication. Strictly speaking, for one matrix multiplication this is algorithmic FLOPS utilization rather than model FLOPS utilization. MFU applies the same idea to all useful operations in a model.

Under consistent FLOP-counting conventions, MFU is always less than or equal to HFU. Both metrics use the same step time and theoretical peak, but HFU counts every operation executed by the hardware, while MFU counts only useful model operations. They are equal when the implementation performs no recomputation, padding, or other redundant work counted by HFU.

Interpreting MFU

These ranges are rough guidelines, not universal thresholds. MFU depends on the model architecture and size, batch and sequence lengths, accelerator type and count, parallelization strategy, and FLOP-counting convention.

  • Below 30% is generally poor, although it may be expected for a small model, a communication-heavy distributed run, or a latency-oriented workload.
  • 30–40% is common at small or medium scale.
  • 40–60% indicates a well-optimized training run.
  • Above 60% is close to the practical limit for many large training workloads.
  • Above 70% usually requires an extremely tuned setup and a workload dominated by large, efficient matrix multiplications.

As a real example, the Llama 3 team reports 38–43% MFU for the 405B model across different scaling configurations.

Arithmetic intensity

Arithmetic intensity (AI) is the ratio of the number of floating-point operations to the amount of data moved:

AI=FLOPsbytes moved\small \mathrm{AI} = \frac{\text{FLOPs}}{\text{bytes moved}}

Assume that all calculations use FP16, so each element occupies 2 bytes. The examples below use an idealized traffic model that counts each input matrix or vector once and ignores cache effects, reloading, and other implementation details.

Consider the element-wise addition of two vectors a,bRNa,b\in\mathbb{R}^{N}:

ci=ai+bi\small c_i=a_i+b_i

The two input vectors require 2N2=4N2N\cdot2=4N bytes to read. The output vector requires N2=2NN\cdot2=2N bytes to write. Therefore, the total data movement is 6N6N bytes. The addition performs NN FLOPs, so its arithmetic intensity is:

AIvector add=N6N=16 FLOP/byte\small \mathrm{AI}_{\text{vector add}} = \frac{N}{6N} =\frac{1}{6}\ \text{FLOP/byte}

This means that the operation performs only one-sixth of a FLOP per moved byte, or equivalently, moves 6 bytes for every FLOP.

Now consider multiplying two square matrices A,BRN×NA,B\in\mathbb{R}^{N\times N}:

C=AB\small C=AB

Under the same idealized traffic model, reading the two input matrices moves 2N22=4N22N^2\cdot2=4N^2 bytes, and writing the output matrix moves another N22=2N2N^2\cdot2=2N^2 bytes. The total data movement is therefore 6N26N^2 bytes. Matrix multiplication performs 2N32N^3 FLOPs, counting one multiplication and one addition for each term in each dot product. Its arithmetic intensity is:

AImatmul=2N36N2=N3 FLOP/byte\small \mathrm{AI}_{\text{matmul}} = \frac{2N^3}{6N^2} =\frac{N}{3}\ \text{FLOP/byte}

Unlike vector addition, matrix multiplication can reuse values from the input matrices many times. Tiled implementations exploit this reuse, so the idealized arithmetic intensity above is a simple baseline rather than a complete model of actual memory traffic.

System balance

It is useful to think of a GPU as a factory connected to a warehouse. The compute units are the factory, device memory is the warehouse, and memory bandwidth is the speed at which raw materials can be delivered. A kernel performs well only when these two pipelines supply and consume data at compatible rates.

The system-balance point is the arithmetic intensity at which the hardware's compute throughput and memory-bandwidth throughput are equal:

AIbalance=Peak compute FLOP/sPeak memory bandwidth (byte/s)[FLOPbyte]\small \mathrm{AI}_{\text{balance}} = \frac{\text{Peak compute FLOP/s}} {\text{Peak memory bandwidth (byte/s)}} \quad\left[\frac{\text{FLOP}}{\text{byte}}\right]

For a kernel with arithmetic intensity AI\mathrm{AI}:

AI<AIbalancememory-boundAI>AIbalancecompute-bound\small \begin{aligned} \mathrm{AI} &< \mathrm{AI}_{\text{balance}} &&\Rightarrow \text{memory-bound} \\ \mathrm{AI} &> \mathrm{AI}_{\text{balance}} &&\Rightarrow \text{compute-bound} \end{aligned}

At the balance point, neither pipeline has spare capacity. Below it, memory cannot deliver data quickly enough to sustain peak compute throughput. Above it, the memory system can supply data faster than the compute units can process it, so the compute units themselves are the limiting resource. Arithmetic throughput is the bottleneck.

When AI<AIbalance\mathrm{AI}<\mathrm{AI}_{\text{balance}}, the memory pipeline is the bottleneck. Data arrives more slowly than the compute units could consume it, so arithmetic units may remain idle while waiting for memory. Increasing peak compute throughput alone usually does little; increasing effective memory bandwidth or reducing data movement is more useful.

Kernel fusion often helps by keeping intermediate values in registers or shared memory instead of writing and rereading them from device memory. This reduces bytes moved and increases arithmetic intensity. However, fusion is not guaranteed to improve performance. Excessive fusion can increase register pressure, reduce occupancy, or create a less efficient kernel.

When AI>AIbalance\mathrm{AI}>\mathrm{AI}_{\text{balance}}, the memory system can supply data faster than the compute units can process it. The arithmetic units stay busy, while some memory bandwidth may remain unused, so computation is the bottleneck. Improvements should target faster math instructions, better tiling, higher occupancy, or reduced instruction overhead.

The following values are approximate system-balance points for dense FP16 Tensor Core computation. They are not the arithmetic intensity of vector addition or matrix multiplication. Each value is a hardware threshold: the ratio of the GPU's peak dense FP16 Tensor Core throughput to its peak memory bandwidth. A kernel must perform roughly this many FLOPs per moved byte for the memory pipeline and the Tensor Cores to operate near their limits at the same time, so neither resource spends most of its time waiting for the other:

AcceleratorV100
SXM
T4A100
SXM
H100
SXM
H200
SXM
System-balance point: dense FP16 Tensor Cores (FLOP/byte)139203201295206

For FP16 vector addition, the arithmetic intensity is 1/61/6 FLOP/byte regardless of the vector length. On a T4, whose balance point is approximately 201 FLOP/byte, this is far below the transition point. Under this traffic pattern, increasing the vector length does not make the operation compute-bound. Some algorithms are therefore inherently memory-bound. Their implementation can be improved, but upgrading only the GPU's compute throughput will usually provide little benefit unless memory bandwidth also increases.

For the idealized square matrix multiplication above, AI=N/3\mathrm{AI}=N/3. Setting this equal to the system-balance point gives the matrix dimension at which the two pipelines are approximately matched:

N3=AIbalanceNbalance=3AIbalance\small \frac{N}{3}=\mathrm{AI}_{\text{balance}} \quad\Longrightarrow\quad N_{\text{balance}}=3\,\mathrm{AI}_{\text{balance}}

For example, on a T4, the operation crosses the balance point when:

N3>201N>603\small \frac{N}{3}>201 \quad\Longrightarrow\quad N>603

The corresponding balance-point dimensions for the other GPUs are:

AcceleratorGEMM matrix dimension at balance, NbalanceN_{\text{balance}}
V100 SXM417
T4609
A100 SXM602
H100 SXM886
H200 SXM618

The same matrix multiplication can therefore be compute-bound on one GPU and memory-bound on another. This leads to an important hardware-aware design principle: neural-network architectures and tensor dimensions can, in principle, be chosen for a specific accelerator. In modern neural networks, however, the matrix dimensions are usually large enough that their main GEMMs are compute-bound across all of the GPUs in this table. The system-balance analysis still matters for smaller projections, unusual shapes, and other operations with lower arithmetic intensity.

Now we know whether the kernel is compute-bound or memory-bound. However, this does not tell us whether its performance is already good enough or whether further optimization is worthwhile. The roofline model helps answer that question.

Roofline

Roofline allows us to understand the upper bound on FLOP/s for an ideal algorithm running on a particular accelerator. Given the algorithm, we can estimate its arithmetic intensity, and given the hardware, we can estimate the maximum FLOP/s achievable at that arithmetic intensity. We have already learned how to measure the actual FLOP/s, so we can plot that measured point on the roofline and see how far it is from the upper bound.

The Roofline is different for each GPU because accelerators have different peak compute throughput and memory bandwidth, which gives them different operations-per-byte thresholds. The ridge point, also called the system-balance point, separates the graph into the memory-bound region on the left, the balanced transition point in the center, and the compute-bound region on the right.

To construct the Roofline, place arithmetic intensity on the horizontal axis, from zero to a sufficiently large value. For each arithmetic-intensity value, compute the maximum FLOP/s that an ideal algorithm could achieve. This performance is capped either by memory bandwidth or by the hardware's peak compute throughput:

Pmax(AI)=min ⁣(AIBW,Peak FLOP/s)\small P_{\max}(\mathrm{AI}) = \min\!\left( \mathrm{AI}\cdot\mathrm{BW}, \mathrm{Peak\ FLOP/s} \right)

Using the dense FP16 Tensor Core throughput and memory bandwidth from the specifications above gives the following Roofline curves. The horizontal axis is logarithmic arithmetic intensity, and the vertical axis is the corresponding upper bound on dense FP16 Tensor Core throughput:

1101001,0002,0001101001,000Arithmetic intensity (FLOP/byte)Peak dense FP16 Tensor Core FLOP/s (TFLOP/s)V100 SXMT4A100 SXMH100 SXMH200 SXM

Any point on a Roofline plot can move horizontally or vertically:

NVIDIA Nsight Compute can generate Roofline charts directly from kernel profiles.

Roofline does not tell us whether the algorithm is efficient in terms of latency or asymptotic complexity. An algorithm with poor asymptotic complexity may still remove implementation overhead and reach close to the Roofline ceiling. Roofline only shows the gap between the observed performance and the peak performance available for that particular arithmetic intensity.

As rough heuristics for the ratio of observed performance to the Roofline ceiling:

How to search for improvement

Amdahl's law estimates the maximum speedup of an entire system when only one part is improved. Suppose a system takes 100 ms, with 10 ms spent in one part and 90 ms in everything else. Improving the 10 ms part by a factor of two gives:

10.9+0.12=1.05263157895\small \frac{1}{0.9+\frac{0.1}{2}} =1.05263157895

The whole system becomes only about 5% faster, even though that part became twice as efficient. Even an infinitely fast 10 ms part could improve the total runtime by at most 10%.

Optimization should be data-driven rather than a gamble. Measure the current FLOP/s and the fraction of total runtime spent in the kernel. Then estimate a realistic improvement—for example, a 10% efficiency increase—and use Amdahl's law to determine whether the resulting end-to-end speedup is worth the effort. Use the Roofline model to estimate the remaining performance gap. This keeps optimization focused on small, justified steps toward the final goal.

References