Home › Blog › Scaling Laws, GPUs and Distributed Training
Deep Dive · With runnable experiments 📈

Scaling Laws, GPUs and Distributed Training: How Frontier Models Get Built

Why do labs keep building bigger models, how much compute does one take, why does all of it run on GPUs, and how do you split one model across thousands of them? This piece covers the three answers together: empirical scaling laws and compute-optimal training, how GPU hardware turns matrix multiplies into FLOPs, and the parallelism strategies and memory accounting behind a training run — with a script that fits a small scaling law, measures the roofline on your own machine, and checks that data-parallel all-reduce gives exactly the same gradient as one big batch.

How to read this: claims about published models and hardware come from the sources in the References; where a widely repeated number isn't published, it's marked as an estimate. "Our run" numbers were printed by the script in Run the Experiments on a 4-thread CPU. The source infographic for this article had several errors, corrected in place and listed in Corrections. This is the sixth piece in a set: the transformer, pretraining, fine-tuning, post-training, inference, and now scale.

The Big Picture

Language-model quality has improved predictably as three inputs grew together: parameters (N, the size of the model), data (D, the number of training tokens), and compute (C, the floating-point operations spent training). The observation that loss falls smoothly and predictably as these grow — a scaling law — is what turned "train a bigger model" from a gamble into an engineering plan. It is also why the rest of this article exists: turning more compute into a model means buying GPUs, and using thousands of GPUs on one model means splitting the work between them.

1 · Scaling principles
How loss depends on N, D and C, and how to split a compute budget between model size and data.
2 · GPU computing
Why neural networks run on GPUs, what limits their speed, and what the numbers on a spec sheet mean.
3 · Distributed training
How one model is spread across thousands of GPUs, and where the memory goes.

Empirical Scaling Laws

Kaplan et al. (OpenAI, 2020) trained many transformer language models of different sizes and found that test loss follows a power law in each of N, D and C when the other two aren't the bottleneck, over more than seven orders of magnitude. On a log-log plot a power law is a straight line: every 10× increase in compute buys the same fractional drop in loss. Other details — depth versus width, number of heads — mattered far less than total size within a wide range.

L(C) ≈ a · C−α   ⟺   log L ≈ log a − α · log C
Loss falls as a power of compute. α is small, so each constant improvement costs a multiplicative increase in compute: returns are diminishing, but predictable.

We ran the same kind of study in miniature: five byte-level GPTs from 12 thousand to 1.8 million non-embedding parameters, each trained on Python source code with validation loss measured at seven points during training. Each curve below is one model; the x-axis is training compute (6·N·D, explained in the next section).

1e10 1e11 1e12 1e13 1.5 2.0 2.5 3.0 3.5 training compute C = 6·N·D (FLOPs, log scale) validation loss (log) 12K 55K 0.22M 0.59M 1.77M
Our run: validation loss against training compute for five model sizes, log-log axes. Small models improve fast at first and then flatten; bigger models start worse at equal compute but keep improving. The dashed line is the lower envelope — the compute frontier — with fitted slope −0.102.

Three things in the chart are the whole idea of scaling laws in miniature. At any fixed number of tokens, the bigger model has lower loss. At a fixed compute budget, the best model is neither the smallest nor the biggest. And the frontier — the best loss reachable at each budget — is close to a straight line on log-log axes, which is a power law: ours is L ∝ C−0.102. The exponent depends on the data, tokenizer and architecture, so it is not comparable to published values; what carries over is the shape.

Counting Compute: C ≈ 6·N·D

Training compute is estimated with a rule of thumb that falls out of counting matrix multiplies (derived line by line in Resource Accounting). Each parameter takes part in one multiply-add per token on the forward pass — 2 FLOPs — and the backward pass costs about twice the forward, for gradients with respect to both activations and weights:

