trnsparse 0.6.0__tar.gz → 0.8.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 (75) hide show
  1. {trnsparse-0.6.0 → trnsparse-0.8.0}/CHANGELOG.md +155 -0
  2. {trnsparse-0.6.0 → trnsparse-0.8.0}/CLAUDE.md +4 -2
  3. {trnsparse-0.6.0 → trnsparse-0.8.0}/PKG-INFO +31 -1
  4. {trnsparse-0.6.0 → trnsparse-0.8.0}/README.md +26 -0
  5. trnsparse-0.8.0/benchmarks/bench_native_trn2.py +397 -0
  6. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/api.md +19 -0
  7. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/architecture.md +14 -0
  8. trnsparse-0.8.0/docs/benchmarks.md +294 -0
  9. trnsparse-0.8.0/docs/installation.md +116 -0
  10. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/iterative_solvers.md +1 -1
  11. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/migration_scipy.md +8 -5
  12. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/quickstart.md +2 -2
  13. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/sparse_attention.md +7 -2
  14. {trnsparse-0.6.0 → trnsparse-0.8.0}/pyproject.toml +13 -1
  15. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/conftest.py +8 -0
  16. trnsparse-0.8.0/tests/test_attn_gather.py +49 -0
  17. trnsparse-0.8.0/tests/test_native_dispatch.py +958 -0
  18. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_nki_bsr.py +5 -1
  19. trnsparse-0.8.0/tests/test_nki_compile.py +185 -0
  20. trnsparse-0.8.0/tests/test_nki_native.py +236 -0
  21. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_nki_screened_spmm.py +5 -1
  22. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_nki_sim.py +79 -19
  23. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_nki_spmm.py +5 -1
  24. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/__init__.py +1 -1
  25. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/formats.py +35 -0
  26. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/iterative.py +19 -7
  27. trnsparse-0.8.0/trnsparse/nki/_native.py +332 -0
  28. trnsparse-0.8.0/trnsparse/nki/_native_attn.py +113 -0
  29. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/nki/dispatch.py +592 -68
  30. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/nki/kernels.py +299 -21
  31. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/ops.py +104 -16
  32. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse.egg-info/PKG-INFO +31 -1
  33. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse.egg-info/SOURCES.txt +7 -0
  34. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse.egg-info/requires.txt +5 -0
  35. trnsparse-0.6.0/docs/benchmarks.md +0 -137
  36. trnsparse-0.6.0/docs/installation.md +0 -21
  37. {trnsparse-0.6.0 → trnsparse-0.8.0}/.github/workflows/ci.yml +0 -0
  38. {trnsparse-0.6.0 → trnsparse-0.8.0}/.github/workflows/notify-umbrella.yml +0 -0
  39. {trnsparse-0.6.0 → trnsparse-0.8.0}/.github/workflows/publish.yml +0 -0
  40. {trnsparse-0.6.0 → trnsparse-0.8.0}/.gitignore +0 -0
  41. {trnsparse-0.6.0 → trnsparse-0.8.0}/.pre-commit-config.yaml +0 -0
  42. {trnsparse-0.6.0 → trnsparse-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  43. {trnsparse-0.6.0 → trnsparse-0.8.0}/CONTRIBUTING.md +0 -0
  44. {trnsparse-0.6.0 → trnsparse-0.8.0}/LICENSE +0 -0
  45. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/bench_bsr_spmm.py +0 -0
  46. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/bench_iterative.py +0 -0
  47. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/bench_screening.py +0 -0
  48. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/bench_spmm.py +0 -0
  49. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/bench_spmv.py +0 -0
  50. {trnsparse-0.6.0 → trnsparse-0.8.0}/benchmarks/conftest.py +0 -0
  51. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/aws_setup.md +0 -0
  52. {trnsparse-0.6.0 → trnsparse-0.8.0}/docs/index.md +0 -0
  53. {trnsparse-0.6.0 → trnsparse-0.8.0}/examples/block_sparse_attention.py +0 -0
  54. {trnsparse-0.6.0 → trnsparse-0.8.0}/examples/sparse_fock.py +0 -0
  55. {trnsparse-0.6.0 → trnsparse-0.8.0}/infra/terraform/.terraform.lock.hcl +0 -0
  56. {trnsparse-0.6.0 → trnsparse-0.8.0}/infra/terraform/README.md +0 -0
  57. {trnsparse-0.6.0 → trnsparse-0.8.0}/infra/terraform/main.tf +0 -0
  58. {trnsparse-0.6.0 → trnsparse-0.8.0}/mkdocs.yml +0 -0
  59. {trnsparse-0.6.0 → trnsparse-0.8.0}/scripts/bench_to_md.py +0 -0
  60. {trnsparse-0.6.0 → trnsparse-0.8.0}/scripts/run_benchmarks.sh +0 -0
  61. {trnsparse-0.6.0 → trnsparse-0.8.0}/scripts/run_neuron_tests.sh +0 -0
  62. {trnsparse-0.6.0 → trnsparse-0.8.0}/scripts/run_simulator_tests.sh +0 -0
  63. {trnsparse-0.6.0 → trnsparse-0.8.0}/setup.cfg +0 -0
  64. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_attention.py +0 -0
  65. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_bsr.py +0 -0
  66. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_examples.py +0 -0
  67. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_formats.py +0 -0
  68. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_iterative.py +0 -0
  69. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_nki_attn.py +0 -0
  70. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_ops.py +0 -0
  71. {trnsparse-0.6.0 → trnsparse-0.8.0}/tests/test_screening.py +0 -0
  72. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/nki/__init__.py +0 -0
  73. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse/screening.py +0 -0
  74. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse.egg-info/dependency_links.txt +0 -0
  75. {trnsparse-0.6.0 → trnsparse-0.8.0}/trnsparse.egg-info/top_level.txt +0 -0
@@ -5,6 +5,161 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.8.0] — 2026-10-30
11
+
12
+ 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.
13
+
14
+ Headline performance: static-structure BSR SpMM, 6.8× faster than dense aten `matmul` at 1% block density, n=8192 (#34).
15
+
16
+ ### Changed
17
+
18
+ #### Native path: static-structure kernels (#30)
19
+
20
+ On TorchNeuron the sparse structure is host-resident, so the native path now
21
+ hands it to the kernels as compile-time constants (`int[]` op arguments,
22
+ tuples inside the kernel) instead of padded, gathered device tensors.
23
+ Measured on Trn2 (`benchmarks/bench_native_trn2.py`; tables on #30):
24
+
25
+ - `bsr_spmm` → `trnsparse::bsr_spmm_static`: walks exactly the stored
26
+ blocks and addresses B by block column; no `(M_tiles, K_max)` padding and
27
+ no device-side gather. 8192×8192 @ 8192×512: 3.1–12.5 ms → 0.7–3.9 ms;
28
+ beats dense aten `matmul` on the device up to ~13% (width 128) to ~34%
29
+ (width 512) block density at n = 8192.
30
+ - Block-sparse attention forward → `trnsparse::attn_fwd_static`: one launch,
31
+ two passes with the softmax statistics on chip; no K/V gather, no host
32
+ reduction, no per-block score round trip through HBM. Backward →
33
+ `trnsparse::attn_bwd_static`: one launch for dQ (row-first) and dK/dV
34
+ (column-first). seq 8192 global mask, fwd+bwd: 127–195 ms → ~4 ms.
35
+ - Screened SpMM masks each tile in the transposed frame, so the masked tile
36
+ is directly the stationary operand (no HBM round trip per tile).
37
+ - `spmm` with host CSR scatters the nnz triplets on the device when that is
38
+ cheaper than copying a host-built dense matrix.
39
+
40
+ One compile per distinct structure (seconds; cached in-process by the
41
+ structure). `TRNSPARSE_STATIC_STRUCTURE=0` restores the gather path;
42
+ structures above `TRNSPARSE_STATIC_MAX_UNROLL` (default 16384 unrolled
43
+ block matmuls) use it automatically.
44
+
45
+ ### Added
46
+
47
+ - `benchmarks/bench_native_trn2.py`: warm-latency benchmark for the native
48
+ path against dense aten on the device, CPU, and `torch.sparse` on CPU.
49
+
50
+ #### Native PyTorch dispatch on TorchNeuron (#29, #30)
51
+
52
+ On TorchNeuron (Neuron SDK 2.33+, `torch-neuronx` 2.14, PyTorch 2.14,
53
+ Trn2/Trn3), every NKI kernel is now registered as a PyTorch custom op with
54
+ `torch_neuronx.nki_op` and dispatched on `device="neuron"`, with no
55
+ `torch_xla` import. Ops: `trnsparse::spmm_dense`, `bsr_spmm`,
56
+ `screened_spmm`, `attn_stats`, `attn_out`, `attn_bwd_dq`, `attn_bwd_dkdv`,
57
+ plus the composite `trnsparse::bsr_attn` / `bsr_attn_bwd`. Kernel bodies
58
+ are unchanged.
59
+
60
+ - Autograd on the native path uses `register_autograd` on the ops instead
61
+ of `torch.autograd.Function`: `spmm_dense`, `bsr_spmm` and
62
+ `screened_spmm` carry their analytic adjoints, and `bsr_attn` carries the
63
+ NKI dQ / dK-dV backward (via `bsr_attn_bwd`). Gradients reach
64
+ `CSRMatrix.values`, `BSRMatrix.blocks` and the dense operands through the
65
+ host-side densify / pad / gather. `_SpMMFunction`, `_BSRSpMMFunction`,
66
+ `_ScreenedSpMMFunction` and `_AttnTiledFunction` remain for the XLA path,
67
+ the simulator and the PyTorch backend. The native BSR backward has no
68
+ per-block Python loop.
69
+ - Placement (#30): the `neuron` device has no sparse CSR tensors
70
+ (aws-neuron-sdk#1442), so sparse structure stays on the host and only
71
+ dense data moves. `spmm`, `spmv`, `spmv_symmetric`, `bsr_spmm`,
72
+ `screened_spmm`, `block_sparse_attention_tiled`, `cg_bsr` and
73
+ `jacobi_preconditioner_bsr` accept host CSR/BSR structure with dense
74
+ operands (and BSR `blocks`) on `neuron`, and return results on the dense
75
+ operand's device. A host CSR against a device operand is densified on the
76
+ host without a torch sparse tensor (`formats._csr_to_dense_host`, static
77
+ shapes), so `spmm` with host-CSR structure traces under
78
+ `torch.compile(..., fullgraph=True)`. Index tensors found on a device are
79
+ read through host copies. Documented in `docs/installation.md` and the
80
+ format and op docstrings.
81
+ - The dispatch path is detected on the first kernel launch: native when
82
+ `torch_neuronx` exports `nki_op` and a `neuron` device is available, XLA
83
+ when `torch_xla` is installed, otherwise none. The XLA path (Trn1) is
84
+ unchanged, as are the PyTorch fallback and the `TRNSPARSE_USE_SIMULATOR`
85
+ path. All kernel launches share one helper (`dispatch._launch`, or its
86
+ halves `_to_device` / `_run` where several attention kernels share
87
+ operands).
88
+ - Custom ops are registered lazily (`trnsparse/nki/_native.py` and
89
+ `_native_attn.py` are imported only when the native path is selected).
90
+ - `TRNSCI_NKI_DISPATCH=auto|native|xla|cpu` forces the dispatch path (a
91
+ suite-wide variable). Forcing an unavailable path raises instead of
92
+ falling back. `TRNSCI_XLA_DEPRECATION=1` makes the XLA path emit a
93
+ one-time `DeprecationWarning` (off by default).
94
+ - New `[torchneuron]` extra (`torch>=2.14`, `torch-neuronx>=2.14`,
95
+ `nki>=0.6`); `[neuron]` stays the PyTorch/XLA lineage.
96
+ - Tests: `conftest.py` sets `NKI_ENABLE_TRACE_CACHE=0` by default.
97
+ `tests/test_native_dispatch.py` (CPU) covers path selection, launch
98
+ routing, lazy registration, op schemas and fake shapes, the registered
99
+ backward formulas, the native wrappers end to end on CPU with real custom
100
+ ops, and a CPU `fullgraph=True` trace of host-CSR `spmm`.
101
+ `tests/test_nki_native.py` (`@pytest.mark.neuron`, native only) covers
102
+ host-structure / device-operand calls, gradients, and
103
+ `torch.compile(backend="neuron", fullgraph=True)` of `spmm` with
104
+ host-CSR structure. Not yet run on hardware.
105
+
106
+ ## [0.7.0] — 2026-10-28
107
+
108
+ Validated on Trn2 (trn2.3xlarge, Neuron SDK 2.33.0, native TorchNeuron device) — see trnsci/trnsci#35.
109
+
110
+ ### Fixed
111
+
112
+ - PyTorch reference attention (`_block_sparse_attn_pytorch` and the
113
+ recompute branch of `_block_sparse_attn_backward`) allocates on Q's
114
+ device instead of the default device.
115
+
116
+ - **NKI 0.7.0 / neuronx-cc 2.28 kernel compatibility (#29).** Kernels that
117
+ passed `nki.simulate` failed to compile on Trn2 (Neuron SDK 2.33):
118
+ - `_attn_out_kernel`, `_attn_bwd_dq_kernel`, `_attn_bwd_dkdv_kernel`:
119
+ softmax normalisation `nl.divide(exp, row_denom)` by a `(128, 1)`
120
+ denominator lowered to `tensor_scalar_arith`, which no longer supports
121
+ `divide`. Now multiplies by `nl.reciprocal(row_denom)` (hoisted per
122
+ block-row in the forward and dQ kernels).
123
+ - `_screened_spmm_kernel`: the `(128, 1) * (1, 128)` Schwarz outer product
124
+ and the `(1, 1)` threshold subtract produced partition-count mismatches
125
+ (`operand0 partition total elements 128 != dst partition total elements
126
+ 1`). `q_k` is now `nl.broadcast_to` `(128, 128)` and the threshold is
127
+ loaded once and broadcast to `(128, 1)`. The soft-mask tensor-tensor
128
+ divide (fails ISA validation `s3s3d3_tt_valid_op`) is now a reciprocal
129
+ multiply.
130
+ - `nki_bsr_attn_bwd`: the host-side `(..., b) → (..., b, 1)` unsqueeze
131
+ for per-row stats matched `q_scaled` / `k_blocks` / `v_blocks` by shape
132
+ whenever `head_dim == 128` (and in the simulator's padded path), feeding
133
+ the kernels a 4-D tensor. Stats are now selected by name.
134
+ - Dispatch: kernels always receive fp32 (Trainium has no fp64 datapath;
135
+ the native device downcast fp64 silently) and results are cast back to
136
+ the caller's dtype. Fixes `expected m1 and m2 to have the same dtype,
137
+ but got: float != double` in the PyTorch-level backward under fp64
138
+ gradcheck. The hardware gradcheck tests now use `eps=1e-2,
139
+ atol=rtol=1e-2`, sized for an fp32 forward (the ops are linear in each
140
+ input, so the larger eps adds no truncation error).
141
+
142
+ - **Block-sparse attention: two host-side gather bugs** (pre-existing, masked
143
+ on Trn2 by the compile failures above; found with neuronx-cc BIRSim):
144
+ - Rows with fewer than `K_max` blocks were padded with sentinel k-slots that
145
+ gathered column block 0, so block 0 was counted again in the softmax and
146
+ in `weights @ V` (local-window masks: ~49% of outputs wrong). Padded slots
147
+ now have K/V (forward, dQ) or Q/dO (dK/dV) zeroed, and their stats are
148
+ dropped in `_attn_host_reduction`.
149
+ - `_attn_bwd_gather` gathered `dO` by column block instead of the row block
150
+ `dO_m`, breaking dQ whenever a row attends to a block other than itself
151
+ (~99% of dQ wrong). The two simulator xfails attributed to a "PSUM
152
+ accumulate bug" (`test_bwd_dq_parity_kmax2`, `test_backward_head_dim_256`)
153
+ were this bug and now pass.
154
+
155
+ ### Added
156
+
157
+ - `tests/test_attn_gather.py`: CPU regression tests for both gather bugs.
158
+ - `tests/test_nki_compile.py` (`nki_simulator` marker): CPU MLIR
159
+ verification (`source_to_mlir(...).verify()`) of every kernel for trn1
160
+ and trn2, attention kernels at head_dim 64, 128 and 256; plus a full NEFF
161
+ compile per kernel/target when `neuronx-cc` is on PATH.
162
+
8
163
  ## [0.6.0] — 2026-04-17
9
164
 
10
165
  ### Added
@@ -25,8 +25,10 @@ trnsparse/
25
25
  │ ├── iterative.py # cg_bsr, power_iteration_bsr, jacobi_preconditioner_bsr
26
26
  │ └── nki/
27
27
  │ ├── __init__.py
28
- │ ├── kernels.py # _bsr_spmm_kernel, _screened_spmm_kernel
29
- │ └── dispatch.py # torch.autograd.Function wrappers + NKI/PyTorch routing
28
+ │ ├── kernels.py # _bsr_spmm_kernel, _screened_spmm_kernel, attention kernels
29
+ │ ├── dispatch.py # dispatch-path selection, _launch, autograd.Function wrappers (XLA/CPU)
30
+ │ ├── _native.py # TorchNeuron nki_op custom ops + register_autograd, loaded lazily
31
+ │ └── _native_attn.py # trnsparse::bsr_attn / bsr_attn_bwd composite ops (native path)
30
32
  ├── tests/
31
33
  ├── examples/
32
34
  │ ├── sparse_fock.py # Schwarz-screened Fock build (3 paths + trnblas)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trnsparse
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -22,6 +22,10 @@ Provides-Extra: neuron
22
22
  Requires-Dist: nki>=0.3.0; extra == "neuron"
23
23
  Requires-Dist: neuronxcc>=2.24; extra == "neuron"
24
24
  Requires-Dist: torch-neuronx>=2.9; extra == "neuron"
25
+ Provides-Extra: torchneuron
26
+ Requires-Dist: torch>=2.14; extra == "torchneuron"
27
+ Requires-Dist: torch-neuronx>=2.14; extra == "torchneuron"
28
+ Requires-Dist: nki>=0.6; extra == "torchneuron"
25
29
  Provides-Extra: dev
26
30
  Requires-Dist: pytest>=7.0; extra == "dev"
27
31
  Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
@@ -65,8 +69,34 @@ Suite-wide tracker: [trnsci/trnsci#1](https://github.com/trnsci/trnsci/issues/1)
65
69
 
66
70
  ```bash
67
71
  pip install trnsparse
72
+
73
+ # Trn2/Trn3 with TorchNeuron (native PyTorch, Neuron SDK 2.33+)
74
+ pip install "trnsparse[torchneuron]" --extra-index-url https://pip.repos.neuron.amazonaws.com
75
+
76
+ # Trn1 / PyTorch-XLA lineage
77
+ pip install "trnsparse[neuron]" --extra-index-url https://pip.repos.neuron.amazonaws.com
68
78
  ```
69
79
 
80
+ Kernels dispatch on whichever Neuron PyTorch lineage is installed:
81
+
82
+ - **TorchNeuron (native)** — Trn2/Trn3, Neuron SDK 2.33+, Python ≥ 3.12,
83
+ `torch` 2.14, `torch-neuronx` 2.14.*. Each kernel is a PyTorch custom op
84
+ (`torch.ops.trnsparse.*`) on the `neuron` device, with its backward
85
+ attached via `register_autograd`; no `torch_xla`.
86
+ - **PyTorch/XLA** — Trn1 (and any host on the `torch-neuronx` 2.9 lineage).
87
+ Kernels dispatch through `torch_xla`, unchanged.
88
+
89
+ **Placement on the native path:** the `neuron` device has no sparse CSR
90
+ tensors, so keep sparse *structure* (CSR `row_ptrs` / `col_indices`, BSR
91
+ `block_row_ptrs` / `block_col_indices`) on the host and put dense operands
92
+ (and BSR `blocks`) on `device="neuron"`. Results come back on the dense
93
+ operand's device. See [installation](docs/installation.md#placement-sparse-structure-on-the-host).
94
+
95
+ Set `TRNSCI_NKI_DISPATCH=native|xla|cpu` (shared by all trnsci libraries)
96
+ to force a path. With an editable install (`pip install -e`), set
97
+ `NKI_ENABLE_TRACE_CACHE=0`: the NKI kernel cache only versions installed
98
+ wheels and can serve stale kernels.
99
+
70
100
  ## Usage
71
101
 
72
102
  ```python
@@ -34,8 +34,34 @@ Suite-wide tracker: [trnsci/trnsci#1](https://github.com/trnsci/trnsci/issues/1)
34
34
 
35
35
  ```bash
36
36
  pip install trnsparse
37
+
38
+ # Trn2/Trn3 with TorchNeuron (native PyTorch, Neuron SDK 2.33+)
39
+ pip install "trnsparse[torchneuron]" --extra-index-url https://pip.repos.neuron.amazonaws.com
40
+
41
+ # Trn1 / PyTorch-XLA lineage
42
+ pip install "trnsparse[neuron]" --extra-index-url https://pip.repos.neuron.amazonaws.com
37
43
  ```
38
44
 
45
+ Kernels dispatch on whichever Neuron PyTorch lineage is installed:
46
+
47
+ - **TorchNeuron (native)** — Trn2/Trn3, Neuron SDK 2.33+, Python ≥ 3.12,
48
+ `torch` 2.14, `torch-neuronx` 2.14.*. Each kernel is a PyTorch custom op
49
+ (`torch.ops.trnsparse.*`) on the `neuron` device, with its backward
50
+ attached via `register_autograd`; no `torch_xla`.
51
+ - **PyTorch/XLA** — Trn1 (and any host on the `torch-neuronx` 2.9 lineage).
52
+ Kernels dispatch through `torch_xla`, unchanged.
53
+
54
+ **Placement on the native path:** the `neuron` device has no sparse CSR
55
+ tensors, so keep sparse *structure* (CSR `row_ptrs` / `col_indices`, BSR
56
+ `block_row_ptrs` / `block_col_indices`) on the host and put dense operands
57
+ (and BSR `blocks`) on `device="neuron"`. Results come back on the dense
58
+ operand's device. See [installation](docs/installation.md#placement-sparse-structure-on-the-host).
59
+
60
+ Set `TRNSCI_NKI_DISPATCH=native|xla|cpu` (shared by all trnsci libraries)
61
+ to force a path. With an editable install (`pip install -e`), set
62
+ `NKI_ENABLE_TRACE_CACHE=0`: the NKI kernel cache only versions installed
63
+ wheels and can serve stale kernels.
64
+
39
65
  ## Usage
40
66
 
41
67
  ```python
@@ -0,0 +1,397 @@
1
+ """Warm-latency benchmark for the native TorchNeuron path (Trn2/Trn3).
2
+
3
+ Not a pytest-benchmark file: a standalone script, one section per process,
4
+ so each run stays short and a compile failure does not take the rest down.
5
+ Every row is printed as one JSON line (and appended to ``--out``).
6
+
7
+ NKI_ENABLE_TRACE_CACHE=0 python benchmarks/bench_native_trn2.py --section bsr --size 4096
8
+
9
+ Sections:
10
+ bsr BSR (128x128) SpMM vs dense aten matmul on the device, CPU dense,
11
+ and torch.sparse BSR/CSR on CPU. Block densities x RHS widths.
12
+ csr CSR SpMM (host structure, densify, NKI dense kernel) at element
13
+ densities, vs the same baselines.
14
+ screen Screened SpMM vs dense masked matmul.
15
+ attn Block-sparse attention (local / dilated / global masks), forward
16
+ and forward+backward, vs dense SDPA on the device and on CPU.
17
+
18
+ Timing: each callable is run ``--warmup`` times (the first call compiles),
19
+ then ``--reps`` times with ``torch.neuron.synchronize()`` after every call;
20
+ the median is reported in milliseconds. Structure stays on the host, dense
21
+ data (blocks, operands) lives on the device, as `docs/installation.md`
22
+ recommends for the native path.
23
+ """
24
+
25
+ # ruff: noqa: B023 (every closure is benchmarked before its loop variables change)
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import math
31
+ import statistics
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ import torch
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
39
+
40
+ import trnsparse # noqa: E402
41
+ from trnsparse.nki import dispatch as _d # noqa: E402
42
+
43
+ DEV = "neuron"
44
+ P = 128
45
+
46
+
47
+ def _sync():
48
+ if hasattr(torch, "neuron"):
49
+ torch.neuron.synchronize()
50
+
51
+
52
+ def bench(fn, warmup=2, reps=10):
53
+ for _ in range(warmup):
54
+ fn()
55
+ _sync()
56
+ ts = []
57
+ for _ in range(reps):
58
+ t0 = time.perf_counter()
59
+ fn()
60
+ _sync()
61
+ ts.append((time.perf_counter() - t0) * 1e3)
62
+ return statistics.median(ts)
63
+
64
+
65
+ def safe_bench(fn, **kw):
66
+ try:
67
+ return bench(fn, **kw)
68
+ except Exception as e: # report, don't abort the section
69
+ print(f"# bench failed: {type(e).__name__}: {str(e)[:200]}", file=sys.stderr)
70
+ return None
71
+
72
+
73
+ def rel_err(x, ref):
74
+ x = x.detach().cpu().double()
75
+ ref = ref.detach().cpu().double()
76
+ return float((x - ref).norm() / ref.norm().clamp_min(1e-30))
77
+
78
+
79
+ def emit(row, out):
80
+ line = json.dumps(row)
81
+ print(line, flush=True)
82
+ if out:
83
+ with open(out, "a") as f:
84
+ f.write(line + "\n")
85
+
86
+
87
+ # ── BSR ──────────────────────────────────────────────────────────────────────
88
+
89
+
90
+ def random_bsr(n, density, seed=0):
91
+ g = torch.Generator().manual_seed(seed)
92
+ nb = n // P
93
+ total = nb * nb
94
+ k = max(1, round(density * total))
95
+ flat = torch.randperm(total, generator=g)[:k].sort().values
96
+ rows, cols = flat // nb, flat % nb
97
+ row_ptrs = torch.zeros(nb + 1, dtype=torch.long)
98
+ row_ptrs[1:] = torch.bincount(rows, minlength=nb).cumsum(0)
99
+ blocks = torch.randn(k, P, P, generator=g) / math.sqrt(P)
100
+ return trnsparse.BSRMatrix(
101
+ blocks=blocks,
102
+ block_col_indices=cols.to(torch.long),
103
+ block_row_ptrs=row_ptrs,
104
+ shape=(n, n),
105
+ block_size=P,
106
+ )
107
+
108
+
109
+ def section_bsr(args):
110
+ densities = [float(x) for x in args.densities.split(",")]
111
+ widths = [int(x) for x in args.widths.split(",")]
112
+ n = args.size
113
+ for width in widths:
114
+ g = torch.Generator().manual_seed(1)
115
+ Bc = torch.randn(n, width, generator=g)
116
+ Bd = Bc.to(DEV)
117
+ dense_dev_ms = None
118
+ cpu_dense_ms = None
119
+ for dens in densities:
120
+ A = random_bsr(n, dens)
121
+ Ac = A.to_dense()
122
+ Ad_dev = trnsparse.BSRMatrix(
123
+ blocks=A.blocks.to(DEV),
124
+ block_col_indices=A.block_col_indices,
125
+ block_row_ptrs=A.block_row_ptrs,
126
+ shape=A.shape,
127
+ block_size=P,
128
+ )
129
+ counts = (A.block_row_ptrs[1:] - A.block_row_ptrs[:-1]).tolist()
130
+ K_max = max(counts)
131
+ row = {
132
+ "section": "bsr",
133
+ "n": n,
134
+ "width": width,
135
+ "block_density": dens,
136
+ "n_blocks": A.n_blocks,
137
+ "K_max": K_max,
138
+ "mean_blocks_per_row": sum(counts) / len(counts),
139
+ }
140
+ ref = Ac.double() @ Bc.double()
141
+ out = trnsparse.bsr_spmm(Ad_dev, Bd)
142
+ row["rel_err"] = rel_err(out, ref)
143
+ row["trnsparse_ms"] = safe_bench(lambda: trnsparse.bsr_spmm(Ad_dev, Bd), reps=args.reps)
144
+
145
+ # Split: device-side prep (gather) vs the kernel launch.
146
+ def prep():
147
+ return _d._bsr_pad_and_gather(_d._BlockView(Ad_dev, Ad_dev.blocks), Bd)
148
+
149
+ bp, bg, *_ = prep()
150
+ ops = _d._get_native_ops()
151
+ # Gather path (main before trnsparse#30 static kernels): device-side
152
+ # pad + gather, then the K_max-padded kernel.
153
+ row["gather_prep_ms"] = safe_bench(prep, reps=args.reps)
154
+ row["gather_kernel_ms"] = safe_bench(lambda: ops["bsr_spmm"](bp, bg), reps=args.reps)
155
+ bp = bg = None # free the gathered tiles
156
+ if "bsr_spmm_static" in ops:
157
+ rp, cl = _d._bsr_structure_lists(A)
158
+ bl = Ad_dev.blocks
159
+ row["static_kernel_ms"] = safe_bench(
160
+ lambda: ops["bsr_spmm_static"](bl, Bd, rp, cl), reps=args.reps
161
+ )
162
+
163
+ Ad = Ac.to(DEV)
164
+ if dense_dev_ms is None:
165
+ dense_dev_ms = safe_bench(lambda: Ad @ Bd, reps=args.reps)
166
+ row["dense_dev_ms"] = dense_dev_ms
167
+ Ad = None
168
+ if cpu_dense_ms is None:
169
+ cpu_dense_ms = safe_bench(lambda: Ac @ Bc, warmup=1, reps=args.cpu_reps)
170
+ row["cpu_dense_ms"] = cpu_dense_ms
171
+ try:
172
+ Absr = Ac.to_sparse_bsr((P, P))
173
+ row["cpu_torch_bsr_ms"] = safe_bench(
174
+ lambda: Absr @ Bc, warmup=1, reps=args.cpu_reps
175
+ )
176
+ except Exception as e:
177
+ row["cpu_torch_bsr_ms"] = f"unsupported: {type(e).__name__}"
178
+ Acsr = Ac.to_sparse_csr()
179
+ row["cpu_torch_csr_ms"] = safe_bench(lambda: Acsr @ Bc, warmup=1, reps=args.cpu_reps)
180
+ # trnsparse on CPU (pytorch backend): the loop-over-blocks fallback.
181
+ prev = trnsparse.get_backend()
182
+ trnsparse.set_backend("pytorch")
183
+ row["cpu_trnsparse_ms"] = safe_bench(
184
+ lambda: trnsparse.bsr_spmm(A, Bc), warmup=1, reps=args.cpu_reps
185
+ )
186
+ trnsparse.set_backend(prev)
187
+ if row["trnsparse_ms"] and row["dense_dev_ms"]:
188
+ row["speedup_vs_dense_dev"] = row["dense_dev_ms"] / row["trnsparse_ms"]
189
+ emit(row, args.out)
190
+
191
+
192
+ # ── CSR ──────────────────────────────────────────────────────────────────────
193
+
194
+
195
+ def section_csr(args):
196
+ densities = [float(x) for x in args.densities.split(",")]
197
+ widths = [int(x) for x in args.widths.split(",")]
198
+ n = args.size
199
+ for width in widths:
200
+ Bc = torch.randn(n, width, generator=torch.Generator().manual_seed(1))
201
+ Bd = Bc.to(DEV)
202
+ for dens in densities:
203
+ g = torch.Generator().manual_seed(0)
204
+ Ac = torch.randn(n, n, generator=g) * (torch.rand(n, n, generator=g) < dens)
205
+ A = trnsparse.from_dense(Ac)
206
+ row = {"section": "csr", "n": n, "width": width, "density": dens, "nnz": A.nnz}
207
+ out = trnsparse.spmm(A, Bd)
208
+ row["rel_err"] = rel_err(out, Ac.double() @ Bc.double())
209
+ row["trnsparse_ms"] = safe_bench(lambda: trnsparse.spmm(A, Bd), reps=args.reps)
210
+ Ad = Ac.to(DEV)
211
+ row["dense_dev_ms"] = safe_bench(lambda: Ad @ Bd, reps=args.reps)
212
+ row["densify_host_h2d_ms"] = safe_bench(
213
+ lambda: trnsparse.formats._csr_to_dense_host(A).to(DEV), reps=args.reps
214
+ )
215
+ if hasattr(_d, "_csr_to_dense_on"):
216
+ row["densify_on_device_ms"] = safe_bench(
217
+ lambda: _d._csr_to_dense_on(A, DEV), reps=args.reps
218
+ )
219
+ row["cpu_dense_ms"] = safe_bench(lambda: Ac @ Bc, warmup=1, reps=args.cpu_reps)
220
+ Acsr = Ac.to_sparse_csr()
221
+ row["cpu_torch_csr_ms"] = safe_bench(lambda: Acsr @ Bc, warmup=1, reps=args.cpu_reps)
222
+ emit(row, args.out)
223
+ Ad = None
224
+
225
+
226
+ # ── Screened SpMM ────────────────────────────────────────────────────────────
227
+
228
+
229
+ def section_screen(args):
230
+ widths = [int(x) for x in args.widths.split(",")]
231
+ n = args.size
232
+ g = torch.Generator().manual_seed(0)
233
+ # Exponentially decaying Schwarz bounds, so a threshold keeps a band.
234
+ pos = torch.arange(n, dtype=torch.float32)
235
+ diag = torch.exp(-pos / (n / 8)) + 1e-12
236
+ Ac = torch.randn(n, n, generator=g)
237
+ for width in widths:
238
+ Bc = torch.randn(n, width, generator=g)
239
+ Bd, Ad, diag_d = Bc.to(DEV), Ac.to(DEV), diag.to(DEV)
240
+ for thr in [float(x) for x in args.thresholds.split(",")]:
241
+ Q = diag.sqrt()
242
+ mask = (Q[:, None] * Q[None, :]) > math.sqrt(thr)
243
+ ref = (Ac.double() * mask) @ Bc.double()
244
+ row = {
245
+ "section": "screen",
246
+ "n": n,
247
+ "width": width,
248
+ "threshold": thr,
249
+ "kept_fraction": float(mask.float().mean()),
250
+ }
251
+ out = trnsparse.screened_spmm(Ad, diag_d, Bd, thr)
252
+ row["rel_err"] = rel_err(out, ref)
253
+ row["trnsparse_ms"] = safe_bench(
254
+ lambda: trnsparse.screened_spmm(Ad, diag_d, Bd, thr), reps=args.reps
255
+ )
256
+ mask_d = mask.to(DEV, torch.float32)
257
+ row["dense_masked_dev_ms"] = safe_bench(lambda: (Ad * mask_d) @ Bd, reps=args.reps)
258
+ row["dense_dev_ms"] = safe_bench(lambda: Ad @ Bd, reps=args.reps)
259
+ mf = mask.float()
260
+ row["cpu_masked_ms"] = safe_bench(lambda: (Ac * mf) @ Bc, warmup=1, reps=args.cpu_reps)
261
+ emit(row, args.out)
262
+
263
+
264
+ # ── Attention ────────────────────────────────────────────────────────────────
265
+
266
+
267
+ def block_mask(kind, nb):
268
+ m = torch.zeros(nb, nb, dtype=torch.bool)
269
+ w = 1 # local window radius in blocks (3 blocks per row)
270
+ if kind == "local":
271
+ for i in range(nb):
272
+ m[i, max(0, i - w) : i + w + 1] = True
273
+ elif kind == "dilated":
274
+ stride = 4
275
+ for i in range(nb):
276
+ m[i, (torch.arange(nb) - i) % stride == 0] = True
277
+ elif kind == "global":
278
+ for i in range(nb):
279
+ m[i, max(0, i - w) : i + w + 1] = True
280
+ m[0, :] = True
281
+ m[:, 0] = True
282
+ else:
283
+ raise ValueError(kind)
284
+ return m
285
+
286
+
287
+ def mask_bsr_from_block_mask(bm):
288
+ nb = bm.shape[0]
289
+ row_ptrs = torch.zeros(nb + 1, dtype=torch.long)
290
+ row_ptrs[1:] = bm.sum(1).cumsum(0)
291
+ cols = bm.nonzero()[:, 1].to(torch.long)
292
+ return trnsparse.BSRMatrix(
293
+ blocks=torch.ones(len(cols), 1, 1), # values unused by attention
294
+ block_col_indices=cols,
295
+ block_row_ptrs=row_ptrs,
296
+ shape=(nb * P, nb * P),
297
+ block_size=P,
298
+ )
299
+
300
+
301
+ def section_attn(args):
302
+ seq = args.size
303
+ nb = seq // P
304
+ F = torch.nn.functional
305
+ for hd in [int(x) for x in args.head_dims.split(",")]:
306
+ 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))
310
+
311
+ def sdpa_fwd(q, k, v, m=None):
312
+ return F.scaled_dot_product_attention(q[None], k[None], v[None], attn_mask=m)[0]
313
+
314
+ dense_fwd = safe_bench(lambda: sdpa_fwd(Qd, Kd, Vd), reps=args.reps)
315
+
316
+ def dense_fwd_bwd(m=None):
317
+ q, k, v = (t.detach().requires_grad_() for t in (Qd, Kd, Vd))
318
+ o = sdpa_fwd(q, k, v, m)
319
+ o.backward(dOd)
320
+ return q.grad
321
+
322
+ 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)
324
+ for kind in args.masks.split(","):
325
+ bm = block_mask(kind, nb)
326
+ mb = mask_bsr_from_block_mask(bm)
327
+ elem_mask = bm.repeat_interleave(P, 0).repeat_interleave(P, 1)
328
+ counts = bm.sum(1).tolist()
329
+ row = {
330
+ "section": "attn",
331
+ "seq": seq,
332
+ "head_dim": hd,
333
+ "mask": kind,
334
+ "block_density": float(bm.float().mean()),
335
+ "K_max": max(counts),
336
+ "mean_blocks_per_row": sum(counts) / len(counts),
337
+ }
338
+ ref = sdpa_fwd(Qc.double(), Kc.double(), Vc.double(), elem_mask)
339
+ out = trnsparse.block_sparse_attention_tiled(Qd, Kd, Vd, mb)
340
+ row["fwd_rel_err"] = rel_err(out, ref)
341
+ row["trnsparse_fwd_ms"] = safe_bench(
342
+ lambda: trnsparse.block_sparse_attention_tiled(Qd, Kd, Vd, mb), reps=args.reps
343
+ )
344
+
345
+ def ts_fwd_bwd():
346
+ q, k, v = (t.detach().requires_grad_() for t in (Qd, Kd, Vd))
347
+ o = trnsparse.block_sparse_attention_tiled(q, k, v, mb)
348
+ o.backward(dOd)
349
+ return q.grad, k.grad, v.grad
350
+
351
+ if not args.no_bwd:
352
+ try:
353
+ gq, gk, gv = ts_fwd_bwd()
354
+ qr, kr, vr = (t.double().requires_grad_() for t in (Qc, Kc, Vc))
355
+ sdpa_fwd(qr, kr, vr, elem_mask).backward(dOc.double())
356
+ row["bwd_rel_err"] = max(
357
+ rel_err(gq, qr.grad), rel_err(gk, kr.grad), rel_err(gv, vr.grad)
358
+ )
359
+ row["trnsparse_fwd_bwd_ms"] = safe_bench(ts_fwd_bwd, reps=args.reps)
360
+ except Exception as e:
361
+ row["trnsparse_fwd_bwd_ms"] = f"failed: {type(e).__name__}: {str(e)[:120]}"
362
+ row["sdpa_dense_dev_fwd_ms"] = dense_fwd
363
+ row["sdpa_dense_dev_fwd_bwd_ms"] = dense_fb
364
+ em_d = elem_mask.to(DEV)
365
+ row["sdpa_masked_dev_fwd_ms"] = safe_bench(
366
+ lambda: sdpa_fwd(Qd, Kd, Vd, em_d), reps=args.reps
367
+ )
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
+ )
372
+ emit(row, args.out)
373
+ em_d = None
374
+
375
+
376
+ def main():
377
+ ap = argparse.ArgumentParser()
378
+ ap.add_argument("--section", required=True, choices=["bsr", "csr", "screen", "attn"])
379
+ ap.add_argument("--size", type=int, default=1024)
380
+ ap.add_argument("--densities", default="0.01,0.05,0.1,0.25,0.5")
381
+ ap.add_argument("--widths", default="128,512")
382
+ ap.add_argument("--thresholds", default="1e-12,1e-4,1e-2")
383
+ ap.add_argument("--head-dims", default="64,128")
384
+ ap.add_argument("--masks", default="local,dilated,global")
385
+ ap.add_argument("--no-bwd", action="store_true")
386
+ ap.add_argument("--reps", type=int, default=10)
387
+ ap.add_argument("--cpu-reps", type=int, default=3)
388
+ ap.add_argument("--out", default="")
389
+ args = ap.parse_args()
390
+ trnsparse.set_backend("nki")
391
+ {"bsr": section_bsr, "csr": section_csr, "screen": section_screen, "attn": section_attn}[
392
+ args.section
393
+ ](args)
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()