K4 (`mlp`) and K1/K3 (`res_layernorm_fold`): -4.8% end-to-end encoder, bitwise identical

#2
CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v1.1 (2026-10)
4
 
5
  Documentation only: README points to
 
1
  # Changelog
2
 
3
+ ## v1.2 (2026-10)
4
+
5
+ K4 (`mlp`) and K1/K3 (`res_layernorm_fold`) reworked: **-4.8% end-to-end encoder**, output
6
+ bitwise identical to v1.1.
7
+
8
+ * Full encoder (`torch.compile(backend="neuron")`, 24 layers, one 16-frame clip, trn2.3xlarge):
9
+ 26.635 -> 25.353 ms/clip wall (3 rounds each), 26.83 -> 25.50 ms device time,
10
+ `mfu_estimated_percent` 39.5% -> 41.7%. Encoder output `torch.equal` to v1.1.
11
+ * Standalone at the encoder shape (2 rounds each, all bitwise identical):
12
+ * K4: 295.3 -> 248.6 us (-15.8%), MFU 70.3% -> 82.8%.
13
+ * K1 (1 branch): 48.0 -> 41.9 us (-12.7%), HBM bandwidth 73% -> 84% of the 716 GB/s ceiling.
14
+ * K3 (2 branches): 57.7 -> 48.2 us (-16.3%), HBM bandwidth 71% -> 85%.
15
+ * K4 schedule: x for row block 0 is staged before the weights (was queued behind 16 MB of
16
+ W1/W2); fc1 bias loads in one 16 KB DMA from a host-packed [128, H/128] layout (was 32 x 512 B
17
+ DMAs that stalled each gelu, and through PSUM the fc1 matmuls); row block 0's x is transposed on
18
+ the idle Tensor engine; b1 rides the Scalar engine's HW-DGE queue. Code: 3D SBUF tiles,
19
+ `nl.tile_size` constants, exact-multiple shapes asserted.
20
+ * K1/K3: every DMA uses `dge_mode.none` (descriptors generated by the runtime before execution).
21
+ Before, each of the ~40 DMAs was issued by GpSimd's software DGE at ~0.75 us apiece, one after
22
+ another, and the first add waited ~8 us for them. Hardware DGE on the Sync queue was as fast
23
+ standalone but slower in the encoder, so it is not used. All token tiles are also resident in SBUF
24
+ and loaded before any compute. Same instructions in the same order, so results are unchanged.
25
+ * `pack_b1` is exported; `mlp` accepts b1 either as [H] (packed on the fly) or pre-packed.
26
+ * K4 now requires K % 512 == 0, H % 128 == 0, R % 1024 == 0 (the encoder shape satisfies all three).
27
+ * Toolchain: everything above was measured on torch-neuronx 2.14.3 with nki 0.7.0b1. Standalone K4
28
+ and K1/K3 were also verified on the latest public NKI release (nki 0.6.0, neuronx-cc 2.27.5334):
29
+ K4 248.6 / 247.7 us vs 291.6 / 294.0 us for v1.1, K1 42.6 us, K3 48.1 us, all bitwise identical.
30
+ The full encoder could not be run on nki 0.6.0 with torch-neuronx 2.14.3 (v1.1 fails there too).
31
+
32
  ## v1.1 (2026-10)
33
 
34
  Documentation only: README points to
build/torch-neuron/layers.py CHANGED
@@ -40,6 +40,7 @@ from .nki_kernels import (
40
  attention_block,
41
  build_rope_tables,
42
  mlp,
 
43
  pack_out_weights,
44
  pack_tables,
45
  pack_v_weights,
@@ -85,7 +86,7 @@ def _pack_layer(lyr, tab):
85
  "wqk_f": wqk_f.to(dev), "bqk_f": bqk_f.to(dev), "tab": tab,
86
  "wvt_f": wvt_f.to(dev), "bv_f": bv_f.to(dev), "wot": wot.to(dev), "bo": bo.to(dev),
87
  "w1t_f": (w1 * g2[None, :]).t().contiguous().to(torch.bfloat16).to(dev),
88
- "b1_f": (b1 + w1 @ t2).contiguous().to(dev),
89
  "w2t": lyr.mlp.fc2.weight.detach().t().contiguous().to(torch.bfloat16).to(dev),
90
  "b2": lyr.mlp.fc2.bias.detach().float().contiguous().to(dev),
91
  }
 
40
  attention_block,
41
  build_rope_tables,
42
  mlp,
43
+ pack_b1,
44
  pack_out_weights,
45
  pack_tables,
46
  pack_v_weights,
 
86
  "wqk_f": wqk_f.to(dev), "bqk_f": bqk_f.to(dev), "tab": tab,
87
  "wvt_f": wvt_f.to(dev), "bv_f": bv_f.to(dev), "wot": wot.to(dev), "bo": bo.to(dev),
88
  "w1t_f": (w1 * g2[None, :]).t().contiguous().to(torch.bfloat16).to(dev),
89
+ "b1_f": pack_b1(b1 + w1 @ t2).to(dev), # [128, H/128], one 16 KB DMA in K4
90
  "w2t": lyr.mlp.fc2.weight.detach().t().contiguous().to(torch.bfloat16).to(dev),
91
  "b2": lyr.mlp.fc2.bias.detach().float().contiguous().to(dev),
92
  }
build/torch-neuron/nki_kernels/__init__.py CHANGED
@@ -8,12 +8,12 @@ from .attention_block import (
8
  pack_v_weights,
9
  pack_weights,
10
  )
11
- from .mlp import mlp
12
  from .res_layernorm import res_layernorm_fold
13
  from .rope_tables import build_rope_tables
14
 
15
  __all__ = [
16
- "res_layernorm_fold", "attention_block", "mlp",
17
  "pack_weights", "pack_tables", "pack_v_weights", "pack_out_weights",
18
  "lane_permutation", "build_rope_tables",
19
  ]
 
8
  pack_v_weights,
9
  pack_weights,
10
  )
