jburtoft commited on
Commit
3b1bffa
·
1 Parent(s): ce0e45f

v1.4: 5-bank QK^T PSUM rotation (<=1024 tokens) + experimental opt-in FP8 MLP

Browse files

QK^T rotates over five PSUM banks across Q groups when a group has <=2 key tiles, so it no longer waits on
the Vector row-max evict: attention device cycles -1.1% to -4.7% vs v1.3; 2048-token layout unchanged.
FP8 e4m3 double_row GELU MLP behind VIT_ENC_MLP_FP8=1 (off by default) with weight and per-layer activation
scaling. Verified on trn2.3xlarge: all 24 DINOv3 cases and V-JEPA2 pass.

CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v1.3 (2026-10)
4
 
5
  Attention-path schedule improvements, plus a SwiGLU fallback fix. Output of every attention kernel is
 
1
  # Changelog
2
 
3
+ ## v1.4 (2026-10)
4
+
5
+ * **QK^T PSUM banks:** for 1024 or fewer tokens per image (two key tiles or fewer), the QK^T matmul now rotates over
6
+ five PSUM banks across Q groups instead of reusing four, so it no longer waits for the Vector row-max evict of the
7
+ tile four back. Attention kernel device cycles vs v1.3: -2.1% (N=256 and N=1024, LNC=2), -1.1% (N=1024, LNC=1),
8
+ -4.7% (all-group kernel, N=256, LNC=1). At 2048 tokens the old layout is kept (the rotation collided with other banks
9
+ and measured +7%). `VIT_ENC_MM1_WIDE=0` restores the v1.3 layout.
10
+ End to end (single core, real HF weights) vs v1.3: DINOv3 ViT-L/16 -0.5% to +3.7%, ViT-H+/16 0.0% to +2.6%.
11
+ V-JEPA2 (2048 tokens): 23.80 ms/clip vs 23.81, output identical to v1.3. All 24 DINOv3 cases pass (rel_l2 vs fp32
12
+ 0.56-1.75e-2).
13
+ * **EXPERIMENTAL FP8 MLP** (`VIT_ENC_MLP_FP8=1`, GELU-MLP models only, off by default): fc1 and fc2 run as e4m3
14
+ `double_row` matmuls, with power-of-two weight scaling and a per-layer GELU-output scale (`calibrate_fp8`). Measured
15
+ on DINOv3 ViT-L/16, single core: +7.3% (224 px, LNC=2) to +10.5% (448 px, LNC=1) end to end. On imagenette
16
+ (real weights), k-NN and linear-probe predictions were identical to fp32. But the CLS feature error rises to
17
+ rel_l2 5.8e-2 (bf16 path: 0.84e-2). **Calibrate before use**: trn2's e4m3 saturates at 240, and DINOv3's first
18
+ block produces GELU outputs near 2,000. Without calibration the default scale (1/32) is used, which is safe for
19
+ DINOv3 but coarse.
20
+ * Outputs are deterministic per compiled graph. Across separate compilations they can differ at bf16-ulp level
21
+ (rel ~1e-5), as in v1.3.
22
+
23
  ## v1.3 (2026-10)
24
 
25
  Attention-path schedule improvements, plus a SwiGLU fallback fix. Output of every attention kernel is
README.md CHANGED
@@ -128,6 +128,11 @@ Notes:
128
  * On a vLLM-Neuron venv (CUDA-tagged torch), `import libtorch_neuronx_lite` before `get_kernel`
129
  so the `torch-neuron` variant is selected. On PyTorch Native this is not needed.
130
 
 
 
 
 
 
131
  Which LNC to use: **LNC=1** (8 logical cores, one process per core) gives the most throughput per
132
  instance. **LNC=2** (4 larger cores) gives the lowest latency per request.
133
 
 
128
  * On a vLLM-Neuron venv (CUDA-tagged torch), `import libtorch_neuronx_lite` before `get_kernel`
129
  so the `torch-neuron` variant is selected. On PyTorch Native this is not needed.
130
 
131
+ **Experimental FP8 MLP** (v1.4, GELU-MLP models only): set `VIT_ENC_MLP_FP8=1` before importing to run the MLP
132
+ matmuls in FP8 e4m3 (`double_row`). That is +7-10% end to end on DINOv3 ViT-L/16, at ~7x the feature error of the
133
+ default bf16 path (CLS rel_l2 5.8e-2 vs 0.84e-2; imagenette k-NN predictions unchanged). Calibrate the per-layer
134
+ activation scale with `calibrate_fp8(runner, gelu_amax)`, because trn2's e4m3 saturates at 240. Off by default.
135
+
136
  Which LNC to use: **LNC=1** (8 logical cores, one process per core) gives the most throughput per
