trnsparse 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {trnsparse-0.8.0 → trnsparse-0.9.0}/CHANGELOG.md +21 -0
  2. {trnsparse-0.8.0 → trnsparse-0.9.0}/PKG-INFO +1 -1
  3. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_native_trn2.py +60 -13
  4. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/architecture.md +18 -0
  5. {trnsparse-0.8.0 → trnsparse-0.9.0}/pyproject.toml +1 -1
  6. trnsparse-0.9.0/tests/test_core_split.py +53 -0
  7. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_native_dispatch.py +53 -0
  8. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_compile.py +116 -0
  9. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_native.py +104 -0
  10. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/__init__.py +1 -1
  11. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/nki/_native_attn.py +20 -4
  12. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/nki/dispatch.py +40 -15
  13. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/nki/kernels.py +146 -41
  14. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse.egg-info/PKG-INFO +1 -1
  15. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse.egg-info/SOURCES.txt +1 -0
  16. {trnsparse-0.8.0 → trnsparse-0.9.0}/.github/workflows/ci.yml +0 -0
  17. {trnsparse-0.8.0 → trnsparse-0.9.0}/.github/workflows/notify-umbrella.yml +0 -0
  18. {trnsparse-0.8.0 → trnsparse-0.9.0}/.github/workflows/publish.yml +0 -0
  19. {trnsparse-0.8.0 → trnsparse-0.9.0}/.gitignore +0 -0
  20. {trnsparse-0.8.0 → trnsparse-0.9.0}/.pre-commit-config.yaml +0 -0
  21. {trnsparse-0.8.0 → trnsparse-0.9.0}/CLAUDE.md +0 -0
  22. {trnsparse-0.8.0 → trnsparse-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  23. {trnsparse-0.8.0 → trnsparse-0.9.0}/CONTRIBUTING.md +0 -0
  24. {trnsparse-0.8.0 → trnsparse-0.9.0}/LICENSE +0 -0
  25. {trnsparse-0.8.0 → trnsparse-0.9.0}/README.md +0 -0
  26. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_bsr_spmm.py +0 -0
  27. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_iterative.py +0 -0
  28. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_screening.py +0 -0
  29. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_spmm.py +0 -0
  30. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/bench_spmv.py +0 -0
  31. {trnsparse-0.8.0 → trnsparse-0.9.0}/benchmarks/conftest.py +0 -0
  32. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/api.md +0 -0
  33. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/aws_setup.md +0 -0
  34. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/benchmarks.md +0 -0
  35. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/index.md +0 -0
  36. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/installation.md +0 -0
  37. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/iterative_solvers.md +0 -0
  38. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/migration_scipy.md +0 -0
  39. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/quickstart.md +0 -0
  40. {trnsparse-0.8.0 → trnsparse-0.9.0}/docs/sparse_attention.md +0 -0
  41. {trnsparse-0.8.0 → trnsparse-0.9.0}/examples/block_sparse_attention.py +0 -0
  42. {trnsparse-0.8.0 → trnsparse-0.9.0}/examples/sparse_fock.py +0 -0
  43. {trnsparse-0.8.0 → trnsparse-0.9.0}/infra/terraform/.terraform.lock.hcl +0 -0
  44. {trnsparse-0.8.0 → trnsparse-0.9.0}/infra/terraform/README.md +0 -0
  45. {trnsparse-0.8.0 → trnsparse-0.9.0}/infra/terraform/main.tf +0 -0
  46. {trnsparse-0.8.0 → trnsparse-0.9.0}/mkdocs.yml +0 -0
  47. {trnsparse-0.8.0 → trnsparse-0.9.0}/scripts/bench_to_md.py +0 -0
  48. {trnsparse-0.8.0 → trnsparse-0.9.0}/scripts/run_benchmarks.sh +0 -0
  49. {trnsparse-0.8.0 → trnsparse-0.9.0}/scripts/run_neuron_tests.sh +0 -0
  50. {trnsparse-0.8.0 → trnsparse-0.9.0}/scripts/run_simulator_tests.sh +0 -0
  51. {trnsparse-0.8.0 → trnsparse-0.9.0}/setup.cfg +0 -0
  52. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/conftest.py +0 -0
  53. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_attention.py +0 -0
  54. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_attn_gather.py +0 -0
  55. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_bsr.py +0 -0
  56. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_examples.py +0 -0
  57. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_formats.py +0 -0
  58. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_iterative.py +0 -0
  59. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_attn.py +0 -0
  60. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_bsr.py +0 -0
  61. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_screened_spmm.py +0 -0
  62. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_sim.py +0 -0
  63. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_nki_spmm.py +0 -0
  64. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_ops.py +0 -0
  65. {trnsparse-0.8.0 → trnsparse-0.9.0}/tests/test_screening.py +0 -0
  66. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/formats.py +0 -0
  67. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/iterative.py +0 -0
  68. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/nki/__init__.py +0 -0
  69. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/nki/_native.py +0 -0
  70. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/ops.py +0 -0
  71. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse/screening.py +0 -0
  72. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse.egg-info/dependency_links.txt +0 -0
  73. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse.egg-info/requires.txt +0 -0
  74. {trnsparse-0.8.0 → trnsparse-0.9.0}/trnsparse.egg-info/top_level.txt +0 -0
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] — 2026-10-30
11
+
12
+ Validated on Trn2 (trn2.3xlarge, Neuron SDK 2.33.0, native TorchNeuron; LNC=1 and LNC=2) — see trnsci/trnsci#35.
13
+
14
+ Headline performance: static BSR SpMM and block-sparse attention split across both LNC=2 cores, with bf16 operands on fp32 PSUM; the fp32 BSR crossover against dense aten at n=8192, width 512 rises from ~27% to ~49% block density, and at seq 8192 attention forward plus backward beats unmasked dense SDPA for all three masks (#36).
15
+
16
+ ### Changed
17
+
18
+ #### Native path: static kernels on both LNC=2 cores, bf16 operands (#30)
19
+
20
+ - `bsr_spmm_static`, `attn_fwd_static` and `attn_bwd_static` split their
21
+ output tiles between the two physical cores of an LNC=2 logical core
22
+ (`program_id`), balanced by stored blocks; each output tile has one
23
+ writer. `TRNSPARSE_CORE_SPLIT=0` turns the split off.
24
+ - bf16 inputs (all operands bf16) run bf16 matmuls with fp32 PSUM
25
+ accumulation; scores and softmax statistics stay fp32, and the attention
26
+ statistics returned by `trnsparse::bsr_attn` are fp32 for bf16 / fp16 Q.
27
+ Mixed fp32/bf16 operands run in fp32.
28
+ - `benchmarks/bench_native_trn2.py`: `--dtype bf16` (matched-precision
29
+ dense aten baselines) and `--fast` (device columns only).
30
+
10
31
  ## [0.8.0] — 2026-10-30
11
32
 
12
33
  Native TorchNeuron dispatch (Trn2/Trn3) alongside the PyTorch/XLA path (Trn1, maintenance); validated on Trn2 (trn2.3xlarge, Neuron SDK 2.33.0) — see trnsci/trnsci#35.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trnsparse
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Sparse matrix operations for AWS Trainium via NKI
5
5
  Author-email: Scott Friedman <scttfrdmn@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -15,6 +15,17 @@ Sections:
15
15
  attn Block-sparse attention (local / dilated / global masks), forward
16
16
  and forward+backward, vs dense SDPA on the device and on CPU.
17
17
 
18
+ Options for the bsr and attn sections:
19
+ --dtype bf16 run the operands in bf16 (fp32 PSUM inside the kernels)
20
+ and the dense aten baselines in bf16 too, so each side has
21
+ matched precision. Errors are against fp64 on the
22
+ bf16-rounded inputs.
23
+ --fast only the device columns (trnsparse e2e, static kernel,
24
+ dense aten); skips the gather path and the CPU baselines.
25
+ TRNSPARSE_CORE_SPLIT=0 in the environment keeps each static kernel on
26
+ one physical core's partition (the whole kernel on both cores, as
27
+ before the split), for A/B runs.
28
+
18
29
  Timing: each callable is run ``--warmup`` times (the first call compiles),