11
+ from .mlp import mlp, pack_b1
12
  from .res_layernorm import res_layernorm_fold
13
  from .rope_tables import build_rope_tables
14
 
15
  __all__ = [
16
+ "res_layernorm_fold", "attention_block", "mlp", "pack_b1",
17
  "pack_weights", "pack_tables", "pack_v_weights", "pack_out_weights",
18
  "lane_permutation", "build_rope_tables",
19
  ]
build/torch-neuron/nki_kernels/mlp.py CHANGED
@@ -1,42 +1,21 @@
1
- """K4 -- fused MLP (fc1 + gelu + fc2), x-prestage ("XP") in prologue mode.
2
-
3
- WHAT IT COMPUTES
4
- ----------------
5
- ``out = gelu(x @ w1.T + b1) @ w2.T + b2``, with the rows sharded across LNC cores and
6
- the weights pre-transposed to bf16. Returns ``[R, K]`` bf16.
7
-
8
- WHY IT IS SHAPED THIS WAY
9
- -------------------------
10
- This is the effective shipping kernel: it is the ``b1pre`` MLP (fc1 bias staged in the
11
- prologue so every gelu's bias is resident from t=0) with the x-prestage applied. Two
12
- design choices carry the performance:
13
-
14
- * b1 first: the 32 per-block ``b1`` bias tiles are DMA'd before the 16 MB of weight
15
- staging, so they land within ~1 us of kernel start and no gelu ever waits on its bias.
16
- * x prestaged in the prologue: BOTH row blocks' ``x`` load + dma_transpose staging is
17
- issued before the ``rb`` loop, in row-block order. rb=0's staging already hid in the
18
- pre-K4 seam (where the Tensor engine is dark for ~148 us because K1/K3 emit no Tensor
19
- instructions); prestaging rb=1 gives it the same cover, filling the ~26.6 us Tensor
20
- idle window that used to sit at the rb0->rb1 boundary. Appending rb=1's stages after
21
- rb=0's, in order, means rb=0's readiness cannot regress -- the first matmul cannot be
22
- delayed.
23
-
24
- The x staging is emitted through a single module-level function (``_stage_xt``) rather
25
- than inlined per row block, so the ``x`` load and the dma_transpose each keep exactly one
26
- source site. The two-row-block staging is preserved exactly: ``xt_sets`` holds one
27
- transposed-tile set per row block, and the loop consumes ``xt_sets[rb]``.
28
-
29
- PSUM: fc1 uses 1 bank, fc2 uses up to 4 banks; the ``y1_psum`` / ``out_psums`` tiles are
30
- declared inside their loops so the ring never accumulates onto a stale PSUM value. The
31
- b2 bias broadcast is kept (removing it regressed the sibling kernel by 23-30%).
32
-
33
- There are no ``engine=`` pins anywhere in this kernel. INFERENCE ONLY: no autograd rule.
34
  """
35
 
36
  from __future__ import annotations
37
 
38
- import math
39
-
40
  import torch
41
  from torch_neuronx import nki_op
42
 
@@ -44,251 +23,134 @@ import nki
44
  import nki.isa as nisa
45
  import nki.language as nl
46
 
47
- #: PE array partition limit, and the moving-operand free-axis limit.
48
- _P_MAX = 128
49
- _F_MOV_MAX = 512
50
-
51
  _BF16 = nl.bfloat16
52
 
53
  #: Logical-to-physical NeuronCore degree. Must match the shipping shard's.
54
  LNC = 2
55
 
56
- _INV_SQRT2 = 1.0 / math.sqrt(2.0)
57
- _INV_SQRT_2PI = 1.0 / math.sqrt(2.0 * math.pi)
58
-
59
-
60
- def _div_ceil(n: int, d: int) -> int:
61
- """Ceiling division (never write (n + d - 1) // d inline)."""
62
- return (n + d - 1) // d
63
-
64
 
65
  def _kernel_assert(condition: bool, error_text: str):
66
- """Assert with an NKI-formatted error message."""
67
  assert condition, f"[INTERNAL_ERROR] [NCC_INKI016] Kernel validation exception: {error_text}"
68
 
69
 
70
- def _stage_xt(x_hbm, r_abs, pr, n_k, K, rb_size):
71
- """Emit one row block's x staging and return its ``n_k`` transposed bf16 tiles.
72
-
73
- ``n_rs`` loads of ``[<=128, K]`` bf16, each followed by ``n_k`` DMA transposes into
74
- the row block's ``xt_tiles``. Kept as a module-level emitter so the ``x`` load and
75
- the dma_transpose each map to a single source site regardless of where it is called.
76
-
77
- Args:
78
- x_hbm: [R, K] @ HBM bf16, the MLP input.
79
- r_abs: absolute starting row of this row block.
80
- pr: number of rows in this row block (<= rb_size).
81
- n_k: number of K tiles (fc1 contraction dimension tiles).
82
- K: hidden-in dimension.
83
- rb_size: row-block width (free axis of the transposed tiles).
84
-
85
- Returns:
86
- list of ``n_k`` transposed ``[128, rb_size]`` bf16 SBUF tiles.
87
- """
88
- n_rs = _div_ceil(pr, _P_MAX)
89
- xt_tiles = []
90
- for _ in range(n_k):
91
- xt_tiles.append(nl.ndarray((_P_MAX, rb_size), dtype=_BF16, buffer=nl.sbuf))
92
  for rs in range(n_rs):
93
- rr0 = rs * _P_MAX
94
- prs = min(_P_MAX, pr - rr0)
95
- x_b16 = nl.ndarray((_P_MAX, K), dtype=_BF16, buffer=nl.sbuf)
96
- nisa.dma_copy(
97
- dst=x_b16[0:prs, 0:K],
98
- src=x_hbm[r_abs + rr0:r_abs + rr0 + prs, 0:K],
99
- )
100
- for kt in range(n_k):
101
- k0 = kt * _P_MAX
102
- pk = min(_P_MAX, K - k0)
103
- nisa.dma_transpose(dst=xt_tiles[kt][0:pk, rr0:rr0 + prs],
104
- src=x_b16[0:prs, k0:k0 + pk])
105
- return xt_tiles
106
 