C ≈ 6 · N · D  FLOPs
2ND forward + 4ND backward. Attention over the context adds a term that is small next to 6N for most models at typical context lengths.
ModelParameters (N)Tokens (D)6·N·DStatus
GPT-3 (2020)175B300B3.15 × 10²³Published; the paper reports 3.14 × 10²³
Gopher (2021)280B300B5.0 × 10²³Published
Chinchilla (2022)70B1.4T5.9 × 10²³Published; same budget as Gopher
Llama 2 70B (2023)70B2T8.4 × 10²³Published
Llama 3 70B (2024)70B15T6.3 × 10²⁴Published
GPT-4 (2023)not disclosednot disclosednot disclosedUndisclosed; 1.8T / 13T / ~10²⁶ are estimates

To turn FLOPs into time: an H100 does at most 989 × 10¹² dense BF16 FLOPs per second, and real training reaches roughly 40% of that (Llama 3 reported 38–43% model FLOPs utilization). Llama 3 70B's 6.3 × 10²⁴ FLOPs at 400 TFLOP/s is about 1.6 × 10¹⁰ GPU-seconds — roughly 4.4 million H100-hours, or about 11 days on 16,000 GPUs. That is an estimate from the formula, not a figure Meta reported for this model.

Compute-Optimal Training: Chinchilla

Given a fixed compute budget C = 6ND, you can train a big model on few tokens or a small model on many. Which is best? Hoffmann et al. (DeepMind, 2022) trained over 400 models and found that N and D should grow in equal proportion: doubling compute should mean roughly √2 more parameters and √2 more tokens. That works out to about 20 tokens per parameter. Their fitted loss function makes the trade-off explicit:

L(N, D) = E + A / Nα + B / Dβ
Fitted values: E = 1.69 (irreducible loss of the text itself), A = 406.4, B = 410.7, α = 0.34, β = 0.28. The second term is the penalty for a model too small, the third for too little data.

The paper's headline test: Chinchilla, 70B parameters on 1.4 trillion tokens, used the same compute as the 280B-parameter Gopher trained on 300 billion — 4× smaller, about 4.7× more data — and outperformed it across a large range of benchmarks. Many earlier large models, it turned out, had been undertrained.

Our miniature study shows the same effect: at each budget, the model that reaches the lowest loss changes as the budget grows.

Compute budgetBest model (N)Tokens it seesTokens per parameter
1e+10 FLOPs12,288135,63411.0
3e+10 FLOPs12,288406,90133.1
1e+11 FLOPs12,2881,356,337110.4
3e+11 FLOPs55,296904,22516.4
1e+12 FLOPs221,184753,5203.4
3e+12 FLOPs589,824847,7111.4

The best model size rises with compute — from 12,288 to 589,824 parameters across 2½ orders of magnitude of budget — which is the qualitative Chinchilla result. The tokens-per-parameter column is not a reproduction of the 20:1 rule: our grid has only five model sizes, the learning rate isn't re-tuned or annealed for each budget as Chinchilla's were, and a byte-level model on 1.6 million tokens is far from the regime of the paper. Treat the trend as real and the ratios as noise.

Beyond Chinchilla. "Compute-optimal" minimizes training compute. A model that will serve billions of requests is cheaper overall if it's smaller and trained on far more tokens, because inference cost scales with N. Llama 3 70B's 15T tokens is about 214 tokens per parameter, ten times the Chinchilla ratio, for exactly this reason. Data quality, architecture changes like mixture-of-experts, and training recipe shift the curves too.

Why GPUs: Thousands of Simple Cores

A transformer's compute is almost entirely matrix multiplication, and a matrix multiply is millions of independent multiply-adds. A CPU has a few dozen powerful cores built for sequential, branchy code with low latency. A GPU has thousands of simpler cores built for throughput: running the same instruction over huge amounts of data at once.

CPU — few powerful cores

Large caches and complex control logic per core; excellent at sequential tasks, operating systems, and branch-heavy code. Tens of cores.

