jburtoft commited on
Commit
fb58c6c
·
1 Parent(s): 5d921b9

v1.1.0: fused attention block for L = 768 / 1024

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
3
  ## v1.0.1 (2026-10)
4
 
5
  Documentation and examples only; kernels unchanged.
 
1
  # Changelog
2
 
3
+ ## v1.1.0 (2026-10)
4
+
5
+ * Fused attention block now covers L = 768 and 1024 (projection and QK^T matmuls in 512-column chunks, grouped
6
+ LayerNorm epilogue). `patch_model` uses it by default at those lengths instead of nkilib `attention_cte`:
7
+ 1.24-1.70x faster than v1.0.0 and 1.41-2.17x faster than stock PyTorch Native at L = 768 / 1024.
8
+ Outputs at L <= 512 are bit-identical to v1.0.0 (simulator).
9
+ * `patch_model(model, attn_lengths=...)` selects the lengths that use the fused block.
10
+
11
  ## v1.0.1 (2026-10)
12
 
13
  Documentation and examples only; kernels unchanged.
README.md CHANGED
@@ -24,12 +24,12 @@ Each layer runs as two fused kernels instead of dozens of small ops:
24
 
25
  | Kernel | Replaces |
26
  |---|---|
27
- | `attn_block` | QKV projection + RoPE + masked softmax attention + output projection + residual + LayerNorm (L <= 512) |
28
  | `gated_mlp_ln` | gated GELU MLP + residual + LayerNorm (weights resident in SBUF, or streamed when they do not fit) |
29
  | `ln` / `res_ln` | (residual +) LayerNorm, using the Vector engine's `bn_stats` / `bn_aggr` (embedding LayerNorm) |
30
 
31
- At L >= 768 the attention core is replaced by `attention_cte` from the Neuron NKI library (nkilib), which
32
- beats the compiler's own attention at those lengths.
33
 
34
  Validated models:
35
 
@@ -87,10 +87,10 @@ batch) shape compiles once.
87
  `patch_model(model)` patches every `NewLayer` and the embedding LayerNorm. Shapes that the kernels do not
88
  support fall back to the stock modules at trace time, so patching is always safe:
89
 
90
- * fused attention block: L in {128, 256, 384, 512}; batch divisible by the number of logical cores, or
91
- (L / 128) divisible by it (an odd batch at LNC=2 splits the queries across the two cores);
92
  * MLP and LayerNorm kernels: batch x L divisible by 128 x LNC;
93
- * L >= 768: stock attention projections + nkilib `attention_cte` + the NKI LayerNorm.
94
 
95
  The raw kernels (`attn_block_kernel`, `gated_mlp_ln_kernel`, `gated_mlp_ln_stream_kernel`,
96
  `res_ln_kernel`) and the host-side weight packer (`pack_attn_block`) are exported too, for use from other
@@ -126,6 +126,20 @@ Hugging Face weights. "Stock" is the same model, also with the tanh GELU, compil
126
  | gte-large-en-v1.5 | 512 x 16 | 111.7 | **68.0** | 1.64x |
127
  | gte-large-en-v1.5 | 512 x 1 | 9.2 | **7.9** | 1.17x |
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  gte-large's MLP weights (1024 x 8192 + 4096 x 1024 per layer) do not fit in SBUF next to the working
130
  set, so its MLP kernel streams them. With every core busy, that streaming competes for HBM bandwidth,
131
  which is why its whole-instance gain is lower than its single-core gain.
@@ -133,21 +147,21 @@ which is why its whole-instance gain is lower than its single-core gain.
133
  ## Accuracy
134
 
135
  Checked against the stock Hugging Face model on CPU in fp32 with the exact GELU, on 64-72 real-sentence
136
- inputs per shape with ragged lengths (6 tokens up to the padded length), at L = 128 / 256 / 512, LNC=1 and
137
- LNC=2. "Stock" is stock bf16 PyTorch Native on the same inputs.
138
 
139
  | Model | Metric | Stock bf16 | These kernels |
140
  |---|---|---|---|
141
- | gte-multilingual-reranker-base | score Pearson | 0.99997-0.99998 | 0.99997-0.99998 |
142
  | gte-multilingual-reranker-base | top-1 passage per query = fp32 | same as stock | same as stock |
143
  | gte-multilingual-base | CLS cosine, mean | 0.99984-0.99987 | 0.99992-0.99993 |
144
  | gte-base-en-v1.5 | CLS cosine, mean | 0.99991-0.99992 | 0.99993 |
145
- | gte-large-en-v1.5 | CLS cosine, mean | 0.99988-0.99991 | 0.99990-0.99992 |
146
- | all embedding models | retrieval top-1 = fp32 | 0.88-1.00 (0.88 on one gte-base shape) | 1.00 |
147
 
148
  The kernels keep the residual sum and the LayerNorm statistics in fp32, so on average they are slightly
149
  closer to fp32 than stock bf16. The worst single input is on par with stock: the lowest per-text cosine
150
- was 0.99900-0.99991 with the kernels against 0.99883-0.99984 for stock. On gte-large it trailed stock's
151
  worst by up to 2.3e-4 in some configurations. Padding is exact: a sequence's output is bit-identical
152
  whatever the other sequences in its batch contain.
153
 
 
24
 
25
  | Kernel | Replaces |
26
  |---|---|