107
 
108
  @nki.jit
109
  def _mlp_k4_xp_kernel(x_hbm, w1t_hbm, b1_hbm, w2t_hbm, b2_hbm):
110
- """gelu(x @ w1.T + b1) @ w2.T + b2, rows sharded, b1 AND both x blocks resident early.
111
-
112
- Args:
113
- x_hbm (nl.ndarray): [R, K] @ HBM, **bfloat16** (kernel 1 emits bf16).
114
- w1t_hbm (nl.ndarray): [K, H] @ HBM, **bf16**, == fc1.weight.T.
115
- b1_hbm (nl.ndarray): [H] @ HBM, float32.
116
- w2t_hbm (nl.ndarray): [H, K] @ HBM, **bf16**, == fc2.weight.T.
117
- b2_hbm (nl.ndarray): [K] @ HBM, float32.
118
-
119
- Returns:
120
- nl.ndarray: [R, K] @ HBM, **bfloat16**.
121
- """
122
  R, K = x_hbm.shape
123
- K_w1t, H = w1t_hbm.shape
124
- H_w2t, K_w2t = w2t_hbm.shape
125
- _kernel_assert(R >= 1 and K >= 1 and H >= 1, f"R({R}) K({K}) H({H}) must be >= 1")
126
- _kernel_assert(K_w1t == K, f"w1t in-dim {K_w1t} != K {K}; expected w1t = w1.T of shape [K, H]")
127
- _kernel_assert(K_w2t == K, f"w2t out-dim {K_w2t} != K {K}; expected w2t = w2.T of shape [H, K]")
128
- _kernel_assert(H_w2t == H, f"w2t in-dim {H_w2t} != H {H}")
129
- _kernel_assert(
130
- R % LNC == 0,
131
- f"the {LNC}-core kernel needs R divisible by {LNC}, got R={R}. "
132
- f"program_id is a symbolic value, so a partial final row block cannot be "
133
- f"clamped; use the one-core pre-transposed kernel for uneven R",
134
- )
135
-
136
  rows_per = R // LNC
137
- pid = nl.program_id(0)
138
- r_base = pid * rows_per
139
-
 
 
 
 
 
140
  out_hbm = nl.ndarray((R, K), dtype=_BF16, buffer=nl.shared_hbm)
141
 
142
- n_k = _div_ceil(K, _P_MAX) # K tiles (fc1 contraction)
143
- n_h = _div_ceil(H, _P_MAX) # H tiles (fc1 out-rows / fc2 contraction)
 
144
 
145
- # b1 FIRST, before the 16 MB of weight DMA: 32 [128,1] tiles issue at t=0 and land in
146
- # ~1 us, so every gelu's bias is resident long before the first matmul retires.
147
- b1_tiles = []
148
- for ht in range(n_h):
149
- h0 = ht * _P_MAX
150
- ph = min(_P_MAX, H - h0)
151
- t = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
152
- nisa.dma_copy(dst=t[0:ph, 0:1], src=b1_hbm[h0:h0 + ph])
153
- b1_tiles.append(t)
154
-
155
- # Weight staging (pre-transposed bf16, one DMA per tile).
156
- w1t_tiles = []
157
  for kt in range(n_k):
158
- k0 = kt * _P_MAX
159
- pk = min(_P_MAX, K - k0)
160
- t = nl.ndarray((_P_MAX, H), dtype=_BF16, buffer=nl.sbuf)
161
- nisa.dma_copy(dst=t[0:pk, :], src=w1t_hbm[k0:k0 + pk, :])
162
- w1t_tiles.append(t)
163
-
164
- w2t_tiles = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  for ht in range(n_h):
166
- h0 = ht * _P_MAX
167
- ph = min(_P_MAX, H - h0)
168
- t = nl.ndarray((_P_MAX, K), dtype=_BF16, buffer=nl.sbuf)
169
- nisa.dma_copy(dst=t[0:ph, :], src=w2t_hbm[h0:h0 + ph, :])
170
- w2t_tiles.append(t)
171
-
172
- # Main fused loop.
173
- rb_size = _F_MOV_MAX
174
- n_rb = _div_ceil(rows_per, rb_size)
175
- n_ko = _div_ceil(K, _F_MOV_MAX)
176
-
177
- # THE X-PRESTAGE: stage EVERY row block's x here, in rb order, so rb=1's serialised
178
- # DMA chain runs inside the pre-K4 seam (Tensor dark) instead of inside the idle
179
- # window at the rb0->rb1 boundary. Appending rb=1's stages after rb=0's leaves rb=0's
180
- # stages exactly where they are on the Sync engine's sub-stream.
181
- xt_sets = []
182
- for rb_pre in range(n_rb):
183
- xt_sets.append(_stage_xt(
184
- x_hbm, r_base + rb_pre * rb_size,
185
- min(rb_size, rows_per - rb_pre * rb_size), n_k, K, rb_size))
186
 
187
  for rb in range(n_rb):
188
- r_local = rb * rb_size
189
- pr = min(rb_size, rows_per - r_local)
190
- n_rs = _div_ceil(pr, _P_MAX)
191
- r_abs = r_base + r_local
192
 
193
- # x_T bf16 tiles: consume the prologue-staged set for this row block.
194
- xt_tiles = xt_sets[rb]
195
-
196
- # ---- fc1: gt_tiles[ht] = gelu(w1 @ x.T + b1), bf16, SBUF-resident ----
197
- gt_tiles = []
198
  for ht in range(n_h):
199
- h0 = ht * _P_MAX
200
- ph = min(_P_MAX, H - h0)
201
- # Declared INSIDE the ht loop, so the ring never reuses a written PSUM tile.
202
- # nc_matmul's default accumulate=None auto-detects "previously written" and
203
- # would accumulate onto a stale value.
204
- y1_psum = nl.ndarray((_P_MAX, rb_size), dtype=nl.float32, buffer=nl.psum)
205
  for kt in nl.affine_range(n_k):