137
  instance. **LNC=2** (4 larger cores) gives the lowest latency per request.
138
 
build/torch-neuron/__init__.py CHANGED
@@ -10,7 +10,7 @@ Model-agnostic building blocks:
10
  mlp, mlp_res_ln, swiglu_stream, swiglu_stream_ln
11
  """
12
 
13
- from .encoder import LNC, BlockWeights, EncoderRunner, pack_block, rope_tables
14
  from .layers import NeuronDINOv3ViTModel, NeuronVJEPA2Encoder
15
  from .nki_kernels import (
16
  attention_block,
@@ -30,7 +30,7 @@ from .nki_kernels import (
30
 
31
  __all__ = [
32
  "NeuronDINOv3ViTModel", "NeuronVJEPA2Encoder",
33
- "BlockWeights", "pack_block", "rope_tables", "EncoderRunner", "LNC",
34
  "attention_block", "attention_block_l1", "attention_block_l1_ln", "res_layernorm_fold",
35
  "mlp", "mlp_res_ln", "swiglu_stream", "swiglu_stream_ln",
36
  "pack_weights", "pack_tables", "pack_v_weights", "pack_out_weights", "lane_permutation",
 
10
  mlp, mlp_res_ln, swiglu_stream, swiglu_stream_ln
11
  """
12
 
13
+ from .encoder import LNC, BlockWeights, EncoderRunner, calibrate_fp8, pack_block, rope_tables
14
  from .layers import NeuronDINOv3ViTModel, NeuronVJEPA2Encoder
