v1.4: 5-bank QK^T PSUM rotation (<=1024 tokens) + experimental opt-in FP8 MLP
Browse filesQK^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 +20 -0
- README.md +5 -0
- build/torch-neuron/__init__.py +2 -2
- build/torch-neuron/encoder.py +23 -1
- build/torch-neuron/nki_kernels/attention_inner.py +13 -1
- build/torch-neuron/nki_kernels/mlp_fp8.py +225 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|