summaryrefslogtreecommitdiff
path: root/src/zbp_scaling/zbp/estimators.py
blob: 81f29a3c30dd4cff126d0c0af5b5274870192411 (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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""Zeroth-order estimators of g = J^T v for a black-box block.

Conventions
-----------
A block maps x (shape [*batch_shape, *feat]) to y.  A *measurement* function
`h(x_pert)` returns v^T f(x_pert) per measurement unit:
  * global units:  h -> [*batch_shape]              (sum over all feature dims)
  * local units:   h -> [*batch_shape, H', W']      (sum over channels only; conv blocks)
`h` accepts any integer multiple of the batch stacked along dim 0, so several
probes can be evaluated in one call.

For local units the directional derivatives D_p (one per output position p) are
box-summed over the receptive field before multiplying the probe, which keeps the
estimator unbiased while removing the variance contributed by far-away positions.

Every estimator returns (g_hat, queries) where queries is the number of block
evaluations *per sample unit* that a physical system would have to perform.
"""
import math
import torch
import torch.nn.functional as F

from .probes import sample_probes


class Local:
    """Receptive-field geometry of a conv-like block: output position p depends on input
    positions q with |q - stride*p|_inf <= radius (input coordinates)."""

    def __init__(self, radius, stride=1):
        self.radius, self.stride = int(radius), int(stride)

    def gather(self, D, in_hw):
        """D: [n, *bs, H', W'] per-output-position derivatives -> S: [n, *bs, H, W] box sums."""
        lead = D.shape[:-2]
        Hp, Wp = D.shape[-2:]
        H, W = in_hw
        k = 2 * self.radius + 1
        Dm = D.reshape(-1, 1, Hp, Wp)
        ones = torch.ones(1, 1, k, k, dtype=D.dtype, device=D.device)
        oph = H - ((Hp - 1) * self.stride + 1)
        opw = W - ((Wp - 1) * self.stride + 1)
        S = F.conv_transpose2d(Dm, ones, stride=self.stride, padding=self.radius, output_padding=(oph, opw))
        return S.reshape(*lead, H, W)


class Local1D:
    """Causal token-window locality for [B, T, d] blocks: output token t depends on input tokens
    s in [t-window+1, t].  Measurement units = output tokens; gather = causal window box-sum."""
    kind = "1d"

    def __init__(self, window):
        self.window = int(window)

    def gather(self, D, in_shape):
        """D: [n, *bs, T] per-output-token derivatives -> S: [n, *bs, T], S[s] = sum_{t=s}^{s+W-1} D[t]."""
        T = D.shape[-1]
        c = torch.cumsum(D, dim=-1)
        pad = torch.zeros_like(c[..., :1])
        c0 = torch.cat([pad, c], dim=-1)                                # c0[t] = sum_{<t}
        hi = torch.clamp(torch.arange(T, device=D.device) + self.window, max=T)
        lo = torch.arange(T, device=D.device)
        return c0[..., hi] - c0[..., lo]


def _probes_for(x, n_probes, family, gen, n_batch_dims, cycle=None):
    bs = x.shape[:n_batch_dims]
    feat = x.shape[n_batch_dims:]
    d = math.prod(feat)
    u = sample_probes(n_probes, tuple(bs), d, family, gen, x.device, x.dtype, cycle=cycle)
    return u.reshape(n_probes, *bs, *feat), d


def zo_vjp_cd_parts(h, x, cfg, gen, n_batch_dims=1, cycle=None):
    """Central-difference measurements together with their probes (global units only):
    returns (u [n,*bs,*feat], D [n,*bs] = u_i^T g + O(eps^2), g_hat, queries)."""
    u, d = _probes_for(x, cfg.n_probes, cfg.probe, gen, n_batch_dims, cycle)
    eps = _eps_for(cfg, d)
    D = _eval_pairs(h, x, u, [eps], n_batch_dims, cfg.batch_probes, cfg.probe_chunk)[0]
    return u, D, _combine(u, D, n_batch_dims, None, x.shape), float(2 * cfg.n_probes)


def _combine(u, D, n_batch_dims, local, in_shape):
    """g_hat = (1/n) sum_i u_i * S_i, where S_i are the (gathered) directional derivatives."""
    n = u.shape[0]
    if local is None:
        S = D.reshape(*D.shape, *([1] * (u.dim() - 1 - n_batch_dims)))
    elif getattr(local, "kind", "2d") == "1d":
        S = local.gather(D, in_shape).unsqueeze(-1)           # [n, *bs, T, 1] broadcast over features
    else:
        S = local.gather(D, in_shape[-2:])                   # [n, *bs, H, W]
        S = S.unsqueeze(n_batch_dims + 1)                     # broadcast over channels: [n, *bs, 1, H, W]
    return (u * S).mean(0)


def _eps_for(cfg, d):
    return cfg.eps if cfg.eps_mode == "coord" else cfg.eps / math.sqrt(d)


def _eval_pairs(h, x, u, eps_list, n_batch_dims, batch_probes=True, chunk=0):
    """D(eps, u_i) = (h(x+eps u_i) - h(x-eps u_i)) / (2 eps) for all probes/eps -> [len(eps), n, *bs, *meas]."""
    n = u.shape[0]
    B0 = x.shape[0]
    outs = []
    if batch_probes:
        step = n if chunk <= 0 else max(1, min(chunk, n))
        for eps in eps_list:
            parts = []
            for i0 in range(0, n, step):
                xs = []
                for i in range(i0, min(n, i0 + step)):
                    xs.append(x + eps * u[i])
                    xs.append(x - eps * u[i])
                hv = h(torch.cat(xs, 0))
                hv = hv.reshape(len(xs) // 2, 2, B0, *hv.shape[1:])
                parts.append((hv[:, 0] - hv[:, 1]) / (2 * eps))
            outs.append(torch.cat(parts, 0))
    else:
        for eps in eps_list:
            outs.append(torch.stack([(h(x + eps * u[i]) - h(x - eps * u[i])) / (2 * eps) for i in range(n)]))
    return torch.stack(outs)


def exact_directional(f, x, v, u, n_batch_dims=1, local=None, chunk=16):
    """Oracle: exact directional derivatives D_i = <v, J u_i> per measurement unit via forward-mode AD
    (vmapped over probes in chunks; falls back to a loop if the block is not vmap-compatible)."""
    def one(t):
        return torch.func.jvp(f, (x,), (t,))[1]
    outs = []
    n = u.shape[0]
    try:
        for i in range(0, n, chunk):
            outs.append(torch.func.vmap(one)(u[i:i + chunk]))
        Ju = torch.cat(outs, 0)
    except Exception:
        Ju = torch.stack([one(u[i]) for i in range(n)])
    prod = Ju * v.unsqueeze(0)
    if local is None:
        return prod.reshape(n, *x.shape[:n_batch_dims], -1).sum(-1)
    if getattr(local, "kind", "2d") == "1d":
        return prod.sum(-1)
    return prod.sum(n_batch_dims + 1)                         # sum over channel dim only


def oracle_vjp(f, x, v, cfg, gen, n_batch_dims=1, local=None):
    """Stage A: (1/n) sum_i u_i <v, J u_i>  (== (1/n) sum_i u_i (u_i^T g)).  Conditionally unbiased."""
    u, d = _probes_for(x, cfg.n_probes, cfg.probe, gen, n_batch_dims)
    with torch.no_grad():
        D = exact_directional(f, x, v, u, n_batch_dims, local)
    return _combine(u, D, n_batch_dims, local, x.shape), 0.0


def oracle_projection(g, cfg, gen, n_batch_dims=1):
    """Projection form of the oracle for a given exact VJP g (global units only)."""
    u, d = _probes_for(g, cfg.n_probes, cfg.probe, gen, n_batch_dims)
    n = u.shape[0]
    bs = g.shape[:n_batch_dims]
    D = (u.reshape(n, *bs, -1) * g.reshape(1, *bs, -1)).sum(-1)
    return _combine(u, D, n_batch_dims, None, g.shape), 0.0


def zo_vjp(h, x, cfg, gen, n_batch_dims=1, local=None, cycle=None):
    """Function-query estimate of J^T v.  Returns (g_hat, queries_per_sample_unit)."""
    if cfg.mode == "cd" and cfg.probe == "coordinate_all" and local is None:
        # analytic combine: u_i = sqrt(d) e_i  ->  g_hat[..., i] = D_i / sqrt(d); the probe tensor is a broadcast
        # view of eye(d), so memory stays O(d^2 + measurements) instead of O(n * batch * d)
        bs = x.shape[:n_batch_dims]
        d = math.prod(x.shape[n_batch_dims:])
        if cfg.n_probes != d:
            raise ValueError(f"coordinate_all needs n == d (n={cfg.n_probes}, d={d})")
        eps = _eps_for(cfg, d)
        ident = math.sqrt(d) * torch.eye(d, device=x.device, dtype=x.dtype)
        uv = ident.reshape(d, *([1] * len(bs)), d).expand(d, *bs, d)
        D = _eval_pairs(h, x, uv, [eps], n_batch_dims, cfg.batch_probes, cfg.probe_chunk)[0]
        return (D.movedim(0, -1) / math.sqrt(d)).reshape(x.shape), float(2 * d)
    if cfg.mode == "cd" and cfg.probe_chunk and cfg.n_probes > 4 * cfg.probe_chunk and cfg.probe != "hadamard_cycle":
        # probe-chunked sample -> evaluate -> accumulate: memory O(chunk * batch * d) for any n
        bs = x.shape[:n_batch_dims]
        d = math.prod(x.shape[n_batch_dims:])
        eps = _eps_for(cfg, d)
        g_acc = torch.zeros_like(x)
        done = 0
        while done < cfg.n_probes:
            c = min(cfg.probe_chunk, cfg.n_probes - done)
            uc, _ = _probes_for(x, c, cfg.probe, gen, n_batch_dims)
            D = _eval_pairs(h, x, uc, [eps], n_batch_dims, cfg.batch_probes, cfg.probe_chunk)[0]
            g_acc += _combine(uc, D, n_batch_dims, local, x.shape) * c
            done += c
        return g_acc / cfg.n_probes, float(2 * cfg.n_probes)
    u, d = _probes_for(x, cfg.n_probes, cfg.probe, gen, n_batch_dims, cycle)
    n = cfg.n_probes
    eps = _eps_for(cfg, d)
    bs = x.shape[:n_batch_dims]
    B0 = x.shape[0]

    if cfg.mode == "forward":
        if cfg.batch_probes:
            hv = h(torch.cat([x] + [x + eps * u[i] for i in range(n)], 0))
            hv = hv.reshape(n + 1, B0, *hv.shape[1:])
            D = (hv[1:] - hv[:1]) / eps
        else:
            h0 = h(x)
            D = torch.stack([(h(x + eps * u[i]) - h0) / eps for i in range(n)])
        return _combine(u, D, n_batch_dims, local, x.shape), float(n + 1)

    if cfg.mode == "cd":
        D = _eval_pairs(h, x, u, [eps], n_batch_dims, cfg.batch_probes, cfg.probe_chunk)[0]
        return _combine(u, D, n_batch_dims, local, x.shape), float(2 * n)

    if cfg.mode == "richardson":
        D = _eval_pairs(h, x, u, [eps, eps / 2], n_batch_dims, cfg.batch_probes, cfg.probe_chunk)
        R = (4 * D[1] - D[0]) / 3
        return _combine(u, R, n_batch_dims, local, x.shape), float(4 * n)

    if cfg.mode in ("ml", "ml_richardson"):
        rich = cfg.mode == "ml_richardson"
        if cfg.level_sampling == "per_sample":
            U = torch.rand((n, *bs), generator=gen, device=x.device, dtype=torch.float64)
        else:
            U = torch.rand((n,), generator=gen, device=x.device, dtype=torch.float64)
            U = U.reshape(n, *([1] * len(bs))).expand(n, *bs)
        N = torch.floor(-torch.log2(U) / cfg.alpha).clamp(max=cfg.max_level).to(torch.int64)
        if cfg.eps_min > 0:   # truncate the ladder where readout noise would dominate (bias O(eps_min^2))
            kmax = max(0, int(math.floor(math.log2(eps / cfg.eps_min)))) if eps > cfg.eps_min else 0
            N = N.clamp(max=kmax)
        Nmax = int(N.max().item())
        K = Nmax + (2 if rich else 1)
        eps_list = [eps * 2.0 ** (-k) for k in range(K)]
        D = _eval_pairs(h, x, u, eps_list, n_batch_dims, cfg.batch_probes, cfg.probe_chunk)   # [K, n, *bs, *meas]
        S = (4 * D[1:] - D[:-1]) / 3 if rich else D
        est = S[0].clone()
        extra = S.dim() - 1 - N.dim()
        for k in range(1, Nmax + 1):
            mask = (N >= k).to(x.dtype).reshape(*N.shape, *([1] * extra))
            est = est + mask * (S[k] - S[k - 1]) / (2.0 ** (-cfg.alpha * k))
        per = 2.0 * (N.to(torch.float64) + 1 + (1 if rich else 0))
        queries = float(per.sum().item() / (math.prod(bs) if len(bs) else 1))
        return _combine(u, est, n_batch_dims, local, x.shape), queries

    raise ValueError(f"unknown ZBP mode {cfg.mode!r}")


def causal_score_coordinate_k_vjp(qkv, v_out, cfg, gen, d, n_heads, dk=None, dv=None, window=None):
    """Estimate attention's K-side VJP by probing causal score rows.

    The black-box interface here is ``softmax(scores) @ value`` rather than K itself.
    One paired query perturbs one valid score coordinate in every (sample, head,
    output-token) row, and the output supplies a separate scalar measurement for
    every such row.  For a row with ``m`` valid keys, the estimator visits all
    coordinates when ``m <= n``; otherwise it samples ``n`` coordinates without
    replacement and applies the Horvitz--Thompson weight ``m / n``.  Thus the
    row-gradient relative MSE is exactly ``max(m / n - 1, 0)`` in the directional-
    derivative limit, instead of ``(m - 1) / n`` for i.i.d. dense probes.

    The estimated score VJP is mapped to K through the digitally known bilinear
    score map QK^T/sqrt(dh).  Q and V are held fixed during the function queries.
    Returns ``(g_k, queries_per_sequence)``; full-attention K is [B,T,d], while
    multi-query K is [B,T,dh].
    """
    if cfg.mode not in ("cd", "oracle"):
        raise ValueError(f"causal score-coordinate probing supports cd/oracle, got {cfg.mode!r}")
    B, T, _ = qkv.shape
    H = int(n_heads)
    dh = int(d) // H
    dk = int(d if dk is None else dk)
    dv = int(d if dv is None else dv)
    if int(d) % H:
        raise ValueError(f"attention width {d} is not divisible by {H} heads")
    if dk not in (int(d), dh) or dv not in (int(d), dh):
        raise ValueError("score-coordinate probing supports full or multi-query K/V layouts")

    q0, k0, value0 = qkv.split([int(d), dk, dv], dim=-1)
    q = q0.view(B, T, H, dh).transpose(1, 2)
    if dk == dh:
        k = k0.view(B, T, 1, dh).transpose(1, 2).expand(B, H, T, dh)
    else:
        k = k0.view(B, T, H, dh).transpose(1, 2)
    if dv == dh:
        value = value0.view(B, T, 1, dh).transpose(1, 2).expand(B, H, T, dh)
    else:
        value = value0.view(B, T, H, dh).transpose(1, 2)
    delta = v_out.view(B, T, H, dh).transpose(1, 2)

    idx = torch.arange(T, device=qkv.device)
    valid = idx[None, :] <= idx[:, None]
    if window is not None:
        valid = valid & (idx[None, :] > idx[:, None] - int(window))
    counts = valid.sum(-1)                                      # [T], valid coordinates per row
    scores = (q @ k.transpose(-2, -1)) / math.sqrt(dh)
    scores = scores.masked_fill(~valid, float("-inf"))

    # Analytic score VJP is useful for projection-only audits.  The CD path below
    # obtains the same quantities solely from paired softmax/value evaluations.
    if cfg.mode == "oracle":
        att = scores.softmax(-1)
        g_att = delta @ value.transpose(-2, -1)
        g_score_exact = att * (g_att - (att * g_att).sum(-1, keepdim=True))
        g_score_exact = g_score_exact.masked_fill(~valid, 0)

    n = int(cfg.n_probes)
    if n <= 0:
        raise ValueError("score-coordinate probing needs at least one probe")
    slots = min(n, T)
    # Independent random subsets for every sample/head/row.  Invalid positions
    # sort last, so the first min(n,m) entries are a uniform subset of the m-prefix.
    priorities = torch.rand((B, H, T, T), generator=gen, device=qkv.device, dtype=torch.float32)
    priorities.masked_fill_(~valid.view(1, 1, T, T), 2.0)
    chosen = priorities.argsort(dim=-1)[..., :slots]             # [B,H,T,slots]
    chosen = chosen.permute(3, 0, 1, 2).contiguous()             # [slots,B,H,T]
    slot_id = torch.arange(slots, device=qkv.device).view(slots, 1, 1, 1)
    active = slot_id < counts.view(1, 1, 1, T)
    active = active.expand(slots, B, H, T)
    weights = torch.where(counts <= n, torch.ones_like(counts, dtype=qkv.dtype),
                          counts.to(qkv.dtype) / n)               # [T]
    g_score = torch.zeros_like(scores)

    if cfg.mode == "oracle":
        picked = torch.gather(g_score_exact.unsqueeze(0).expand(slots, -1, -1, -1, -1),
                              -1, chosen.unsqueeze(-1)).squeeze(-1)
        contrib = picked * active.to(qkv.dtype) * weights.view(1, 1, 1, T)
        for j in range(slots):
            g_score.scatter_add_(-1, chosen[j].unsqueeze(-1), contrib[j].unsqueeze(-1))
        queries = 0.0
    else:
        eps = float(cfg.eps)  # directions are unit coordinates, so coord/norm conventions coincide
        if eps <= 0:
            raise ValueError("score-coordinate probing needs eps > 0")
        chunk = slots if cfg.probe_chunk <= 0 else max(1, min(int(cfg.probe_chunk), slots))
        for i0 in range(0, slots, chunk):
            ids = chosen[i0:i0 + chunk]
            act = active[i0:i0 + chunk]
            c = ids.shape[0]
            plus = scores.unsqueeze(0).expand(c, -1, -1, -1, -1).clone()
            minus = plus.clone()
            shift = act.to(qkv.dtype).unsqueeze(-1) * eps
            plus.scatter_add_(-1, ids.unsqueeze(-1), shift)
            minus.scatter_add_(-1, ids.unsqueeze(-1), -shift)
            y_plus = plus.softmax(-1) @ value.unsqueeze(0)
            y_minus = minus.softmax(-1) @ value.unsqueeze(0)
            if cfg.readout_noise > 0:
                y_plus = y_plus + cfg.readout_noise * torch.randn(
                    y_plus.shape, generator=gen, device=y_plus.device, dtype=y_plus.dtype)
                y_minus = y_minus + cfg.readout_noise * torch.randn(
                    y_minus.shape, generator=gen, device=y_minus.device, dtype=y_minus.dtype)
            deriv = ((y_plus - y_minus) * delta.unsqueeze(0)).sum(-1) / (2 * eps)
            contrib = deriv * act.to(qkv.dtype) * weights.view(1, 1, 1, T)
            for j in range(c):
                g_score.scatter_add_(-1, ids[j].unsqueeze(-1), contrib[j].unsqueeze(-1))
        queries = float(2 * n)

    _LAST_SCORE_VJP['g'] = g_score
    gk_heads = g_score.transpose(-2, -1) @ q / math.sqrt(dh)
    if dk == dh:
        gk = gk_heads.sum(1).transpose(1, 2)                      # shared K accumulates all heads
    else:
        gk = gk_heads.transpose(1, 2).reshape(B, T, int(d))
    return gk, queries


def make_h(f, v, n_batch_dims=1, local=None, readout_noise=0.0, gen=None):
    """Measurement function h(x_pert) = <v, f(x_pert)> per unit, tolerant to stacked batches.
    readout_noise > 0 adds i.i.d. Gaussian noise to every measured output coordinate of every query
    (a physical readout-noise model; v is applied digitally afterwards)."""
    B0 = v.shape[0]

    def h(xp):
        with torch.no_grad():
            y = f(xp)
            if readout_noise > 0:
                y = y + readout_noise * torch.randn(y.shape, generator=gen, device=y.device, dtype=y.dtype)
            k = y.shape[0] // B0
            vv = v.repeat(k, *([1] * (v.dim() - 1))) if k > 1 else v
            prod = y * vv
            if local is None:
                return prod.reshape(*prod.shape[:n_batch_dims], -1).sum(-1)
            if getattr(local, "kind", "2d") == "1d":
                return prod.sum(-1)                           # per output token
            return prod.sum(n_batch_dims)                     # keep spatial dims
    return h


def control_vjp(g, cfg, gen, n_batch_dims=1):
    """Control estimators.  'zero': no credit is propagated (downstream layers frozen).
    'noise': isotropic Gaussian noise with the per-sample norm that the oracle estimate with
    the same n would have (sqrt(1 + (d-1)/n) ||g||): same magnitude, zero information."""
    if cfg.mode == "zero":
        return torch.zeros_like(g), 0.0
    if cfg.mode == "noise":
        bs = g.shape[:n_batch_dims]
        gf = g.reshape(*bs, -1)
        d = gf.shape[-1]
        z = torch.randn(gf.shape, generator=gen, device=g.device, dtype=g.dtype)
        z = z / z.norm(dim=-1, keepdim=True).clamp_min(1e-30)
        target = gf.norm(dim=-1, keepdim=True) * math.sqrt(1 + (d - 1) / cfg.n_probes)
        return (z * target).reshape(g.shape), 0.0
    raise ValueError(cfg.mode)


def causal_score_coordinate_qk_vjp(qkv, v_out, cfg, gen, d, n_heads, dk=None, dv=None, window=None):
    """Same score-space probing as causal_score_coordinate_k_vjp, but returns the Q-side VJP as well
    (both follow from the score-VJP through the known bilinear map S = Q K^T / sqrt(d_h))."""
    B, T, _ = qkv.shape
    H = int(n_heads); dh = int(d) // H
    dk = int(d if dk is None else dk)
    gk, queries = causal_score_coordinate_k_vjp(qkv, v_out, cfg, gen, d, n_heads, dk, dv, window)
    # recover g_score from gk is not possible in general; recompute the score VJP estimate via the same
    # probes by calling the K routine on a transposed view is not exact either, so instead re-derive:
    # g_K[s] = sum_t g_S[t,s] q_t / sqrt(dh)  and  g_Q[t] = sum_s g_S[t,s] k_s / sqrt(dh).
    # We obtain g_S by solving nothing: the K routine exposes it through `_last_score_vjp`.
    g_score = _LAST_SCORE_VJP["g"]
    k0 = qkv[..., int(d):int(d) + dk]
    if dk == dh:
        k = k0.view(B, T, 1, dh).transpose(1, 2).expand(B, H, T, dh)
    else:
        k = k0.view(B, T, H, dh).transpose(1, 2)
    gq = (g_score @ k) / math.sqrt(dh)                              # [B,H,T,dh]
    gq = gq.transpose(1, 2).reshape(B, T, int(d))
    return gq, gk, queries


_LAST_SCORE_VJP = {"g": None}