27
+ | `attn_block` | QKV projection + RoPE + masked softmax attention + output projection + residual + LayerNorm (L <= 1024) |
28
  | `gated_mlp_ln` | gated GELU MLP + residual + LayerNorm (weights resident in SBUF, or streamed when they do not fit) |
29
  | `ln` / `res_ln` | (residual +) LayerNorm, using the Vector engine's `bn_stats` / `bn_aggr` (embedding LayerNorm) |
30
 
31
+ Other lengths of 768 or more (for example 1536) use `attention_cte` from the Neuron NKI library (nkilib) for
32
+ the attention core, which beats the compiler's own attention at those lengths.
33
 
34
  Validated models:
35
 
 
87
  `patch_model(model)` patches every `NewLayer` and the embedding LayerNorm. Shapes that the kernels do not
88
  support fall back to the stock modules at trace time, so patching is always safe:
89
 
90
+ * fused attention block: L in {128, 256, 384, 512, 768, 1024}; batch divisible by the number of logical
91
+ cores, or (L / 128) divisible by it (an odd batch at LNC=2 splits the queries across the two cores);
92
  * MLP and LayerNorm kernels: batch x L divisible by 128 x LNC;
93
+ * other L >= 768: stock attention projections + nkilib `attention_cte` + the NKI LayerNorm.
94
 
95
  The raw kernels (`attn_block_kernel`, `gated_mlp_ln_kernel`, `gated_mlp_ln_stream_kernel`,
96
  `res_ln_kernel`) and the host-side weight packer (`pack_attn_block`) are exported too, for use from other
 
126
  | gte-large-en-v1.5 | 512 x 16 | 111.7 | **68.0** | 1.64x |
127
  | gte-large-en-v1.5 | 512 x 1 | 9.2 | **7.9** | 1.17x |
128
 
129
+ **Long sequences** (single process, ms per batch, median; v1.0.0 used `attention_cte` + unfused projections at
130
+ these lengths, v1.1.0 runs the fused attention block):
131
+
132
+ | Model | LNC | L x batch | Stock | v1.0.0 | **v1.1.0** | vs stock |
133
+ |---|---|---|---|---|---|---|
134
+ | gte-multilingual-reranker-base | 2 | 768 x 8 | 40.0 | 33.3 | **20.1** | 1.99x |
135
+ | gte-multilingual-reranker-base | 2 | 1024 x 8 | 54.7 | 46.6 | **29.3** | 1.87x |
136
+ | gte-multilingual-reranker-base | 2 | 1024 x 1 | 8.0 | 7.7 | **5.7** | 1.41x |
137
+ | gte-multilingual-reranker-base | 1 | 1024 x 8 | 107.2 | 82.9 | **54.0** | 1.98x |
138
+ | gte-large-en-v1.5 | 2 | 768 x 8 | 108.0 | 95.3 | **62.2** | 1.74x |
139
+ | gte-large-en-v1.5 | 2 | 1024 x 8 | 155.7 | 127.9 | **87.3** | 1.78x |
140
+ | gte-large-en-v1.5 | 2 | 1024 x 1 | 21.5 | 18.8 | **15.1** | 1.43x |
141
+ | gte-large-en-v1.5 | 1 | 1024 x 8 | 307.5 | 242.0 | **168.7** | 1.82x |
142
+
143
  gte-large's MLP weights (1024 x 8192 + 4096 x 1024 per layer) do not fit in SBUF next to the working
144
  set, so its MLP kernel streams them. With every core busy, that streaming competes for HBM bandwidth,
145
  which is why its whole-instance gain is lower than its single-core gain.
 
147
  ## Accuracy
148
 
149
  Checked against the stock Hugging Face model on CPU in fp32 with the exact GELU, on 64-72 real-sentence
150
+ inputs per shape with ragged lengths (6 tokens up to the padded length), at L = 128 / 256 / 512 / 768 / 1024,
151
+ LNC=1 and LNC=2 (768 / 1024 measured on the reranker and gte-large). "Stock" is stock bf16 PyTorch Native on the same inputs.
152
 
153
  | Model | Metric | Stock bf16 | These kernels |
154
  |---|---|---|---|
155
+ | gte-multilingual-reranker-base | score Pearson | 0.99997-0.99999 | 0.99997-0.99998 |
156
  | gte-multilingual-reranker-base | top-1 passage per query = fp32 | same as stock | same as stock |
157
  | gte-multilingual-base | CLS cosine, mean | 0.99984-0.99987 | 0.99992-0.99993 |
158
  | gte-base-en-v1.5 | CLS cosine, mean | 0.99991-0.99992 | 0.99993 |
159
+ | gte-large-en-v1.5 | CLS cosine, mean | 0.99986-0.99991 | 0.99990-0.99992 |
160
+ | all embedding models | retrieval top-1 = fp32 | 0.88-1.00 | 0.88-1.00 (never below stock) |
161
 
162
  The kernels keep the residual sum and the LayerNorm statistics in fp32, so on average they are slightly
163
  closer to fp32 than stock bf16. The worst single input is on par with stock: the lowest per-text cosine
164
+ was 0.99900-0.99991 with the kernels against 0.99834-0.99984 for stock. On gte-large it trailed stock's
165
  worst by up to 2.3e-4 in some configurations. Padding is exact: a sequence's output is bit-identical
