Working the roofline for DeepSeek-V3 on Hopper

In part two, we looked at how our choices of parallelism, activation checkpointing and low precision influence how much memory we need to train DeepSeek-V3, and found a configuration that let us fit it on a 2048 H800 cluster."Found" is perhaps too strong a word: it is not a coincidence that if you keep the architecture fixed and look for a configuration that works well for it on the hardware DeepSeek trained on, you end up with the configuration DeepSeek picked. An interesting and important operating mode is codesign, where we (infra) can have input into aspects of the architecture (e.g., expert sparsity) so that it runs better. This is a discussion unto itself, which we will delay for later. However, you often have some choice in how exactly you get the model to fit, and we'd like to choose the configuration that maximizes our useful FLOPs/sec. For example, our first question was whether to use FSDP (ZeRO-3) or pipeline parallelism. It is quite painful to implement pipeline parallelism, so we would really like to decide on a strategy without having to build both systems and benchmark them on a full-size cluster. And even if we did build and run the benchmark, how would we know that we hadn't made a mistake that invalidated the comparison?

The classic tool for answering questions like this is a roofline analysis: at the end of the day, the number of FLOPs we need to run is fixed, and so the most important factor of our system's performance will be its distributed communication (much in the same way the most important factor of kernel performance is if it is compute or bandwidth bound). A roofline analysis will tell us that FSDP is a bad idea for DSv3, because it will be communications bound on InfiniBand bandwidth. If we consider speed of light (SOL), where we assume our compute and communications go as fast as physically possible (either according to the hardware spec sheets or controlled microbenchmarks), we can estimate the overall wall time of a training step with a spreadsheet.

The goals for this part are two-fold. First, we will walk through the speed of light rooflines for DeepSeek-V3 on Hopper. But second, and more importantly, I want to explain the underlying reasoning behind the approximations we will use in this analysis. For example, the 6ND formula is really useful, but it can also feel a bit like magic the first time you encounter it. I want to explain it in enough detail that you could derive it from first principles and also tell when you shouldn't use it.We are going to continue to omit MTP from this analysis for simplicity.

What's speed of light?

In physics, nothing can go faster than speed of light: it is a strict upper bound on the speed of objects in the universe. When doing roofline analysis, using speed of light quantities for your GPU's FLOP/s and your network's bandwidth tells you the best performance your hardware can potentially deliver. It may not be possible to achieve this performance, but it can roughly tell you how much you are leaving on the table.

However, there is a problem: what exactly is the speed of light for our hardware? An obvious choice is to use the TFLOP/s advertised in NVIDIA marketing materials, which are computed by taking the GPU's advertised clock frequency, number of arithmetic units and FLOP/cycle the units can take. But these numbers are essentially never achievable in practice for matrix multiplies, even under optimal microbenchmarking conditions. If you issue wgmma instructions in a loop with zeroed tensors, you can indeed get over 95% of the theoretical peak performance. But this doesn't translate into matrix multiply performance:

Because the divergence between marketing and achievable matmul FLOPs can be quite large, we will prefer to use achievable FLOPs in our analysis below, e.g., by benchmarking cublas's GEMM speed for large enough inputs on the actual GPUs you plan to run your training on (e.g., in case they're power capped). This microbenchmark is not too hard to reproduce by hand, but in this analysis we're going to use the benchmarks from HuggingFace's Smol Training Playbook, assuming that the tensor core FLOPs of H100 match those of H800:

cellquantityvalue (exact)scientific SIformula
πbf16H800 BF16 peak, spec (FLOP/s)989,000,000,000,0009.89 × 1014989 TFLOP/s(hardware spec)
πsolbf16BF16 GEMM, measured (FLOP/s)757,700,000,000,0007.58 × 1014758 TFLOP/s(Smol, H100: torch.matmul, best of 5)
ηbf16share of the spec peak achieved7.66 × 10−176.6 %πsolbf16 / πbf16
πfp8H800 FP8 peak, spec (FLOP/s)1,979,000,000,000,0001.98 × 10151.98 PFLOP/s(hardware spec, dense)
πsolfp8FP8 GEMM, measured (FLOP/s)1,456,600,000,000,0001.46 × 10151.46 PFLOP/s(Smol, H100: e4m3 _scaled_mm, best of 5)
ηfp8share of the spec peak achieved7.36 × 10−173.6 %πsolfp8 / πfp8

For network bandwidth, you can get pretty close to the spec sheet for InfiniBand, so we just use the full spec sheet 50 GB/s. However, DeepSeek reported themselves in their paper that they measured only 160 GB/s unidirectional bandwidth on H800's NVLink, so we will adopt that for our calculations (this won't matter much, except for mostly arguing that NVLink is still not the bottleneck, even with the nerfed H800 NVLink).

