summaryrefslogtreecommitdiff
path: root/src/zbp_scaling/zbp/joint.py
blob: f29c21029c417b2d65f1012d540dee6da0319e79 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
"""Model-level (non-chained) estimators that bracket chained ZBP:

'np'      classic node perturbation with block-boundary structure: all block inputs are perturbed
          simultaneously with independent probes, ONE scalar D = sum_l <u_l, g_l> (= the directional
          derivative of the loss) is measured, and every block gets g_hat_l = (1/n) sum_i u_{l,i} D_i.
          No compounding; variance of block l ~ (d_l/n) sum_l' |g_l'|^2; O(n) network forwards.
'direct'  per-block projection of the EXACT input error (no compounding, no cross-block terms);
          physically this is INP-like probing through all downstream blocks: O(n L^2) block forwards.

Both are simulated in oracle form (exact directional derivatives) with a two-pass replay:
pass 1 runs the network with exact backward and captures every block's full input error; the
projected errors are then injected in pass 2, whose backward builds the local parameter gradients
from the injected errors exactly as chained ZBP would.
"""
import torch
from .autograd import zbp_blocks, get_generator
from .probes import sample_probes


def joint_backward(model, loss_fn, x, y, cfg, mode):
    # only the physical blocks (those in the joint mode) are perturbed / replayed; digital blocks stay exact
    blocks = [b for b in zbp_blocks(model) if b.estimate_input_grad and b.cfg.mode == mode]
    cfgs = [b.cfg for b in blocks]
    # ---- pass 1: exact input errors
    for b in blocks:
        b.cfg = b.cfg.replace(mode="exact")
        b.capture = True
    model.zero_grad(set_to_none=True)
    loss = loss_fn(model(x), y)
    loss.backward()
    gs = [b.captured for b in blocks]
    for b in blocks:
        b.capture = False
        b.captured = None
    model.zero_grad(set_to_none=True)
    # ---- projections (probes drawn in chunks so that memory is O(chunk * sum_l |g_l|), not O(n * ...))
    gen = get_generator(x.device)
    n = cfg.n_probes
    chunk = cfg.probe_chunk if cfg.probe_chunk and cfg.probe_chunk > 0 else 8
    acc = [torch.zeros_like(g) for g in gs]
    for i0 in range(0, n, chunk):
        c = min(chunk, n - i0)
        us, Ds = [], []
        for b, g in zip(blocks, gs):
            bs = g.shape[:b.batch_dims]
            d = g[0].numel() // (int(torch.tensor(bs[1:]).prod()) if len(bs) > 1 else 1)
            u = sample_probes(c, tuple(bs), d, cfg.probe, gen, g.device, g.dtype).reshape(c, *g.shape)
            D = (u.reshape(c, *bs, -1) * g.reshape(1, *bs, -1)).sum(-1)    # [c, *bs]
            us.append(u); Ds.append(D)
        if mode == "np":
            # one scalar per (probe, sample): sum over blocks (and over tokens/positions within a sample)
            Dtot = sum(D.reshape(c, D.shape[1], -1).sum(-1) for D in Ds)    # [c, B]
            for a, u, g in zip(acc, us, gs):
                shape = (c, g.shape[0]) + (1,) * (g.dim() - 1)
                a.add_((u * Dtot.reshape(shape)).sum(0))
        elif mode == "direct":
            for a, u, D, g in zip(acc, us, Ds, gs):
                extra = g.dim() - len(D.shape[1:])
                a.add_((u * D.reshape(*D.shape, *([1] * extra))).sum(0))
        else:
            raise ValueError(mode)
        del us, Ds
    for b, a in zip(blocks, acc):
        b.replay = a / n
    if mode == "np":
        queries = 2.0 * n                                                   # full-network forwards per sample
    else:
        L = len(blocks)
        queries = 2.0 * n * sum(range(1, L + 1))                            # block forwards per sample
    # ---- pass 2: replay projected errors, build local parameter gradients
    for b in blocks:
        b.cfg = b.cfg.replace(mode="replay")
    model.zero_grad(set_to_none=True)
    loss = loss_fn(model(x), y)
    loss.backward()
    for b, c in zip(blocks, cfgs):
        b.cfg = c
        b.replay = None
        b.stats["queries"] += queries / max(1, len(blocks))
        b.stats["backward_calls"] += 1
    return loss