LAB 1 / FOUNDATION

The virtual GPU and the byte counter

Two pieces before any ZeRO logic exists: a communicator that counts every byte it moves, and a model whose parameters live in one flat vector. Build these first, because every claim the prototype makes is a reading off one of them.

Why a flat parameter vector

A shard is a slice. If the parameters are a tree of per-layer tensors, sharding means walking that tree and deciding what to do with a weight matrix that does not divide evenly. If they are one flat float32 array of length n, rank r owns theta[r*n/N : (r+1)*n/N] and there is nothing to decide.

Real frameworks do exactly this and call it flattening or bucketing. It is not a simplification invented for the prototype.

zerosim/model.py
"""A small MLP with an explicit flat parameter vector.

Keeping every parameter in one flat float32 array is what makes sharding and
byte accounting simple: a shard is a slice, not a tree walk.
"""
from __future__ import annotations
import numpy as np


class MLP:
    def __init__(self, dims=(64, 128, 128, 64), seed=0):
        self.dims = dims
        rng = np.random.default_rng(seed)
        self.shapes, self.slices, n = [], [], 0
        for a, b in zip(dims[:-1], dims[1:]):
            for shape in ((a, b), (b,)):
                size = int(np.prod(shape))
                self.shapes.append(shape)
                self.slices.append(slice(n, n + size))
                n += size
        self.n_params = n
        theta = np.zeros(n, dtype=np.float32)
        for shape, sl in zip(self.shapes, self.slices):
            if len(shape) == 2:
                theta[sl] = (rng.standard_normal(sl.stop - sl.start)
                             * np.sqrt(2.0 / shape[0])).astype(np.float32)
        self.theta0 = theta

    def unpack(self, theta):
        return [theta[sl].reshape(s) for s, sl in zip(self.shapes, self.slices)]

    def loss_and_grad(self, theta, x, y):
        """Forward and backward for MSE on a plain ReLU MLP."""
        ps = self.unpack(theta)
        acts, h = [x], x
        pre = []
        for i in range(0, len(ps), 2):
            W, b = ps[i], ps[i + 1]
            z = h @ W + b
            pre.append(z)
            h = np.maximum(z, 0) if i + 2 < len(ps) else z
            acts.append(h)
        diff = h - y
        loss = float(np.mean(diff ** 2))
        g = (2.0 / (x.shape[0] * h.shape[1])) * diff
        grads = [None] * len(ps)
        for i in range(len(ps) - 2, -1, -2):
            W = ps[i]
            a_in = acts[i // 2]
            grads[i] = (a_in.T @ g).astype(np.float32)
            grads[i + 1] = g.sum(axis=0).astype(np.float32)
            if i > 0:
                g = (g @ W.T) * (pre[i // 2 - 1] > 0)
        flat = np.concatenate([gr.ravel() for gr in grads]).astype(np.float32)
        return loss, flat

Check the gradient before trusting anything

A hand-written backward pass is the most likely place for a silent error, and every later result depends on it. Compare against finite differences on a handful of coordinates:

the check that matters
th = m.theta0.copy(); eps = 1e-3
for idx in (0, 500, 5000, 12000):
    p = th.copy(); p[idx] += eps; lp, _ = m.loss_and_grad(p, x, y)
    p = th.copy(); p[idx] -= eps; lm, _ = m.loss_and_grad(p, x, y)
    print(idx, g[idx], (lp - lm) / (2 * eps))

# idx      0 analytic  0.001464 numeric  0.001431 diff 3.3e-05
# idx    500 analytic -0.005282 numeric -0.005245 diff 3.7e-05
# idx   5000 analytic -0.011015 numeric -0.011086 diff 7.1e-05
# idx  12000 analytic  0.019557 numeric  0.019670 diff 1.1e-04

Agreement to about 1e-4 with eps = 1e-3 in float32 is what a correct gradient looks like. Exact agreement would be suspicious.

The communicator

This is the instrument. Three collectives, and a byte counter on each. The costs are the ring costs from the collectives page rather than naive ones, because that is what a real implementation moves.

What each collective is charged, per rank
CollectiveCost per rankAt N = 32
all-reduce2(N−1)/N × bytes1.9375×
reduce-scatter(N−1)/N × bytes0.9688×
all-gather(N−1)/N × bytes0.9688×

The two halves summing to the whole is not a coincidence, and it is the identity the whole session turned on. Charging them this way means the prototype reproduces it rather than assuming it.

zerosim/comm.py
"""Collectives over a list of per-rank tensors, with every byte counted."""
from __future__ import annotations
from collections import defaultdict
import numpy as np


class Comm:
    """Simulates a communicator over `world` ranks.

    Every collective takes a list of per-rank arrays and returns a list of
    per-rank arrays. Bytes are attributed to the sending rank, using the ring
    cost 2(N-1)/N * bytes for all-reduce and (N-1)/N * bytes for the two halves,
    which is what a real NCCL ring moves.
    """

    def __init__(self, world: int):
        self.world = world
        self.sent = defaultdict(float)     # rank -> bytes sent
        self.by_op = defaultdict(float)    # op name -> bytes sent, all ranks

    # ---- accounting -------------------------------------------------
    def _charge(self, op: str, per_rank_bytes: float):
        for r in range(self.world):
            self.sent[r] += per_rank_bytes
        self.by_op[op] += per_rank_bytes * self.world

    def reset(self):
        self.sent.clear()
        self.by_op.clear()

    def bytes_per_rank(self) -> float:
        return max(self.sent.values()) if self.sent else 0.0

    # ---- collectives ------------------------------------------------
    def all_reduce_mean(self, shards):
        """Every rank in, every rank out with the mean. Ring cost 2(N-1)/N."""
        n = self.world
        total = sum(shards) / n
        self._charge("all_reduce", 2 * (n - 1) / n * shards[0].nbytes)
        return [total.copy() for _ in range(n)]

    def reduce_scatter_mean(self, shards):
        """Every rank in, rank i out with the mean of slice i. Cost (N-1)/N."""
        n = self.world
        total = sum(shards) / n
        parts = np.array_split(total, n)
        self._charge("reduce_scatter", (n - 1) / n * shards[0].nbytes)
        return [p.copy() for p in parts]

    def all_gather(self, parts):
        """Rank i in with slice i, every rank out with the whole. Cost (N-1)/N."""
        n = self.world
        full = np.concatenate(parts)
        self._charge("all_gather", (n - 1) / n * full.nbytes)
        return [full.copy() for _ in range(n)]

One design decision worth defending in the README

Charging the ring cost rather than the naive all-to-all cost is a modelling choice, and it is the right one because it is what NCCL actually does. But it means the prototype cannot discover that rings are cheaper than all-to-all — that is built in. If you want that result too, add a naive collective alongside and charge it (N−1) × bytes, then compare.

A sanity test for the communicator

tests/test_comm.py
import numpy as np
from zerosim.comm import Comm

def test_reduce_scatter_then_all_gather_equals_all_reduce():
    N = 4
    shards = [np.full(8, i + 1, dtype=np.float32) for i in range(N)]

    a = Comm(N); ar = a.all_reduce_mean(shards)[0]
    b = Comm(N); rs = b.reduce_scatter_mean(shards); ag = b.all_gather(rs)[0]

    assert np.array_equal(ar, ag)                       # same result
    assert b.bytes_per_rank() == a.bytes_per_rank()     # same cost

That test is the collectives page written as an assertion, and it should be the first thing in the repository that passes.

BACKBuild plan