cellquantityvalue (exact)scientific SIformula
βIBIB bandwidth per GPU (B/s)50,000,000,0005.00 × 101050 GB/s(hardware spec; DeepSeek's report uses it as is)
βNVNVLink per GPU, per direction, spec (B/s)200,000,000,0002.00 × 1011200 GB/s(hardware spec: H800, cut from H100's 450)
βsolNVNVLink, achieved (B/s)160,000,000,0001.60 × 1011160 GB/s(DeepSeek-V3 report)
ηNVshare of the spec bandwidth achieved0.88.00 × 10−180 %βsolNV / βNV

What's a roofline?

There are other good introductions to roofline analysis; you might also consider reading Making Deep Learning Go Brrrr From First Principles or All About Rooflines.

The term roofline originally comes from the Williams, Waterman and Patterson paper in the context of understanding program performance when memory bandwidth was the constraining factor. Its name comes from the classic shape of the graph you get when you plot arithmetic intensity (the number of FLOPs you perform per byte you read from or write to memory) against attainable FLOP/s.

peak compute (FLOP/s) memory bandwidth (bytes/s) ridge point = peak / bandwidth ← memory-bound compute-bound → elementwise op large matmul arithmetic intensity (FLOP per byte), log scale attainable FLOP/s, log scale

Roofline analysis is a natural thing to do when authoring GPU kernels. The roofline tells you if a kernel is compute bound or memory bound. When a kernel is memory bound, making the compute run faster is useless, since after you finish your compute you will simply idle waiting for bytes to show up from HBM. Instead, fusing multiple kernels together helps you amortize the cost of HBM reads over more compute, helping push you into the compute bound regime. Conversely, we don't usually worry about fusing GEMMs together (modulo attention or low batch inference), because matrix multiplication naturally has a high arithmetic intensity and is compute-bound in most regimes. It's also fairly difficult to "fuse" two GEMMs together (or even usefully fuse into a GEMM prologue), because input tiles are used multiple times when computing the GEMM: to avoid repeatedly recomputing an input tile, you need to store it somewhere that all of your SMs can access, and has enough space to store everything you need. Usually HBM is the most convenient thing that actually fits. The fact that Flash Attention can keep the score matrix on-chip without going to HBM is something of a minor miracle.

Rooflines in distributed training

We can also do a roofline analysis on an entire distributed train step. However, there are two big differences. First, the HBM is no longer the bottleneck: instead, the bottleneck will usually be your cross-node bandwidthBlackwell's NVL72 spans across nodes, but it's usually useful to think of your NVL domain as a "node" in this analysis., e.g., your InfiniBand bandwidth. Second, the amount of communication a distributed training setup performs depends on your choice of parallelization—in contrast, the amount of FLOPs you need to do, on a fixed architecture, is fixed, no matter how you parallelize it! A good analogy is this: imagine you have a pizza pie representing how much work (FLOPs) you need to do. No matter how you slice the pie, reducing the individual amount of work a given node needs to do, all of the slices still add up to the original pie. The analogy works less well when you think about recompute conjuring more pizza to eat—a good thing in real life, and a bad thing in distributed training!

In both cases, the rule of thumb is that you want to be compute bound, since that usually means you are efficiently utilizing the hardware resources you have available. In distributed training, this means you want your compute to take longer than your communications, since a well-implemented trainer will manage to overlap the comms and make them effectively free.Actually, it's not free. If you don't have enough compute to cover up your comms, you probably want to change something (your architecture, or your parallelism if your architecture is fixed) to put yourself back in the good compute bound regime.There are also architectural changes you might make which make it harder to be compute-bound, especially optimizations for compute efficiency. For example, switching your GEMMs to low precision can exacerbate comms-boundedness, since it makes your GEMMs run a lot faster without necessarily reducing the amount of communications you need to do.

We've promised that we would show that ZeRO-3 makes you comms bound for the original DeepSeek-V3 training setup. Here's how we will figure this out:

  1. We'll count the FLOPs a single training step takes. Dividing by the speed of light FLOP/s of our hardware gives the time we spend in compute.
  2. For each parallelism, we'll count the bytes it moves per link and what bandwidth it uses (NVLink inside a node, InfiniBand between nodes). Dividing the per-link bytes by the speed of light byte/s on that network link gives us the time we spend in comms.
  3. By comparing these times, we can tell if we are compute bound (compute time is larger) or comms bound (the links are accounted separately and either could be the bottleneck, although typically the bottleneck is InfiniBand).

How many FLOPs do we need to do?

The classic training FLOPs approximation is 6ND, where N is the number of activated parameters in the model and D is the number of tokens (either the total number of trainable tokens, giving the total amount of FLOPs for the training run, or simply the number of tokens in a global batch, giving the total amount of FLOPs for a single train stepIt's fairly typical to treat the global batch size as a fixed quantity that can't be changed, as it is typically set by science: beyond a critical batch size, increasing batch size yields diminishing returns on optimization efficiency.). This formula is indifferent to our choice of parallelism: we always have to run these FLOPs somewhere, one way or another, no matter how we have split up our work.

Most matrix multiplies in DSv3 are done in FP8 precision. So we can get a quick and dirty estimate of step time by simply dividing the FLOPs by our speed of light FP8 TFLOP/s:

cellquantityvalue (exact)scientific SIformula
Nactivated params36,625,618,4323.66 × 101036.6 G(model input)
Bsequences per step15,360(model input)
Stokens per sequence4,096(model input)
Dtokens per step62,914,5606.29 × 10762.9 MB · S
CFLOPs per step13,825,708,010,263,019,5201.38 × 101913.8 EFLOP6 · N · D
GPUsGPUs in the cluster2,048(model input)
Tcstep time at speed of light (s)4.63 × 1004.63 sC / (GPUs · πsolfp8) (every GEMM at FP8: see T+c)

Where does 6ND come from?

The core of the 6ND approximation is based off the idea that your non-attention matmuls take the bulk of the FLOPs in your model (in part 1, we used this idea to justify treating the matmuls specially in our architecture diagram). The number of FLOPs per token that a learned linear transformation computes scales linearly with the parameters of that matmul. We can see this by assigning each FLOP we need to do to a parameter in this diagram:

every weight does one multiply and one add per token: 2 FLOPs per parameter

So you derive 6ND by multiplying N (the parameter countStrictly, we shouldn't include non-matmul params like the RMSNorm parameters or the router bias in the parameter count, but these parameters are proportionally very small and don't make a big difference either way.) by D (number of tokens) and then scaling it by 6 (two FLOPs per matmul times three matmuls total: one in forwards, two in backwards computing the gradient of the input and the gradient of the weight).

x from the previous layer dx to the previous layer dW to the optimizer y = x · W forward 2 FLOPs per weight per token dx = dy · Wᵀ backward: input gradient 2 FLOPs per weight per token dW = xᵀ · dy backward: weight gradient 2 FLOPs per weight per token y to the next layer dy from the next layer 3 matmuls × 2 FLOPs per weight per token = 6 FLOPs per parameter per token

Why is this an appropriate approximation to make? We are making an argument about asymptotic behavior when we scale compute. However, upon close reflection, you might think it a bit strange to reason about the asymptotic behavior of DeepSeek-V3: after all, there's only one size for this model—671B! What exactly are you scaling?

In pretraining research, it is natural to think of an architecture as actually a family of architectures of different sizes, designed to be as similar to each other as possible, so that we can do science on the smaller sizes, fit scaling laws, and then extrapolate them to the expensive hero run. What happens to the FLOPs of a model as we increase compute, trying to keep the architecture otherwise the same? Given some compute budget C, here is a standard recipe for defining this family (for example, this matches this scaling ladder design in the MAI-Thinking tech report):

When we increase depth, all of the transformer block FLOPs increase uniformly linearly with depth. However, when we increase width, while non-matmul compute increases linearly, the learned linear transformations (matmuls that are activation × weight) increase quadratically, since both the input and output spaces get bigger. This means that, in the limit, learned linear transformation FLOPs dominate everything else.

non-matmul op, e.g. RMSNorm d +d d → 2d values: 2× the FLOPs learned projection, d × d weight d +d d +d d² → 4d² weights: 4× the FLOPs

Is this model too simple?

Yes. There are three main refinements, ordered in how much they impact your overall wall time roofline.

  1. The 6ND approximation undercounts attention FLOPs, since the attention core (the QKᵀ and PV matmuls) doesn't actually have any trainable parameters (it scales on sequence length). The attention is non-negligible and we need to account for it.
  2. We are doing some recompute to help us fit the activations in memory. We need to add these recompute FLOPs to our FLOP count.
  3. Not all matrix multiplies in DSv3 are FP8; notably attention, router and LM head are in BF16. We have a different speed of light number of BF16 FLOPs, so we should use a different divisor for these FLOPs. In DSv3, this isn't a huge difference (and thus last on our list), because most GEMM compute is in FP8, but this could be a substantial correction factor if you were analyzing a model that didn't have one dominant compute dtype.

Let's take these in order. Inside attention, there are attention activation matmuls that don't (directly) take a weight matrix. These scale more slowly than the parameter count (linearly, not quadratically, in width), but they also scale with sequence length. It's common to pretrain with some fixed sequence length, and then only scale the sequence length in some midtraining for context length. This commonly results in a situation where the 6ND approximation is good for pretraining (sequence length not too long) but bad for midtraining (long sequence length means attention is now a bottleneck). The diagram below shows us how the proportion of FLOPs changes as we scale up our compute and sequence length:The LM head (yellow) segment of this graph is worth a comment. The compute needed by the LM head scales with your vocabulary size, which is typically fixed throughout your entire scaling ladder. So on small models, the LM head is an extremely expensive and a very noticeable part of your compute, but because its compute is only linear in hidden size, its share asymptotes to nothing in the limit (like the non-matmul operations).

To actually compute attention FLOPs, the conventional formula is 4S2d (where S is the sequence length and d is the total attention width across all heads), but if you have causal masking, this halves the number of FLOPs.A causal query at position i attends to i keys, itself included, so the average is (S + 1)/2 keys rather than S/2. But that's only 0.02% more so we keep the simpler formula. We're also going to assume a FlashAttention style kernel implementation, which implies some built-in recompute in the backwards.

cellquantityvalue (exact)scientific SIformula
LmoeMoE layers58(architecture)
Ldnsdense layers3(architecture)
Llayers61Lmoe + Ldns
nhattention heads128(architecture)
dhhead dim · qk nope, v128(architecture)
dRhhead dim · qk rope64(architecture)
cqkQKᵀ-shaped FLOPs · one layer, one sequence412,316,860,4164.12 × 1011412 GFLOP2 · nh · (dh + dRh) · S · S / 2 (contracts dh + dRh; 2 FLOPs per multiply-add; causal keeps half the S · S pairs)
cpvPV-shaped FLOPs · one layer, one sequence274,877,906,9442.75 × 1011275 GFLOP2 · nh · dh · S · S / 2 (contracts dh)
cattnattention FLOPs · one layer, one sequence, fwd687,194,767,3606.87 × 1011687 GFLOPcqk + cpv (QKᵀ, then PV)
Cattnattention FLOPs per step2,317,946,433,212,252,1602.32 × 10182.32 EFLOPL · B · (4 · cqk + 3 · cpv) (QKᵀ-shaped matmuls run 4×: forward, FlashAttention's backward recompute, dQ, dK; PV-shaped run 3×: forward, dV, dP; every layer, every sequence)
Tattnattention core time, BF16 (s)1.49 × 1001.49 sCattn / (GPUs · πsolbf16)

Next, we need to account for recompute. Our chosen recompute policy from part 2 recomputes many norms, which aren't modeled in our 6ND FLOPs approximation; the only GEMMs we recompute are the outputs of the MLA up-projections. We can count specifically the parameters involved in recompute:

This recompute is very cheap; it adds very little to our overall wall time!

cellquantityvalue (exact)scientific SIformula
Nupqq up-projection params, per layer37,748,7363.77 × 10737.7 M(read off the diagram)
Nupkvkv up-projection params, per layer16,777,2161.68 × 10716.8 M(read off the diagram)
Nrcreplayed GEMM params3,326,083,0723.33 × 1093.33 GL · (Nupq + Nupkv)
Crcrecompute FLOPs per step418,518,105,996,656,6404.19 × 1017419 PFLOP2 · Nrc · D (forward replay: 2 FLOPs per param per token)
Trcrecompute time, FP8 (s)1.40 × 10−1140 msCrc / (GPUs · πsolfp8)

Finally, let's properly account the precision each matmul occurs in. Here is the architecture diagram from part 2 again, with the matmuls color coded by their compute precision:

The majority of FLOPs are in FP8, so we'll compute this factor by subtracting out the BF16 FLOPs from our already computed FP8 FLOPs and then figuring their wall time with separate speed of light numbers (attention is skipped here since we already computed its wall time above). You can see that the BF16 compute is negligible; we only needed to adjust our net wall time by 121 ms.

cellquantityvalue (exact)scientific SIformula
Nbf16params in BF16 GEMMs1,033,109,5041.03 × 1091.03 G(checkpoint tally: output head 7,168 × 129,280 + 58 routers × 7,168 × 256)
Cbf16BF16 GEMM FLOPs inside 6ND389,985,779,255,869,4403.90 × 1017390 PFLOP6 · Nbf16 · D
Cfp8FP8 GEMM FLOPs inside 6ND13,435,722,231,007,150,0801.34 × 101913.4 EFLOPC − Cbf16
Tfp8FP8 GEMM time (s)4.50 × 1004.50 sCfp8 / (GPUs · πsolfp8)
Tbf16BF16 GEMM time, head + routers (s)2.51 × 10−1251 msCbf16 / (GPUs · πsolbf16)
ΔTbf16step time added by BF16 pricing (s)1.21 × 10−1121 msTfp8 + Tbf16 − Tc (vs every GEMM at FP8)

Putting it all together:

cellquantityvalue (exact)scientific SIformula
Tfp8FP8 GEMM time (s)4.50 × 1004.50 sCfp8 / (GPUs · πsolfp8)
Tattnattention core time, BF16 (s)1.49 × 1001.49 sCattn / (GPUs · πsolbf16)
Trcrecompute time, FP8 (s)1.40 × 10−1140 msCrc / (GPUs · πsolfp8)
Tbf16BF16 GEMM time, head + routers (s)2.51 × 10−1251 msCbf16 / (GPUs · πsolbf16)
T+cstep time, all corrections (s)6.39 × 1006.39 sTfp8 + Tbf16 + Tattn + Trc

Expert parallelism

How much time do the dispatch and combine communications take in the expert parallel region? This is a bit more annoying to do in the Hopper era, because our EP spans across nodes, rather than being in a single NVL72 domain as would be the case in GB200/GB300. But it is not too complicated. Let's assemble our assumptions for our roofline:

In practice, we will be able to do a little better than having to send four copies of each token's hidden vector over InfiniBand, since if the expert is colocated on the same node no IB communication is needed. A nice and simple ratio is to say we need to send each token's hidden vector to 3.5 remote nodes, which is the expected number of communications you'd get if you picked exactly four of the eight nodes uniformly at random per token.This is an overestimate, since DeepSeek-V3 limits routing to at most four nodes, not exactly four. Specifically, the router picks the four nodes whose two best experts score highest, then takes the top eight experts among those nodes. This means that potentially one of the four selected nodes could end up hosting none of the active experts. The graph has an option to switch to DeepSeek-V3 routing: about one token in 22 touches only three nodes, pushing the mean down to 3.46, assuming independent, uniform affinity scores.

With this, we can work out the speed of light wall time that we will spend on EP communications:

cellquantityvalue (exact)scientific SIformula
dhidden dim7,168(architecture)
EPexpert parallelism64(model input)
NVLGPUs per node8(hardware spec)
Mnodes a token is sent to4(node limit, deduped: at most 4)
MIBremote nodes per token3.5M · (1 − NVL / EP)
Vdispdispatch bytes/token·layer25,0882.51 × 10425.1 KBMIB · d · 1 (FP8)
Vcombcombine bytes/token·layer50,1765.02 × 10450.2 KBMIB · d · 2 (BF16)
VtokEP IB bytes per token8,730,6248.73 × 1068.73 MB2 · (Vdisp + Vcomb) · Lmoe (fwd+bwd)
DGPUtokens per GPU per step30,7203.07 × 10430.7 KD / GPUs
VEPEP IB bytes per GPU·step268,204,769,2802.68 × 1011268 GBVtok · DGPU
TEPEP time per step (s)5.36 × 1005.36 sVEP / βIB

We shouldn't really round FP8 to one byte: the DeepSeek-V3 FP8 format quantizes activations in 1×128 tiles, each with its own scaling factor, so every copy of a token's hidden vector carries 7,168 / 128 = 56 scales, 224 B at four bytes each. Accounting for this:DeepEP also has an 8 B header, the token's top-k indices and weights (32 B each), and padding to a multiple of 16 B. These I omit for brevity.

cellquantityvalue (exact)scientific SIformula
V5FP8 scales per token56d / 128 (1×128 tiles)
V+dispdispatch bytes/token·layer25,8722.59 × 10425.9 KBMIB · (d + 4 · V5)
V+tokEP IB bytes per token8,821,5688.82 × 1068.82 MB2 · (V+disp + Vcomb) · Lmoe
V+EPEP IB bytes per GPU·step270,998,568,9602.71 × 1011271 GBV+tok · DGPU
T+EPEP time per step (s)5.42 × 1005.42 sV+EP / βIB

As a reminder, this was our compute wall time for a step:

cellquantityvalue (exact)scientific SIformula
T+cstep time, all corrections (s)6.39 × 1006.39 sTfp8 + Tbf16 + Tattn + Trc
fEPEP time, share of compute8.48 × 10−184.8 %T+EP / T+c

We can see that at speed of light, EP communication alone uses up 84.8 % of our compute time.

ZeRO-1

In our memory analysis, we determined that we would need to shard our optimizer states (including the FP32 master parameters). When we do this, we need to do a reduce-scatter to get each GPU the summed gradients for its shard, and then after the (sharded) optimizer step, an all-gather that sends the updated BF16 parameters back to every GPU. Calculating the cost of these collectives is a little nontrivial due to the need to reason about hierarchical all-gather in the presence of EP, so we'll take a detour to explain this. We'll then reuse these numbers later when we analyze FSDP/ZeRO-3.

Accounting for hierarchical all-gather over IB and NVLink

To get accurate numbers for how long our all-gathers take, we need to reason about whether the all-gather for a parameter happens entirely over InfiniBand, or if some communications can be handled in NVLink. Suppose we are doing an all-gather over the entire cluster (DP2048). We don't actually need to send our parameters to 2048 GPUs over IB: we can use a hierarchical collective. First, each GPU all-gathers over IB with only the GPUs that share its local rank on the other 255 nodes, which gets it one eighth of the parameters. Then the eight GPUs on a node all-gather their eighths over NVLink. This nets out to an eight-fold reduction on IB comms compared to the full parameters. In the diagram below, mouse over a GPU to see the route its shard takes to arrive at our GPU.

However, the routed expert parameters are handled differently. With EP, nodes on the same NVLink domain already have different experts and don't need to all gather across NVLink (we use the NVLink to do the EP all-to-all comms). These parameters only all-gather over IB and don't use NVLink in their all-gather at all, thus we have to pay their full cost on IB.

The upshot is that we have to split the parameters in our model into two categories: the non-expert parameters (sharded over DP2048) and the expert parameters (sharded over EDP32) and charge them differently for InfiniBand usage. This split is pretty intuitive, but here it is on the architecture diagram (green boxes are the routed experts and blue boxes are everything else that holds parameters):

Let's write the spreadsheet. We will carefully denominate our values as parameters (not bytes), because the comms dtype differs depending on what we are doing: we reduce-scatter FP32 gradients, but we all-gather BF16 updated weights. First, the non-experts, which communicate with IB over all ranks modulo NVLink:

cellquantityvalue (exact)scientific SIformula
Nnenon-expert params17,117,648,3841.71 × 101017.1 G(checkpoint tally: attention, shared experts, dense FFNs, norms, routers, embedding, head)
Ψneparams/GPU over IB2,139,706,0482.14 × 1092.14 GNne / NVL (NVLink gathers the other ⅞)
IBDPIB all-gather group256GPUs / NVL (one GPU per node)
VneIB params/GPU per move2,131,347,821.252.13 × 1092.13 GΨne · (1 − 1 / IBDP)

Note there's one small corrective factor above: we don't communicate all of Ψ! We don't need to do IB communications for the slice that lives on our node, so we only need to communicate (1 − 1/IBDP). It's worth noting that besides this factor, the amount of data we need to transmit in an all-gather isn't affected by how many nodes we are sharded to.

Continuing with experts, where our whole EDP slice crosses IB:An interesting observation is that the experts (after EP sharding) are actually less parameters than the non-expert parameters, but they still end up taking up more IB communications because none of them can be communicated over NVLink.

cellquantityvalue (exact)scientific SIformula
Nexprouted-expert params653,908,770,8166.54 × 1011654 G(checkpoint tally: 58 MoE layers × 256 experts × 3 × 7,168 × 2,048)
Ψexpparams/GPU over IB10,217,324,5441.02 × 101010.2 GNexp / EP (our EP slice, all of it)
EDPIB all-gather group32GPUs / EP (replicas of our slice)
VexpIB params/GPU per move9,898,033,1529.90 × 1099.90 GΨexp · (1 − 1 / EDP)

This gives us the following costs for ZeRO-1's all-gather and reduce-scatter:

cellquantityvalue (exact)scientific SIformula
VagBF16 AG bytes/GPU24,058,761,946.52.41 × 101024.1 GB(Vne + Vexp) · 2 (BF16)
TagAG time (s)4.81 × 10−1481 msVag / βIB
VrsFP32 RS bytes/GPU48,117,523,8934.81 × 101048.1 GB(Vne + Vexp) · 4 (FP32)
TrsRS time (s)9.62 × 10−1962 msVrs / βIB

If we only applied ZeRO-1 (without PP or FSDP), this would be our overall IB cost, pushing us squarely into the comms bound regime. However, we'll see that in PP (but not FSDP), the model sharding reduces the number of parameters the ZeRO-1 groups need to operate on.

cellquantityvalue (exact)scientific SIformula
VsyncZeRO-1 bytes/GPU per step72,176,285,839.57.22 × 101072.2 GBVag + Vrs
TsyncZeRO-1 time per step (s)1.44 × 1001.44 sVsync / βIB

ZeRO-3/FSDP

ZeRO-3, aka FSDP, requires more all-gathers (as we now need to all-gather our parameters for both forwards and backwards). The fact that EP + ZeRO-1 is already comms bound is bad news for doing FSDP, whose extra parameter all-gathers and reduce-scatters would also compete for IB bandwidth. Let's see exactly how bad.

Non-PP microbatching with gradient accumulation

Straight off the bat, there is a complication. Usually, when we say FSDP, we mean all-gathering parameters before forwards, all-gathering parameters before backwardsYou can skip resharding the parameters for the very last transformer block and then skip its backwards all-gather, but as you increase the number of layers this optimization matters proportionally less, so we don't show this optimization in the two-layer toy diagrams below. and reduce-scattering gradients to their shards, as you see in this diagram:How to read the memory graph? Each color specifies what (parameters, gradients, activations) the memory is used for. We assume that staging buffers, e.g., what the FSDP all-gather gathers into, are allocated persistently, rather than deallocated when not needed. While technically we don't need this staging buffer until the point when the all-gather is initiated, it doesn't really help with the high watermark to reduce the span except for the very last all-gather, and it's common to prefetch the next all-gather at the beginning of a transformer block, so you need it allocated at that point anyway.

However, recall from part 2 that we not only need to cut down on parameter memory, but also saved activation memory. When we used PP, this reduced the activation memory on GPUs because each GPU held fewer layers, and activation memory is stored per layer.We covered this in the previous part, but it bears repeating: although PP implies microbatching, since you must have more than one microbatch to cover up pipeline bubbles, the additional memory savings you would get from running microbatches are used up by the fact that our PP schedule will have multiple microbatches in flight at once—this is the 8.5 microbatches factor. However, FSDP doesn't reduce our activation memory (compared with ZeRO-1, it only shards parameters and gradients). We cannot fit on a Hopper without doing something about the activation memory!You can get the activations to fit without microbatching if you adopt a full recompute policy (recompute every transformer block immediately before backwards). However, this costs us a full forward's pass worth of wall time (a third more of the overall compute), as well as needing to run the EP comms again. This usually is a pretty bad trade!

Even if we're not using PP, we can still use microbatching (aka gradient accumulation) to reduce the memory usage of activations. We split our local batch into microbatches and run what is essentially a degenerate PP schedule where there is only one pipeline stage and you always run the backwards immediately after the forwards. The key that makes it work is that every microbatch's gradient can be computed individually and then summed together to compute the gradient of the full global batch (thanks to the linearity of derivatives). Here is an example of two microbatches:

Notice that the overall compute time stays the sameIn reality, it won't stay the same, because smaller batch size makes it harder for a matrix multiply to utilize all of the hardware. (computation is conserved!) but the number of collectives increases. This is the cost of microbatching: the more finely we split our local batch, the less compute there is to overlap the comms with. You might also wonder why we have to reduce-scatter over every microbatch: the problem (once again), is memory—without reduce-scattering, you are forced to hold onto the gradients of all parameters, not just your local FSDP shard! Eventually, there is not enough compute and our comms are exposed:

We can see intuitively from the diagrams above that eight microbatches is comms bound, but it is also the only microbatching setup that fits our activations in memory! In the above diagrams, I used our computed all-gather and reduce-scatter costs from the ZeRO-1 section to size the comms; let's now work it out on the spreadsheet.

Computing the cost of FSDP

In full FSDP, we do two BF16 all-gathers (one for forward, and one for backward) and one FP32 reduce-scatter on the parameters for every microbatch, replacing the single per-step collectives that the ZeRO-1 requires.

cellquantityvalue (exact)scientific SIformula
VmbZeRO-3 bytes/GPU per mb96,235,047,7869.62 × 101096.2 GB2 · Vag + Vrs
TmbZeRO-3 time per mb (s)1.92 × 1001.92 sVmb / βIB
T2mbZeRO-3 time, 2 mbs (s)3.85 × 1003.85 s2 · Tmb
T4mbZeRO-3 time, 4 mbs (s)7.70 × 1007.70 s4 · Tmb
T8mbZeRO-3 time, 8 mbs (s)1.54 × 10115.4 s8 · Tmb

For reference, these were the other numbers we've already computed:

cellquantityvalue (exact)scientific SIformula
T+cstep time, all corrections (s)6.39 × 1006.39 sTfp8 + Tbf16 + Tattn + Trc
T+EPEP time per step (s)5.42 × 1005.42 sV+EP / βIB

Our EP and FSDP traffic at one microbatch alone would make us comms bound; the fact that we have to do multiple microbatches to make activations fit in memory is added insult to injury. We can in fact plot the percentage of how much exposed time we have as we increase the number of microbatches.The graph interpolates between the actual physically possible microbatch counts. You could in fact achieve these effective microbatch sizes by tweaking your global batch size!

At the eight microbatches we need to fit, 69% of the step is exposed communication. Spoiler alert: PP will do much better (4%). To summarize: if you want to use FSDP, you are stuck in a "pincer" of two competing constraints:

  1. FSDP doesn't save activation memory, so you want to decrease the microbatch size (increase the number of microbatches) to make things fit. But...
  2. FSDP communications become more and more exposed the smaller your microbatch size is, so you end up with a lot of exposed comms with many microbatches.

Pipeline parallelism

Let's see now why pipeline parallelism is a better trade. Pipeline parallel only needs to move the residual stream across pipeline stages, which is relatively cheap since it scales only in hidden size. As in part 2, we analyze DualPipeV on 8 ranks, each holding two chunks of the model.DeepSeek's own run used DualPipe on 16 ranks, which also places two of the same 16 chunks on every rank, so the per-rank parameter share, the bubble and the residual-stream traffic below come out the same either way. We will easily not be IB bottlenecked:

cellquantityvalue (exact)scientific SIformula
PPpipeline ranks8(the previous post's DualPipeV layout; DeepSeek's own run used DualPipe on 16)
sGPUchunks per GPU2(DualPipeV: 16 stages folded onto the 8 ranks)
DPPtokens per replica·step245,7602.46 × 105246 KD · PP / GPUs
Vresresidual bytes per token14,3361.43 × 10414.3 KBd · 2 (BF16)
VPPPP IB bytes per GPU·step14,092,861,4401.41 × 101014.1 GB2 · sGPU · Vres · DPP (an interior rank: each token passes through it once per chunk, sending its activation forward and its gradient back)
TPPPP time per step (s)2.82 × 10−1282 msVPP / βIB

However, we do pay a penalty: the PP bubble.

stage 1 bubble F1 F2 F3 F4 B1 F5 B2 F6 B3 F7 B4 F8 B5 B6 B7 B8 stage 2 F1 F2 F3 B1 F4 B2 F5 B3 F6 B4 F7 B5 F8 B6 B7 B8 stage 3 F1 F2 B1 F3 B2 F4 B3 F5 B4 F6 B5 F7 B6 F8 B7 B8 stage 4 bubble F1 B1 F2 B2 F3 B3 F4 B4 F5 B5 F6 B6 F7 B7 F8 B8 4 stages, 8 microbatches, B = 2F time →

When we start a training step, later stages have to wait for microbatches to be processed by the earlier stages before they can start doing work. Similarly, at the end of the step, stages may run out of work to do while we are finishing running the backwards. It's beyond the scope of this article to do the full derivations for pipeline schedule bubbles, but a standard 1F1B schedule would have a bubble of (PP − 1)(F + B), where F and B are one stage's forward and backward time for one microbatch. Each stage does m microbatches of F + B work per step, so the bubble's share of compute is just (PP − 1)/m:Notice this depends only on the number of stages and microbatches, not on the model's size. In general, you can reduce your pipeline bubble by having more microbatches (at the cost of worse GEMM utilization at smaller microbatch size), but once you run out of local batch size you cannot increase the microbatch count any more.

cellquantityvalue (exact)scientific SIformula
mmicrobatches per replica·step60DPP / S (one sequence each)
b1F1B1F1B bubble, share of compute1.17 × 10−111.7 %(PP − 1) / m (F + B cancels)
T1F1B1F1B bubble time per step (s)7.45 × 10−1745 msT+c · b1F1B

This is already massively better than our FSDP exposed time. But it gets even better. If we use a DualPipeV scheduleIn the original DualPipeV article, Table 1, you can see the tradeoffs DualPipeV has compared to DeepSeek's original DualPipe schedule: at equal device count, it uses half the parameter memory, but twice the PP communication and a slightly larger bubble, (d − 1)/2 versus d/2 − 1 in common time units., we can reduce the bubble even further: (PP − 1)(F&B + B − 3W), where F, B and W are one chunk's times, W is the weight-gradient half of the backward (W ≈ F) and F&B is a forward and a backward run overlapped.

cellquantityvalue (exact)scientific SIformula
tBbackward time, in units of F2(B ≈ 2F)
tWweight-gradient time, in F1(W ≈ F)
tF&Boverlapped F&B time, in F31 + tB (compute-bound: F + B)
bDualDualPipeV bubble, share of compute3.89 × 10−23.89 %(PP − 1) · (tF&B + tB − 3 · tW) / (sGPU · m · (1 + tB))
TDualDualPipeV bubble time per step (s)2.48 × 10−1248 msT+c · bDual

One final note! If you do PP, you still need to do your ZeRO-1 collectives, but you only need to do them for the parameters on your pipeline stage, reducing the comms by a factor of PP.

cellquantityvalue (exact)scientific SIformula
VPPneIB non-expert params/GPU per move259,105,029.252.59 × 108259 MΨne / PP · (1 − PP / IBDP) (32 nodes hold our rank's chunks)
VPPexpIB expert params/GPU per move957,874,1769.58 × 108958 MΨexp / PP · (1 − PP / EDP) (4 replicas of our slice)
VPPsyncZeRO-1 bytes/GPU per step7,301,875,231.57.30 × 1097.30 GB(VPPne + VPPexp) · (2 + 4) (BF16 AG + FP32 RS; average rank, the one with the embedding and head moves more)
TPPsyncZeRO-1 time per step (s)1.46 × 10−1146 msVPPsync / βIB

We can plot our idle time and see that our bubble decreases as we increase the number of microbatches.Why does the activation memory usage fall as we increase the number of microbatches? Increasing the number of microbatches indeed doesn't affect the number of microbatches in flight for a given schedule (always 8.5, in the DualPipeV schedule). But if we keep our local batch size fixed, it will decrease the size of our microbatches, thereby also decreasing memory.

Putting it together

We have seen that PP is clearly superior for training DSv3 on 2048 H800s. If we assume perfect overlap, we can now estimate what our train step time should be. We see that with PP, the InfiniBand terms sum to less than the compute, so at speed of light the step is compute bound, with the DualPipeV bubble as the only idle time. The margin is thin: comms are within 10% of compute, likely an artifact of infra-model codesign!

cellquantityvalue (exact)scientific SIformula
T+cstep time, all corrections (s)6.39 × 1006.39 sTfp8 + Tbf16 + Tattn + Trc
T+EPEP time per step (s)5.42 × 1005.42 sV+EP / βIB
TPPPP time per step (s)2.82 × 10−1282 msVPP / βIB
TPPsyncZeRO-1 time per step (s)1.46 × 10−1146 msVPPsync / βIB
TIBIB time per step, PP layout (s)5.85 × 1005.85 sT+EP + TPP + TPPsync (under T+c: compute bound, comms overlapped)
TDualDualPipeV bubble time per step (s)2.48 × 10−1248 msT+c · bDual
Tstepstep time at speed of light (s)6.64 × 1006.64 sT+c + TDual (compute plus the bubble)

We can compare this against the self-reported pretraining time of DSv3 and see how different our SOL roofline is compared to the actual training numbers.

cellquantityvalue (exact)scientific SIformula
Tstepstep time at speed of light (s)6.64 × 1006.64 sT+c + TDual (compute plus the bubble)
DGPUtokens per GPU per step30,7203.07 × 10430.7 KD / GPUs
ρsoltokens/s per GPU at speed of light4.63 × 103DGPU / Tstep
ρDStokens/s per GPU, as reported1.54 × 10314.8 · 1012 / (2,664,000 · 3,600) (DeepSeek-V3 report: 14.8T pretraining tokens in 2.664M H800 GPU-hours)
fsolspeed of light over reality3.00 × 1003.00 ×ρsol / ρDS

We are off by 3×! Where does the difference come from?

Comparing against the DeepSeek trace

DeepSeek published a profiling trace of one rank of a 64-GPU H800 profiling run of their EP64 setup. It captures a forward chunk of four MoE layers for a 4,096-token microbatch, overlapped with a backward chunk of four MoE layers for another. Routing is simulated as perfectly balanced and PP communication is left out. We can use this to localize some of the difference between the real run and SOL, by going through each kernel in the profile one by one and comparing it with what our SOL says it should be:Trace times are averaged over the chunk's four layers. The routed-expert GEMMs are additionally normalized to 32,768 routed rows (the capture delivered slightly fewer: 32,512 forward, 32,128 backward), and their weight-gradient entries are the elapsed span of four concurrent expert kernels rather than a per-kernel mean, so the totals are not raw wall times.

the per-kernel speed-of-light sheet
cellquantityvalue (exact)scientific SIformula
Ndnqq down-projection params, per layer11,010,048(read off the diagram)
Ndnkvkv down-projection params, per layer4,128,768(read off the diagram)
Noattention output projection params, per layer117,440,512(read off the diagram)
Nrrouter params, per layer1,835,008(read off the diagram)
N13gate + up params, one expert29,360,128(read off the diagram)
N2down params, one expert14,680,064(read off the diagram)
krouted experts per token8(architecture)
nSMSMs per H800132(hardware spec)
ncSMs running compute132(model input; DeepSeek's run gives 20 to communication, leaving 112)
πcfp8FP8 GEMM rate on nc SMs (FLOP/s)1,456,600,000,000,0001.46 × 10151.46 PFLOP/sπsolfp8 · nc / nSM
πcbf16BF16 GEMM rate on nc SMs (FLOP/s)757,700,000,000,0007.58 × 1014758 TFLOP/sπsolbf16 · nc / nSM
tdnqQ down projection (s)6.19 × 10−561.9 µs2 · Ndnq · S / πcfp8
tdnkvKV down projection (s)2.32 × 10−523.2 µs2 · Ndnkv · S / πcfp8
tupqQ up projection (s)2.12 × 10−4212 µs2 · Nupq · S / πcfp8
tupkvKV up projection (s)9.44 × 10−594.4 µs2 · Nupkv · S / πcfp8
tattnattention core, forward (s)9.07 × 10−4907 µscattn / πcbf16
tBattnattention core, backward (s)2.36 × 10−32,360 µs(3 · cqk + 2 · cpv) / πcbf16 (recomputed QKᵀ, dQ, dK; dV, dP)
toattention output projection (s)6.60 × 10−4660 µs2 · No · S / πcfp8
trrouter (s)1.98 × 10−519.8 µs2 · Nr · S / πcbf16
tsh13shared expert gate/up (s)1.65 × 10−4165 µs2 · N13 · S / πcfp8
tsh2shared expert down (s)8.26 × 10−582.6 µs2 · N2 · S / πcfp8
trt13routed experts gate/up (s)1.32 × 10−31,320 µs2 · N13 · k · S / πcfp8 (each GPU gets k · S routed rows on average)
trt2routed experts down (s)6.60 × 10−4660 µs2 · N2 · k · S / πcfp8
tΣFF: forward (s)4.21 × 10−34,210 µstdnq + tdnkv + tupq + tupkv + tattn + to + tr + tsh13 + tsh2 + trt13 + trt2
tΣBB: input gradients (s)5.97 × 10−35,970 µstΣF − tattn + tBattn + tupq + tupkv (each GEMM's input gradient costs its forward, the attention core its own backward, plus the replayed up-projections)
tΣWW: weight gradients (s)3.30 × 10−33,300 µstΣF − tattn (each weight gradient costs its GEMM's forward; the attention core has no weights)
speed of light on
kernelF · forwardB · input gradientsW · weight gradients
SOLtrace×SOLtrace×SOLtrace×
Q down projection (FP8)61.9 µs1041.6761.9 µs1111.7961.9 µs1362.20
KV down projection (FP8)23.2 µs53.32.2923.2 µs64.82.7923.2 µs69.83.00
Q up projection (FP8)212 µs3721.75212 µs3321.56212 µs4622.18
KV up projection (FP8)94.4 µs2442.5994.4 µs2202.3394.4 µs2222.35
FlashAttention (BF16)907 µs1,2371.362,360 µs3,7791.60
attention output projection (FP8)660 µs9011.36660 µs9271.40660 µs1,4992.27
router (BF16)19.8 µs46.52.3419.8 µs47.32.3819.8 µs40.52.04
shared expert gate/up (FP8)165 µs2591.57165 µs2411.46165 µs3702.24
shared expert down (FP8)82.6 µs1381.6782.6 µs1261.5382.6 µs1812.19
routed experts gate/up (FP8)1,320 µs1,8771.421,320 µs2,0041.521,320 µs2,7102.05
routed experts down (FP8)660 µs1,0431.58660 µs8821.34660 µs1,3041.97
Q up projection, replayed (FP8)212 µs3741.76
KV up projection, replayed (FP8)94.4 µs2442.59
everything else (not a matmul)02,71305,3070578
idle (not waiting on communication)02810263021
total4,210 µs9,2672.205,970 µs14,9222.503,300 µs7,5932.30

You can see that our speed of light uniformly overestimates our GEMM performance, which is not surprising because on real sizes it can be difficult to achieve microbenchmark performance in optimal conditions.

However, careful inspection of the profile reveals another issue which is reducing GEMM performance: our speed of light analysis assumes that overlapping compute with communications is free, but it's not; DeepSeek's DeepEP kernel requires 20 SMs to handle the communication, which implies our SOL time should grow by 18% (you can toggle this on the table). We can also see that in reality, ignoring all non-matmul compute cost us 2.7 ms of the forward's 9.3 ms, and 5.3 ms of the backward's 14.9 ms.

We should also take a look at the actual comms performance compared to SOL:

the communication speed-of-light sheet
cellquantityvalue (exact)scientific SIformula
tdispdispatch, one layer and microbatch (s)2.12 × 10−32,120 µsS · V+disp / βIB
tcombcombine, one layer and microbatch (s)4.11 × 10−34,110 µsS · Vcomb / βIB
tΣEPEP, one direction (s)6.23 × 10−36,230 µstdisp + tcomb (the forward and the backward each send one dispatch and one combine)
kernelF · forwardB · backward
SOLtrace×SOLtrace×
dispatch (FP8, with scales)2,120 µs4,3462.052,120 µs3,4051.61
combine (BF16)4,110 µs5,9941.464,110 µs6,3681.55
notify (the handshake before each transfer)04,93203,212
dispatch layout and FP8 casts01030270
idle (not waiting on compute)076.00179
total6,230 µs15,4502.486,230 µs13,4352.16

Same conventions as the kernel table, though averaging is somewhat misleading here, as the DeepEP notify handshake is quite variable, since it blocks on remote ranks. You can also see that SOL systematically overestimated our network performance, but that the proportions are correct: for example, dispatch to combine stays near 1:2 in both columns. In fact, the DeepSeek trace reveals that their compute and comms are balanced on a knife's edge: in this single-rank capture, compute and communication occupy the GPU almost equally (about 31 ms against 29 ms per layer, and one of the four layers tips the other way), so it is at best narrowly compute bound. This is unlikely to be an accident; they probably tuned their system based on real-world profiles to balance it as closely as possible!

This is not a complete explanation for the end-to-end 3× difference, but the trace accounts for most of it. The compute runs 2.2 to 2.5× its SOL time; comms are also slower by the same 2.2 to 2.5×. The profile is not a full train step and it measures a steady-state layer with balanced routing. We could imagine that real routing imbalance, the pipeline bubble, MTP, the batch-size ramp early in training, checkpointing and stragglers combine to cover the rest of the gap.

Conclusion

We have come a long way. By carefully reasoning out the mechanics of how our distributed training works, we can put together a simple set of formulas that let us model how our system will behave, even if we don't actually have a cluster of 2048 H800s. The obvious benefit of this is that we can now make predictions about our system performance (for example, we see that FSDP will be hopelessly comms bound). But to me, the more important takeaway is that the act of rooflining forces us to truly understand what it is our system is doing. For example, we took 6ND apart to understand how the characteristics of our job change as we scale compute. The roofline will never exactly predict real-world behavior, but the clarity of a simple model means that we can figure out what went wrong (either in the model, or in our implementation).