GPU — many simple cores

Thousands of arithmetic units grouped into streaming multiprocessors (SMs), plus tensor cores that each multiply a small matrix tile per instruction. Built for parallel, regular math.

To multiply C = A × B on a GPU, the output matrix is cut into tiles. Each tile is assigned to a block of threads on one SM, which loads the matching strips of A and B from high-bandwidth memory (HBM) into fast on-chip shared memory, computes the tile's partial products on tensor cores, and accumulates. Thousands of tiles run in parallel. Loading each strip once into on-chip memory and reusing it many times is the key trick — it's what makes large matrix multiplies compute-bound rather than memory-bound, as the next sections show.

Inside an H100

NVIDIA's H100 (Hopper architecture) is the workhorse of 2023–2025 frontier training. The SXM version's headline numbers, from NVIDIA's specifications:

SpecH100 SXMNote
HBM3 memory80 GBHolds weights, gradients, optimizer state and activations
Memory bandwidth3.35 TB/sHow fast data moves from HBM to the cores
FP32 (non-tensor)67 TFLOPSOrdinary CUDA-core arithmetic
BF16 / FP16 tensor989 TFLOPS denseThe often-quoted 1,979 assumes 2:4 structured sparsity, which ordinary training doesn't use
FP8 tensor1,979 TFLOPS dense3,958 with sparsity; new in Hopper
NVLink900 GB/sGPU-to-GPU bandwidth within a server

Precision is the biggest lever. Fewer bits per number means more multiplies per tensor-core instruction, less memory and less bandwidth:

FormatBitsTypical useH100 peak (dense)
FP3232Master weights, optimizer state67 TFLOPS (no tensor cores)
TF3219 usedFP32 matmuls routed to tensor cores≈ 495 TFLOPS
BF16 / FP1616Mixed-precision training — the default989 TFLOPS, ≈ 15× FP32
FP88Training (with care) and inference1,979 TFLOPS
INT88Quantized inference1,979 TOPS
INT44Weight-only quantized inferenceNo INT4 tensor-core mode on Hopper; INT4 weights are unpacked to higher precision

Compute-Bound vs Memory-Bound

Whether a GPU is limited by arithmetic or by memory depends on the operation's arithmetic intensity: FLOPs performed per byte moved from memory. The H100's balance point is 989 × 10¹² FLOP/s ÷ 3.35 × 10¹² B/s ≈ 295 FLOPs per byte in BF16. Below that, the cores wait on memory; above it, memory waits on the cores. This is the roofline model.

achievable FLOP/s = min( peak FLOP/s,  bandwidth × arithmetic intensity )
An n×n matrix multiply does 2n³ FLOPs on 3n² numbers, so its intensity grows with n. An elementwise add does 1 FLOP per 3 numbers read or written, whatever its size.

We measured both on a CPU, which has the same roofline shape with smaller numbers:

matmul n=3214.2 GFLOP/s matmul n=128193.4 GFLOP/s matmul n=512491.3 GFLOP/s matmul n=2048367.9 GFLOP/s elementwise add1.1 GFLOP/s
Our run: measured float32 throughput on a 4-thread CPU. Small matrix multiplies are dominated by overheads and memory traffic; throughput climbs about 35× by n = 512, where the cores are kept busy (n = 2048 is a little lower, most likely because its matrices no longer fit in the CPU's caches). The elementwise add stays near zero FLOPs regardless of size, because it's limited by memory bandwidth.

This single idea explains a lot of practice. Big matrix multiplies (large batch × large hidden size) are compute-bound and use the GPU well. Elementwise operations, normalizations and softmax are memory-bound, which is why kernel fusion — doing several of them in one pass over memory, as FlashAttention does for attention — pays off. And decode at inference time is memory-bound because it multiplies the weights by a single token's vector. It's also why tiny models waste hardware: their matrices are too small to keep the cores busy, the same way our n = 32 multiply did.

Four Kinds of Parallelism

A 70B model doesn't fit on one GPU for training (see Training Memory), and even if it did, 6.3 × 10²⁴ FLOPs on one H100 would take a century and a half. Training is spread across many GPUs by splitting along four different axes:

TypeWhat's splitCommunicationUsed for
Data parallel (DP)The batch. Every GPU has a full copy of the model and processes different examples.All-reduce the gradients once per stepScaling throughput; simplest and most common
Tensor parallel (TP)Individual weight matrices inside each layer, e.g. the columns of the MLP's first matrix and the rows of the second (Megatron-LM)All-reduce of activations inside every layer — heavy, so kept within one NVLink serverLayers too large for one GPU's memory or compute
Pipeline parallel (PP)The layers: GPU 1 holds layers 1–20, GPU 2 layers 21–40, and so onActivations passed between neighbouring stagesModels too deep for one server; spans nodes
Expert parallel (EP)The experts of a mixture-of-experts layer, placed on different GPUsAll-to-all: route each token to its expert's GPU and backMixture-of-experts models

The difference between tensor and pipeline parallelism is worth being precise about, because they're often blurred: tensor parallelism splits a layer across GPUs; pipeline parallelism splits the stack of layers. A fifth axis, context (sequence) parallelism, splits a long sequence across GPUs, and is how long-context training fits in memory. Real runs combine these — Llama 3's largest models used tensor, context, pipeline and fully sharded data parallelism at once across up to 16,000 H100s.

GPU 1 · L1–8GPU 2 · L9–16GPU 3 · L17–24GPU 4 · L25–32 time → (numbers are micro-batches; grey is the idle "bubble")
Pipeline parallelism (GPipe-style forward pass): the batch is cut into micro-batches so that stages work simultaneously on different ones. The idle triangles at the start and end are the pipeline bubble; more micro-batches make it a smaller fraction of the step. (Original diagram.)

How Data-Parallel Training Works

Every step of data-parallel training has the same shape:

  1. Split the batch. A global batch of B examples is divided into equal shards, one per GPU.
  2. Forward and backward locally. Each GPU runs its identical model copy on its own shard and computes gradients from its shard alone.
  3. All-reduce. The GPUs sum their gradients so that every GPU ends with the same total, then divide by the number of GPUs to get the mean.
  4. Update. Each GPU applies the identical optimizer step to its identical weights, so the copies stay in sync without ever sending the weights themselves.

Because the loss is a mean over examples, the average of per-shard mean gradients equals the gradient of the full batch. Data parallelism is not an approximation: it computes the same step as one GPU with the whole batch, just faster. Our script checks this with four processes, each taking 4 of 16 sequences, using PyTorch's real all_reduce:

data parallel on 4 processes: max |grad difference| vs single big batch = 1.49e-08 over 84,288 gradient values

The difference is at the level of float32 rounding — the only difference is the order in which numbers were added. The all-reduce itself is usually a ring: each GPU sends and receives 2(n−1)/n of the gradient size, about twice the gradient size whatever the number of GPUs, which is why data parallelism scales to thousands of GPUs. NCCL implements it on NVIDIA hardware; our script uses the CPU backend, gloo.

Where the Memory Goes in Training

With mixed-precision training and the Adam optimizer, every parameter costs 16 bytes before any activations are stored — the accounting from the ZeRO paper:

ComponentBytes per parameterWhy
Weights (BF16)2Used in the forward and backward passes
Gradients (BF16)2One per weight
FP32 master weights4Small updates vanish if added to 16-bit weights
Adam first moment (m)4Running mean of gradients
Adam second moment (v)4Running mean of squared gradients
Total16A 70B model needs 1,120 GB — 14 H100s' worth — plus activations

Plain data parallelism keeps all 16 bytes on every GPU, which is why it alone can't train a 70B model. ZeRO (and PyTorch's FSDP, which implements the same idea) removes the redundancy by sharding across the n data-parallel GPUs: stage 1 shards the optimizer state, stage 2 also the gradients, stage 3 also the weights, which are gathered layer by layer just before they're needed. Try it:

Activations — each layer's intermediate outputs, saved for the backward pass — come on top and grow with batch size and sequence length. They're reduced by activation checkpointing (store only some layers' outputs and recompute the rest during backward, trading about a third more compute for memory), by tensor and sequence parallelism, and by smaller micro-batches.

The Real-World Stack

A frontier training cluster is thousands of servers, each with 8 GPUs connected by NVLink and NVSwitch; servers connect through InfiniBand or RoCE Ethernet networks. Parallelism is mapped onto this hierarchy: tensor parallelism inside a server where bandwidth is highest, pipeline stages across servers, data parallelism across the whole cluster.

LayerExamples
FrameworkPyTorch, JAX
Distributed training librariesMegatron-LM (tensor and pipeline parallelism), DeepSpeed (ZeRO), PyTorch FSDP
CommunicationNCCL collectives (all-reduce, all-gather, reduce-scatter, all-to-all)
HardwareNVIDIA H100 / B200, Google TPU; NVLink inside a node, InfiniBand between nodes
Cluster operationsJob scheduling (Slurm, Kubernetes), frequent checkpointing, automatic restart after hardware failures

At this scale failure is routine, not exceptional: the Llama 3 paper reports hundreds of unexpected interruptions during a 54-day period of pretraining, most attributed to hardware, which is why checkpointing and fast automatic restarts are part of the training system itself.

Scale in one paragraph
Loss falls as a predictable power law in parameters, data and compute, and compute is about 6·N·D FLOPs; for a fixed budget, parameters and tokens should grow together (about 20 tokens per parameter for training-optimal models, more when inference cost matters). That compute runs on GPUs because transformers are matrix multiplies, which are compute-bound when large; everything else is memory-bound and gets fused. And because a model's training state is 16 bytes per parameter and its compute is enormous, training is split across thousands of GPUs by batch (data), within layers (tensor), across layers (pipeline) and across experts — with gradients combined by all-reduce into exactly the step one giant GPU would have taken.

Run the Experiments

One script, three experiments: the roofline on your own CPU, the miniature scaling study with a compute-frontier fit, and a four-process data-parallel check with real torch.distributed all-reduce. It trains on the Python standard library's own source code, so it needs no downloads. About 8 minutes on a 4-thread CPU. Needs PyTorch 2.x.

"""Three experiments: the roofline on your own CPU, a miniature scaling-law study, and data-parallel all-reduce."""
import math, os, pathlib, sysconfig, time
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
import torch.nn as nn
import torch.nn.functional as F

torch.set_num_threads(4)

# ======================= 1. Compute-bound vs memory-bound (roofline) =======================
def bench(fn, reps=10):
    fn(); t = time.perf_counter()
    for _ in range(reps): fn()
    return (time.perf_counter() - t) / reps

def roofline():
    print("matrix multiply (n x n) @ (n x n), float32:")
    for n in (32, 128, 512, 2048):
        a, b = torch.randn(n, n), torch.randn(n, n)
        t = bench(lambda: a @ b, 20 if n < 2048 else 5)
        flops, bytes_moved = 2 * n ** 3, 3 * n * n * 4
        print(f"  n={n:>4}: {flops / t / 1e9:7.1f} GFLOP/s   arithmetic intensity {flops / bytes_moved:6.1f} FLOP/byte")
    x, y = torch.randn(32_000_000), torch.randn(32_000_000)
    t = bench(lambda: x + y, 5)
    print(f"elementwise add, 32M floats: {3 * 4 * 32e6 / t / 1e9:.1f} GB/s, {32e6 / t / 1e9:.2f} GFLOP/s (1 FLOP per 12 bytes)")

# ======================= 2. A miniature scaling-law study =======================
root = pathlib.Path(sysconfig.get_paths()["stdlib"])
text = b"".join(p.read_bytes() for p in sorted(root.glob("*.py")))          # ~4.8 MB of real text
data = torch.tensor(list(text), dtype=torch.long)
split = int(0.95 * len(data))
train_data, val_data = data[:split], data[split:]
T, B = 64, 16

class Block(nn.Module):
    def __init__(self, d, h):
        super().__init__()
        self.h, self.ln1, self.ln2 = h, nn.LayerNorm(d), nn.LayerNorm(d)
        self.qkv, self.proj = nn.Linear(d, 3 * d), nn.Linear(d, d)
        self.up, self.down = nn.Linear(d, 4 * d), nn.Linear(4 * d, d)
    def forward(self, x):
        Bb, Tt, d = x.shape
        q, k, v = (t.view(Bb, Tt, self.h, d // self.h).transpose(1, 2) for t in self.qkv(self.ln1(x)).split(d, -1))
        x = x + self.proj(F.scaled_dot_product_attention(q, k, v, is_causal=True).transpose(1, 2).reshape(Bb, Tt, d))
        return x + self.down(F.gelu(self.up(self.ln2(x))))
class GPT(nn.Module):
    def __init__(self, d, L, h=4):
        super().__init__()
        self.tok, self.pos = nn.Embedding(256, d), nn.Embedding(T, d)
        self.blocks = nn.Sequential(*[Block(d, h) for _ in range(L)])
        self.ln, self.head = nn.LayerNorm(d), nn.Linear(d, 256, bias=False)
    def forward(self, x):
        return self.head(self.ln(self.blocks(self.tok(x) + self.pos(torch.arange(x.shape[1])))))

def batch(src, g):
    i = torch.randint(0, len(src) - T - 1, (B,), generator=g)
    return torch.stack([src[j:j + T] for j in i]), torch.stack([src[j + 1:j + T + 1] for j in i])

@torch.no_grad()
def val_loss(m):
    g = torch.Generator().manual_seed(123)
    return sum(F.cross_entropy(m(x).reshape(-1, 256), y.reshape(-1)).item()
               for x, y in (batch(val_data, g) for _ in range(25))) / 25

def scaling_study():
    print(f"\ncorpus: {len(data):,} byte tokens of Python standard-library source")
    configs = [(32, 1), (48, 2), (96, 2), (128, 3), (192, 4)]      # (width d, layers L)
    checkpoints = [50, 100, 200, 400, 800, 1200, 1600]
    runs = {}
    for d, L in configs:
        torch.manual_seed(0)
        model, N = GPT(d, L), 12 * L * d * d                       # N = non-embedding parameters
        opt = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=0.1)
        g, pts = torch.Generator().manual_seed(0), []
        for step in range(1, checkpoints[-1] + 1):
            for grp in opt.param_groups: grp["lr"] = 2e-3 * min(1, step / 50)
            x, y = batch(train_data, g)
            loss = F.cross_entropy(model(x).reshape(-1, 256), y.reshape(-1))
            opt.zero_grad(); loss.backward(); opt.step()
            if step in checkpoints:
                D = step * B * T
                pts.append((6 * N * D, D, val_loss(model.eval()))); model.train()
        runs[N] = pts
        print(f"  N={N:>9,}: val loss " + "  ".join(f"{l:.2f}" for _, _, l in pts) + f"   (after {checkpoints[-1] * B * T:,} tokens)")

    # compute frontier: best loss achievable at each compute budget, then a power-law fit L = a * C^-alpha
    all_pts = sorted((C, l) for pts in runs.values() for C, _, l in pts)
    frontier, best = [], float("inf")
    for C, l in all_pts:
        if l < best: best = l; frontier.append((C, l))
    xs, ys = [math.log(C) for C, _ in frontier], [math.log(l) for _, l in frontier]
    n = len(xs); mx, my = sum(xs) / n, sum(ys) / n
    slope = sum((a - mx) * (b - my) for a, b in zip(xs, ys)) / sum((a - mx) ** 2 for a in xs)
    print(f"compute frontier: {len(frontier)} points, fitted L ~ C^{slope:.3f}")

    # compute-optimal model size: at a fixed budget, which N reaches the lowest loss?
    def loss_at(pts, C):
        for (c0, _, l0), (c1, _, l1) in zip(pts, pts[1:]):
            if c0 <= C <= c1:
                w = (math.log(C) - math.log(c0)) / (math.log(c1) - math.log(c0))
                return l0 + w * (l1 - l0)
        return None
    print("compute-optimal model size at fixed budgets:")
    for C in (1e10, 3e10, 1e11, 3e11, 1e12, 3e12):
        cands = {N: loss_at(p, C) for N, p in runs.items() if loss_at(p, C) is not None}
        Nopt = min(cands, key=cands.get)
        row = "  ".join(f"{N:>7,}:{l:.2f}" for N, l in sorted(cands.items()))
        print(f"  C={C:.0e}: best N = {Nopt:>9,} (D = {C / 6 / Nopt:>10,.0f} tokens, {C / 6 / Nopt / Nopt:5.1f} tokens/param)   [{row}]")

# ======================= 3. Data parallelism: all-reduce == one big batch =======================
def worker(rank, world, shared_x, shared_y, init_state, out):
    os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT="29531")
    dist.init_process_group("gloo", rank=rank, world_size=world)
    torch.set_num_threads(1)
    model = GPT(48, 2); model.load_state_dict(init_state)
    shard = slice(rank * 4, rank * 4 + 4)                           # each rank gets 4 of the 16 sequences
    loss = F.cross_entropy(model(shared_x[shard]).reshape(-1, 256), shared_y[shard].reshape(-1))
    loss.backward()
    for p in model.parameters():                                    # all-reduce: sum, then average
        dist.all_reduce(p.grad, op=dist.ReduceOp.SUM); p.grad /= world
    if rank == 0:
        out.put(torch.cat([p.grad.flatten() for p in model.parameters()]))
    dist.destroy_process_group()

if __name__ == "__main__":
    roofline()
    scaling_study()
    torch.manual_seed(1)
    ref = GPT(48, 2)
    x, y = batch(train_data, torch.Generator().manual_seed(9))
    F.cross_entropy(ref(x).reshape(-1, 256), y.reshape(-1)).backward()      # one process, full batch of 16
    full = torch.cat([p.grad.flatten() for p in ref.parameters()])
    ctx = mp.get_context("spawn"); q = ctx.Queue()
    procs = [ctx.Process(target=worker, args=(r, 4, x, y, ref.state_dict(), q)) for r in range(4)]
    for p in procs: p.start()
    dp = q.get()
    for p in procs: p.join()
    print(f"\ndata parallel on 4 processes: max |grad difference| vs single big batch = {(dp - full).abs().max():.2e} "
          f"over {full.numel():,} gradient values")

Output (PyTorch on a 4-thread CPU; timings vary by machine, but the shapes of the results and the gradient check hold):

matrix multiply (n x n) @ (n x n), float32:
  n=  32:    14.2 GFLOP/s   arithmetic intensity    5.3 FLOP/byte
  n= 128:   193.4 GFLOP/s   arithmetic intensity   21.3 FLOP/byte
  n= 512:   491.3 GFLOP/s   arithmetic intensity   85.3 FLOP/byte
  n=2048:   367.9 GFLOP/s   arithmetic intensity  341.3 FLOP/byte
elementwise add, 32M floats: 12.8 GB/s, 1.07 GFLOP/s (1 FLOP per 12 bytes)

corpus: 4,776,939 byte tokens of Python standard-library source
  N=   12,288: val loss 3.43  3.01  2.70  2.50  2.36  2.24  2.14   (after 1,638,400 tokens)
  N=   55,296: val loss 3.14  2.86  2.55  2.38  2.20  2.01  1.89   (after 1,638,400 tokens)
  N=  221,184: val loss 2.92  2.61  2.40  2.17  1.85  1.69  1.59   (after 1,638,400 tokens)
  N=  589,824: val loss 2.82  2.50  2.34  2.01  1.68  1.56  1.48   (after 1,638,400 tokens)
  N=1,769,472: val loss 2.68  2.44  2.23  1.87  1.60  1.49  1.43   (after 1,638,400 tokens)
compute frontier: 15 points, fitted L ~ C^-0.102
compute-optimal model size at fixed budgets:
  C=1e+10: best N =    12,288 (D =    135,634 tokens,  11.0 tokens/param)   [ 12,288:2.88]
  C=3e+10: best N =    12,288 (D =    406,901 tokens,  33.1 tokens/param)   [ 12,288:2.50   55,296:2.91]
  C=1e+11: best N =    12,288 (D =  1,356,337 tokens, 110.4 tokens/param)   [ 12,288:2.21   55,296:2.45  221,184:2.75]
  C=3e+11: best N =    55,296 (D =    904,225 tokens,  16.4 tokens/param)   [ 55,296:2.16  221,184:2.37  589,824:2.59]
  C=1e+12: best N =   221,184 (D =    753,520 tokens,   3.4 tokens/param)   [221,184:1.89  589,824:2.18  1,769,472:2.47]
  C=3e+12: best N =   589,824 (D =    847,711 tokens,   1.4 tokens/param)   [589,824:1.67  1,769,472:2.06]

data parallel on 4 processes: max |grad difference| vs single big batch = 1.49e-08 over 84,288 gradient values

⚠️ Corrections and Notes

Corrections to the source infographic:
  • Training memory is 16 bytes per parameter, not 12. The infographic's 2 + 2 + 4 + 4 omits the FP32 master weights; with mixed-precision Adam it's 2 + 2 + 12. Its 70B example should be 1,120 GB, not 840 GB.
  • "~1,979 TFLOPS FP16" is the sparse figure. The H100 SXM's dense BF16/FP16 tensor throughput is 989 TFLOPS; 1,979 assumes 2:4 structured sparsity.
  • INT4 is not a Hopper tensor-core mode (the A100 had one); INT4 is used for weight-only quantization with higher-precision math.
  • The precision "speed vs FP32" column understates the gap: BF16 on tensor cores (989 TFLOPS) is about 15× FP32 on CUDA cores (67 TFLOPS), not 2×.
  • GPT-4's 1.8T parameters, 13T tokens and ~10²⁶ FLOPs are unofficial estimates; OpenAI's technical report does not disclose them.
  • Tensor parallelism was described as a "layer split". Splitting layers is pipeline parallelism; tensor parallelism splits the matrices inside a layer.
  • "Multi-Wode" should read "Multi-Node".
  • The infographic's "~9,000+ CUDA cores" for the H100 could not be verified against NVIDIA's own pages for this article and is left out.
Checked against sources: Kaplan et al.'s power laws over more than seven orders of magnitude; Chinchilla's fitted loss (E = 1.69, A = 406.4, B = 410.7, α = 0.34, β = 0.28), ~20 tokens per parameter and the Gopher comparison; GPT-3's 3.14 × 10²³ FLOPs and 300B tokens; Llama 2's 2T and Llama 3's 15T tokens; Llama 3's 4D parallelism, up to 16K H100s and 38–43% MFU; H100 SXM specifications; ZeRO's 16-bytes-per-parameter accounting; Megatron-LM tensor parallelism; GPipe micro-batching. The Llama 3 70B training-time figure is our estimate from 6ND and an assumed 400 TFLOP/s. Our scaling exponent comes from a toy byte-level setup and is not comparable to published exponents.

🔗 References