206
- k0 = kt * _P_MAX
207
- pk = min(_P_MAX, K - k0)
208
- nisa.nc_matmul(
209
- dst=y1_psum[0:ph, 0:pr],
210
- stationary=w1t_tiles[kt][0:pk, h0:h0 + ph],
211
- moving=xt_tiles[kt][0:pk, 0:pr],
212
- )
213
- # b1 comes from the prologue-resident tile.
214
- gt = nl.ndarray((_P_MAX, rb_size), dtype=_BF16, buffer=nl.sbuf)
215
- nisa.activation(dst=gt[0:ph, 0:pr], op=nl.gelu,
216
- data=y1_psum[0:ph, 0:pr], bias=b1_tiles[ht][0:ph, 0:1])
217
- gt_tiles.append(gt)
218
-
219
- # ---- fc2: out[r,k] = sum_h g[r,h] * w2[k,h] + b2[k] ----
220
  for ko in range(n_ko):
221
- ko0 = ko * _F_MOV_MAX
222
- nk = min(_F_MOV_MAX, K - ko0)
223
- out_psums = []
224
  for _ in range(n_rs):
225
- out_psums.append(
226
- nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=nl.float32, buffer=nl.psum)
227
- )
228
  for ht in nl.affine_range(n_h):
229
- h0 = ht * _P_MAX
230
- ph = min(_P_MAX, H - h0)
231
  for rs in range(n_rs):
232
- rr0 = rs * _P_MAX
233
- prs = min(_P_MAX, pr - rr0)
234
- nisa.nc_matmul(
235
- dst=out_psums[rs][0:prs, 0:nk],
236
- stationary=gt_tiles[ht][0:ph, rr0:rr0 + prs],
237
- moving=w2t_tiles[ht][0:ph, ko0:ko0 + nk],
238
- )
239
- # b2 broadcast, hoisted out of the rs loop (removing the broadcast itself
240
- # cost 23% and 30% in two separate attempts).
241
- b2_bc = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=nl.float32, buffer=nl.sbuf)
242
- nisa.dma_copy(dst=b2_bc[0:_P_MAX, 0:nk],
243
- src=b2_hbm.ap(pattern=[[0, _P_MAX], [1, nk]], offset=ko0))
244
  for rs in range(n_rs):
245
- rr0 = rs * _P_MAX
246
- prs = min(_P_MAX, pr - rr0)
247
- out_sb = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=_BF16, buffer=nl.sbuf)
248
- nisa.tensor_tensor(dst=out_sb[0:prs, 0:nk],
249
- data1=out_psums[rs][0:prs, 0:nk],
250
- data2=b2_bc[0:prs, 0:nk],
251
- op=nl.add)
252
- nisa.dma_copy(
253
- dst=out_hbm[r_abs + rr0:r_abs + rr0 + prs, ko0:ko0 + nk],
254
- src=out_sb[0:prs, 0:nk],
255
- )
256
 
257
  return out_hbm
258
 
259
 
 
 
 
 
 
260
  def _no_grad(*tensors) -> None:
261
  """Refuse gradient-tracking inputs -- this op registers no autograd rule."""
262
  for t in tensors:
263
  if t is not None and t.requires_grad:
264
- raise RuntimeError(
265
- "nki_mlp_k4_xp registers no autograd rule (INFERENCE ONLY). "
266
- "Use under torch.no_grad()/eval()."
267
- )
268
 
269
 
270
  @nki_op("vjepa2::nki_mlp_k4_xp", mutates_args={})
271
- def nki_mlp_k4_xp(
272
- x: torch.Tensor,
273
- w1t: torch.Tensor,
274
- b1: torch.Tensor,
275
- w2t: torch.Tensor,
276
- b2: torch.Tensor,
277
- ) -> torch.Tensor:
278
- """Fused MLP on LNC=2, pre-transposed bf16 weights, x prestaged in the prologue.
279
-
280
- Args:
281
- x : [R, K] **bf16** on 'neuron'. ``R`` must be divisible by :data:`LNC`.
282
- w1t : [K, H] bf16 == fc1.weight.T. b1 : [H] float32.
283
- w2t : [H, K] bf16 == fc2.weight.T. b2 : [K] float32.
284
-
285
- Returns:
286
- [R, K] bf16.
287
- """
288
  _no_grad(x, w1t, b1, w2t, b2)
 
 
289
  return _mlp_k4_xp_kernel[LNC](x, w1t, b1, w2t, b2)
290
 
291
 
292
- #: Export alias. Same object as the nki_op above, so the runtime
293
- #: op-name string ("vjepa2::nki_mlp_k4_xp") is unchanged whichever name is used.
294
  mlp = nki_mlp_k4_xp
 
1
+ """K4 -- fused MLP (fc1 + gelu + fc2).
2
+
3
+ ``out = gelu(x @ w1.T + b1) @ w2.T + b2``, rows sharded across LNC cores, bf16 matmuls with
4
+ fp32 PSUM accumulation. Same instruction order as v10 (bit-exact with mlp.py), with:
5
+
6
+ * 3D SBUF tiles instead of lists of tiles: W1 [P, n_k, H], W2 [P, n_h, K], x per row block
7
+ [P, n_rs, K], fc1 output [P, n_h, FMOV]. DMAs stay per tile (v14 merged them into single
8
+ access-pattern DMAs and lost ~8 us: one completion signal per 8 MB weight), b2 is one
9
+ partition-broadcast access-pattern load [P, K];
10
+ * hardware sizes from ``nl.tile_size`` instead of literals;
11
+ * exact-multiple shapes asserted up front instead of ``min()`` remainder handling.
12
+
13
+ Prologue order (v3/v8/v10): b1 (Scalar HW-DGE queue) -> x(rb0) + Tensor-engine transposes
14
+ -> W1 -> x(rb1..) + DMA transposes -> W2. INFERENCE ONLY: no autograd rule.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """
16
 