15
  from .nki_kernels import (
16
  attention_block,
 
30
 
31
  __all__ = [
32
  "NeuronDINOv3ViTModel", "NeuronVJEPA2Encoder",
33
+ "BlockWeights", "pack_block", "rope_tables", "EncoderRunner", "LNC", "calibrate_fp8",
34
  "attention_block", "attention_block_l1", "attention_block_l1_ln", "res_layernorm_fold",
35
  "mlp", "mlp_res_ln", "swiglu_stream", "swiglu_stream_ln",
36
  "pack_weights", "pack_tables", "pack_v_weights", "pack_out_weights", "lane_permutation",
build/torch-neuron/encoder.py CHANGED
@@ -36,10 +36,14 @@ from .nki_kernels.attention_block import LNC
36
  from .nki_kernels.mlp import nki_mlp_k4_xp
37
  from .nki_kernels.mlp import pack_b1 as _mlp_pack_b1
38
  from .nki_kernels.mlp_res_ln import nki_mlp_res_ln
 
 
 
 
39
  from .nki_kernels.res_layernorm import nki_res_layernorm_fold
40
  from .nki_kernels.swiglu_stream import nki_swiglu_stream, nki_swiglu_stream_ln
41
 
42
- __all__ = ["BlockWeights", "pack_block", "rope_tables", "EncoderRunner", "LNC"]
43
 
44
  _SBUF_BUDGET = 180 * 1024 # per-partition bytes the LNC=1 resident-weights decision allows
45
  _SW_MIN_ROWS = 256 # below this many rows per core, SwiGLU runs in torch (measured faster)
@@ -138,6 +142,10 @@ def pack_block(bw: BlockWeights, num_heads: int, rope: str, device) -> dict:
138
  # Pre-pack fc1 bias to [128, H_mlp/128] so K4/K4+LN load it in one DMA (see mlp.pack_b1).
139
  p["b1"] = _mlp_pack_b1((_z(_f(bw.fc1_b), w1.shape[0]) + w1 @ t2).contiguous())
140
  p["w2t"] = (w2 * ls2[:, None]).t().contiguous().to(torch.bfloat16)
 
 
 
 
141
  p["b2"] = (_z(_f(bw.fc2_b), H) * ls2).contiguous()
142
  return {k: (v.to(device) if torch.is_tensor(v) else v) for k, v in p.items()}
143
 
@@ -160,6 +168,15 @@ def rope_tables(cos: torch.Tensor, sin: torch.Tensor, n_prefix: int, n_pad: int,
160
  return _ab.pack_tables(C, S, nh_p, D, rope).to(device)
161
 
162
 
 
 
 
 
 
 
 
 
 
163
  class EncoderRunner:
164
  """Runs packed blocks on a stacked ``[B*N, H]`` fp32 residual stream (rows of image b at b*N)."""
165
 
@@ -203,6 +220,8 @@ class EncoderRunner:
203
  g = torch.nn.functional.silu((z2 @ p["s_w1t"]).float() + p["s_b1u"])
204
  u = (z2 @ p["s_wgt"]).float() + p["s_bgu"]
205
  return (((g * u).to(torch.bfloat16) @ p["s_w2t"]).float() + p["s_b2"]).to(torch.bfloat16).unsqueeze(0)
 
 
206
  return nki_mlp_k4_xp(z2, p["w1t"], p["b1"], p["w2t"], p["b2"]).unsqueeze(0)
207
 
208
  def _ffn_fused(self, z2, p, x, eps_next):
@@ -211,6 +230,9 @@ class EncoderRunner:
211
  return None
212
  return nki_swiglu_stream_ln(z2, p["s_w1t"], p["s_b1"], p["s_wgt"], p["s_bg"],
213
  p["s_w2t"], p["s_b2"], x, eps_next)
 
 
 
214
  return nki_mlp_res_ln(z2, p["w1t"], p["b1"], p["w2t"], p["b2"], x, eps_next)
215
 
216
  def __call__(self, x: torch.Tensor, tab: torch.Tensor, n_valid: int, B: int) -> torch.Tensor:
 
36
  from .nki_kernels.mlp import nki_mlp_k4_xp
37
  from .nki_kernels.mlp import pack_b1 as _mlp_pack_b1
38
  from .nki_kernels.mlp_res_ln import nki_mlp_res_ln
39
+ import os as _osx
40
+ _MLP_FP8 = _osx.environ.get("VIT_ENC_MLP_FP8", "0") == "1" # EXPERIMENTAL: FP8 e4m3 GELU MLP (see README)
41
+ if _MLP_FP8:
42
+ _mf8 = _il.import_module(__package__ + ".nki_kernels.mlp_fp8")
43
  from .nki_kernels.res_layernorm import nki_res_layernorm_fold
44
  from .nki_kernels.swiglu_stream import nki_swiglu_stream, nki_swiglu_stream_ln
45
 
46
+ __all__ = ["BlockWeights", "pack_block", "rope_tables", "EncoderRunner", "LNC", "calibrate_fp8"]
47
 
48
  _SBUF_BUDGET = 180 * 1024 # per-partition bytes the LNC=1 resident-weights decision allows
49
  _SW_MIN_ROWS = 256 # below this many rows per core, SwiGLU runs in torch (measured faster)
 
142
  # Pre-pack fc1 bias to [128, H_mlp/128] so K4/K4+LN load it in one DMA (see mlp.pack_b1).
143
  p["b1"] = _mlp_pack_b1((_z(_f(bw.fc1_b), w1.shape[0]) + w1 @ t2).contiguous())
144
  p["w2t"] = (w2 * ls2[:, None]).t().contiguous().to(torch.bfloat16)
145
+ if _MLP_FP8:
146
+ p["w1p8"], p["sw1"] = _mf8.pack_fp8_weight((w1 * g2[None]).t().contiguous())
147
+ p["w2p8"], p["sw2"] = _mf8.pack_fp8_weight((w2 * ls2[:, None]).t().contiguous())
148
+ p["g8"] = 1.0 / 32 # GELU-output scale; per-layer calibration recommended (calibrate_fp8)
149
  p["b2"] = (_z(_f(bw.fc2_b), H) * ls2).contiguous()
150
  return {k: (v.to(device) if torch.is_tensor(v) else v) for k, v in p.items()}
151
 
 
168
  return _ab.pack_tables(C, S, nh_p, D, rope).to(device)
169
 
170
 
171
+ def calibrate_fp8(runner, gelu_amax, headroom: float = 2.0):
172
+ """EXPERIMENTAL (VIT_ENC_MLP_FP8=1). Set each layer's power-of-two GELU-output scale from the observed max |GELU
173
+ output| per layer (list, one per block): scale = 2^floor(log2(128 / (amax * headroom))). trn2's e4m3 saturates at
174
+ 240; uncalibrated activations above that turn into inf/NaN."""
175
+ import math
176
+ for p, a in zip(runner.L, gelu_amax):
177
+ p["g8"] = 2.0 ** math.floor(math.log2(128.0 / max(a * headroom, 1e-6)))
178
+
179
+
180
  class EncoderRunner:
181
  """Runs packed blocks on a stacked ``[B*N, H]`` fp32 residual stream (rows of image b at b*N)."""
182
 
 
220
  g = torch.nn.functional.silu((z2 @ p["s_w1t"]).float() + p["s_b1u"])
221
  u = (z2 @ p["s_wgt"]).float() + p["s_bgu"]
222
  return (((g * u).to(torch.bfloat16) @ p["s_w2t"]).float() + p["s_b2"]).to(torch.bfloat16).unsqueeze(0)
223
+ if _MLP_FP8:
224
+ return _mf8.nki_mlp_fp8(z2, p["w1p8"], p["b1"], p["w2p8"], p["b2"], p["sw1"], p["sw2"], p["g8"]).unsqueeze(0)
225
  return nki_mlp_k4_xp(z2, p["w1t"], p["b1"], p["w2t"], p["b2"]).unsqueeze(0)
226
 
227
  def _ffn_fused(self, z2, p, x, eps_next):
 
230
  return None
231
  return nki_swiglu_stream_ln(z2, p["s_w1t"], p["s_b1"], p["s_wgt"], p["s_bg"],
232
  p["s_w2t"], p["s_b2"], x, eps_next)
233
+ if _MLP_FP8:
234
+ return _mf8.nki_mlp_fp8_res_ln(z2, p["w1p8"], p["b1"], p["w2p8"], p["b2"], x, eps_next,
235
+ p["sw1"], p["sw2"], p["g8"])
236
  return nki_mlp_res_ln(z2, p["w1t"], p["b1"], p["w2t"], p["b2"], x, eps_next)
237
 
238
  def __call__(self, x: torch.Tensor, tab: torch.Tensor, n_valid: int, B: int) -> torch.Tensor:
build/torch-neuron/nki_kernels/attention_inner.py CHANGED
@@ -109,6 +109,11 @@ _MAX_GLOBAL_CP_DEGREE = 32
109
  # Tile size / threshold constants used by the LIVE path.
110
  _Q_GRP_SZ = 128
111
  _V_TILE_SZ = 128 # V tile size for loading and MM2 operations
 
 
 
 
 
112
  _K_TILE_SZ = 512 # K tile size for loading and MM1+masking operations
113
  _EXP_TILE_SZ = 512 # Tile size for exp instructions (must equal _K_TILE_SZ)
114
  _LARGE_TILE_SZ = 2048 # Larger tile size for allocations/pipelining (4 x 512 tiles)
@@ -694,7 +699,14 @@ def _allocate_attention_buffers(
694
  for k_tile_idx in range(4):
695
  mm1_psum_tile = nl.ndarray(
696
  (atp.sb_p, nl.tile_size.psum_fmax), dtype=ac.mm_out_dtype, buffer=nl.psum,
697
- address=(0, (k_tile_idx % 4) * PSUM_BANK_SIZE),
 
 
 
 
 
 
 
698
  )
699
  tile_row.append(mm1_psum_tile)
700
  grp_row.append(tile_row)
 
109
  # Tile size / threshold constants used by the LIVE path.
110
  _Q_GRP_SZ = 128
111
  _V_TILE_SZ = 128 # V tile size for loading and MM2 operations
112
+ import os as _os
113
+ _MM1_WIDE = _os.environ.get("VIT_ENC_MM1_WIDE", "1") == "1" # set 0 to restore the v1.3 4-bank MM1 layout
114
+ _MM1_BANKS = (0, 1, 2, 5, 6) # MM1 rotation; bank 4 = P.V (single large tile), 3/7 = reciprocal transposes
115
+ _MM1_WIDE_MAX_KTILES = 2 # only widen when <= this many key tiles per group: at 4 key tiles (N=2048) the
116
+ # 5-bank rotation collides with P.V/producer banks and measured +7% slower
117
  _K_TILE_SZ = 512 # K tile size for loading and MM1+masking operations
118
  _EXP_TILE_SZ = 512 # Tile size for exp instructions (must equal _K_TILE_SZ)
119
  _LARGE_TILE_SZ = 2048 # Larger tile size for allocations/pipelining (4 x 512 tiles)
 
699
  for k_tile_idx in range(4):
700
  mm1_psum_tile = nl.ndarray(
701
  (atp.sb_p, nl.tile_size.psum_fmax), dtype=ac.mm_out_dtype, buffer=nl.psum,
702
+ # v1.4: rotate MM1 over 5 banks (0,1,2,5,6) across consecutive Q groups instead
703
+ # of reusing banks 0-3 per group, so a QK matmul does not wait for the Vector row-max evict
704
+ # of the tile 4 back. Bank 4 = P.V, 3/7 = reciprocal transposes. Banks 5/6 are also the
705
+ # producer's projection banks; the tile framework orders reuse by address, so overlap
706
+ # with a later image's producer stays correct (it may serialise).
707
+ address=(0, (_MM1_BANKS[(grp_idx * 4 + k_tile_idx) % len(_MM1_BANKS)]
708
+ if (_MM1_WIDE and atp.num_k_tiles_per_section <= _MM1_WIDE_MAX_KTILES)
709
+ else (k_tile_idx % 4)) * PSUM_BANK_SIZE),
710
  )
711
  tile_row.append(mm1_psum_tile)
712
  grp_row.append(tile_row)
build/torch-neuron/nki_kernels/mlp_fp8.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # FP8 (e4m3, double_row) variant of the GELU MLP. EXPERIMENTAL, opt-in. See NOTICE.
3
+ """fc1 + GELU + fc2 with FP8 e4m3 ``double_row`` matmuls. EXPERIMENTAL, opt-in (``VIT_ENC_MLP_FP8=1``).
4
+
5
+ Same math as ``mlp.nki_mlp_k4_xp``: ``gelu(x @ w1.T + b1) @ w2.T + b2``, but both matmuls run in
6
+ FP8 ``double_row`` mode (two 128-row halves of a 256-deep contraction per instruction):
7
+
8
+ * weights are host-packed bf16 ``[K/256, 128, 2, F]`` (:func:`pack_fp8_weight`) and cast to e4m3
9
+ in SBUF once per kernel;
10
+ * x^T is staged as bf16 ``[128, rows]`` per 128-row K tile (as in ``mlp``), then paired into
11
+ ``[128, 2, rows]`` e4m3 tiles (rows ``2j`` and ``2j+1`` of the K-tile list);
12
+ * the GELU output ``g^T`` tiles are paired the same way for fc2;
13
+ * PSUM accumulates in fp32; bias, GELU and the output cast are unchanged.
14
+
15
+ e4m3 on trn2 is the legacy format (max 240); values are cast with the hardware's saturating
16
+ convert. Requires K % 256 == 0 and H % 256 == 0. INFERENCE ONLY.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import torch
21
+ from torch_neuronx import nki_op
22
+
23
+ import nki
24
+ import nki.isa as nisa
25
+ import nki.language as nl
26
+
27
+ import os as _os
28
+ LNC = int(_os.environ.get("NEURON_LOGICAL_NC_CONFIG", "2"))
29
+
30
+ _P_MAX = 128
31
+ _F_MOV_MAX = 512
32
+ _BF16 = nl.bfloat16
33
+ _E4 = nl.float8_e4m3
34
+
35
+
36
+ def _div_ceil(n, d):
37
+ return (n + d - 1) // d
38
+
39
+
40
+ def _kernel_assert(cond, msg):
41
+ assert cond, f"[INTERNAL_ERROR] [NCC_INKI016] Kernel validation exception: {msg}"
42
+
43
+
44
+ def _mlp_fp8_body(x_hbm, w1p_hbm, b1_hbm, w2p_hbm, b2_hbm, inv_sw1, g_scale, out_scale, a_hbm, eps, fuse):
45
+ """x [R,K] bf16; w1p [K/256,128,2,H] bf16 (= pack(fc1.weight.T)); b1 [128,H/128] f32;
46
+ w2p [H/256,128,2,K] bf16 (= pack(fc2.weight.T)); b2 [K] f32 -> [R,K] bf16."""
47
+ R, K = x_hbm.shape
48
+ nk2, P, TWO, H = w1p_hbm.shape
49
+ nh2 = w2p_hbm.shape[0]
50
+ _kernel_assert(P == _P_MAX and TWO == 2 and nk2 * 256 == K, "w1p must be [K/256,128,2,H]")
51
+ _kernel_assert(nh2 * 256 == H and w2p_hbm.shape[3] == K, "w2p must be [H/256,128,2,K]")
52
+ _kernel_assert(R % LNC == 0, "R must be divisible by LNC")
53
+ rows_per = R // LNC
54
+ pid = nl.program_id(0)
55
+ r_base = pid * rows_per
56
+ if fuse:
57
+ xnew_hbm = nl.ndarray((R, K), dtype=nl.float32, buffer=nl.shared_hbm)
58
+ z_hbm = nl.ndarray((R, K), dtype=_BF16, buffer=nl.shared_hbm)
59
+ b2_full = nl.ndarray((_P_MAX, K), dtype=nl.float32, buffer=nl.sbuf)
60
+ nisa.dma_copy(dst=b2_full, src=b2_hbm.ap(pattern=[[0, _P_MAX], [1, K]], offset=0))
61
+ acc_s = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
62
+ neg_shift = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
63
+ neg_mu = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
64
+ var = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
65
+ rstd = nl.ndarray((_P_MAX, 1), dtype=nl.float32, buffer=nl.sbuf)
66
+ inv_k = 1.0 / float(K)
67
+ neg_inv_k = -inv_k
68
+ else:
69
+ out_hbm = nl.ndarray((R, K), dtype=_BF16, buffer=nl.shared_hbm)
70
+ n_h = H // _P_MAX
71
+ n_ko = _div_ceil(K, _F_MOV_MAX)
72
+
73
+ b1_sb = nl.ndarray((_P_MAX, n_h), dtype=nl.float32, buffer=nl.sbuf)
74
+ nisa.dma_copy(dst=b1_sb, src=b1_hbm, dge_mode=nisa.dge_mode.hwdge, engine=nisa.engine.scalar)
75
+
76
+ # weights: one DMA per 256-deep chunk (host-packed), cast to e4m3 once
77
+ w1_8 = []
78
+ for c in range(nk2):
79
+ wb = nl.ndarray((_P_MAX, 2, H), dtype=_BF16, buffer=nl.sbuf)
80
+ nisa.dma_copy(dst=wb, src=w1p_hbm[c])
81
+ w8 = nl.ndarray((_P_MAX, 2, H), dtype=_E4, buffer=nl.sbuf)
82
+ nisa.tensor_copy(dst=w8, src=wb)
83
+ w1_8.append(w8)
84
+ w2_8 = []
85
+ for c in range(nh2):
86
+ wb = nl.ndarray((_P_MAX, 2, K), dtype=_BF16, buffer=nl.sbuf)
87
+ nisa.dma_copy(dst=wb, src=w2p_hbm[c])
88
+ w8 = nl.ndarray((_P_MAX, 2, K), dtype=_E4, buffer=nl.sbuf)
89
+ nisa.tensor_copy(dst=w8, src=wb)
90
+ w2_8.append(w8)
91
+
92
+ rb_size = _F_MOV_MAX
93
+ n_rb = _div_ceil(rows_per, rb_size)
94
+ for rb in range(n_rb):
95
+ r_local = rb * rb_size
96
+ pr = min(rb_size, rows_per - r_local)
97
+ n_rs = _div_ceil(pr, _P_MAX)
98
+ r_abs = r_base + r_local
99
+ # x^T paired into [128, 2, pr] e4m3: pair c holds K rows [c*256, c*256+128) and [c*256+128, c*256+256)
100
+ xt8 = []
101
+ for c in range(nk2):
102
+ xb = nl.ndarray((_P_MAX, 2, rb_size), dtype=_BF16, buffer=nl.sbuf)
103
+ for half in range(2):
104
+ k0 = c * 256 + half * _P_MAX
105
+ nisa.dma_transpose(dst=xb[:, half, 0:pr],
106
+ src=x_hbm[r_abs:r_abs + pr, k0:k0 + _P_MAX])
107
+ x8 = nl.ndarray((_P_MAX, 2, rb_size), dtype=_E4, buffer=nl.sbuf)
108
+ nisa.tensor_copy(dst=x8[:, :, 0:pr], src=xb[:, :, 0:pr])
109
+ xt8.append(x8)
110
+ # fc1 (H out-rows in 128 tiles), GELU into bf16, then pair two H tiles into one e4m3 [128,2,pr]
111
+ g8 = []
112
+ for hp in range(nh2):
113
+ gb = nl.ndarray((_P_MAX, 2, rb_size), dtype=_BF16, buffer=nl.sbuf)
114
+ for half in range(2):
115
+ ht = hp * 2 + half
116
+ h0 = ht * _P_MAX
117
+ ps = nl.ndarray((_P_MAX, rb_size), dtype=nl.float32, buffer=nl.psum)
118
+ for c in nl.affine_range(nk2):
119
+ nisa.nc_matmul(dst=ps[:, 0:pr], stationary=w1_8[c][:, :, h0:h0 + _P_MAX],
120
+ moving=xt8[c][:, :, 0:pr], perf_mode=nisa.matmul_perf_mode.double_row)
121
+ # PSUM holds (x @ (w1*sw1)); gelu(scale*psum + b1) with scale = 1/sw1 undoes the weight scale
122
+ nisa.activation(dst=gb[:, half, 0:pr], op=nl.gelu, data=ps[:, 0:pr],
123
+ bias=b1_sb[:, ht:ht + 1], scale=inv_sw1)
124
+ # scale the GELU output into e4m3 range (legacy e4m3 max = 240) before the cast
125
+ gs = nl.ndarray((_P_MAX, 2, rb_size), dtype=nl.float32, buffer=nl.sbuf)
126
+ nisa.tensor_scalar(dst=gs[:, :, 0:pr], data=gb[:, :, 0:pr], op0=nl.multiply, operand0=g_scale)
127
+ gq = nl.ndarray((_P_MAX, 2, rb_size), dtype=_E4, buffer=nl.sbuf)
128
+ nisa.tensor_copy(dst=gq[:, :, 0:pr], src=gs[:, :, 0:pr])
129
+ g8.append(gq)
130
+ # fc2: out[r, k] = sum_h g[r,h] w2[k,h]; stationary = g^T pair [128,2,128 rows], moving = w2 pair
131
+ if fuse:
132
+ for rs in range(n_rs):
133
+ rr0 = rs * _P_MAX
134
+ prs = min(_P_MAX, pr - rr0)
135
+ r0 = r_abs + rr0
136
+ row = nl.ndarray((_P_MAX, K), dtype=nl.float32, buffer=nl.sbuf)
137
+ z_t = nl.ndarray((_P_MAX, K), dtype=_BF16, buffer=nl.sbuf)
138
+ nisa.dma_copy(dst=row[0:prs, 0:K], src=a_hbm[r0:r0 + prs, 0:K])
139
+ for ko in range(n_ko):
140
+ ko0 = ko * _F_MOV_MAX
141
+ nk = min(_F_MOV_MAX, K - ko0)
142
+ ps = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=nl.float32, buffer=nl.psum)
143
+ for hp in nl.affine_range(nh2):
144
+ nisa.nc_matmul(dst=ps[0:prs, 0:nk], stationary=g8[hp][:, :, rr0:rr0 + prs],
145
+ moving=w2_8[hp][:, :, ko0:ko0 + nk], perf_mode=nisa.matmul_perf_mode.double_row)
146
+ # row += psum * out_scale (residual accumulated in fp32)
147
+ nisa.scalar_tensor_tensor(dst=row[0:prs, ko0:ko0 + nk], data=ps[0:prs, 0:nk], op0=nl.multiply,
148
+ operand0=out_scale, op1=nl.add, operand1=row[0:prs, ko0:ko0 + nk])
149
+ nisa.tensor_tensor(dst=row[0:prs, 0:K], data1=row[0:prs, 0:K], data2=b2_full[0:prs, 0:K], op=nl.add)
150
+ nisa.dma_copy(dst=xnew_hbm[r0:r0 + prs, 0:K], src=row[0:prs, 0:K])
151
+ nisa.tensor_scalar(dst=neg_shift[0:prs, 0:1], data=row[0:prs, 0:1], op0=nl.multiply, operand0=-1.0)
152
+ nisa.activation_reduce(dst=z_t[0:prs, 0:K], op=nl.copy, data=row[0:prs, 0:K],
153
+ reduce_op=nl.add, reduce_res=acc_s[0:prs, 0:1], bias=neg_shift[0:prs, 0:1], scale=1.0)
154
+ nisa.tensor_scalar(dst=neg_mu[0:prs, 0:1], data=acc_s[0:prs, 0:1], op0=nl.multiply,
155
+ operand0=neg_inv_k, op1=nl.add, operand1=neg_shift[0:prs, 0:1])
156
+ nisa.activation_reduce(dst=z_t[0:prs, 0:K], op=nl.square, data=row[0:prs, 0:K],
157
+ reduce_op=nl.add, reduce_res=acc_s[0:prs, 0:1], bias=neg_mu[0:prs, 0:1], scale=1.0)
158
+ nisa.tensor_scalar(dst=var[0:prs, 0:1], data=acc_s[0:prs, 0:1], op0=nl.multiply,
159
+ operand0=inv_k, op1=nl.add, operand1=eps)
160
+ nisa.activation(dst=rstd[0:prs, 0:1], op=nl.rsqrt, data=var[0:prs, 0:1])
161
+ nisa.tensor_scalar(dst=z_t[0:prs, 0:K], data=row[0:prs, 0:K], op0=nl.add, operand0=neg_mu[0:prs, 0:1],
162
+ op1=nl.multiply, operand1=rstd[0:prs, 0:1])
163
+ nisa.dma_copy(dst=z_hbm[r0:r0 + prs, 0:K], src=z_t[0:prs, 0:K])
164
+ continue
165
+ for ko in range(n_ko):
166
+ ko0 = ko * _F_MOV_MAX
167
+ nk = min(_F_MOV_MAX, K - ko0)
168
+ b2_bc = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=nl.float32, buffer=nl.sbuf)
169
+ nisa.dma_copy(dst=b2_bc[:, 0:nk], src=b2_hbm.ap(pattern=[[0, _P_MAX], [1, nk]], offset=ko0))
170
+ for rs in range(n_rs):
171
+ rr0 = rs * _P_MAX
172
+ prs = min(_P_MAX, pr - rr0)
173
+ ps = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=nl.float32, buffer=nl.psum)
174
+ for hp in nl.affine_range(nh2):
175
+ nisa.nc_matmul(dst=ps[0:prs, 0:nk], stationary=g8[hp][:, :, rr0:rr0 + prs],
176
+ moving=w2_8[hp][:, :, ko0:ko0 + nk], perf_mode=nisa.matmul_perf_mode.double_row)
177
+ o = nl.ndarray((_P_MAX, _F_MOV_MAX), dtype=_BF16, buffer=nl.sbuf)
178
+ # out = psum * out_scale + b2, out_scale = 1 / (g_scale * sw2)
179
+ nisa.scalar_tensor_tensor(dst=o[0:prs, 0:nk], data=ps[0:prs, 0:nk], op0=nl.multiply,
180
+ operand0=out_scale, op1=nl.add, operand1=b2_bc[0:prs, 0:nk])
181
+ nisa.dma_copy(dst=out_hbm[r_abs + rr0:r_abs + rr0 + prs, ko0:ko0 + nk], src=o[0:prs, 0:nk])
182
+ if fuse:
183
+ return xnew_hbm, z_hbm
184
+ return out_hbm
185
+
186
+
187
+ @nki.jit
188
+ def _mlp_fp8_kernel(x_hbm, w1p_hbm, b1_hbm, w2p_hbm, b2_hbm, inv_sw1, g_scale, out_scale):
189
+ return _mlp_fp8_body(x_hbm, w1p_hbm, b1_hbm, w2p_hbm, b2_hbm, inv_sw1, g_scale, out_scale, None, 0.0, 0)
190
+
191
+
192
+ @nki.jit
193
+ def _mlp_fp8_ln_kernel(x_hbm, w1p_hbm, b1_hbm, w2p_hbm, b2_hbm, a_hbm, inv_sw1, g_scale, out_scale, eps):
194
+ return _mlp_fp8_body(x_hbm, w1p_hbm, b1_hbm, w2p_hbm, b2_hbm, inv_sw1, g_scale, out_scale, a_hbm, eps, 1)
195
+
196
+
197
+ _E4_TARGET = 128.0 # scale tensors so |max| lands at 128 (< legacy e4m3 max 240), power of two
198
+
199
+
200
+ def _pow2_scale(amax: float) -> float:
201
+ import math
202
+ return 2.0 ** math.floor(math.log2(_E4_TARGET / max(amax, 1e-12)))
203
+
204
+
205
+ def pack_fp8_weight(wt: torch.Tensor):
206
+ """``[Kc, F]`` (contraction-major) -> (``[Kc/256, 128, 2, F]`` bf16 scaled, scale). Scale is a power of two."""
207
+ Kc, F = wt.shape
208
+ assert Kc % 256 == 0, f"contraction {Kc} must be a multiple of 256"
209
+ s = _pow2_scale(wt.abs().max().item())
210
+ return (wt * s).to(torch.bfloat16).reshape(Kc // 256, 2, 128, F).transpose(1, 2).contiguous(), s
211
+
212
+
213
+ @nki_op("vit_enc::nki_mlp_fp8", mutates_args={})
214
+ def nki_mlp_fp8(x: torch.Tensor, w1p: torch.Tensor, b1: torch.Tensor, w2p: torch.Tensor,
215
+ b2: torch.Tensor, sw1: float, sw2: float, g_scale: float) -> torch.Tensor:
216
+ """EXPERIMENTAL FP8 GELU MLP. ``w1p, sw1`` / ``w2p, sw2`` from :func:`pack_fp8_weight`; ``g_scale`` = power-of-two
217
+ scale for the GELU output (choose so max|gelu| * g_scale <= 128)."""
218
+ return _mlp_fp8_kernel[LNC](x, w1p, b1, w2p, b2, 1.0 / sw1, g_scale, 1.0 / (g_scale * sw2))
219
+
220
+
221
+ @nki_op("vit_enc::nki_mlp_fp8_res_ln", mutates_args={})
222
+ def nki_mlp_fp8_res_ln(x: torch.Tensor, w1p: torch.Tensor, b1: torch.Tensor, w2p: torch.Tensor, b2: torch.Tensor,
223
+ a: torch.Tensor, eps: float, sw1: float, sw2: float, g_scale: float) -> tuple[torch.Tensor, torch.Tensor]:
224
+ """EXPERIMENTAL FP8 GELU MLP + residual + next-layer affine-free LN. Returns (x_new fp32, z bf16)."""
225
+ return _mlp_fp8_ln_kernel[LNC](x, w1p, b1, w2p, b2, a, 1.0 / sw1, g_scale, 1.0 / (g_scale * sw2), eps)