trnsparse 0.9.0__tar.gz → 0.10.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 (78) hide show
  1. {trnsparse-0.9.0 → trnsparse-0.10.0}/CHANGELOG.md +42 -0
  2. {trnsparse-0.9.0 → trnsparse-0.10.0}/PKG-INFO +1 -1
  3. trnsparse-0.10.0/benchmarks/bench_multicore_trn2.py +466 -0
  4. trnsparse-0.10.0/benchmarks/bench_multiproc_trn2.py +130 -0
  5. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/api.md +31 -0
  6. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/benchmarks.md +159 -0
  7. {trnsparse-0.9.0 → trnsparse-0.10.0}/pyproject.toml +1 -1
  8. trnsparse-0.10.0/tests/test_multicore.py +206 -0
  9. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_native_dispatch.py +57 -5
  10. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_compile.py +43 -0
  11. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/__init__.py +1 -1
  12. trnsparse-0.10.0/trnsparse/multicore.py +315 -0
  13. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/nki/_native.py +82 -0
  14. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/nki/dispatch.py +41 -10
  15. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/nki/kernels.py +187 -8
  16. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse.egg-info/PKG-INFO +1 -1
  17. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse.egg-info/SOURCES.txt +4 -0
  18. {trnsparse-0.9.0 → trnsparse-0.10.0}/.github/workflows/ci.yml +0 -0
  19. {trnsparse-0.9.0 → trnsparse-0.10.0}/.github/workflows/notify-umbrella.yml +0 -0
  20. {trnsparse-0.9.0 → trnsparse-0.10.0}/.github/workflows/publish.yml +0 -0
  21. {trnsparse-0.9.0 → trnsparse-0.10.0}/.gitignore +0 -0
  22. {trnsparse-0.9.0 → trnsparse-0.10.0}/.pre-commit-config.yaml +0 -0
  23. {trnsparse-0.9.0 → trnsparse-0.10.0}/CLAUDE.md +0 -0
  24. {trnsparse-0.9.0 → trnsparse-0.10.0}/CODE_OF_CONDUCT.md +0 -0
  25. {trnsparse-0.9.0 → trnsparse-0.10.0}/CONTRIBUTING.md +0 -0
  26. {trnsparse-0.9.0 → trnsparse-0.10.0}/LICENSE +0 -0
  27. {trnsparse-0.9.0 → trnsparse-0.10.0}/README.md +0 -0
  28. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_bsr_spmm.py +0 -0
  29. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_iterative.py +0 -0
  30. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_native_trn2.py +0 -0
  31. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_screening.py +0 -0
  32. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_spmm.py +0 -0
  33. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/bench_spmv.py +0 -0
  34. {trnsparse-0.9.0 → trnsparse-0.10.0}/benchmarks/conftest.py +0 -0
  35. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/architecture.md +0 -0
  36. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/aws_setup.md +0 -0
  37. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/index.md +0 -0
  38. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/installation.md +0 -0
  39. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/iterative_solvers.md +0 -0
  40. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/migration_scipy.md +0 -0
  41. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/quickstart.md +0 -0
  42. {trnsparse-0.9.0 → trnsparse-0.10.0}/docs/sparse_attention.md +0 -0
  43. {trnsparse-0.9.0 → trnsparse-0.10.0}/examples/block_sparse_attention.py +0 -0
  44. {trnsparse-0.9.0 → trnsparse-0.10.0}/examples/sparse_fock.py +0 -0
  45. {trnsparse-0.9.0 → trnsparse-0.10.0}/infra/terraform/.terraform.lock.hcl +0 -0
  46. {trnsparse-0.9.0 → trnsparse-0.10.0}/infra/terraform/README.md +0 -0
  47. {trnsparse-0.9.0 → trnsparse-0.10.0}/infra/terraform/main.tf +0 -0
  48. {trnsparse-0.9.0 → trnsparse-0.10.0}/mkdocs.yml +0 -0
  49. {trnsparse-0.9.0 → trnsparse-0.10.0}/scripts/bench_to_md.py +0 -0
  50. {trnsparse-0.9.0 → trnsparse-0.10.0}/scripts/run_benchmarks.sh +0 -0
  51. {trnsparse-0.9.0 → trnsparse-0.10.0}/scripts/run_neuron_tests.sh +0 -0
  52. {trnsparse-0.9.0 → trnsparse-0.10.0}/scripts/run_simulator_tests.sh +0 -0
  53. {trnsparse-0.9.0 → trnsparse-0.10.0}/setup.cfg +0 -0
  54. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/conftest.py +0 -0
  55. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_attention.py +0 -0
  56. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_attn_gather.py +0 -0
  57. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_bsr.py +0 -0
  58. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_core_split.py +0 -0
  59. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_examples.py +0 -0
  60. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_formats.py +0 -0
  61. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_iterative.py +0 -0
  62. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_attn.py +0 -0
  63. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_bsr.py +0 -0
  64. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_native.py +0 -0
  65. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_screened_spmm.py +0 -0
  66. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_sim.py +0 -0
  67. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_nki_spmm.py +0 -0
  68. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_ops.py +0 -0
  69. {trnsparse-0.9.0 → trnsparse-0.10.0}/tests/test_screening.py +0 -0
  70. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/formats.py +0 -0
  71. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/iterative.py +0 -0
  72. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/nki/__init__.py +0 -0
  73. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/nki/_native_attn.py +0 -0
  74. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/ops.py +0 -0
  75. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse/screening.py +0 -0
  76. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse.egg-info/dependency_links.txt +0 -0
  77. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse.egg-info/requires.txt +0 -0
  78. {trnsparse-0.9.0 → trnsparse-0.10.0}/trnsparse.egg-info/top_level.txt +0 -0
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] — 2026-10-31
11
+
12
+ Validated on Trn2 (trn2.3xlarge, Neuron SDK 2.33.0, native TorchNeuron) — see trnsci/trnsci#35.
13
+
14
+ Headline: pre-transposed static BSR SpMM beats dense aten up to ~82–98% block density on one core (was 26–49%), plus `ShardedBSR`/`ShardedAttention` across logical NeuronCores (#38).
15
+
16
+ ### Added
17
+
18
+ #### Static BSR SpMM and block-sparse attention across logical NeuronCores (#30)
19
+
20
+ - `trnsparse.multicore`: `ShardedBSR` (block rows split across the logical
21
+ cores of a Trn2 chip, balanced by stored blocks), `ShardedAttention`
22
+ (single-head attention forward split by query block rows),
23
+ `partition_block_rows` and `logical_cores`. Per-core operands are built
24
+ on the host and stay resident; one kernel launch per core, issued
25
+ without waiting.
26
+ - Pre-transposed BSR kernel (`bsr_spmm_t_keyed`, the `ShardedBSR`
27
+ default). The blocks are stored transposed, side by side, so the kernel
28
+ needs no in-kernel `load_transpose2d`. It loads up to 16 of a row's
29
+ blocks per DMA, reads PSUM out with one copy, and shares each B tile
30
+ across groups of up to 8 block rows, one PSUM bank each
31
+ (`TRNSPARSE_ROW_GROUP`). The kernel has autograd.
32
+ - `benchmarks/bench_multicore_trn2.py` (1, 2 and 4 logical cores, against
33
+ dense aten on 1 core and split across cores) and
34
+ `benchmarks/bench_multiproc_trn2.py` (one process per core).
35
+
36
+ ### Changed
37
+
38
+ - The static kernels take their structure as a registered key, a content
39
+ hash (`kernels.register_structure`), instead of int lists. The key
40
+ crosses the custom-op boundary as one int, so the host cost of a launch
41
+ no longer grows with the stored-block count: on Trn2 it was 0.13 ms at
42
+ 41 blocks and 0.67 ms at 2048, and is now about 0.12 ms at any size. The
43
+ public `bsr_spmm` and attention-forward paths use the keyed ops.
44
+ - Native kernels run on the logical core that holds their operands
45
+ (`neuron:k`) instead of always on the default `neuron` device. Operands
46
+ spread over two cores raise an error. TorchNeuron does not copy between
47
+ logical cores: `.to()` returns the tensor still on its old core.
48
+ - Kernel docstrings now give the measured `nl.reciprocal` behaviour:
49
+ about 1.5e-6 relative error in range, but it silently returns 1e7 or 0
50
+ outside roughly |x| in [2^-42, 2^41].
51
+
10
52
  ## [0.9.0] — 2026-10-30
11
53
 
12
54
  Validated on Trn2 (trn2.3xlarge, Neuron SDK 2.33.0, native TorchNeuron; LNC=1 and LNC=2) — see trnsci/trnsci#35.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trnsparse
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -0,0 +1,466 @@
1
+ """Multi-core (logical NeuronCore) scaling benchmark for Trn2, native TorchNeuron.
2
+
3
+ Run with every logical core of the chip visible to one process:
4
+
5
+ NEURON_RT_VISIBLE_CORES=0-3 NKI_ENABLE_TRACE_CACHE=0 \\
6
+ python benchmarks/bench_multicore_trn2.py --section bsr --size 8192 --widths 512
7
+
8
+ Sections:
9
+ bsr static BSR SpMM on 1, 2 and 4 logical cores (`ShardedBSR`,
10
+ block rows balanced by stored blocks) vs dense aten `A @ B` on
11
+ one core and with A's rows split across 2 and 4 cores.
12
+ attn single-head block-sparse attention forward, query block rows
13
+ split across 1, 2 and 4 cores (`ShardedAttention`), vs dense
14
+ and masked SDPA on one core and with the query rows split
15
+ across 4 cores.
16
+ heads multi-head block-sparse attention (``--heads``) forward and
17
+ forward+backward with whole heads on different cores, vs
18
+ dense SDPA over the same heads on one core and split across
19
+ cores.
20
+
21
+ Every case keeps its operands resident on the cores that use them (built
22
+ on the host, copied once; TorchNeuron does not copy between logical cores),
23
+ issues one launch per core from this thread, and then synchronizes every
24
+ core used. That is the time of a sharded call whose inputs and outputs stay
25
+ on the cores. The host gather of a sharded result is reported separately
26
+ (`*_gather_ms`). Warm median of ``--reps`` calls; the first calls compile.
27
+ """
28
+
29
+ # ruff: noqa: B023 (every closure is benchmarked before its loop variables change)
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import gc
34
+ import json
35
+ import statistics
36
+ import sys
37
+ import time
38
+ from pathlib import Path
39
+
40
+ import torch
41
+
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
43
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
44
+
45
+ from bench_native_trn2 import ( # noqa: E402
46
+ block_mask,
47
+ mask_bsr_from_block_mask,
48
+ random_bsr,
49
+ )
50
+
51
+ import trnsparse # noqa: E402
52
+ from trnsparse.multicore import ShardedAttention, ShardedBSR, logical_cores # noqa: E402
53
+
54
+ P = 128
55
+ F = torch.nn.functional
56
+
57
+
58
+ def pick(cores, k):
59
+ """k devices: the first k cores (wrapping onto the visible ones for smoke runs)."""
60
+ return [cores[i % len(cores)] for i in range(k)]
61
+
62
+
63
+ def sync(devs):
64
+ for i in sorted({d.index for d in devs}):
65
+ torch.neuron.synchronize(i)
66
+
67
+
68
+ def bench(fn, devs, warmup=2, reps=10):
69
+ for _ in range(warmup):
70
+ fn()
71
+ sync(devs)
72
+ ts = []
73
+ for _ in range(reps):
74
+ t0 = time.perf_counter()
75
+ fn()
76
+ sync(devs)
77
+ ts.append((time.perf_counter() - t0) * 1e3)
78
+ return statistics.median(ts)
79
+
80
+
81
+ def safe(fn, devs, **kw):
82
+ try:
83
+ return bench(fn, devs, **kw)
84
+ except Exception as e: # report, don't abort the section
85
+ print(f"# bench failed: {type(e).__name__}: {str(e)[:300]}", file=sys.stderr)
86
+ return None
87
+
88
+
89
+ def issue_time(fn, devs, reps=5):
90
+ """Host time to issue the launches (before any synchronization), median."""
91
+ ts = []
92
+ for _ in range(reps):
93
+ sync(devs)
94
+ t0 = time.perf_counter()
95
+ fn()
96
+ ts.append((time.perf_counter() - t0) * 1e3)
97
+ sync(devs)
98
+ return statistics.median(ts)
99
+
100
+
101
+ def cpu_bench(fn, reps=2):
102
+ fn()
103
+ ts = []
104
+ for _ in range(reps):
105
+ t0 = time.perf_counter()
106
+ fn()
107
+ ts.append((time.perf_counter() - t0) * 1e3)
108
+ return statistics.median(ts)
109
+
110
+
111
+ def rel_err(x, ref):
112
+ x = x.detach().cpu().double()
113
+ ref = ref.detach().cpu().double()
114
+ return float((x - ref).norm() / ref.norm().clamp_min(1e-30))
115
+
116
+
117
+ def emit(row, out):
118
+ line = json.dumps(row)
119
+ print(line, flush=True)
120
+ if out:
121
+ with open(out, "a") as f:
122
+ f.write(line + "\n")
123
+
124
+
125
+ def free():
126
+ gc.collect()
127
+
128
+
129
+ # ── BSR ──────────────────────────────────────────────────────────────────────
130
+
131
+
132
+ def _row_check(A, B, rows):
133
+ """fp64 reference for block rows `rows` of A @ B (host)."""
134
+ ptrs = A.block_row_ptrs.tolist()
135
+ cols = A.block_col_indices.tolist()
136
+ Bd = B.double()
137
+ out = []
138
+ for m in rows:
139
+ acc = torch.zeros(P, B.shape[1], dtype=torch.float64)
140
+ for idx in range(ptrs[m], ptrs[m + 1]):
141
+ j = cols[idx]
142
+ acc += A.blocks[idx].double() @ Bd[j * P : (j + 1) * P]
143
+ out.append(acc)
144
+ return torch.cat(out)
145
+
146
+
147
+ def section_bsr(args, cores):
148
+ n = args.size
149
+ core_counts = [int(c) for c in args.cores.split(",")]
150
+ for width in [int(x) for x in args.widths.split(",")]:
151
+ g = torch.Generator().manual_seed(1)
152
+ Bc = torch.randn(n, width, generator=g)
153
+ B_on = {d: Bc.to(d) for d in cores[: max(core_counts)]}
154
+ # Dense aten, one core and A's rows split across k cores (B on every core).
155
+ Ac = torch.randn(n, n, generator=g) / n**0.5
156
+ dense = {}
157
+ for k in core_counts:
158
+ devs = pick(cores, k)
159
+ slabs = [Ac[i * n // k : (i + 1) * n // k].to(devs[i]) for i in range(k)]
160
+ dense[k] = safe(
161
+ lambda: [s @ B_on[d] for s, d in zip(slabs, devs, strict=True)],
162
+ devs,
163
+ reps=args.reps,
164
+ )
165
+ slabs = None
166
+ free()
167
+ cpu_dense = cpu_bench(lambda: Ac @ Bc) if args.cpu else None
168
+ Ac = None
169
+ free()
170
+ for dens in [float(x) for x in args.densities.split(",")]:
171
+ A = random_bsr(n, dens)
172
+ counts = (A.block_row_ptrs[1:] - A.block_row_ptrs[:-1]).tolist()
173
+ row = {
174
+ "section": "bsr",
175
+ "n": n,
176
+ "width": width,
177
+ "block_density": dens,
178
+ "n_blocks": A.n_blocks,
179
+ "K_max": max(counts),
180
+ "cpu_dense_ms": cpu_dense,
181
+ }
182
+ for k in core_counts:
183
+ row[f"dense_{k}c_ms"] = dense[k]
184
+ # The public one-core API (as in #34 / #36), for continuity.
185
+ d0 = cores[0]
186
+ A0 = trnsparse.BSRMatrix(
187
+ blocks=A.blocks.to(d0),
188
+ block_col_indices=A.block_col_indices,
189
+ block_row_ptrs=A.block_row_ptrs,
190
+ shape=A.shape,
191
+ block_size=P,
192
+ )
193
+ trnsparse.bsr_spmm(A0, B_on[d0])
194
+ row["api_1c_ms"] = safe(lambda: trnsparse.bsr_spmm(A0, B_on[d0]), [d0], reps=args.reps)
195
+ A0 = None
196
+ variants = [("", False)] if not args.only_pretransposed else []
197
+ if args.pretransposed or args.only_pretransposed:
198
+ variants.append(("t_", True))
199
+ for tag, pre in variants:
200
+ for k in core_counts:
201
+ devs = pick(cores, k)
202
+ t0 = time.perf_counter()
203
+ S = ShardedBSR(A, devs, pretransposed=pre)
204
+ reps_B = [B_on[d] for d in S.devices]
205
+ outs = S.spmm(reps_B)
206
+ sync(S.devices)
207
+ row[f"sparse_{tag}{k}c_first_ms"] = (
208
+ time.perf_counter() - t0
209
+ ) * 1e3 # incl. compile
210
+ row[f"sparse_{tag}{k}c_blocks"] = S.stored_blocks
211
+ # correctness: two block rows from every shard
212
+ chk = []
213
+ for p, rows in enumerate(S.rows):
214
+ sel = rows[:1] + rows[-1:]
215
+ o = outs[p].cpu().reshape(len(rows), P, width)
216
+ pos = [0, len(rows) - 1]
217
+ chk.append(rel_err(o[pos].reshape(-1, width), _row_check(A, Bc, sel)))
218
+ row[f"sparse_{tag}{k}c_rel_err"] = max(chk)
219
+ row[f"sparse_{tag}{k}c_ms"] = safe(
220
+ lambda: S.spmm(reps_B), S.devices, reps=args.reps
221
+ )
222
+ row[f"sparse_{tag}{k}c_issue_ms"] = issue_time(
223
+ lambda: S.spmm(reps_B), S.devices
224
+ )
225
+ if k == max(core_counts) and not pre:
226
+ row[f"sparse_{tag}{k}c_gather_ms"] = safe(
227
+ lambda: S.gather(S.spmm(reps_B)), S.devices, reps=max(3, args.reps // 2)
228
+ )
229
+ outs = S = None
230
+ free()
231
+ k1 = min(core_counts)
232
+ for k in core_counts:
233
+ if row.get(f"sparse_t_{k}c_ms") and row.get(f"sparse_{k}c_ms"):
234
+ row[f"t_speedup_{k}c"] = row[f"sparse_{k}c_ms"] / row[f"sparse_t_{k}c_ms"]
235
+ if row.get(f"sparse_{k}c_ms") and row.get(f"sparse_{k1}c_ms"):
236
+ row[f"sparse_scaling_{k}c"] = row[f"sparse_{k1}c_ms"] / row[f"sparse_{k}c_ms"]
237
+ if row.get(f"sparse_{k}c_ms") and dense.get(k1):
238
+ row[f"dense_{k1}c_over_sparse_{k}c"] = dense[k1] / row[f"sparse_{k}c_ms"]
239
+ if row.get(f"sparse_{k}c_ms") and dense.get(k):
240
+ row[f"dense_{k}c_over_sparse_{k}c"] = dense[k] / row[f"sparse_{k}c_ms"]
241
+ emit(row, args.out)
242
+ B_on = None
243
+ free()
244
+
245
+
246
+ # ── Attention: one head, query rows split ────────────────────────────────────
247
+
248
+
249
+ def _sdpa(q, k, v, m=None):
250
+ return F.scaled_dot_product_attention(q[None], k[None], v[None], attn_mask=m)[0]
251
+
252
+
253
+ def section_attn(args, cores):
254
+ seq = args.size
255
+ nb = seq // P
256
+ core_counts = [int(c) for c in args.cores.split(",")]
257
+ kmax = max(core_counts)
258
+ for hd in [int(x) for x in args.head_dims.split(",")]:
259
+ g = torch.Generator().manual_seed(0)
260
+ Qc, Kc, Vc = (torch.randn(seq, hd, generator=g) for _ in range(3))
261
+ K_on = {d: Kc.to(d) for d in pick(cores, kmax)}
262
+ V_on = {d: Vc.to(d) for d in pick(cores, kmax)}
263
+ dense = {}
264
+ for k in core_counts:
265
+ devs = pick(cores, k)
266
+ qs = [Qc[i * seq // k : (i + 1) * seq // k].to(devs[i]) for i in range(k)]
267
+ dense[k] = safe(
268
+ lambda: [_sdpa(q, K_on[d], V_on[d]) for q, d in zip(qs, devs, strict=True)],
269
+ devs,
270
+ reps=args.reps,
271
+ )
272
+ qs = None
273
+ for kind in args.masks.split(","):
274
+ bm = block_mask(kind, nb)
275
+ mb = mask_bsr_from_block_mask(bm)
276
+ counts = bm.sum(1).tolist()
277
+ row = {
278
+ "section": "attn",
279
+ "seq": seq,
280
+ "head_dim": hd,
281
+ "mask": kind,
282
+ "block_density": float(bm.float().mean()),
283
+ "K_max": max(counts),
284
+ }
285
+ for k in core_counts:
286
+ row[f"sdpa_dense_{k}c_ms"] = dense[k]
287
+ # masked SDPA on one core and with rows split over kmax cores
288
+ em = bm.repeat_interleave(P, 0).repeat_interleave(P, 1)
289
+ for k in (1, kmax):
290
+ devs = pick(cores, k)
291
+ qs = [Qc[i * seq // k : (i + 1) * seq // k].to(devs[i]) for i in range(k)]
292
+ ems = [em[i * seq // k : (i + 1) * seq // k].to(devs[i]) for i in range(k)]
293
+ row[f"sdpa_masked_{k}c_ms"] = safe(
294
+ lambda: [
295
+ _sdpa(q, K_on[d], V_on[d], m) for q, m, d in zip(qs, ems, devs, strict=True)
296
+ ],
297
+ devs,
298
+ reps=args.reps,
299
+ )
300
+ qs = ems = None
301
+ free()
302
+ ref = None
303
+ if args.check:
304
+ ref = _sdpa(Qc.double(), Kc.double(), Vc.double(), em)
305
+ em = None
306
+ for k in core_counts:
307
+ devs = pick(cores, k)
308
+ SA = ShardedAttention(mb, devs)
309
+ Qs = SA.shard_rows(Qc)
310
+ KVs = SA.replicate_kv(Kc, Vc)
311
+ outs = SA.forward(Qs, KVs)
312
+ sync(SA.devices)
313
+ if ref is not None:
314
+ row[f"sparse_{k}c_rel_err"] = rel_err(SA.gather(outs), ref)
315
+ row[f"sparse_{k}c_fwd_ms"] = safe(
316
+ lambda: SA.forward(Qs, KVs), SA.devices, reps=args.reps
317
+ )
318
+ row[f"sparse_{k}c_issue_ms"] = issue_time(lambda: SA.forward(Qs, KVs), SA.devices)
319
+ if k == kmax:
320
+ row[f"sparse_{k}c_gather_ms"] = safe(
321
+ lambda: SA.gather(SA.forward(Qs, KVs)),
322
+ SA.devices,
323
+ reps=max(3, args.reps // 2),
324
+ )
325
+ if k == 1:
326
+ Q1, K1, V1 = (t.to(devs[0]) for t in (Qc, Kc, Vc))
327
+ row["api_1c_fwd_ms"] = safe(
328
+ lambda: trnsparse.block_sparse_attention_tiled(Q1, K1, V1, mb),
329
+ devs,
330
+ reps=args.reps,
331
+ )
332
+ Q1 = K1 = V1 = None
333
+ KVs = None
334
+ SA = Qs = outs = None
335
+ free()
336
+ k1 = min(core_counts)
337
+ for k in core_counts:
338
+ if row.get(f"sparse_{k}c_fwd_ms") and row.get(f"sparse_{k1}c_fwd_ms"):
339
+ row[f"sparse_scaling_{k}c"] = (
340
+ row[f"sparse_{k1}c_fwd_ms"] / row[f"sparse_{k}c_fwd_ms"]
341
+ )
342
+ emit(row, args.out)
343
+ K_on = V_on = None
344
+ free()
345
+
346
+
347
+ # ── Attention: heads across cores ────────────────────────────────────────────
348
+
349
+
350
+ def section_heads(args, cores):
351
+ seq = args.size
352
+ nb = seq // P
353
+ H = args.heads
354
+ core_counts = [int(c) for c in args.cores.split(",")]
355
+ for hd in [int(x) for x in args.head_dims.split(",")]:
356
+ g = torch.Generator().manual_seed(0)
357
+ Qc, Kc, Vc, dOc = (torch.randn(H, seq, hd, generator=g) for _ in range(4))
358
+
359
+ def place(k):
360
+ """Head h on core h % k: per core, lists of that core's heads."""
361
+ devs = pick(cores, k)
362
+ out = []
363
+ for i, d in enumerate(devs):
364
+ hs = list(range(i, H, k))
365
+ out.append((d, [tuple(t[h].to(d) for t in (Qc, Kc, Vc, dOc)) for h in hs]))
366
+ return devs, out
367
+
368
+ def place_dense(k):
369
+ devs = pick(cores, k)
370
+ out = []
371
+ for i, d in enumerate(devs):
372
+ hs = list(range(i, H, k))
373
+ out.append(tuple(t[hs][None].to(d) for t in (Qc, Kc, Vc, dOc)))
374
+ return devs, out
375
+
376
+ dense_fwd, dense_fb = {}, {}
377
+ for k in core_counts:
378
+ devs, pl = place_dense(k)
379
+ dense_fwd[k] = safe(
380
+ lambda: [F.scaled_dot_product_attention(q, kk, v) for q, kk, v, _ in pl],
381
+ devs,
382
+ reps=args.reps,
383
+ )
384
+
385
+ def dfb():
386
+ for q, kk, v, do in pl:
387
+ q, kk, v = (t.detach().requires_grad_() for t in (q, kk, v))
388
+ F.scaled_dot_product_attention(q, kk, v).backward(do)
389
+
390
+ dense_fb[k] = safe(dfb, devs, reps=args.reps)
391
+ pl = None
392
+ free()
393
+ for kind in args.masks.split(","):
394
+ bm = block_mask(kind, nb)
395
+ mb = mask_bsr_from_block_mask(bm)
396
+ row = {
397
+ "section": "heads",
398
+ "seq": seq,
399
+ "head_dim": hd,
400
+ "heads": H,
401
+ "mask": kind,
402
+ "block_density": float(bm.float().mean()),
403
+ }
404
+ for k in core_counts:
405
+ row[f"sdpa_dense_{k}c_fwd_ms"] = dense_fwd[k]
406
+ row[f"sdpa_dense_{k}c_fwd_bwd_ms"] = dense_fb[k]
407
+ for k in core_counts:
408
+ devs, pl = place(k)
409
+
410
+ def fwd():
411
+ return [
412
+ trnsparse.block_sparse_attention_tiled(q, kk, v, mb)
413
+ for _, hs in pl
414
+ for q, kk, v, _ in hs
415
+ ]
416
+
417
+ def fb():
418
+ for _, hs in pl:
419
+ for q, kk, v, do in hs:
420
+ q, kk, v = (t.detach().requires_grad_() for t in (q, kk, v))
421
+ trnsparse.block_sparse_attention_tiled(q, kk, v, mb).backward(do)
422
+
423
+ row[f"sparse_{k}c_fwd_ms"] = safe(fwd, devs, reps=args.reps)
424
+ if not args.no_bwd:
425
+ row[f"sparse_{k}c_fwd_bwd_ms"] = safe(fb, devs, reps=args.reps)
426
+ pl = None
427
+ free()
428
+ k1 = min(core_counts)
429
+ for k in core_counts:
430
+ for ph in ("fwd", "fwd_bwd"):
431
+ a, b = row.get(f"sparse_{k1}c_{ph}_ms"), row.get(f"sparse_{k}c_{ph}_ms")
432
+ if a and b:
433
+ row[f"sparse_scaling_{k}c_{ph}"] = a / b
434
+ emit(row, args.out)
435
+
436
+
437
+ def main():
438
+ ap = argparse.ArgumentParser()
439
+ ap.add_argument("--section", required=True, choices=["bsr", "attn", "heads"])
440
+ ap.add_argument("--size", type=int, default=8192)
441
+ ap.add_argument("--densities", default="0.01,0.05,0.1,0.25,0.5")
442
+ ap.add_argument("--widths", default="128,512")
443
+ ap.add_argument("--head-dims", default="64,128")
444
+ ap.add_argument("--masks", default="local,dilated,global")
445
+ ap.add_argument("--heads", type=int, default=4)
446
+ ap.add_argument("--cores", default="1,2,4")
447
+ ap.add_argument("--no-bwd", action="store_true")
448
+ ap.add_argument("--check", action="store_true", help="fp64 reference for attention")
449
+ ap.add_argument("--cpu", action="store_true", help="CPU dense baseline")
450
+ ap.add_argument(
451
+ "--pretransposed", action="store_true", help="also the pre-transposed BSR kernel"
452
+ )
453
+ ap.add_argument(
454
+ "--only-pretransposed", action="store_true", help="only the pre-transposed BSR kernel"
455
+ )
456
+ ap.add_argument("--reps", type=int, default=10)
457
+ ap.add_argument("--out", default="")
458
+ args = ap.parse_args()
459
+ trnsparse.set_backend("nki")
460
+ cores = logical_cores()
461
+ print(f"# {len(cores)} logical cores visible", file=sys.stderr)
462
+ {"bsr": section_bsr, "attn": section_attn, "heads": section_heads}[args.section](args, cores)
463
+
464
+
465
+ if __name__ == "__main__":
466
+ main()
@@ -0,0 +1,130 @@
1
+ """One process per logical NeuronCore: BSR SpMM and dense aten, Trn2.
2
+
3
+ The companion of `bench_multicore_trn2.py`, which issues every core's launch
4
+ from one Python thread. Here each of k processes owns one logical core
5
+ (`NEURON_RT_VISIBLE_CORES=<i>`), its share of the block rows (the same
6
+ `partition_block_rows` split as `ShardedBSR`) and its own copy of B, so the
7
+ host-side launch costs run in parallel. Each rep: all processes meet at a
8
+ barrier, launch, synchronize, and meet again; the coordinator times the
9
+ span between the two barriers (the barrier cost itself is measured with an
10
+ empty step and reported as ``barrier_ms``).
11
+
12
+ NKI_ENABLE_TRACE_CACHE=0 python benchmarks/bench_multiproc_trn2.py --size 8192 --width 512 --procs 4
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import multiprocessing as mp
20
+ import os
21
+ import statistics
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ ROOT = Path(__file__).resolve().parent.parent
27
+
28
+
29
+ def worker(i, k, core, args, bar, q):
30
+ os.environ["NEURON_RT_VISIBLE_CORES"] = str(core)
31
+ sys.path.insert(0, str(ROOT))
32
+ sys.path.insert(0, str(ROOT / "benchmarks"))
33
+ import torch
34
+ import torch_neuronx # noqa: F401
35
+ from bench_native_trn2 import random_bsr
36
+
37
+ import trnsparse
38
+ from trnsparse.multicore import ShardedBSR, partition_block_rows
39
+
40
+ trnsparse.set_backend("nki")
41
+ dev = torch.device("neuron", 0)
42
+ n, w = args.size, args.width
43
+ g = torch.Generator().manual_seed(1)
44
+ Bc = torch.randn(n, w, generator=g)
45
+ Ac = torch.randn(n, n, generator=g) / n**0.5
46
+ B = Bc.to(dev)
47
+ slab = Ac[i * n // k : (i + 1) * n // k].to(dev)
48
+ Ac = None
49
+
50
+ def sync():
51
+ torch.neuron.synchronize()
52
+
53
+ def steps():
54
+ yield "barrier", lambda: None
55
+ yield "dense", lambda: slab @ B
56
+ for dens in [float(x) for x in args.densities.split(",")]:
57
+ A = random_bsr(n, dens)
58
+ rows = partition_block_rows(A.block_row_ptrs.tolist(), k)[i]
59
+ # This process's rows only: a one-core ShardedBSR over the sub-matrix.
60
+ from trnsparse.multicore import _sub_bsr
61
+
62
+ sub = _sub_bsr(A, rows, "cpu")
63
+ S = ShardedBSR(sub, [dev], pretransposed=args.pretransposed)
64
+ yield f"sparse_{dens}", lambda S=S: S.spmm([B])
65
+
66
+ for name, fn in steps():
67
+ fn()
68
+ fn()
69
+ sync()
70
+ for _ in range(args.reps):
71
+ bar.wait()
72
+ fn()
73
+ sync()
74
+ bar.wait()
75
+ if i == 0:
76
+ q.put(("done", name))
77
+
78
+
79
+ def main():
80
+ ap = argparse.ArgumentParser()
81
+ ap.add_argument("--size", type=int, default=8192)
82
+ ap.add_argument("--width", type=int, default=512)
83
+ ap.add_argument("--densities", default="0.01,0.05,0.1,0.25,0.5")
84
+ ap.add_argument("--procs", type=int, default=4)
85
+ ap.add_argument("--cores", default="", help="comma list of core ids (default 0..procs-1)")
86
+ ap.add_argument("--pretransposed", action="store_true")
87
+ ap.add_argument("--reps", type=int, default=10)
88
+ ap.add_argument("--out", default="")
89
+ args = ap.parse_args()
90
+ k = args.procs
91
+ cores = [int(c) for c in args.cores.split(",")] if args.cores else list(range(k))
92
+ ctx = mp.get_context("spawn")
93
+ bar = ctx.Barrier(k + 1)
94
+ q = ctx.Queue()
95
+ ps = [ctx.Process(target=worker, args=(i, k, cores[i], args, bar, q)) for i in range(k)]
96
+ for p in ps:
97
+ p.start()
98
+ results = {}
99
+ names = ["barrier", "dense"] + [f"sparse_{d}" for d in args.densities.split(",")]
100
+ for name in names:
101
+ ts = []
102
+ for _ in range(args.reps):
103
+ bar.wait()
104
+ t0 = time.perf_counter()
105
+ bar.wait()
106
+ ts.append((time.perf_counter() - t0) * 1e3)
107
+ results[name] = statistics.median(ts)
108
+ q.get()
109
+ for p in ps:
110
+ p.join()
111
+ row = {
112
+ "section": "bsr_multiproc",
113
+ "n": args.size,
114
+ "width": args.width,
115
+ "procs": k,
116
+ "pretransposed": args.pretransposed,
117
+ "barrier_ms": results["barrier"],
118
+ "dense_ms": results["dense"],
119
+ }
120
+ for d in args.densities.split(","):
121
+ row[f"sparse_{float(d)}_ms"] = results[f"sparse_{d}"]
122
+ line = json.dumps(row)
123
+ print(line, flush=True)
124
+ if args.out:
125
+ with open(args.out, "a") as f:
126
+ f.write(line + "\n")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -50,3 +50,34 @@ lineages, detected on the first kernel launch (force with
50
50
  on the host; dense operands and BSR `blocks` may be on `neuron`; results are
51
51
  on the dense operand's device. See
52
52
  [Installation](installation.md#placement-sparse-structure-on-the-host).
53
+
54
+ ## Multi-core (`trnsparse.multicore`, native path)
55
+
56
+ A Trn2 chip at LNC=2 has four logical NeuronCores, which TorchNeuron
57
+ exposes as `neuron:0` … `neuron:3` (`NEURON_RT_VISIBLE_CORES=0-3`). Each
58
+ static kernel already splits its work across the two physical cores of one
59
+ logical core; these classes split it across logical cores.
60
+
61
+ - `logical_cores(n=None)`: the visible logical cores as devices.
62
+ - `partition_block_rows(row_ptrs, n_parts)`: block rows split into groups
63
+ balanced by stored blocks (the same greedy as the in-kernel split).
64
+ - `ShardedBSR(A, devices=None, pretransposed=True)`: A split by block rows,
65
+ with each core's blocks resident on that core.
66
+ - `.replicate(B)` copies B to every core.
67
+ - `.spmm(B_replicas)` returns one output per core, issuing one kernel
68
+ launch per core without waiting.
69
+ - `.gather(outs)` assembles C on the host.
70
+ - `pretransposed=True` runs the pre-transposed kernel (no in-kernel
71
+ transpose, fewer DMAs, and B tiles shared by groups of up to 8 block
72
+ rows). Gradients reach each core's B replica and block copy.
73
+ - `ShardedAttention(mask, devices=None)`: a single-head block-sparse
74
+ attention forward, split by query block rows. Use `.shard_rows(Q)`,
75
+ `.replicate_kv(K, V)`, `.forward(Qts, KVs)` and `.gather(outs)`. It is
76
+ forward only. For several heads, put whole heads on different cores and
77
+ call `block_sparse_attention_tiled` there; that path has autograd.
78
+
79
+ TorchNeuron does not copy tensors between logical cores. A `neuron:0`
80
+ tensor's `.to("neuron:1")` comes back still on core 0, and `copy_` across
81
+ cores raises. So per-core operands are built on the host and copied once,
82
+ and results return through the host. The kernel ops run on the logical core
83
+ of their operands, and operands on two different cores are an error.