17
  from __future__ import annotations
18
 
 
 
19
  import torch
20
  from torch_neuronx import nki_op
21
 
 
23
  import nki.isa as nisa
24
  import nki.language as nl
25
 
26
+ P = nl.tile_size.pmax # partitions / stationary width (128)
27
+ FMOV = nl.tile_size.gemm_moving_fmax # moving free-axis limit = one PSUM bank of fp32 (512)
 
 
28
  _BF16 = nl.bfloat16
29
 
30
  #: Logical-to-physical NeuronCore degree. Must match the shipping shard's.
31
  LNC = 2
32
 
 
 
 
 
 
 
 
 
33
 
34
  def _kernel_assert(condition: bool, error_text: str):
 
35
  assert condition, f"[INTERNAL_ERROR] [NCC_INKI016] Kernel validation exception: {error_text}"
36
 
37
 
38
+ def _load_rows(x_hbm, r0, n_rs, K):
39
+ """x[r0 : r0 + n_rs*P, :] into [P, n_rs, K], one DMA per row chunk so consumers start early."""
40
+ x_sb = nl.ndarray((P, n_rs, K), dtype=_BF16, buffer=nl.sbuf)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  for rs in range(n_rs):
42
+ nisa.dma_copy(dst=x_sb[:, rs, :], src=x_hbm[r0 + rs * P:r0 + (rs + 1) * P, :])
43
+ return x_sb
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  @nki.jit
47
  def _mlp_k4_xp_kernel(x_hbm, w1t_hbm, b1_hbm, w2t_hbm, b2_hbm):
48
+ """Args: x [R, K] bf16, w1t [K, H] bf16 (= fc1.weight.T), b1 [P, H/P] fp32 (pack_b1),
49
+ w2t [H, K] bf16 (= fc2.weight.T), b2 [K] fp32. Returns [R, K] bf16."""
 
 
 
 
 
 
 
 
 
 
50
  R, K = x_hbm.shape
51
+ H = w1t_hbm.shape[1]
 
 
 
 
 
 
 
 
 
 
 
 
52
  rows_per = R // LNC