19
30
  then ``--reps`` times with ``torch.neuron.synchronize()`` after every call;
20
31
  the median is reported in milliseconds. Structure stays on the host, dense
@@ -106,21 +117,26 @@ def random_bsr(n, density, seed=0):
106
117
  )
107
118
 
108
119
 
120
+ _DTYPES = {"fp32": torch.float32, "bf16": torch.bfloat16}
121
+
122
+
109
123
  def section_bsr(args):
110
124
  densities = [float(x) for x in args.densities.split(",")]
111
125
  widths = [int(x) for x in args.widths.split(",")]
112
126
  n = args.size
127
+ dt = _DTYPES[args.dtype]
113
128
  for width in widths:
114
129
  g = torch.Generator().manual_seed(1)
115
- Bc = torch.randn(n, width, generator=g)
116
- Bd = Bc.to(DEV)
130
+ Bc = torch.randn(n, width, generator=g).to(dt).float() # values exact in dt
131
+ Bd = Bc.to(DEV, dt)
117
132
  dense_dev_ms = None
118
133
  cpu_dense_ms = None
119
134
  for dens in densities:
120
135
  A = random_bsr(n, dens)
136
+ A.blocks = A.blocks.to(dt).float()
121
137
  Ac = A.to_dense()
122
138
  Ad_dev = trnsparse.BSRMatrix(
123
- blocks=A.blocks.to(DEV),
139
+ blocks=A.blocks.to(DEV, dt),
124
140
  block_col_indices=A.block_col_indices,
125
141
  block_row_ptrs=A.block_row_ptrs,
126
142
  shape=A.shape,
@@ -130,6 +146,8 @@ def section_bsr(args):
130
146
  K_max = max(counts)
131
147
  row = {
132
148
  "section": "bsr",
149
+ "dtype": args.dtype,
150
+ "core_split": _core_split(),
133
151
  "n": n,
134
152
  "width": width,
135
153
  "block_density": dens,
@@ -141,13 +159,28 @@ def section_bsr(args):
141
159
  out = trnsparse.bsr_spmm(Ad_dev, Bd)
142
160
  row["rel_err"] = rel_err(out, ref)
143
161
  row["trnsparse_ms"] = safe_bench(lambda: trnsparse.bsr_spmm(Ad_dev, Bd), reps=args.reps)
162
+ ops = _d._get_native_ops()
163
+ if args.fast:
164
+ rp, cl = _d._bsr_structure_lists(A)
165
+ bl = Ad_dev.blocks
166
+ row["static_kernel_ms"] = safe_bench(
167
+ lambda: ops["bsr_spmm_static"](bl, Bd, rp, cl), reps=args.reps
168
+ )
169
+ Ad = Ac.to(DEV, dt)
170
+ if dense_dev_ms is None:
171
+ dense_dev_ms = safe_bench(lambda: Ad @ Bd, reps=args.reps)
172
+ row["dense_dev_ms"] = dense_dev_ms
173
+ Ad = None
174
+ if row["trnsparse_ms"] and dense_dev_ms:
175
+ row["speedup_vs_dense_dev"] = dense_dev_ms / row["trnsparse_ms"]
176
+ emit(row, args.out)
177
+ continue
144
178
 
145
179
  # Split: device-side prep (gather) vs the kernel launch.
146
180
  def prep():
147
181
  return _d._bsr_pad_and_gather(_d._BlockView(Ad_dev, Ad_dev.blocks), Bd)
148
182
 
149
183
  bp, bg, *_ = prep()
150
- ops = _d._get_native_ops()
151
184
  # Gather path (main before trnsparse#30 static kernels): device-side
152
185
  # pad + gather, then the K_max-padded kernel.
153
186
  row["gather_prep_ms"] = safe_bench(prep, reps=args.reps)
@@ -160,7 +193,7 @@ def section_bsr(args):
160
193
  lambda: ops["bsr_spmm_static"](bl, Bd, rp, cl), reps=args.reps
161
194
  )
162
195
 
163
- Ad = Ac.to(DEV)
196
+ Ad = Ac.to(DEV, dt)
164
197
  if dense_dev_ms is None:
165
198
  dense_dev_ms = safe_bench(lambda: Ad @ Bd, reps=args.reps)
166
199
  row["dense_dev_ms"] = dense_dev_ms
@@ -302,11 +335,12 @@ def section_attn(args):
302
335
  seq = args.size
303
336
  nb = seq // P
304
337
  F = torch.nn.functional
338
+ dt = _DTYPES[args.dtype]
305
339
  for hd in [int(x) for x in args.head_dims.split(",")]:
306
340
  g = torch.Generator().manual_seed(0)
307
- Qc, Kc, Vc = (torch.randn(seq, hd, generator=g) for _ in range(3))
308
- dOc = torch.randn(seq, hd, generator=g)
309
- Qd, Kd, Vd, dOd = (t.to(DEV) for t in (Qc, Kc, Vc, dOc))
341
+ Qc, Kc, Vc = (torch.randn(seq, hd, generator=g).to(dt).float() for _ in range(3))
342
+ dOc = torch.randn(seq, hd, generator=g).to(dt).float()
343
+ Qd, Kd, Vd, dOd = (t.to(DEV, dt) for t in (Qc, Kc, Vc, dOc))
310
344
 
311
345
  def sdpa_fwd(q, k, v, m=None):
312
346
  return F.scaled_dot_product_attention(q[None], k[None], v[None], attn_mask=m)[0]
@@ -320,7 +354,9 @@ def section_attn(args):
320
354
  return q.grad
321
355
 
322
356
  dense_fb = safe_bench(dense_fwd_bwd, reps=args.reps)
323
- cpu_fwd = safe_bench(lambda: sdpa_fwd(Qc, Kc, Vc), warmup=1, reps=args.cpu_reps)
357
+ cpu_fwd = None
358
+ if not args.fast:
359
+ cpu_fwd = safe_bench(lambda: sdpa_fwd(Qc, Kc, Vc), warmup=1, reps=args.cpu_reps)
324
360
  for kind in args.masks.split(","):
325
361
  bm = block_mask(kind, nb)
326
362
  mb = mask_bsr_from_block_mask(bm)
@@ -328,6 +364,8 @@ def section_attn(args):
328
364
  counts = bm.sum(1).tolist()
329
365
  row = {
330
366
  "section": "attn",
367
+ "dtype": args.dtype,
368
+ "core_split": _core_split(),
331
369
  "seq": seq,
332
370
  "head_dim": hd,
333
371
  "mask": kind,
@@ -365,14 +403,21 @@ def section_attn(args):
365
403
  row["sdpa_masked_dev_fwd_ms"] = safe_bench(
366
404
  lambda: sdpa_fwd(Qd, Kd, Vd, em_d), reps=args.reps
367
405
  )
368
- row["sdpa_cpu_fwd_ms"] = cpu_fwd
369
- row["sdpa_masked_cpu_fwd_ms"] = safe_bench(
370
- lambda: sdpa_fwd(Qc, Kc, Vc, elem_mask), warmup=1, reps=args.cpu_reps
371
- )
406
+ if not args.fast:
407
+ row["sdpa_cpu_fwd_ms"] = cpu_fwd
408
+ row["sdpa_masked_cpu_fwd_ms"] = safe_bench(
409
+ lambda: sdpa_fwd(Qc, Kc, Vc, elem_mask), warmup=1, reps=args.cpu_reps
410
+ )
372
411
  emit(row, args.out)
373
412
  em_d = None
374
413
 
375
414
 
415
+ def _core_split():
416
+ from trnsparse.nki import kernels
417
+
418
+ return kernels._core_split_enabled()
419
+
420
+
376
421
  def main():
377
422
  ap = argparse.ArgumentParser()
378
423
  ap.add_argument("--section", required=True, choices=["bsr", "csr", "screen", "attn"])
@@ -383,6 +428,8 @@ def main():
383
428
  ap.add_argument("--head-dims", default="64,128")
384
429
  ap.add_argument("--masks", default="local,dilated,global")
385
430
  ap.add_argument("--no-bwd", action="store_true")
431
+ ap.add_argument("--dtype", default="fp32", choices=sorted(_DTYPES))
432
+ ap.add_argument("--fast", action="store_true")
386
433
  ap.add_argument("--reps", type=int, default=10)
387
434
  ap.add_argument("--cpu-reps", type=int, default=3)
388
435
  ap.add_argument("--out", default="")
@@ -97,6 +97,24 @@ cost is one compile per distinct structure (seconds), which a reused pattern
97
97
  amortizes. `TRNSPARSE_STATIC_STRUCTURE=0` restores the gather path. Measured
98
98
  crossovers against dense aten: `docs/benchmarks.md`.
99
99
 
100
+ Under LNC=2 (the Trn2 default) NKI traces each kernel once per physical
101
+ core, with `program_id` 0 and 1 as Python ints. The static kernels split
102
+ their output tiles between the two traces, balanced by stored blocks:
103
+
104
+ - SpMM: block rows and 512-column tiles.
105
+ - Attention forward: block rows.
106
+ - Attention backward: one pool of dQ block rows and dK/dV block columns.
107
+
108
+ Each output tile has exactly one writer. `TRNSPARSE_CORE_SPLIT=0` turns the
109
+ split off, for A/B measurements.
110
+
111
+ The static kernels take fp32 or bf16 operands. When every operand is bf16,
112
+ the matmuls run in bf16 and accumulate in fp32 PSUM. Scores, softmax
113
+ statistics and score-space arithmetic stay fp32; only P and dS are rounded
114
+ to bf16 for their matmuls. Outputs come back in bf16, and the attention
115
+ statistics (`row_max`, `row_denom`) are always fp32. Mixed fp32/bf16
116
+ operands run in fp32.
117
+
100
118
  ## Formats
101
119
 
102
120
  - **CSR**: row pointer + column indices + values. Preferred for construction,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trnsparse"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Sparse matrix operations for AWS Trainium via NKI"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,53 @@
1
+ """CPU tests for the static kernels' work split across physical cores (#30)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from trnsparse.nki import kernels as K
8
+
9
+
10
+ @pytest.mark.parametrize("n_prog", [1, 2, 3])
11
+ @pytest.mark.parametrize(
12
+ "costs",
13
+ [
14
+ [],
15
+ [1.0],
16
+ [3, 3, 3, 3],
17
+ [41, 3, 3, 3, 3, 3, 3, 3, 3, 3], # global-token row first
18
+ [0.25, 5, 0.25, 7, 2, 2, 9],
19
+ ],
20
+ )
21
+ def test_assign_units_partitions(costs, n_prog):
22
+ owned = [K._assign_units(costs, n_prog, pid) for pid in range(n_prog)]
23
+ flat = sorted(u for o in owned for u in o)
24
+ assert flat == list(range(len(costs))) # every unit exactly once: no shared writes
25
+ for o in owned:
26
+ assert o == sorted(o)
27
+
28
+
29
+ def test_assign_units_balances_skew():
30
+ costs = [41, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
31
+ loads = [sum(costs[u] for u in K._assign_units(costs, 2, pid)) for pid in range(2)]
32
+ assert max(loads) == 41 # the heavy row alone; the rest go to the other core
33
+ even = [4] * 10
34
+ loads = [sum(even[u] for u in K._assign_units(even, 2, pid)) for pid in range(2)]
35
+ assert loads == [20, 20]
36
+
37
+
38
+ def test_assign_units_deterministic():
39
+ costs = [5, 1, 5, 1, 2, 2]
40
+ assert K._assign_units(costs, 2, 0) == K._assign_units(list(costs), 2, 0)
41
+
42
+
43
+ def test_core_split_env(monkeypatch):
44
+ monkeypatch.setenv("TRNSPARSE_CORE_SPLIT", "0")
45
+ assert not K._core_split_enabled()
46
+ monkeypatch.setenv("TRNSPARSE_CORE_SPLIT", "1")
47
+ assert K._core_split_enabled()
48
+ monkeypatch.delenv("TRNSPARSE_CORE_SPLIT")
49
+ assert K._core_split_enabled()
50
+
51
+
52
+ def test_row_block_counts():
53
+ assert K._row_block_counts((0, 2, 2, 5)) == [2, 0, 3]
@@ -845,6 +845,59 @@ class TestNativePathOnCpu:
845
845
  for g_, w_ in zip(got, want, strict=True):
846
846
  torch.testing.assert_close(g_, w_, atol=1e-4, rtol=1e-4)
847
847
 
848
+ def test_static_kernels_bf16_operand_contract(self, cpu_native, monkeypatch):
849
+ """bf16 inputs reach the static kernels as bf16 operands; statistics stay fp32."""
850
+ seen = {}
851
+
852
+ def recorder(name, ref, out_dtype_of):
853
+ def run(*args):
854
+ seen[name] = [a.dtype for a in args if isinstance(a, torch.Tensor)]
855
+ f32 = [a.float() if isinstance(a, torch.Tensor) else a for a in args]
856
+ res = ref(*f32)
857
+ dt = out_dtype_of(args)
858
+ if isinstance(res, tuple):
859
+ return (res[0].to(dt),) + tuple(
860
+ r.to(dt if name == "bwd" else torch.float32) for r in res[1:]
861
+ )
862
+ return res.to(dt)
863
+
864
+ return run
865
+
866
+ for attr, name, ref, odt in (
867
+ ("_bsr_spmm_static_kernel", "spmm", _ref_bsr_spmm_static, lambda a: a[1].dtype),
868
+ ("_attn_fwd_static_kernel", "fwd", _ref_attn_fwd_static, lambda a: a[2].dtype),
869
+ ("_attn_bwd_static_kernel", "bwd", _ref_attn_bwd_static, lambda a: a[4].dtype),
870
+ ):
871
+ fn = recorder(name, ref, odt)
872
+ monkeypatch.setattr(kernels, attr, fn, raising=False)
873
+ monkeypatch.setattr(dispatch, attr, fn, raising=False)
874
+
875
+ bf = torch.bfloat16
876
+ g = torch.Generator().manual_seed(21)
877
+ dense = torch.randn(256, 256, generator=g)
878
+ dense[:128, 128:] = 0
879
+ A = trnsparse.BSRMatrix.from_dense(dense, block_size=P)
880
+ A.blocks = A.blocks.to(bf)
881
+ C = trnsparse.bsr_spmm(A, torch.randn(256, 64, generator=g).to(bf))
882
+ assert C.dtype == bf and seen["spmm"] == [bf, bf]
883
+ # Mixed precision runs in fp32 and returns the promoted dtype.
884
+ C = trnsparse.bsr_spmm(A, torch.randn(256, 64, generator=g))
885
+ assert C.dtype == torch.float32 and seen["spmm"] == [torch.float32] * 2
886
+
887
+ seq_len, hd = 2 * P, 64
888
+ mask = trnsparse.BSRMatrix.from_dense(torch.ones(seq_len, seq_len), block_size=P)
889
+ Q, K, V, dO = (torch.randn(seq_len, hd, generator=g).to(bf) for _ in range(4))
890
+ out, rm, rd = dispatch.nki_bsr_attn_tiled(Q, K, V, mask, return_stats=True)
891
+ assert out.dtype == bf and seen["fwd"] == [bf] * 3
892
+ assert rm.dtype == torch.float32 and rd.dtype == torch.float32
893
+ o2, rm2, rd2 = torch.ops.trnsparse.bsr_attn(
894
+ Q, K, V, mask.block_col_indices, mask.block_row_ptrs, P
895
+ )
896
+ assert o2.dtype == bf and rm2.dtype == torch.float32 and rd2.dtype == torch.float32
897
+ dQ, dK, dV = dispatch.nki_bsr_attn_bwd(Q, K, V, dO, out, mask, rm, rd)
898
+ assert seen["bwd"] == [bf] * 7 + [torch.float32] * 6
899
+ assert {t.dtype for t in (dQ, dK, dV)} == {bf}
900
+
848
901
  def test_column_structure(self):
849
902
  assert dispatch._column_structure([0, 2, 2, 5], [0, 1, 0, 1, 2], 3) == (
850
903
  [0, 2, 4, 5],
@@ -183,3 +183,119 @@ def test_kernel_neff_compiles(case, target, tmp_path):
183
183
  )
184
184
  driver._compile_bir_to_neff(bir, opts, inputs)
185
185
  assert (tmp_path / "file.neff").stat().st_size > 0
186
+
187
+
188
+ # ── Static kernels on both physical cores, fp32 and bf16 (#30) ───────────────
189
+ #
190
+ # Under LNC=2 the static kernels are traced once per physical core and split
191
+ # their output tiles between the two traces. Verify both traces for every
192
+ # kernel and input dtype, including structures with fewer units than cores
193
+ # (one block row: the BSR kernel splits columns instead) and skewed rows.
194
+
195
+
196
+ def _bf16(*shape):
197
+ import ml_dtypes
198
+
199
+ return _f32(*shape).astype(ml_dtypes.bfloat16)
200
+
201
+
202
+ def _static_lnc2_cases():
203
+ cases = {}
204
+ for dtype, mk in (("f32", _f32), ("bf16", _bf16)):
205
+ cases[("_bsr_spmm_static_kernel", f"skew-n512-{dtype}")] = lambda mk=mk: (
206
+ mk(6, _P, _P),
207
+ mk(4 * _P, 512),
208
+ (0, 4, 4, 5, 6),
209
+ (0, 1, 2, 3, 0, 3),
210
+ )
211
+ cases[("_bsr_spmm_static_kernel", f"n1024-{dtype}")] = lambda mk=mk: (
212
+ mk(3, _P, _P),
213
+ mk(3 * _P, 1024),
214
+ (0, 2, 2, 3),
215
+ (0, 2, 2),
216
+ )
217
+ cases[("_bsr_spmm_static_kernel", f"onerow-n128-{dtype}")] = lambda mk=mk: (
218
+ mk(2, _P, _P),
219
+ mk(2 * _P, 128),
220
+ (0, 2),
221
+ (0, 1),
222
+ )
223
+ for hd in (64, 128):
224
+ cases[("_attn_fwd_static_kernel", f"hd{hd}-{dtype}")] = lambda hd=hd, mk=mk: (
225
+ mk(hd, 3 * _P),
226
+ mk(hd, 3 * _P),
227
+ mk(3 * _P, hd),
228
+ (0, 2, 2, 5),
229
+ (0, 1, 0, 1, 2),
230
+ )
231
+ cases[("_attn_bwd_static_kernel", f"hd{hd}-{dtype}")] = lambda hd=hd, mk=mk: (
232
+ mk(hd, 3 * _P),
233
+ mk(hd, 3 * _P),
234
+ mk(hd, 3 * _P),
235
+ mk(hd, 3 * _P),
236
+ mk(3 * _P, hd),
237
+ mk(3 * _P, hd),
238
+ mk(3 * _P, hd),
239
+ _f32(3 * _P, 1),
240
+ np.abs(_f32(3 * _P, 1)),
241
+ _f32(3 * _P, 1),
242
+ _f32(1, 3 * _P),
243
+ np.abs(_f32(1, 3 * _P)),
244
+ _f32(1, 3 * _P),
245
+ (0, 2, 2, 5),
246
+ (0, 1, 0, 1, 2),
247
+ (0, 2, 4, 5),
248
+ (0, 2, 0, 2, 2),
249
+ 0.125,
250
+ )
251
+ return cases
252
+
253
+
254
+ _STATIC_LNC2 = _static_lnc2_cases()
255
+
256
+
257
+ @pytest.mark.parametrize("lnc", [1, 2])
258
+ @pytest.mark.parametrize("case", sorted(_STATIC_LNC2), ids=lambda c: f"{c[0]}-{c[1]}")
259
+ def test_static_kernel_lnc_mlir_verifies(case, lnc):
260
+ from trnsparse.nki import kernels
261
+
262
+ if case[1].endswith("bf16"):
263
+ pytest.importorskip("ml_dtypes")
264
+ kernel = getattr(kernels, case[0])
265
+ result = source_to_mlir(
266
+ kernel, *_STATIC_LNC2[case](), target="trn2", lnc=lnc, frontend=FrontendKind.TRACER
267
+ )
268
+ result.verify()
269
+
270
+
271
+ @pytest.mark.skipif(shutil.which("neuronx-cc") is None, reason="neuronx-cc not on PATH")
272
+ @pytest.mark.parametrize("case", sorted(_STATIC_LNC2), ids=lambda c: f"{c[0]}-{c[1]}")
273
+ def test_static_kernel_lnc2_neff_compiles(case, tmp_path):
274
+ """Full CPU NEFF compile of both per-core traces (LNC=2, trn2)."""
275
+ try:
276
+ from nki.compiler import driver
277
+ from nki.compiler.frontend import TracerFrontend
278
+ from nki.compiler.ncc_driver import CompileOptions
279
+ except ImportError as e:
280
+ pytest.skip(f"nki compiler driver API not available: {e}")
281
+ if not hasattr(driver, "_compile_bir_to_neff"):
282
+ pytest.skip("nki.compiler.driver._compile_bir_to_neff not available in this nki")
283
+ if case[1].endswith("bf16"):
284
+ pytest.importorskip("ml_dtypes")
285
+
286
+ from trnsparse.nki import kernels
287
+
288
+ kernel = getattr(kernels, case[0])
289
+ fn = getattr(kernel, "func", kernel)
290
+ inputs = dict(zip(inspect.signature(fn).parameters, _STATIC_LNC2[case](), strict=True))
291
+ opts = CompileOptions(
292
+ target="trn2",
293
+ lnc=2,
294
+ artifacts_dir=str(tmp_path),
295
+ output_path=str(tmp_path / "file.neff"),
296
+ )
297
+ bir = driver.compile_to_bir(
298
+ fn, frontend=TracerFrontend(), inputs=inputs, compile_opts=opts, enable_cache=False
299
+ )
300
+ driver._compile_bir_to_neff(bir, opts, inputs)
301
+ assert (tmp_path / "file.neff").stat().st_size > 0
@@ -234,3 +234,107 @@ class TestTorchCompile:
234
234
 
235
235
  out = f(A.values, A.col_indices, A.row_ptrs, B.to(DEV))
236
236
  torch.testing.assert_close(out.cpu(), dense @ B, atol=ATOL, rtol=RTOL)
237
+
238
+
239
+ def _rel(x, ref):
240
+ x, ref = x.detach().cpu().double(), ref.detach().cpu().double()
241
+ return float((x - ref).norm() / ref.norm())
242
+
243
+
244
+ def _skewed_bsr(n_rows, n_cols, seed):
245
+ """Block row 0 dense (a global-token row), the rest one or two blocks; a middle row empty."""
246
+ g = torch.Generator().manual_seed(seed)
247
+ bm = torch.zeros(n_rows, n_cols, dtype=torch.bool)
248
+ bm[0, :] = True
249
+ for i in range(1, n_rows):
250
+ bm[i, i % n_cols] = True
251
+ bm[i, (3 * i + 1) % n_cols] = True
252
+ if n_rows > 2:
253
+ bm[n_rows // 2, :] = False
254
+ dense = torch.randn(n_rows * P, n_cols * P, generator=g)
255
+ dense *= bm.repeat_interleave(P, 0).repeat_interleave(P, 1)
256
+ return dense, trnsparse.BSRMatrix.from_dense(dense, block_size=P)
257
+
258
+
259
+ class TestTwoCoreAndBf16:
260
+ """Static kernels split across both physical cores (LNC=2), fp32 and bf16 (#30).
261
+
262
+ A core that skipped its share would leave whole output tiles unwritten,
263
+ so the fp32 cases use tight tolerances over every row and column tile.
264
+ """
265
+
266
+ @pytest.mark.parametrize(
267
+ "n_rows,n_cols,N",
268
+ [(6, 6, 128), (6, 4, 1024), (1, 3, 512), (1, 2, 128), (9, 5, 200)],
269
+ )
270
+ def test_bsr_static_fp32_every_tile(self, n_rows, n_cols, N):
271
+ dense, A = _skewed_bsr(n_rows, n_cols, seed=n_rows * 10 + N)
272
+ B = torch.randn(n_cols * P, N, generator=torch.Generator().manual_seed(1))
273
+ A.blocks = A.blocks.to(DEV)
274
+ C = trnsparse.bsr_spmm(A, B.to(DEV))
275
+ assert C.dtype == torch.float32
276
+ torch.testing.assert_close(C.cpu(), dense @ B, atol=ATOL, rtol=RTOL)
277
+
278
+ @pytest.mark.parametrize("n_rows,n_cols,N", [(6, 6, 128), (6, 4, 1024), (1, 3, 512)])
279
+ def test_bsr_static_bf16(self, n_rows, n_cols, N):
280
+ dense, A = _skewed_bsr(n_rows, n_cols, seed=n_rows * 10 + N)
281
+ B = torch.randn(n_cols * P, N, generator=torch.Generator().manual_seed(1))
282
+ A.blocks = A.blocks.to(DEV, torch.bfloat16)
283
+ Bh = B.to(DEV, torch.bfloat16)
284
+ C = trnsparse.bsr_spmm(A, Bh)
285
+ assert C.dtype == torch.bfloat16 and C.device.type == DEV
286
+ ref = dense.bfloat16().double() @ B.bfloat16().double() # bf16 inputs, exact sum
287
+ assert _rel(C, ref) < 1e-2
288
+
289
+ def test_bsr_static_bf16_grad(self):
290
+ dense, A = _skewed_bsr(4, 4, seed=2)
291
+ B = torch.randn(4 * P, 64, generator=torch.Generator().manual_seed(2))
292
+ A.blocks = A.blocks.to(DEV, torch.bfloat16).requires_grad_(True)
293
+ Bd = B.to(DEV, torch.bfloat16).requires_grad_(True)
294
+ trnsparse.bsr_spmm(A, Bd).float().sum().backward()
295
+ ref = dense.bfloat16().double().T @ torch.ones(4 * P, 64, dtype=torch.float64)
296
+ assert Bd.grad.dtype == torch.bfloat16
297
+ assert _rel(Bd.grad, ref) < 1e-2
298
+
299
+ @pytest.mark.parametrize("dtype,tol", [(torch.float32, 1e-5), (torch.bfloat16, 3e-2)])
300
+ @pytest.mark.parametrize("hd", [64, 128])
301
+ def test_attention_static_two_core(self, dtype, tol, hd):
302
+ from trnsparse.ops import _block_sparse_attn_backward, _block_sparse_attn_pytorch
303
+
304
+ torch.manual_seed(7)
305
+ seq_len = 5 * P
306
+ # Global-token block row/column 0 plus a local band: skewed per-row cost.
307
+ nb = seq_len // P
308
+ bm = torch.zeros(nb, nb)
309
+ bm[0, :] = 1
310
+ bm[:, 0] = 1
311
+ for i in range(nb):
312
+ bm[i, max(0, i - 1) : i + 2] = 1
313
+ mask = trnsparse.BSRMatrix.from_dense(
314
+ bm.repeat_interleave(P, 0).repeat_interleave(P, 1), block_size=P
315
+ )
316
+ Q, K, V, dO = (torch.randn(seq_len, hd).to(dtype) for _ in range(4))
317
+ Qd, Kd, Vd = (t.to(DEV).requires_grad_(True) for t in (Q, K, V))
318
+ out = trnsparse.block_sparse_attention_tiled(Qd, Kd, Vd, mask)
319
+ assert out.dtype == dtype
320
+ q64, k64, v64, do64 = (t.double() for t in (Q, K, V, dO))
321
+ scale = hd**-0.5
322
+ ref, rm, rd = _block_sparse_attn_pytorch(q64, k64, v64, mask, scale, return_stats=True)
323
+ assert _rel(out, ref) < tol
324
+ out.backward(dO.to(DEV))
325
+ dQ, dK, dV = _block_sparse_attn_backward(
326
+ q64, k64, v64, ref, do64, mask, scale, row_max=rm, row_denom=rd
327
+ )
328
+ for got, exp in ((Qd.grad, dQ), (Kd.grad, dK), (Vd.grad, dV)):
329
+ assert got.dtype == dtype
330
+ assert _rel(got, exp) < tol
331
+
332
+ def test_attention_bf16_stats_are_fp32(self):
333
+ seq_len, hd = 2 * P, 64
334
+ mask = _block_causal_mask(seq_len)
335
+ Q, K, V = (torch.randn(seq_len, hd, dtype=torch.bfloat16).to(DEV) for _ in range(3))
336
+ out, rm, rd = torch.ops.trnsparse.bsr_attn(
337
+ Q, K, V, mask.block_col_indices, mask.block_row_ptrs, P
338
+ )
339
+ assert out.dtype == torch.bfloat16
340
+ assert rm.dtype == torch.float32 and rd.dtype == torch.float32
@@ -5,7 +5,7 @@ CSR/COO formats, SpMV, SpMM, and integral screening for
5
5
  sparse scientific computing. Part of the trnsci scientific computing suite.
6
6
  """
7
7
 
8
- __version__ = "0.8.0"
8
+ __version__ = "0.9.0"
9
9
 
10
10
  from .formats import BSRMatrix, COOMatrix, CSRMatrix, eye_sparse, from_dense, from_scipy
11
11
  from .iterative import (
@@ -32,6 +32,15 @@ import torch
32
32
  from . import dispatch as _d
33
33
 
34
34
 
35
+ def _stats_dtype(dtype: torch.dtype) -> torch.dtype:
36
+ """Softmax statistics dtype: fp32 for low-precision Q (bf16 / fp16), else Q's.
37
+
38
+ The backward recomputes P = exp(S - row_max) / row_denom; a bf16
39
+ row_max would scale every P of the row by up to exp(2^-9 · |row_max|).
40
+ """
41
+ return torch.float32 if dtype in (torch.bfloat16, torch.float16) else dtype
42
+
43
+
35
44
  def _pattern(block_col_indices, block_row_ptrs, seq_len, block_size):
36
45
  return _d._MaskPattern(block_col_indices, block_row_ptrs, (seq_len, seq_len), block_size)
37
46
 
@@ -47,17 +56,24 @@ def bsr_attn(
47
56
  ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
48
57
  mask = _pattern(block_col_indices, block_row_ptrs, Q.shape[0], block_size)
49
58
  out, row_max, row_denom = _d.nki_bsr_attn_tiled(Q, K, V, mask, return_stats=True)
50
- # Fixed output contract (Q's dtype and device) to match the fake.
51
- return tuple(t.to(device=Q.device, dtype=Q.dtype) for t in (out, row_max, row_denom))
59
+ # Fixed output contract (Q's device; out in Q's dtype, the statistics in
60
+ # `_stats_dtype`) to match the fake.
61
+ sdt = _stats_dtype(Q.dtype)
62
+ return (
63
+ out.to(device=Q.device, dtype=Q.dtype),
64
+ row_max.to(device=Q.device, dtype=sdt),
65
+ row_denom.to(device=Q.device, dtype=sdt),
66
+ )
52
67
 
53
68
 
54
69
  @bsr_attn.register_fake
55
70
  def _bsr_attn_fake(Q, K, V, block_col_indices, block_row_ptrs, block_size):
56
71
  M_tiles = Q.shape[0] // block_size
72
+ sdt = _stats_dtype(Q.dtype)
57
73
  return (
58
74
  torch.empty_like(Q),
59
- Q.new_empty((M_tiles, block_size)),
60
- Q.new_empty((M_tiles, block_size)),
75
+ Q.new_empty((M_tiles, block_size), dtype=sdt),
76
+ Q.new_empty((M_tiles, block_size), dtype=sdt),
61
77
  )
62
78
 
63
79