File size: 11,755 Bytes
c855122 cabaa8f 1fc5661 cabaa8f c855122 cabaa8f c855122 cabaa8f c855122 1fc5661 c855122 cabaa8f c855122 1fc5661 c855122 1fc5661 c855122 | 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 | """K1/K3 -- fused residual add + affine-free LayerNorm (the "fold" kernel).
WHAT IT COMPUTES
----------------
Given a residual stream ``a`` [R, H] fp32 and a stack of 1..4 branch tensors
``b`` [B, R, H] bf16, per token row this kernel computes::
x_new = a + sum_j b[j] # fp32, the untouched residual stream
z = (x_new - mean(x_new)) * rstd # bf16, LayerNorm WITHOUT affine
and returns ``(x_new, z)``. gamma/beta are NOT applied here -- they have been folded
into the downstream consumers' packed weights at freeze time (K1's z feeds K2's Q/K/V
projections; K3's z feeds K4's fc1). The affine fold is exact:
``W @ (z*g + bt) + c == (W * g) @ z + (W @ bt + c)``, so scaling the consumer's input
columns by gamma and adding ``W @ bt`` to its bias reproduces the same math with the
affine removed from the norm kernel.
WHY IT IS SHAPED THIS WAY
-------------------------
* LNC = 2 shard: the kernel is launched on both NeuronCores; each owns ``R // 2`` rows
selected by ``program_id(0)``, so the two cores never touch the same tokens.
* Every token tile has its own SBUF slot ([P, n_t, H]) and all loads are issued before any
compute, so tile ``t+1``'s loads never wait behind tile ``t``'s stores. When the tiles of one core do not
fit SBUF (large batch: e.g. ViT-S >= 8k rows/core, ViT-L >= ~2k), they are processed in groups that do,
with the same pattern inside each group (v1.7; up to v1.6 these shapes failed to compile). One DMA per tile and
operand: a single whole-shard DMA would signal completion only at the end. (External
contribution by jlongen-aws, PR #2 on ``jburtoft/vjepa2-neuron-kernels``; replaces the
depth-4 ring, same instructions in the same order so results are unchanged.)
* ``dge_mode.none`` on every DMA: the runtime generates the descriptors before execution,
so no engine spends time issuing them. The default (software DGE) issued each of the ~40
DMAs on GpSimd at ~0.75 us apiece, one after another, and the first add waited ~8 us for
them. Hardware DGE on the Sync queue was as fast standalone, but inside the encoder it
delayed K2/K4's loads on that queue by about as much as it saved, so it is not used.
* fp32 residual / bf16 branch: ``a`` (the residual stream) stays fp32 so ``x_new`` is
bitwise-invariant math and can be carried forward losslessly; the branch inputs and
the normalized output ``z`` are bf16, which is the precision boundary the downstream
kernels consume.
* Thinned stat chain: mean and variance are computed with a shift-and-reduce chain
(neg_shift -> copy-reduce -> neg_mu -> square-reduce -> var -> rsqrt) that keeps the
per-lane statistics in fp32; the final normalize is a single fused ``tensor_scalar``
(``z = (x_new + neg_mu) * rstd``) whose two per-lane operands cost the same as one op
and whose bf16 cast rides the write. Each per-lane statistic has its own column per tile
(``[128, n_t]``) so tile ``t+1``'s chain does not wait on tile ``t``'s.
This kernel is LNC-parametric (``LNC`` from ``NEURON_LOGICAL_NC_CONFIG``). There are no
``engine=`` pins. INFERENCE ONLY: no autograd rule is registered.
"""
from __future__ import annotations
import torch
from torch_neuronx import nki_op
import nki
import nki.isa as nisa
import nki.language as nl
#: SBUF partition count, and the token-tile height (rows processed per instruction).
_P_MAX = 128
#: The bf16 dtype used for branch inputs and the normalized output.
_BF16 = nl.bfloat16
#: Per-partition SBUF bytes one tile group's a/b/s slots may use. 200 KiB is the largest footprint measured to
#: compile (ViT-H+, 16 tiles, one branch); 210 KiB failed. Above it, tiles are processed in groups (v1.7).
_SBUF_GROUP_BUDGET = 200 * 1024
#: Logical-to-physical NeuronCore degree. Rows are split evenly across LNC cores.
import os as _os
#: logical-core degree the kernels are built for; must match NEURON_LOGICAL_NC_CONFIG.
LNC = int(_os.environ.get("NEURON_LOGICAL_NC_CONFIG", "2"))
def _kernel_assert(condition: bool, error_text: str):
"""Assert with an NKI-formatted error message."""
assert condition, f"[INTERNAL_ERROR] [NCC_INKI016] Kernel validation exception: {error_text}"
@nki.jit
def _res_layernorm_fold_kernel(a_hbm, b_hbm, eps):
"""Emit the fold kernel body: ``(a + sum(b), LN_no_affine(a + sum(b)))``, token-major.
gamma/beta live in the consumers' packed weights, so this kernel takes no affine
parameters. Rows are sharded across ``LNC`` cores; every token tile is loaded up front
into its own SBUF slot (all loads issued before any compute), summed in fp32, normalized
with the thinned stat chain, and the normalized result is cast to bf16 on write. Every DMA
uses ``dge_mode.none`` so the runtime pre-generates the descriptors.
Args:
a_hbm (nl.ndarray): [R, H] @ HBM float32 -- the residual stream.
b_hbm (nl.ndarray): [B, R, H] @ HBM bf16 -- B branch tensors (1 <= B <= 4), summed in.
eps (float): LayerNorm epsilon.
Returns:
tuple: ``(x_new [R, H] float32, z [R, H] bf16)`` both in shared HBM.
"""
R, H = a_hbm.shape
n_b, Rb, Hb = b_hbm.shape
_kernel_assert(R >= 1 and H >= 1, f"R({R}) H({H}) must be >= 1")
_kernel_assert(Rb == R and Hb == H, f"b {(Rb, Hb)} must match a {(R, H)}")
_kernel_assert(1 <= n_b <= 4, f"b must stack 1..4 branches, got {n_b}")
_kernel_assert(
H <= 16384,
f"H={H} is one instruction's free axis here; no chunking is implemented.",
)
_kernel_assert(
R % (LNC * _P_MAX) == 0,
f"need R divisible by LNC*{_P_MAX} = {LNC * _P_MAX}, got R={R}",
)
rows_per = R // LNC
n_t = rows_per // _P_MAX
pid = nl.program_id(0)
r_base = pid * rows_per
x_new_hbm = nl.ndarray((R, H), dtype=nl.float32, buffer=nl.shared_hbm)
z_hbm = nl.ndarray((R, H), dtype=_BF16, buffer=nl.shared_hbm)
inv_h = 1.0 / float(H)
neg_inv_h = -inv_h
# Tile GROUPS (v1.7): the kernel stages every tile of a group in SBUF at once (loads first, see the
# module doc), so the group size G is capped by the SBUF budget. Measured footprint per tile and
# partition is the a (fp32), b (bf16 x n_b) and s (fp32) slots, H * (8 + 2 n_b) bytes -- the compiler
# reuses z's slot (ViT-H+ 16 tiles = 200 KiB compiles; ViT-S 56 tiles = 210 KiB does not).
# G = all tiles when they fit (the <= v1.6 schedule, unchanged), else the largest G under
# _SBUF_GROUP_BUDGET. Same per-tile instructions in the same order -> bit-identical results.
per_tile = H * (8 + 2 * n_b)
G = max(1, min(n_t, _SBUF_GROUP_BUDGET // per_tile))
n_g = (n_t + G - 1) // G
# Scratch destination for the reduce activations (its full tile is discarded; only
# the reduce result is consumed).
sink = nl.ndarray((_P_MAX, H), dtype=nl.float32, buffer=nl.sbuf)
for g in range(n_g):
t0 = g * G
gt = min(G, n_t - t0)
# One SBUF slot per tile of this group; all of the group's loads are issued before any compute or store.
# a_sb[:, t, :] is tile t's residual, b_sb[:, j, t, :] is branch j of tile t.
a_sb = nl.ndarray((_P_MAX, G, H), dtype=nl.float32, buffer=nl.sbuf)
b_sb = nl.ndarray((_P_MAX, n_b, G, H), dtype=_BF16, buffer=nl.sbuf)
for t in range(gt):
r0 = r_base + (t0 + t) * _P_MAX
nisa.dma_copy(dst=a_sb[:, t, :], src=a_hbm[r0:r0 + _P_MAX, 0:H],
dge_mode=nisa.dge_mode.none)
for j in range(n_b):
nisa.dma_copy(dst=b_sb[:, j, t, :], src=b_hbm[j, r0:r0 + _P_MAX, 0:H],
dge_mode=nisa.dge_mode.none)
s_sb = nl.ndarray((_P_MAX, G, H), dtype=nl.float32, buffer=nl.sbuf)
z_sb = nl.ndarray((_P_MAX, G, H), dtype=_BF16, buffer=nl.sbuf)
# Per-lane statistics, one column per tile so tile t+1's chain doesn't wait on tile t's.
acc = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
acc2 = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
neg_shift = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
neg_mu = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
var = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
rstd = nl.ndarray((_P_MAX, G), dtype=nl.float32, buffer=nl.sbuf)
for t in range(gt):
r0 = r_base + (t0 + t) * _P_MAX
s = s_sb[:, t, :]
c = slice(t, t + 1)
# ---- x_new = a + sum_j b[j] in fp32, then write the residual stream out ----
nisa.tensor_tensor(dst=s, data1=a_sb[:, t, :], data2=b_sb[:, 0, t, :], op=nl.add)
for j in range(1, n_b):
nisa.tensor_tensor(dst=s, data1=s, data2=b_sb[:, j, t, :], op=nl.add)
nisa.dma_copy(dst=x_new_hbm[r0:r0 + _P_MAX, 0:H], src=s, dge_mode=nisa.dge_mode.none)
# ---- thinned stat chain: mean via shift-and-reduce, variance, rstd ----
nisa.tensor_scalar(dst=neg_shift[:, c], data=s_sb[:, t, 0:1],
op0=nl.multiply, operand0=-1.0)
nisa.activation_reduce(dst=sink, op=nl.copy, data=s,
reduce_op=nl.add, reduce_res=acc[:, c],
bias=neg_shift[:, c], scale=1.0)
nisa.tensor_scalar(dst=neg_mu[:, c], data=acc[:, c], op0=nl.multiply, operand0=neg_inv_h,
op1=nl.add, operand1=neg_shift[:, c])
nisa.activation_reduce(dst=sink, op=nl.square, data=s,
reduce_op=nl.add, reduce_res=acc2[:, c], bias=neg_mu[:, c], scale=1.0)
nisa.tensor_scalar(dst=var[:, c], data=acc2[:, c], op0=nl.multiply, operand0=inv_h,
op1=nl.add, operand1=eps)
nisa.activation(dst=rstd[:, c], op=nl.rsqrt, data=var[:, c])
# ---- one-op affine-free apply: z = (x_new + neg_mu) * rstd, bf16 cast on write.
# Both operands are per-lane vectors; the fused two-op tensor_scalar costs one op.
nisa.tensor_scalar(dst=z_sb[:, t, :], data=s,
op0=nl.add, operand0=neg_mu[:, c],
op1=nl.multiply, operand1=rstd[:, c])
nisa.dma_copy(dst=z_hbm[r0:r0 + _P_MAX, 0:H], src=z_sb[:, t, :],
dge_mode=nisa.dge_mode.none)
return x_new_hbm, z_hbm
def _no_grad(*tensors: torch.Tensor) -> None:
"""Refuse gradient-tracking inputs -- this op registers no autograd rule."""
for t in tensors:
if t.requires_grad:
raise RuntimeError(
"nki_res_layernorm_fold registers no autograd rule. Use under "
"torch.no_grad()/eval()."
)
@nki_op("vit_enc::nki_res_layernorm_fold", mutates_args={})
def nki_res_layernorm_fold(
a: torch.Tensor,
b: torch.Tensor,
eps: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Fused residual add + affine-free LayerNorm on LNC=2 (gamma/beta folded away).
The consumers MUST have been packed with folded weights; feeding ``z`` to unfolded
weights silently drops the affine.
Args:
a: [R, H] float32 on 'neuron'. ``R`` divisible by ``LNC * 128``.
b: [B, R, H] bf16 on 'neuron'. B branch tensors (1 <= B <= 4), summed in.
eps: LayerNorm epsilon.
Returns:
``(x_new [R, H] float32, z [R, H] bf16)``.
"""
_no_grad(a, b)
return _res_layernorm_fold_kernel[LNC](a, b, eps)
#: Requested export alias. Same object as the incumbent-named nki_op, so the runtime
#: op-name string ("vit_enc::nki_res_layernorm_fold") is unchanged whichever name is used.
res_layernorm_fold = nki_res_layernorm_fold
|