166
  whatever the other sequences in its batch contain.
167
 
build/torch-neuron/__init__.py CHANGED
@@ -36,7 +36,7 @@ from .torch_ops import (
36
  res_ln,
37
  )
38
 
39
- __version__ = "1.0.0"
40
 
41
  __all__ = [
42
  "patch_model",
 
36
  res_ln,
37
  )
38
 
39
+ __version__ = "1.1.0"
40
 
41
  __all__ = [
42
  "patch_model",
build/torch-neuron/attn_block_kernel.py CHANGED
@@ -35,7 +35,9 @@ Query split: at LNC > 1 with a batch not divisible by the core count, every core
35
  every sequence but only its own share of the query tiles (needs (L/128) % LNC == 0). No cross-core
36
  reduction is needed.
37
 
38
- L must be a multiple of 128 and <= 512. Right padding only. Inference only.
 
 
39
  """
40
 
41
  import nki
@@ -44,6 +46,7 @@ import nki.language as nl
44
 
45
  P = 128
46
  FMAX = 512
 
47
 
48
 
49
  PSUM_BANK = 2048 # bytes per partition per PSUM bank
@@ -85,7 +88,7 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
85
  assert NH * D == H
86
  assert R == B * L
87
  assert L % P == 0
88
- assert L <= FMAX
89
  assert H % P == 0
90
  assert NH % quad == 0
91
  n_prog = nl.num_programs(0)
@@ -143,9 +146,18 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
143
  SP = FMAX // L
144
  if SP > 4:
145
  SP = 4
 
 
146
  if Bp % SP != 0:
147
  SP = 1
148
  LW = L * SP # projection moving width
 
 
 
 
 
 
 
149
  cosw = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
150
  sinw = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
151
  for sp in range(SP):
@@ -161,12 +173,14 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
161
 
162
  # ---------------- x rows, x + bo, x^T ----------------
163
  xr = nl.ndarray((P, ntw, H), dtype=dt, buffer=nl.sbuf)
164
- xb = nl.ndarray((P, ntw, H), dtype=nl.float32, buffer=nl.sbuf)
 
165
  xT = nl.ndarray((P, n_k, LW), dtype=dt, buffer=nl.sbuf)
166
  for t in range(ntw):
167
  nisa.dma_copy(dst=xr[:, t, :], src=x_hbm[nl.ds(r0 + t * P, P), 0:H])
168
- nisa.tensor_tensor(dst=xb[:, t, :], data1=xr[:, t, :], data2=bo, op=nl.add,
169
- engine=nisa.engine.gpsimd)
 
170
  for kt in range(n_k):
171
  nisa.dma_transpose(dst=xT[:, kt, t * P:(t + 1) * P], src=xr[:, t, kt * P:(kt + 1) * P])
172
 
@@ -186,8 +200,15 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
186
  for par in range(2):
187
  gi = (qk * NQ + j) * 2 + par
188
  ps = nl.ndarray((P, LW), dtype=nl.float32, buffer=nl.psum)
189
- for kt in nl.affine_range(n_k):
190
- nisa.nc_matmul(dst=ps, stationary=wqk[:, kt, gi * P:(gi + 1) * P], moving=xT[:, kt, :])
 
 
 
 
 
 
 
191
  pt = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
192
  nisa.activation(dst=pt, op=nl.copy, data=ps, bias=bqk[:, gi:gi + 1])
193
  pr.append(pt)
@@ -240,25 +261,40 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
240
  xq = nl.ndarray((P, H), dtype=dt, buffer=nl.sbuf)
241
  nisa.dma_copy(dst=xq, src=x_hbm[nl.ds(r0 + (qbase + i) * P, P), 0:H])
242
  nisa.tensor_tensor(dst=xbq[:, i, :], data1=xq, data2=bo, op=nl.add, engine=nisa.engine.gpsimd)
243
- s_all = nl.ndarray((P, nq, H), dtype=nl.float32, buffer=nl.sbuf)
244
- mv_all = nl.ndarray((P, nq, 2), dtype=nl.float32, buffer=nl.sbuf)
 
 
 
245
  for qi in range(nq):
 
246
  sp = qi // nt
247
  qt = qi % nt
248
  c0 = sp * L # column offset of this sequence in qa/ka
249
  t_off = sp * nt # token-tile offset of this sequence in va / xb
250
  qcol = c0 + qt * P # query column start in qa
251
- xres = xb[:, t_off + qt, :]
252
  if QS == 1:
253
  qcol = (qbase + qi) * P
254
  xres = xbq[:, qi, :]
 
 
 
 
 
 
255
  o_sb = nl.ndarray((P, H), dtype=dt, buffer=nl.sbuf)
256
  for gg in range(NG):
257
  S = nl.ndarray((P, G, L), dtype=nl.float32, buffer=nl.psum)
258
  for hl in range(G):
259
  h = gg * G + hl
260
- nisa.nc_matmul(dst=S[:, hl, :], stationary=qa[0:D + 1, h, nl.ds(qcol, P)],
261
- moving=ka[0:D + 1, h, c0:c0 + L])
 
 
 
 
 
 
262
  negmax = nl.ndarray((P, G), dtype=nl.float32, buffer=nl.sbuf)
263
  nisa.tensor_reduce(dst=negmax, op=nl.maximum, data=S, axis=2, negate=True)
264
  Pm = nl.ndarray((P, G, L), dtype=dt, buffer=nl.sbuf)
@@ -285,7 +321,7 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
285
  oT = nl.ndarray((P, n_k, P), dtype=dt, buffer=nl.sbuf)
286
  nisa.tensor_copy(dst=oT, src=oTp, engine=nisa.engine.scalar)
287
 
288
- s_sb = s_all[:, qi, :]
289
  for ko in range(n_ko):
290
  k0 = ko * FMAX
291
  nk = min(FMAX, H - k0)
@@ -297,16 +333,19 @@ def attn_block_kernel(x_hbm, wqk_hbm, bqk_hbm, wv_hbm, bv_hbm, wo_hbm, bo_hbm, g
297
  stats = nl.ndarray((P, 6 * n_ch), dtype=nl.float32, buffer=nl.sbuf)
298
  for c in range(n_ch):
299
  nisa.bn_stats(dst=stats[:, c * 6:(c + 1) * 6], data=s_sb[:, c * csz:(c + 1) * csz])
300
- nisa.bn_aggr(dst=mv_all[:, qi, :], data=stats)
301
 
302
- # ---------------- deferred LayerNorm epilogue ----------------
303
- rstd_all = nl.ndarray((P, nq, 1), dtype=nl.float32, buffer=nl.sbuf)
304
- nisa.activation(dst=rstd_all, op=nl.rsqrt, data=mv_all[:, :, 1:2], bias=eps_sb)
305
- for qi in range(nq):
306
- yr = r0 + qi * P
307
- if QS == 1:
308
- yr = r0 + (qbase + qi) * P
309
- _ln_out(s_all[:, qi, :], mv_all[:, qi, 0:1], rstd_all[:, qi, :], ga, be, y_hbm,
310
- yr, P, H, dt)
 
 
 
311
 
312
  return y_hbm
 
35
  every sequence but only its own share of the query tiles (needs (L/128) % LNC == 0). No cross-core
36
  reduction is needed.
37
 
38
+ L must be a multiple of 128 and <= 1024. Above 512 keys (the matmul moving-size limit) the projection and QK^T
39
+ matmuls are issued in 512-column chunks; one head's score row (<= 1024 fp32 = 2 PSUM banks) still fits in PSUM, so
40
+ the softmax stays exact (no running max). Right padding only. Inference only.
41
  """
42
 
43
  import nki
 
46
 
47
  P = 128
48
  FMAX = 512
49
+ LMAX = 1024
50
 
51
 
52
  PSUM_BANK = 2048 # bytes per partition per PSUM bank
 
88
  assert NH * D == H
89
  assert R == B * L
90
  assert L % P == 0
91
+ assert L <= LMAX
92
  assert H % P == 0
93
  assert NH % quad == 0
94
  n_prog = nl.num_programs(0)
 
146
  SP = FMAX // L
147
  if SP > 4:
148
  SP = 4
149
+ if SP < 1:
150
+ SP = 1
151
  if Bp % SP != 0:
152
  SP = 1
153
  LW = L * SP # projection moving width
154
+ # Long sequences (L > 512): x + bo is formed per query tile instead of for the whole sequence, and the deferred
155
+ # LayerNorm epilogue runs per group of LN_GROUP query tiles, to bound SBUF use. For L <= 512 a pass has at most
156
+ # 4 query tiles, so the epilogue is deferred over the whole pass as before.
157
+ LONG = 0
158
+ if L > FMAX:
159
+ LONG = 1
160
+ LN_GROUP = 4
161
  cosw = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
162
  sinw = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
163
  for sp in range(SP):
 
173
 
174
  # ---------------- x rows, x + bo, x^T ----------------
175
  xr = nl.ndarray((P, ntw, H), dtype=dt, buffer=nl.sbuf)
176
+ if LONG == 0:
177
+ xb = nl.ndarray((P, ntw, H), dtype=nl.float32, buffer=nl.sbuf)
178
  xT = nl.ndarray((P, n_k, LW), dtype=dt, buffer=nl.sbuf)
179
  for t in range(ntw):
180
  nisa.dma_copy(dst=xr[:, t, :], src=x_hbm[nl.ds(r0 + t * P, P), 0:H])
181
+ if LONG == 0:
182
+ nisa.tensor_tensor(dst=xb[:, t, :], data1=xr[:, t, :], data2=bo, op=nl.add,
183
+ engine=nisa.engine.gpsimd)
184
  for kt in range(n_k):
185
  nisa.dma_transpose(dst=xT[:, kt, t * P:(t + 1) * P], src=xr[:, t, kt * P:(kt + 1) * P])
186
 
 
200
  for par in range(2):
201
  gi = (qk * NQ + j) * 2 + par
202
  ps = nl.ndarray((P, LW), dtype=nl.float32, buffer=nl.psum)
203
+ if LW <= FMAX:
204
+ for kt in nl.affine_range(n_k):
205
+ nisa.nc_matmul(dst=ps, stationary=wqk[:, kt, gi * P:(gi + 1) * P], moving=xT[:, kt, :])
206
+ else:
207
+ for c0w in range(0, LW, FMAX):
208
+ cw = min(FMAX, LW - c0w)
209
+ for kt in nl.affine_range(n_k):
210
+ nisa.nc_matmul(dst=ps[:, c0w:c0w + cw], stationary=wqk[:, kt, gi * P:(gi + 1) * P],
211
+ moving=xT[:, kt, c0w:c0w + cw])
212
  pt = nl.ndarray((P, LW), dtype=dt, buffer=nl.sbuf)
213
  nisa.activation(dst=pt, op=nl.copy, data=ps, bias=bqk[:, gi:gi + 1])
214
  pr.append(pt)
 
261
  xq = nl.ndarray((P, H), dtype=dt, buffer=nl.sbuf)
262
  nisa.dma_copy(dst=xq, src=x_hbm[nl.ds(r0 + (qbase + i) * P, P), 0:H])
263
  nisa.tensor_tensor(dst=xbq[:, i, :], data1=xq, data2=bo, op=nl.add, engine=nisa.engine.gpsimd)
264
+ ng = nq
265
+ if ng > LN_GROUP:
266
+ ng = LN_GROUP
267
+ s_all = nl.ndarray((P, ng, H), dtype=nl.float32, buffer=nl.sbuf)
268
+ mv_all = nl.ndarray((P, ng, 2), dtype=nl.float32, buffer=nl.sbuf)
269
  for qi in range(nq):
270
+ gi_ = qi % ng # slot in the current LayerNorm group
271
  sp = qi // nt
272
  qt = qi % nt
273
  c0 = sp * L # column offset of this sequence in qa/ka
274
  t_off = sp * nt # token-tile offset of this sequence in va / xb
275
  qcol = c0 + qt * P # query column start in qa
 
276
  if QS == 1:
277
  qcol = (qbase + qi) * P
278
  xres = xbq[:, qi, :]
279
+ elif LONG == 1:
280
+ xres = nl.ndarray((P, H), dtype=nl.float32, buffer=nl.sbuf)
281
+ nisa.tensor_tensor(dst=xres, data1=xr[:, t_off + qt, :], data2=bo, op=nl.add,
282
+ engine=nisa.engine.gpsimd)
283
+ else:
284
+ xres = xb[:, t_off + qt, :]
285
  o_sb = nl.ndarray((P, H), dtype=dt, buffer=nl.sbuf)
286
  for gg in range(NG):
287
  S = nl.ndarray((P, G, L), dtype=nl.float32, buffer=nl.psum)
288
  for hl in range(G):
289
  h = gg * G + hl
290
+ if L <= FMAX:
291
+ nisa.nc_matmul(dst=S[:, hl, :], stationary=qa[0:D + 1, h, nl.ds(qcol, P)],
292
+ moving=ka[0:D + 1, h, c0:c0 + L])
293
+ else:
294
+ for k0c in range(0, L, FMAX):
295
+ kw = min(FMAX, L - k0c)
296
+ nisa.nc_matmul(dst=S[:, hl, k0c:k0c + kw], stationary=qa[0:D + 1, h, nl.ds(qcol, P)],
297
+ moving=ka[0:D + 1, h, c0 + k0c:c0 + k0c + kw])
298
  negmax = nl.ndarray((P, G), dtype=nl.float32, buffer=nl.sbuf)
299
  nisa.tensor_reduce(dst=negmax, op=nl.maximum, data=S, axis=2, negate=True)
300
  Pm = nl.ndarray((P, G, L), dtype=dt, buffer=nl.sbuf)
 
321
  oT = nl.ndarray((P, n_k, P), dtype=dt, buffer=nl.sbuf)
322
  nisa.tensor_copy(dst=oT, src=oTp, engine=nisa.engine.scalar)
323
 
324
+ s_sb = s_all[:, gi_, :]
325
  for ko in range(n_ko):
326
  k0 = ko * FMAX
327
  nk = min(FMAX, H - k0)
 
333
  stats = nl.ndarray((P, 6 * n_ch), dtype=nl.float32, buffer=nl.sbuf)
334
  for c in range(n_ch):
335
  nisa.bn_stats(dst=stats[:, c * 6:(c + 1) * 6], data=s_sb[:, c * csz:(c + 1) * csz])
336
+ nisa.bn_aggr(dst=mv_all[:, gi_, :], data=stats)
337
 
338
+ # ---------------- deferred LayerNorm epilogue (once per group of query tiles) ----------------
339
+ if gi_ == ng - 1 or qi == nq - 1:
340
+ g0 = qi - gi_ # first query tile of this group
341
+ gn = gi_ + 1
342
+ rstd_all = nl.ndarray((P, ng, 1), dtype=nl.float32, buffer=nl.sbuf)
343
+ nisa.activation(dst=rstd_all[:, 0:gn, :], op=nl.rsqrt, data=mv_all[:, 0:gn, 1:2], bias=eps_sb)
344
+ for qj in range(gn):
345
+ yr = r0 + (g0 + qj) * P
346
+ if QS == 1:
347
+ yr = r0 + (qbase + g0 + qj) * P
348
+ _ln_out(s_all[:, qj, :], mv_all[:, qj, 0:1], rstd_all[:, qj, :], ga, be, y_hbm,
349
+ yr, P, H, dt)
350
 
351
  return y_hbm
build/torch-neuron/torch_ops.py CHANGED
@@ -18,10 +18,10 @@ from .res_ln_kernel import res_ln_kernel
18
 
19
  LNC = int(os.environ.get("NEURON_LOGICAL_NC_CONFIG", "2"))
20
 
21
- # Fused attention block: supported sequence lengths (multiples of 128, <= 512).
22
- ATTN_LENGTHS = (128, 256, 384, 512)
23
- # nkilib attention_cte (with key padding as sequence bounds) replaces the stock attention core from
24
- # this length up. Below it the compiler's own attention is faster.
25
  CTE_MIN_LEN = 768
26
  # Key-bias clamp: padded keys get this additive bias (exp underflows to 0 in fp32 and bf16).
27
  MASK_BIAS = -30000.0
@@ -184,11 +184,11 @@ def _layer_forward(self, hidden_states, attention_bias, rope_embeds=None, paddin
184
  return (hidden_states,)
185
 
186
 
187
- def patch_model(model, long_attention=True):
188
  """Patch every NewLayer / NewEmbeddings in a NewBERT (Alibaba-NLP "new-impl") model, in place:
189
 
190
  * embedding LayerNorm -> NKI LayerNorm
191
- * attention half of each layer -> fused attention block (L in ATTN_LENGTHS)
192
  * gated MLP + mlp_ln -> fused gated-MLP + residual + LayerNorm
193
  * attention core at L >= CTE_MIN_LEN -> nkilib attention_cte (if long_attention)
194
 
@@ -212,7 +212,7 @@ def patch_model(model, long_attention=True):
212
  if type(mod).__name__ == "NewLayer":
213
  assert "Gated" in type(mod.mlp).__name__, "only the gated (NewGatedMLP) MLP is supported"
214
  mod.mlp_block = NkiMlpLN(mod.mlp, mod.mlp_ln)
215
- mod.attn_block = NkiAttnBlock(mod.attention, mod.attn_ln, rot)
216
  mod.forward = types.MethodType(_layer_forward, mod)
217
  n += 1
218
  return n
 
18
 
19
  LNC = int(os.environ.get("NEURON_LOGICAL_NC_CONFIG", "2"))
20
 
21
+ # Fused attention block: supported sequence lengths (multiples of 128, <= 1024).
22
+ ATTN_LENGTHS = (128, 256, 384, 512, 768, 1024)
23
+ # Lengths not in ATTN_LENGTHS from this length up use nkilib attention_cte (key padding as sequence bounds)
24
+ # for the attention core; below it the compiler's own attention is faster.
25
  CTE_MIN_LEN = 768
26
  # Key-bias clamp: padded keys get this additive bias (exp underflows to 0 in fp32 and bf16).
27
  MASK_BIAS = -30000.0
 
184
  return (hidden_states,)
185
 
186
 
187
+ def patch_model(model, long_attention=True, attn_lengths=ATTN_LENGTHS):
188
  """Patch every NewLayer / NewEmbeddings in a NewBERT (Alibaba-NLP "new-impl") model, in place:
189
 
190
  * embedding LayerNorm -> NKI LayerNorm
191
+ * attention half of each layer -> fused attention block (L in attn_lengths, default ATTN_LENGTHS)
192
  * gated MLP + mlp_ln -> fused gated-MLP + residual + LayerNorm
193
  * attention core at L >= CTE_MIN_LEN -> nkilib attention_cte (if long_attention)
194
 
 
212
  if type(mod).__name__ == "NewLayer":
213
  assert "Gated" in type(mod.mlp).__name__, "only the gated (NewGatedMLP) MLP is supported"
214
  mod.mlp_block = NkiMlpLN(mod.mlp, mod.mlp_ln)
215
+ mod.attn_block = NkiAttnBlock(mod.attention, mod.attn_ln, rot, lengths=attn_lengths)
216
  mod.forward = types.MethodType(_layer_forward, mod)
217
  n += 1
218
  return n
tests/bench_model.py CHANGED
@@ -41,6 +41,8 @@ def build(v):
41
  m = m.to(torch.bfloat16)
42
  if v == "nki":
43
  pkg.patch_model(m)
 
 
44
  return m.to(dev)
45
 
46
 
 
41
  m = m.to(torch.bfloat16)
42
  if v == "nki":
43
  pkg.patch_model(m)
44
+ elif v == "nki_cte": # v1.0 behaviour: fused attention block only up to 512, nkilib cte above
45
+ pkg.patch_model(m, attn_lengths=(128, 256, 384, 512))
46
  return m.to(dev)
47
 
48
 
tests/test_device.py CHANGED
@@ -27,7 +27,8 @@ MODEL = os.environ.get("GTE_TEST_MODEL", "Alibaba-NLP/gte-multilingual-reranker-
27
  RESULTS = os.environ.get("GTE_RESULTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
28
  "device_results.jsonl"))
29
  # (L, batch): batch 32 is the main throughput shape; batch 3 exercises the LNC2 query split.
30
- SHAPES = [(256, 32), (128, 32), (512, 3)]
 
31
 
32
  TOPICS = {
33
  "What are the benefits of renewable energy?": [
@@ -125,7 +126,7 @@ def setup():
125
  dev = torch.device("neuron")
126
  stock = _load(torch.bfloat16).to(dev)
127
  patched = _load(torch.bfloat16)
128
- n = pkg.patch_model(patched)
129
  patched = patched.to(dev)
130
  return dict(pkg=pkg, tok=tok, cpu=cpu, dev=dev, stock=stock, patched=patched, n_layers=n, calls=calls)
131
 
@@ -181,7 +182,9 @@ def test_accuracy_and_isolation(setup, L, bs):
181
  with open(RESULTS, "a") as f:
182
  f.write(json.dumps(rec) + "\n")
183
  nl = setup["n_layers"]
184
- assert traced.get("attn_block", 0) >= nl and traced.get("gated_mlp_ln", 0) >= nl and traced.get("ln", 0) >= 1, rec
 
 
185
  assert pr_nki >= 0.9999, rec
186
  assert top1_nki >= top1_stock, rec
187
  assert isolated, rec
 
27
  RESULTS = os.environ.get("GTE_RESULTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
28
  "device_results.jsonl"))
29
  # (L, batch): batch 32 is the main throughput shape; batch 3 exercises the LNC2 query split.
30
+ SHAPES = [(256, 32), (128, 32), (512, 3), (1024, 8), (768, 3)]
31
+ ATTN_LENGTHS = tuple(int(v) for v in os.environ.get("GTE_ATTN_LENGTHS", "128,256,384,512,768,1024").split(","))
32
 
33
  TOPICS = {
34
  "What are the benefits of renewable energy?": [
 
126
  dev = torch.device("neuron")
127
  stock = _load(torch.bfloat16).to(dev)
128
  patched = _load(torch.bfloat16)
129
+ n = pkg.patch_model(patched, attn_lengths=ATTN_LENGTHS)
130
  patched = patched.to(dev)
131
  return dict(pkg=pkg, tok=tok, cpu=cpu, dev=dev, stock=stock, patched=patched, n_layers=n, calls=calls)
132
 
 
182
  with open(RESULTS, "a") as f:
183
  f.write(json.dumps(rec) + "\n")
184
  nl = setup["n_layers"]
185
+ if L in ATTN_LENGTHS:
186
+ assert traced.get("attn_block", 0) >= nl, rec
187
+ assert traced.get("gated_mlp_ln", 0) >= nl and traced.get("ln", 0) >= 1, rec
188
  assert pr_nki >= 0.9999, rec
189
  assert top1_nki >= top1_stock, rec
190
  assert isolated, rec
tests/test_device_embed.py CHANGED
@@ -1,7 +1,7 @@
1
  """On-device accuracy for NewBERT embedding models (gte-*-en-v1.5, gte-multilingual-base): CLS embeddings of the
2
  patched model vs the stock Hugging Face model on CPU in fp32 (exact GELU), and vs stock PyTorch Native.
3
 
4
- Gates: mean per-text cosine >= 0.9999 (patched vs CPU fp32); min per-text cosine within COS_MIN_SLACK of stock
5
  bf16 PyTorch Native's min (an absolute 0.9999 floor on the minimum is not met by stock bf16 itself);
6
  retrieval top-1 per query equal to the CPU fp32 ranking at least as often as stock Native; padding isolation
7
  bit-exact; fused ops actually traced.
@@ -25,8 +25,9 @@ pytestmark = pytest.mark.neuron
25
  MODEL = os.environ.get("GTE_TEST_MODEL", "Alibaba-NLP/gte-large-en-v1.5")
26
  RESULTS = os.environ.get("GTE_RESULTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
27
  "device_results.jsonl"))
28
- SHAPES = [(256, 32), (128, 32), (512, 3)]
29
  COS_MEAN_MIN = 0.9999
 
30
  COS_MIN_SLACK = 1e-3 # worst single text may trail stock's worst by this much (bf16 noise over 24 layers)
31
 
32
 
@@ -69,7 +70,7 @@ def setup():
69
  dev = torch.device("neuron")
70
  stock = _CLS(_load(torch.bfloat16)).to(dev)
71
  p = _load(torch.bfloat16)
72
- n = pkg.patch_model(p)
73
  patched = _CLS(p).to(dev)
74
  return dict(pkg=pkg, tok=tok, cpu=cpu, dev=dev, stock=stock, patched=patched, n_layers=n, calls=calls)
75
 
@@ -138,8 +139,10 @@ def test_embed_accuracy_and_isolation(setup, L, bs):
138
  with open(RESULTS, "a") as f:
139
  f.write(json.dumps(rec) + "\n")
140
  nl = setup["n_layers"]
141
- assert traced.get("attn_block", 0) >= nl and traced.get("gated_mlp_ln", 0) >= nl, rec
142
- assert float(cos_nki.mean()) >= COS_MEAN_MIN, rec
 
 
143
  assert float(cos_nki.min()) >= float(cos_stock.min()) - COS_MIN_SLACK, rec
144
  assert agree_nki >= agree_stock, rec
145
  assert isolated, rec
 
1
  """On-device accuracy for NewBERT embedding models (gte-*-en-v1.5, gte-multilingual-base): CLS embeddings of the
2
  patched model vs the stock Hugging Face model on CPU in fp32 (exact GELU), and vs stock PyTorch Native.
3
 
4
+ Gates: mean per-text cosine >= min(0.9999, stock bf16 mean) (patched vs CPU fp32; stock itself is below 0.9999 at L=1024); min per-text cosine within COS_MIN_SLACK of stock
5
  bf16 PyTorch Native's min (an absolute 0.9999 floor on the minimum is not met by stock bf16 itself);
6
  retrieval top-1 per query equal to the CPU fp32 ranking at least as often as stock Native; padding isolation
7
  bit-exact; fused ops actually traced.
 
25
  MODEL = os.environ.get("GTE_TEST_MODEL", "Alibaba-NLP/gte-large-en-v1.5")
26
  RESULTS = os.environ.get("GTE_RESULTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
27
  "device_results.jsonl"))
28
+ SHAPES = [(256, 32), (128, 32), (512, 3), (1024, 8), (768, 3)]
29
  COS_MEAN_MIN = 0.9999
30
+ ATTN_LENGTHS = tuple(int(v) for v in os.environ.get("GTE_ATTN_LENGTHS", "128,256,384,512,768,1024").split(","))
31
  COS_MIN_SLACK = 1e-3 # worst single text may trail stock's worst by this much (bf16 noise over 24 layers)
32
 
33
 
 
70
  dev = torch.device("neuron")
71
  stock = _CLS(_load(torch.bfloat16)).to(dev)
72
  p = _load(torch.bfloat16)
73
+ n = pkg.patch_model(p, attn_lengths=ATTN_LENGTHS)
74
  patched = _CLS(p).to(dev)
75
  return dict(pkg=pkg, tok=tok, cpu=cpu, dev=dev, stock=stock, patched=patched, n_layers=n, calls=calls)
76
 
 
139
  with open(RESULTS, "a") as f:
140
  f.write(json.dumps(rec) + "\n")
141
  nl = setup["n_layers"]
142
+ if L in ATTN_LENGTHS:
143
+ assert traced.get("attn_block", 0) >= nl, rec
144
+ assert traced.get("gated_mlp_ln", 0) >= nl, rec
145
+ assert float(cos_nki.mean()) >= min(COS_MEAN_MIN, float(cos_stock.mean())), rec
146
  assert float(cos_nki.min()) >= float(cos_stock.min()) - COS_MIN_SLACK, rec
147
  assert agree_nki >= agree_stock, rec
148
  assert isolated, rec
tests/test_pack_cpu.py CHANGED
@@ -20,7 +20,7 @@ transformers = pytest.importorskip("transformers")
20
  pytestmark = pytest.mark.cpu
21
 
22
  MODELS = [os.environ.get("GTE_TEST_MODEL", "Alibaba-NLP/gte-multilingual-reranker-base")]
23
- LMAX = 512 # == max(torch_ops.ATTN_LENGTHS)
24
 
25
 
26
  @pytest.fixture(scope="module", params=MODELS)
@@ -58,7 +58,7 @@ def _emulate(pk, xn, kb, NH, D, L):
58
  return layer_norm(xn + o, pk["g"][0], pk["b"][0])
59
 
60
 
61
- @pytest.mark.parametrize("L", [128, 256, 384, 512])
62
  def test_pack_matches_hf_newlayer(new_model, L):
63
  enc = new_model
64
  cfg = enc.config
 
20
  pytestmark = pytest.mark.cpu
21
 
22
  MODELS = [os.environ.get("GTE_TEST_MODEL", "Alibaba-NLP/gte-multilingual-reranker-base")]
23
+ LMAX = 1024 # == max(torch_ops.ATTN_LENGTHS)
24
 
25
 
26
  @pytest.fixture(scope="module", params=MODELS)
 
58
  return layer_norm(xn + o, pk["g"][0], pk["b"][0])
59
 
60
 
61
+ @pytest.mark.parametrize("L", [128, 256, 384, 512, 768, 1024])
62
  def test_pack_matches_hf_newlayer(new_model, L):
63
  enc = new_model
64
  cfg = enc.config
tests/test_sim.py CHANGED
@@ -128,6 +128,18 @@ def test_attn_block_h1024(B, L, lnc):
128
  assert rel_l2(y, attn_block_ref(*ref)) < TOL
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def test_attn_block_short_sequence():
132
  """One valid token (all other keys padded) and one full-length sequence in the same pass."""
133
  args, ref = _attn_case(2, 256, seed=7, lengths=[1, 256])
 
128
  assert rel_l2(y, attn_block_ref(*ref)) < TOL
129
 
130
 
131
+ # long sequences (key chunks of 512, grouped LayerNorm epilogue), H 768 and 1024, ragged padding incl. inside the
132
+ # last key chunk; LNC2 with odd B uses the query split.
133
+ @pytest.mark.parametrize("B,L,lnc,Hm", [(1, 768, 1, 768), (2, 1024, 1, 768), (1, 1024, 2, 768), (3, 768, 2, 768),
134
+ (1, 1024, 1, 1024), (2, 768, 2, 1024)])
135
+ def test_attn_block_long(B, L, lnc, Hm):
136
+ lengths = [L - 29 * (i + 1) for i in range(B)]
137
+ lengths[0] = L - 600 if L == 1024 else lengths[0] # padding reaches into the first key chunk
138
+ args, ref = _attn_case(B, L, seed=B * 1000 + L + lnc + Hm, lengths=lengths, H=Hm)
139
+ y = np.asarray(nki.simulate(attn_block_kernel[lnc])(*args, EPS)).astype(np.float32).reshape(B, L, Hm)
140
+ assert rel_l2(y, attn_block_ref(*ref)) < TOL
141
+
142
+
143
  def test_attn_block_short_sequence():
144
  """One valid token (all other keys padded) and one full-length sequence in the same pass."""
145
  args, ref = _attn_case(2, 256, seed=7, lengths=[1, 256])