53
+ _kernel_assert(tuple(w1t_hbm.shape) == (K, H) and tuple(w2t_hbm.shape) == (H, K),
54
+ f"expected w1t [K, H] and w2t [H, K], got {w1t_hbm.shape}, {w2t_hbm.shape}")
55
+ _kernel_assert(tuple(b1_hbm.shape) == (P, H // P), f"b1 must be pack_b1(b1): [{P}, {H // P}]")
56
+ _kernel_assert(K % FMOV == 0 and H % P == 0 and R % (LNC * FMOV) == 0,
57
+ f"needs K % {FMOV} == 0, H % {P} == 0, R % {LNC * FMOV} == 0; got R={R} K={K} H={H}")
58
+ n_k, n_h, n_ko, n_rs, n_rb = K // P, H // P, K // FMOV, FMOV // P, rows_per // FMOV
59
+
60
+ r_base = nl.program_id(0) * rows_per
61
  out_hbm = nl.ndarray((R, K), dtype=_BF16, buffer=nl.shared_hbm)
62
 
63
+ # b1 on the Scalar engine's HW-DGE queue, so gelu's wait doesn't count W1/W2 traffic.
64
+ b1 = nl.ndarray((P, n_h), dtype=nl.float32, buffer=nl.sbuf)
65
+ nisa.dma_copy(dst=b1, src=b1_hbm, dge_mode=nisa.dge_mode.hwdge, engine=nisa.engine.scalar)
66
 
67
+ # x for rb0, transposed on the (idle) Tensor engine: xt[rb][:, kt, :] is x.T's K tile kt.
68
+ xt = []
69
+ for _ in range(n_rb):
70
+ xt.append(nl.ndarray((P, n_k, FMOV), dtype=_BF16, buffer=nl.sbuf))
71
+ x0 = _load_rows(x_hbm, r_base, n_rs, K)
 
 
 
 
 
 
 
72
  for kt in range(n_k):
73
+ tp = nl.ndarray((P, FMOV), dtype=_BF16, buffer=nl.psum)
74
+ for rs in range(n_rs):
75
+ nisa.nc_transpose(dst=tp[:, rs * P:(rs + 1) * P], data=x0[:, rs, kt * P:(kt + 1) * P],
76
+ engine=nisa.engine.tensor)
77
+ nisa.tensor_copy(dst=xt[0][:, kt, :], src=tp)
78
+
79
+ # W1 as [P, n_k, H]: w1[p, kt, h] = w1t[kt*P + p, h]. One DMA per K tile.
80
+ w1 = nl.ndarray((P, n_k, H), dtype=_BF16, buffer=nl.sbuf)
81
+ for kt in range(n_k):
82
+ nisa.dma_copy(dst=w1[:, kt, :], src=w1t_hbm[kt * P:(kt + 1) * P, :])
83
+
84
+ # Later row blocks' x via DMA transposes, streaming under rb0's matmuls.
85
+ for rb in range(1, n_rb):
86
+ xr = _load_rows(x_hbm, r_base + rb * FMOV, n_rs, K)
87
+ for rs in range(n_rs):
88
+ for kt in range(n_k):
89
+ nisa.dma_transpose(dst=xt[rb][:, kt, rs * P:(rs + 1) * P],
90
+ src=xr[:, rs, kt * P:(kt + 1) * P])
91
+
92
+ # W2 as [P, n_h, K]: w2[p, ht, k] = w2t[ht*P + p, k]. One DMA per H tile, so fc2 can
93
+ # start on the first strips (a single 8 MB DMA signals completion only at the end).
94
+ w2 = nl.ndarray((P, n_h, K), dtype=_BF16, buffer=nl.sbuf)
95
  for ht in range(n_h):
96
+ nisa.dma_copy(dst=w2[:, ht, :], src=w2t_hbm[ht * P:(ht + 1) * P, :])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  for rb in range(n_rb):
99
+ r_abs = r_base + rb * FMOV
 
 
 
100
 
101
+ # fc1 + gelu: g[:, ht, :] = gelu(w1[:, :, ht].T @ x.T + b1[:, ht]), kept in SBUF.
102
+ g = nl.ndarray((P, n_h, FMOV), dtype=_BF16, buffer=nl.sbuf)
 
 
 
103
  for ht in range(n_h):
104
+ # Declared per ht so the PSUM ring never accumulates onto a stale value.
105
+ y1 = nl.ndarray((P, FMOV), dtype=nl.float32, buffer=nl.psum)
 
 
 
 
106
  for kt in nl.affine_range(n_k):
107
+ nisa.nc_matmul(dst=y1, stationary=w1[:, kt, ht * P:(ht + 1) * P], moving=xt[rb][:, kt, :])
108
+ nisa.activation(dst=g[:, ht, :], op=nl.gelu, data=y1, bias=b1[:, ht:ht + 1])
109
+
110
+ # fc2 + b2: out[rows, ko chunk] = sum_ht g[:, ht, rows].T @ w2[:, ht, chunk] + b2.
 
 
 
 
 
 
 
 
 
 
111
  for ko in range(n_ko):
112
+ k0 = ko * FMOV
113
+ acc = []
 
114
  for _ in range(n_rs):
115
+ acc.append(nl.ndarray((P, FMOV), dtype=nl.float32, buffer=nl.psum))
 
 
116
  for ht in nl.affine_range(n_h):
 
 
117
  for rs in range(n_rs):
118
+ nisa.nc_matmul(dst=acc[rs], stationary=g[:, ht, rs * P:(rs + 1) * P],
119
+ moving=w2[:, ht, k0:k0 + FMOV])
120
+ # b2 chunk broadcast across partitions (stride 0), loaded per (rb, ko) as in v10.
121
+ b2 = nl.ndarray((P, FMOV), dtype=nl.float32, buffer=nl.sbuf)
122
+ nisa.dma_copy(dst=b2, src=b2_hbm.ap(pattern=[[0, P], [1, FMOV]], offset=k0))
123
+ o = nl.ndarray((P, n_rs, FMOV), dtype=_BF16, buffer=nl.sbuf)
 
 
 
 
 
 
124
  for rs in range(n_rs):
125
+ nisa.tensor_tensor(dst=o[:, rs, :], data1=acc[rs], data2=b2, op=nl.add)
126
+ nisa.dma_copy(dst=out_hbm[r_abs + rs * P:r_abs + (rs + 1) * P, k0:k0 + FMOV], src=o[:, rs, :])
 
 
 
 
 
 
 
 
 
127
 
128
  return out_hbm
129
 
130
 
131
+ def pack_b1(b1: torch.Tensor) -> torch.Tensor:
132
+ """Pack fc1.bias [H] fp32 into [P, H/P] so b1p[p, ht] == b1[ht*P + p]."""
133
+ return b1.reshape(-1, P).T.contiguous()
134
+
135
+
136
  def _no_grad(*tensors) -> None:
137
  """Refuse gradient-tracking inputs -- this op registers no autograd rule."""
138
  for t in tensors:
139
  if t is not None and t.requires_grad:
140
+ raise RuntimeError("nki_mlp_k4_xp registers no autograd rule (INFERENCE ONLY). "
141
+ "Use under torch.no_grad()/eval().")
 
 
142
 
143
 
144
  @nki_op("vjepa2::nki_mlp_k4_xp", mutates_args={})
145
+ def nki_mlp_k4_xp(x: torch.Tensor, w1t: torch.Tensor, b1: torch.Tensor,
146
+ w2t: torch.Tensor, b2: torch.Tensor) -> torch.Tensor:
147
+ """Fused MLP on LNC=2. x [R, K] bf16, w1t [K, H] bf16, b1 [H] or pack_b1(b1) [P, H/P] fp32,
148
+ w2t [H, K] bf16, b2 [K] fp32. Returns [R, K] bf16."""
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  _no_grad(x, w1t, b1, w2t, b2)
150
+ if b1.dim() == 1: # accept the pre-v1.2 [H] bias; NeuronVJEPA2Encoder passes it pre-packed
151
+ b1 = pack_b1(b1)
152
  return _mlp_k4_xp_kernel[LNC](x, w1t, b1, w2t, b2)
153
 
154
 
155
+ #: Export alias, so callers using ``mlp`` (e.g. layers.py) pick up this op unchanged.
 
156
  mlp = nki_mlp_k4_xp
build/torch-neuron/nki_kernels/res_layernorm.py CHANGED
@@ -19,9 +19,15 @@ WHY IT IS SHAPED THIS WAY
19
  -------------------------
20
  * LNC = 2 shard: the kernel is launched on both NeuronCores; each owns ``R // 2`` rows
21
  selected by ``program_id(0)``, so the two cores never touch the same tokens.
22
- * Ring of depth 4 over the token tiles: four rotating sets of SBUF tiles let the DMA
23
- loads of tile ``t+1`` overlap the compute of tile ``t`` without a write-after-read
24
- hazard on the buffers.
 
 
 
 
 
 
25
  * fp32 residual / bf16 branch: ``a`` (the residual stream) stays fp32 so ``x_new`` is
26
  bitwise-invariant math and can be carried forward losslessly; the branch inputs and
27
  the normalized output ``z`` are bf16, which is the precision boundary the downstream
@@ -54,9 +60,6 @@ _BF16 = nl.bfloat16
54
  #: Logical-to-physical NeuronCore degree. Rows are split evenly across LNC cores.
55
  LNC = 2
56
 
57
- #: Ring depth: number of rotating SBUF tile sets that overlap load with compute.
58
- _RING = 4
59
-
60
 
61
  def _kernel_assert(condition: bool, error_text: str):
62
  """Assert with an NKI-formatted error message."""
@@ -68,8 +71,8 @@ def _res_layernorm_fold_kernel(a_hbm, b_hbm, eps):
68
  """Emit the fold kernel body: ``(a + sum(b), LN_no_affine(a + sum(b)))``, token-major.
69
 
70
  gamma/beta live in the consumers' packed weights, so this kernel takes no affine
71
- parameters. Rows are sharded across ``LNC`` cores; each token tile is loaded through
72
- a depth-4 ring, summed in fp32, normalized with the thinned stat chain, and the
73
  normalized result is cast to bf16 on write.
74
 
75
  Args:
@@ -97,8 +100,7 @@ def _res_layernorm_fold_kernel(a_hbm, b_hbm, eps):
97
 
98
  rows_per = R // LNC
99
  n_t = rows_per // _P_MAX
100
- pid = nl.program_id(0)
101
- r_base = pid * rows_per
102
 
103
  x_new_hbm = nl.ndarray((R, H), dtype=nl.float32, buffer=nl.shared_hbm)
104
  z_hbm = nl.ndarray((R, H), dtype=_BF16, buffer=nl.shared_hbm)
@@ -106,69 +108,62 @@ def _res_layernorm_fold_kernel(a_hbm, b_hbm, eps):
106
  inv_h = 1.0 / float(H)
107
  neg_inv_h = -inv_h
108
 
109
- # Ring buffers: _RING rotating sets of (a tile, B branch tiles, sum tile, z tile).
110
- a_sb = []
111
- b_sb = []
112
- s_sb = []
113
- z_sb = []
114
- for _i in range(_RING):
115
- a_sb.append(nl.ndarray((_P_MAX, H), dtype=nl.float32, buffer=nl.sbuf))
116
- _slot = []
117
- for _j in range(n_b):
118
- _slot.append(nl.ndarray((_P_MAX, H), dtype=_BF16, buffer=nl.sbuf))
119
- b_sb.append(_slot)
120
- s_sb.append(nl.ndarray((_P_MAX, H), dtype=nl.float32, buffer=nl.sbuf))
121
- z_sb.append(nl.ndarray((_P_MAX, H), dtype=_BF16, buffer=nl.sbuf))
122
 
123
  # Scratch destination for the reduce activations (its full tile is discarded; only
124
  # the reduce result is consumed).
125
  sink = nl.ndarray((_P_MAX, H), dtype=nl.float32, buffer=nl.sbuf)
126
 
127
- # Per-lane [128, 1] statistics.
128
- acc = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
129
- neg_shift = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
130
- neg_mu = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
131
- var = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
132
- rstd = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
 
133
 
134
  for t in range(n_t):
135
- k = t % _RING
136
  r0 = r_base + t * _P_MAX
137
-
138
- # ---- load residual + branches for this tile ----
139
- nisa.dma_copy(dst=a_sb[k], src=a_hbm[r0:r0 + _P_MAX, 0:H])
140
- for j in range(n_b):
141
- nisa.dma_copy(dst=b_sb[k][j], src=b_hbm[j, r0:r0 + _P_MAX, 0:H])
142
 
143
  # ---- x_new = a + sum_j b[j] in fp32, then write the residual stream out ----
144
- nisa.tensor_tensor(dst=s_sb[k], data1=a_sb[k], data2=b_sb[k][0], op=nl.add)
145
  for j in range(1, n_b):
146
- nisa.tensor_tensor(dst=s_sb[k], data1=s_sb[k], data2=b_sb[k][j], op=nl.add)
147
 
148
- nisa.dma_copy(dst=x_new_hbm[r0:r0 + _P_MAX, 0:H], src=s_sb[k])
149
 
150
  # ---- thinned stat chain: mean via shift-and-reduce, variance, rstd ----
151
- nisa.tensor_scalar(dst=neg_shift, data=s_sb[k][0:_P_MAX, 0:1],
152
  op0=nl.multiply, operand0=-1.0)
153
- nisa.activation_reduce(dst=sink, op=nl.copy, data=s_sb[k],
154
- reduce_op=nl.add, reduce_res=acc,
155
- bias=neg_shift, scale=1.0)
156
- nisa.tensor_scalar(dst=neg_mu, data=acc, op0=nl.multiply, operand0=neg_inv_h,
157
- op1=nl.add, operand1=neg_shift)
158
- nisa.activation_reduce(dst=sink, op=nl.square, data=s_sb[k],
159
- reduce_op=nl.add, reduce_res=acc, bias=neg_mu, scale=1.0)
160
- nisa.tensor_scalar(dst=var, data=acc, op0=nl.multiply, operand0=inv_h,
161
  op1=nl.add, operand1=eps)
162
- nisa.activation(dst=rstd, op=nl.rsqrt, data=var)
163
 
164
  # ---- one-op affine-free apply: z = (x_new + neg_mu) * rstd, bf16 cast on write.
165
- # Both operands are per-lane [128, 1] vectors; the fused two-op tensor_scalar
166
- # costs the same as one op.
167
- nisa.tensor_scalar(dst=z_sb[k], data=s_sb[k],
168
- op0=nl.add, operand0=neg_mu,
169
- op1=nl.multiply, operand1=rstd)
170
 
171
- nisa.dma_copy(dst=z_hbm[r0:r0 + _P_MAX, 0:H], src=z_sb[k])
172
 
173
  return x_new_hbm, z_hbm
174
 
 
19
  -------------------------
20
  * LNC = 2 shard: the kernel is launched on both NeuronCores; each owns ``R // 2`` rows
21
  selected by ``program_id(0)``, so the two cores never touch the same tokens.
22
+ * Every token tile has its own SBUF slot ([P, n_t, H]; ~116 KB/partition at H=1024, B=2)
23
+ and all loads are issued before any compute, so tile ``t+1``'s loads never wait behind
24
+ tile ``t``'s stores. One DMA per tile and operand: a single whole-shard DMA would signal
25
+ completion only at the end.
26
+ * ``dge_mode.none`` on every DMA: the runtime generates the descriptors before execution,
27
+ so no engine spends time issuing them. The default (software DGE) issued each of the ~40
28
+ DMAs on GpSimd at ~0.75 us apiece, one after another. Hardware DGE on the Sync queue was
29
+ as fast standalone, but inside the encoder it delayed K2/K4's loads on that queue by
30
+ about as much as it saved.
31
  * fp32 residual / bf16 branch: ``a`` (the residual stream) stays fp32 so ``x_new`` is
32
  bitwise-invariant math and can be carried forward losslessly; the branch inputs and
33
  the normalized output ``z`` are bf16, which is the precision boundary the downstream
 
60
  #: Logical-to-physical NeuronCore degree. Rows are split evenly across LNC cores.
61
  LNC = 2
62
 
 
 
 
63
 
64
  def _kernel_assert(condition: bool, error_text: str):
65
  """Assert with an NKI-formatted error message."""
 
71
  """Emit the fold kernel body: ``(a + sum(b), LN_no_affine(a + sum(b)))``, token-major.
72
 
73
  gamma/beta live in the consumers' packed weights, so this kernel takes no affine
74
+ parameters. Rows are sharded across ``LNC`` cores; every token tile is loaded up
75
+ front into its own SBUF slot, summed in fp32, normalized with the thinned stat chain, and the
76
  normalized result is cast to bf16 on write.
77
 
78
  Args:
 
100
 
101
  rows_per = R // LNC
102
  n_t = rows_per // _P_MAX
103
+ r_base = nl.program_id(0) * rows_per
 
104
 
105
  x_new_hbm = nl.ndarray((R, H), dtype=nl.float32, buffer=nl.shared_hbm)
106
  z_hbm = nl.ndarray((R, H), dtype=_BF16, buffer=nl.shared_hbm)
 
108
  inv_h = 1.0 / float(H)
109
  neg_inv_h = -inv_h
110
 
111
+ # One SBUF slot per tile; all loads issued before any compute or store (see module doc).
112
+ a_sb = nl.ndarray((_P_MAX, n_t, H), dtype=nl.float32, buffer=nl.sbuf)
113
+ b_sb = nl.ndarray((_P_MAX, n_b, n_t, H), dtype=_BF16, buffer=nl.sbuf)
114
+ for t in range(n_t):
115
+ r0 = r_base + t * _P_MAX
116
+ nisa.dma_copy(dst=a_sb[:, t, :], src=a_hbm[r0:r0 + _P_MAX, 0:H], dge_mode=nisa.dge_mode.none)
117
+ for j in range(n_b):
118
+ nisa.dma_copy(dst=b_sb[:, j, t, :], src=b_hbm[j, r0:r0 + _P_MAX, 0:H], dge_mode=nisa.dge_mode.none)
119
+
120
+ s_sb = nl.ndarray((_P_MAX, n_t, H), dtype=nl.float32, buffer=nl.sbuf)
121
+ z_sb = nl.ndarray((_P_MAX, n_t, H), dtype=_BF16, buffer=nl.sbuf)
 
 
122
 
123
  # Scratch destination for the reduce activations (its full tile is discarded; only
124
  # the reduce result is consumed).
125
  sink = nl.ndarray((_P_MAX, H), dtype=nl.float32, buffer=nl.sbuf)
126
 
127
+ # Per-lane statistics, one column per tile so tile t+1's chain doesn't wait on tile t's.
128
+ acc = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
129
+ acc2 = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
130
+ neg_shift = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
131
+ neg_mu = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
132
+ var = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
133
+ rstd = nl.ndarray((_P_MAX, n_t), dtype=nl.float32, buffer=nl.sbuf)
134
 
135
  for t in range(n_t):
 
136
  r0 = r_base + t * _P_MAX
137
+ s = s_sb[:, t, :]
138
+ c = slice(t, t + 1)
 
 
 
139
 
140
  # ---- x_new = a + sum_j b[j] in fp32, then write the residual stream out ----
141
+ nisa.tensor_tensor(dst=s, data1=a_sb[:, t, :], data2=b_sb[:, 0, t, :], op=nl.add)
142
  for j in range(1, n_b):
143
+ nisa.tensor_tensor(dst=s, data1=s, data2=b_sb[:, j, t, :], op=nl.add)
144
 
145
+ nisa.dma_copy(dst=x_new_hbm[r0:r0 + _P_MAX, 0:H], src=s, dge_mode=nisa.dge_mode.none)
146
 
147
  # ---- thinned stat chain: mean via shift-and-reduce, variance, rstd ----
148
+ nisa.tensor_scalar(dst=neg_shift[:, c], data=s_sb[:, t, 0:1],
149
  op0=nl.multiply, operand0=-1.0)
150
+ nisa.activation_reduce(dst=sink, op=nl.copy, data=s,
151
+ reduce_op=nl.add, reduce_res=acc[:, c],
152
+ bias=neg_shift[:, c], scale=1.0)
153
+ nisa.tensor_scalar(dst=neg_mu[:, c], data=acc[:, c], op0=nl.multiply, operand0=neg_inv_h,
154
+ op1=nl.add, operand1=neg_shift[:, c])
155
+ nisa.activation_reduce(dst=sink, op=nl.square, data=s,
156
+ reduce_op=nl.add, reduce_res=acc2[:, c], bias=neg_mu[:, c], scale=1.0)
157
+ nisa.tensor_scalar(dst=var[:, c], data=acc2[:, c], op0=nl.multiply, operand0=inv_h,
158
  op1=nl.add, operand1=eps)
159
+ nisa.activation(dst=rstd[:, c], op=nl.rsqrt, data=var[:, c])
160
 
161
  # ---- one-op affine-free apply: z = (x_new + neg_mu) * rstd, bf16 cast on write.
162
+ nisa.tensor_scalar(dst=z_sb[:, t, :], data=s,
163
+ op0=nl.add, operand0=neg_mu[:, c],
164
+ op1=nl.multiply, operand1=rstd[:, c])
 
 
165
 
166
+ nisa.dma_copy(dst=z_hbm[r0:r0 + _P_MAX, 0:H], src=z_sb[:, t, :], dge_mode=nisa.dge_mode.none)
167
 
168
  return x_new_hbm, z_hbm
169