trnsparse 0.4.0__tar.gz → 0.4.2__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 (66) hide show
  1. {trnsparse-0.4.0 → trnsparse-0.4.2}/CHANGELOG.md +55 -0
  2. {trnsparse-0.4.0/trnsparse.egg-info → trnsparse-0.4.2}/PKG-INFO +14 -1
  3. {trnsparse-0.4.0 → trnsparse-0.4.2}/README.md +13 -0
  4. trnsparse-0.4.2/docs/sparse_attention.md +162 -0
  5. trnsparse-0.4.2/examples/block_sparse_attention.py +284 -0
  6. trnsparse-0.4.2/examples/sparse_fock.py +164 -0
  7. {trnsparse-0.4.0 → trnsparse-0.4.2}/mkdocs.yml +2 -0
  8. {trnsparse-0.4.0 → trnsparse-0.4.2}/pyproject.toml +1 -1
  9. trnsparse-0.4.2/tests/test_attention.py +93 -0
  10. trnsparse-0.4.2/tests/test_examples.py +70 -0
  11. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/__init__.py +1 -1
  12. {trnsparse-0.4.0 → trnsparse-0.4.2/trnsparse.egg-info}/PKG-INFO +14 -1
  13. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse.egg-info/SOURCES.txt +4 -0
  14. trnsparse-0.4.0/examples/sparse_fock.py +0 -80
  15. {trnsparse-0.4.0 → trnsparse-0.4.2}/.github/workflows/ci.yml +0 -0
  16. {trnsparse-0.4.0 → trnsparse-0.4.2}/.github/workflows/notify-umbrella.yml +0 -0
  17. {trnsparse-0.4.0 → trnsparse-0.4.2}/.github/workflows/publish.yml +0 -0
  18. {trnsparse-0.4.0 → trnsparse-0.4.2}/.gitignore +0 -0
  19. {trnsparse-0.4.0 → trnsparse-0.4.2}/.pre-commit-config.yaml +0 -0
  20. {trnsparse-0.4.0 → trnsparse-0.4.2}/CLAUDE.md +0 -0
  21. {trnsparse-0.4.0 → trnsparse-0.4.2}/CODE_OF_CONDUCT.md +0 -0
  22. {trnsparse-0.4.0 → trnsparse-0.4.2}/CONTRIBUTING.md +0 -0
  23. {trnsparse-0.4.0 → trnsparse-0.4.2}/LICENSE +0 -0
  24. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/bench_bsr_spmm.py +0 -0
  25. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/bench_iterative.py +0 -0
  26. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/bench_screening.py +0 -0
  27. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/bench_spmm.py +0 -0
  28. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/bench_spmv.py +0 -0
  29. {trnsparse-0.4.0 → trnsparse-0.4.2}/benchmarks/conftest.py +0 -0
  30. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/api.md +0 -0
  31. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/architecture.md +0 -0
  32. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/aws_setup.md +0 -0
  33. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/benchmarks.md +0 -0
  34. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/index.md +0 -0
  35. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/installation.md +0 -0
  36. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/iterative_solvers.md +0 -0
  37. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/migration_scipy.md +0 -0
  38. {trnsparse-0.4.0 → trnsparse-0.4.2}/docs/quickstart.md +0 -0
  39. {trnsparse-0.4.0 → trnsparse-0.4.2}/infra/terraform/.terraform.lock.hcl +0 -0
  40. {trnsparse-0.4.0 → trnsparse-0.4.2}/infra/terraform/README.md +0 -0
  41. {trnsparse-0.4.0 → trnsparse-0.4.2}/infra/terraform/main.tf +0 -0
  42. {trnsparse-0.4.0 → trnsparse-0.4.2}/scripts/bench_to_md.py +0 -0
  43. {trnsparse-0.4.0 → trnsparse-0.4.2}/scripts/run_benchmarks.sh +0 -0
  44. {trnsparse-0.4.0 → trnsparse-0.4.2}/scripts/run_neuron_tests.sh +0 -0
  45. {trnsparse-0.4.0 → trnsparse-0.4.2}/scripts/run_simulator_tests.sh +0 -0
  46. {trnsparse-0.4.0 → trnsparse-0.4.2}/setup.cfg +0 -0
  47. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/conftest.py +0 -0
  48. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_bsr.py +0 -0
  49. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_formats.py +0 -0
  50. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_iterative.py +0 -0
  51. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_nki_bsr.py +0 -0
  52. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_nki_screened_spmm.py +0 -0
  53. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_nki_sim.py +0 -0
  54. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_nki_spmm.py +0 -0
  55. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_ops.py +0 -0
  56. {trnsparse-0.4.0 → trnsparse-0.4.2}/tests/test_screening.py +0 -0
  57. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/formats.py +0 -0
  58. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/iterative.py +0 -0
  59. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/nki/__init__.py +0 -0
  60. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/nki/dispatch.py +0 -0
  61. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/nki/kernels.py +0 -0
  62. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/ops.py +0 -0
  63. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse/screening.py +0 -0
  64. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse.egg-info/dependency_links.txt +0 -0
  65. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse.egg-info/requires.txt +0 -0
  66. {trnsparse-0.4.0 → trnsparse-0.4.2}/trnsparse.egg-info/top_level.txt +0 -0
@@ -5,6 +5,61 @@ 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
+ ## [0.4.2] — 2026-04-15
9
+
10
+ ### Added
11
+
12
+ - **`examples/block_sparse_attention.py`** — block-sparse attention
13
+ reference using `BSRMatrix` + `bsr_spmm`. Three mask patterns
14
+ (local window, dilated, global tokens); verifies against a dense
15
+ reference; reports block density and timing for the `bsr_spmm` step.
16
+ Closes [#21](https://github.com/trnsci/trnsparse/issues/21).
17
+ - **`docs/sparse_attention.md`** — writeup: how BSR-128 maps to
18
+ Longformer/BigBird-style attention masks, block density arithmetic,
19
+ pattern construction helpers, and the fused-tile follow-up.
20
+ - **`docs/iterative_solvers.md`** now linked in `mkdocs.yml` nav
21
+ (was present but missing from navigation).
22
+ - **`tests/test_attention.py`** — 8 CPU tests: mask shape/symmetry
23
+ checks + parity against dense reference at `atol=1e-4` for all three
24
+ patterns and the full-attention edge case.
25
+
26
+ ### Notes
27
+
28
+ No API changes, no kernel changes. The claim in #21 is: `bsr_spmm` is
29
+ the block-sparse attention primitive; `BSRMatrix` captures the mask.
30
+ The example and docs make that explicit.
31
+
32
+ ## [0.4.1] — 2026-04-14
33
+
34
+ ### Added
35
+
36
+ - **`examples/sparse_fock.py`** rewritten around v0.4.0's
37
+ `screened_spmm`. Three paths side-by-side on the same inputs:
38
+ (1) v0.1.x unfused `schwarz_bounds → screen → from_dense → spmm`;
39
+ (2) v0.4.0 fused `screened_spmm` (one call);
40
+ (3) full Fock build — the coulomb from path 2 contracted against MO
41
+ coefficients via `trnblas.gemm` for `F_MO = C.T @ J @ C` (falls
42
+ back to `torch.matmul` if trnblas isn't installed). On a 50-basis
43
+ synthetic system, the fused path is ~130× faster than the unfused
44
+ (dominated by eliminating the Python `from_dense` CSR construction).
45
+ Closes #6.
46
+ - **`examples/pyscf_bridge.py`** (new) — optional PySCF-driven demo.
47
+ Builds H2O (or benzene, or H2), pulls real AO ERIs via
48
+ `mol.intor("int2e")`, feeds the `(μμ|μμ)` diagonal into
49
+ `schwarz_bounds` + `screened_spmm` against a mock density matrix.
50
+ Reports realistic sparsity at `threshold=1e-8`. Requires
51
+ `pip install pyscf`; tests skip cleanly if not available.
52
+ Closes #13.
53
+ - **`tests/test_examples.py`** — 2 CPU smoke tests plus a
54
+ PySCF-gated test. Exercises the `sparse_fock` unfused + fused
55
+ paths end-to-end and asserts parity (`atol=1e-6`).
56
+
57
+ ### Notes
58
+
59
+ No API changes, no kernel changes — pure integration demo release.
60
+ Users already on v0.4.0 can stay there; upgrade to v0.4.1 only to
61
+ pick up the new examples.
62
+
8
63
  ## [0.4.0] — 2026-04-14
9
64
 
10
65
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trnsparse
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -102,3 +102,16 @@ stats = trnsparse.sparsity_stats(Q)
102
102
  ## License
103
103
 
104
104
  Apache 2.0 — Copyright 2026 Scott Friedman
105
+
106
+
107
+ ## Disclaimer
108
+
109
+ trnsci is an **independent open-source project**. It is not sponsored by, endorsed by, or affiliated with Amazon.com, Inc., Amazon Web Services, Inc., or Annapurna Labs Ltd.
110
+
111
+ "AWS", "Amazon", "Trainium", "Inferentia", "NeuronCore", "Neuron SDK", and related identifiers are trademarks of their respective owners and are used here solely for descriptive and interoperability purposes. Use does not imply endorsement, partnership, or any other relationship.
112
+
113
+ All work, opinions, analyses, benchmark results, architectural commentary, and editorial judgments in this repository and on [trnsci.dev](https://trnsci.dev) are those of the project's contributors. They do not represent the views, positions, or commitments of Amazon, AWS, or Annapurna Labs.
114
+
115
+ Feedback directed at the Neuron SDK or Trainium hardware is good-faith ecosystem commentary from independent users. It is not privileged information, is not pre-reviewed by AWS, and should not be read as authoritative about product roadmap, behavior, or quality.
116
+
117
+ For official AWS guidance, see [aws-neuron documentation](https://awsdocs-neuron.readthedocs-hosted.com/) and the [AWS Trainium product page](https://aws.amazon.com/ai/machine-learning/trainium/).
@@ -71,3 +71,16 @@ stats = trnsparse.sparsity_stats(Q)
71
71
  ## License
72
72
 
73
73
  Apache 2.0 — Copyright 2026 Scott Friedman
74
+
75
+
76
+ ## Disclaimer
77
+
78
+ trnsci is an **independent open-source project**. It is not sponsored by, endorsed by, or affiliated with Amazon.com, Inc., Amazon Web Services, Inc., or Annapurna Labs Ltd.
79
+
80
+ "AWS", "Amazon", "Trainium", "Inferentia", "NeuronCore", "Neuron SDK", and related identifiers are trademarks of their respective owners and are used here solely for descriptive and interoperability purposes. Use does not imply endorsement, partnership, or any other relationship.
81
+
82
+ All work, opinions, analyses, benchmark results, architectural commentary, and editorial judgments in this repository and on [trnsci.dev](https://trnsci.dev) are those of the project's contributors. They do not represent the views, positions, or commitments of Amazon, AWS, or Annapurna Labs.
83
+
84
+ Feedback directed at the Neuron SDK or Trainium hardware is good-faith ecosystem commentary from independent users. It is not privileged information, is not pre-reviewed by AWS, and should not be read as authoritative about product roadmap, behavior, or quality.
85
+
86
+ For official AWS guidance, see [aws-neuron documentation](https://awsdocs-neuron.readthedocs-hosted.com/) and the [AWS Trainium product page](https://aws.amazon.com/ai/machine-learning/trainium/).
@@ -0,0 +1,162 @@
1
+ # Block-sparse attention
2
+
3
+ ## The connection
4
+
5
+ Transformer attention computes `O = softmax(Q @ K.T / √d) @ V`. The expensive
6
+ part at long sequence lengths is the `(seq_len, seq_len)` score matrix and the
7
+ final `attn_weights @ V` matmul. Sparse attention restricts which positions can
8
+ attend to each other, zeroing out large regions of the score matrix. What
9
+ remains is a sparse × dense matmul: exactly `bsr_spmm`.
10
+
11
+ The block size that matters is 128. Trainium's Tensor Engine is a 128-partition
12
+ systolic array; `nc_matmul` consumes a 128×K×N tile in one call. A local-window
13
+ attention mask with window granularity of 128 tokens has exactly `2w+1` nonzero
14
+ 128×128 blocks per block-row — each nonzero block maps one-to-one to one
15
+ `nc_matmul` call, with no gather overhead. The tile size of the mask and the
16
+ tile size of the hardware coincide.
17
+
18
+ cuSPARSE's BSR is a specialization added on top of CSR. Here it's the other
19
+ way: BSR is what the attention mask asks for, and the hardware is built around
20
+ that shape.
21
+
22
+ ## Building patterns
23
+
24
+ ### Local window (Longformer-style)
25
+
26
+ Each token block attends to its `window` nearest neighbors in block space:
27
+
28
+ ```python
29
+ import trnsparse
30
+
31
+ def local_window_mask(seq_len: int, block_size: int, window: int) -> trnsparse.BSRMatrix:
32
+ """BSRMatrix for sliding-window attention."""
33
+ import torch
34
+ n_blocks = seq_len // block_size
35
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
36
+ for i in range(n_blocks):
37
+ lo, hi = max(0, i - window), min(n_blocks, i + window + 1)
38
+ block_mask[i, lo:hi] = True
39
+ mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
40
+ return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
41
+ ```
42
+
43
+ At `seq_len=4096` and `window=2`: `5` nonzero blocks per row, `5/32 ≈ 15.6%`
44
+ block density vs 100% for dense attention. Memory for the stored blocks:
45
+ `32 rows × 5 blocks × 128 × 128 × 4 bytes ≈ 10 MB` vs 64 MB for the full
46
+ float32 attention matrix.
47
+
48
+ ### Dilated sparse
49
+
50
+ Every `stride`-th block column is attended to. Block `(i, j)` is nonzero
51
+ iff `(i - j) % stride == 0`:
52
+
53
+ ```python
54
+ def dilated_mask(seq_len: int, block_size: int, stride: int) -> trnsparse.BSRMatrix:
55
+ import torch
56
+ n_blocks = seq_len // block_size
57
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
58
+ for i in range(n_blocks):
59
+ for j in range(n_blocks):
60
+ if (i - j) % stride == 0:
61
+ block_mask[i, j] = True
62
+ mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
63
+ return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
64
+ ```
65
+
66
+ ### Global tokens (BigBird-style)
67
+
68
+ The first `n_global` token-blocks attend to and are attended to by every
69
+ block. Remaining block-rows follow a local window:
70
+
71
+ ```python
72
+ def global_token_mask(
73
+ seq_len: int, block_size: int, window: int, n_global: int
74
+ ) -> trnsparse.BSRMatrix:
75
+ import torch
76
+ n_blocks = seq_len // block_size
77
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
78
+ block_mask[:n_global, :] = True # global rows attend everywhere
79
+ block_mask[:, :n_global] = True # global columns attended by all
80
+ for i in range(n_global, n_blocks):
81
+ lo, hi = max(0, i - window), min(n_blocks, i + window + 1)
82
+ block_mask[i, lo:hi] = True
83
+ mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
84
+ return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
85
+ ```
86
+
87
+ ## The computation
88
+
89
+ Given a `mask_bsr` constructed above:
90
+
91
+ ```python
92
+ import torch
93
+
94
+ def block_sparse_attention(Q, K, V, mask_bsr):
95
+ """Q, K, V: (seq_len, head_dim) → (seq_len, head_dim)."""
96
+ scale = Q.shape[-1] ** -0.5
97
+ scores = (Q @ K.T) * scale
98
+
99
+ # Mask out unattended positions before softmax.
100
+ mask_dense = mask_bsr.to_dense().bool()
101
+ masked_scores = scores.masked_fill(~mask_dense, float("-inf"))
102
+ attn_weights = torch.softmax(masked_scores, dim=-1)
103
+
104
+ # Positions outside the mask go to zero after softmax; from_dense
105
+ # with threshold=0 will still store them unless they're exactly zero.
106
+ # Use threshold slightly above zero to keep only the attended blocks.
107
+ attn_bsr = trnsparse.BSRMatrix.from_dense(
108
+ attn_weights, block_size=mask_bsr.block_size, threshold=1e-9
109
+ )
110
+ return trnsparse.bsr_spmm(attn_bsr, V)
111
+ ```
112
+
113
+ **What this materializes**: the full `(seq_len, seq_len)` score matrix. At
114
+ `seq_len=4096` that's 64 MB of float32 even before the matmul. See
115
+ [What's next](#whats-next) for the fused-tile path that avoids this.
116
+
117
+ **Gradients**: `bsr_spmm` is differentiable (see `architecture.md`). The
118
+ backward through `block_sparse_attention` works out-of-the-box with
119
+ `torch.autograd`; the block-selection step (`from_dense`) is non-differentiable
120
+ by construction and is treated as a constant by the autograd graph.
121
+
122
+ ## Block density arithmetic
123
+
124
+ For `seq_len = S`, `block_size = b`, `window = w` local-window mask:
125
+
126
+ ```
127
+ blocks per row = 2w + 1 (clamped at edges)
128
+ block density = (2w+1) / (S/b)
129
+ ```
130
+
131
+ | seq_len | window | n_blocks_per_row | block density |
132
+ |--------:|-------:|-----------------:|:--------------|
133
+ | 1024 | 2 | 5 | 39.1% |
134
+ | 2048 | 2 | 5 | 19.5% |
135
+ | 4096 | 2 | 5 | 9.8% |
136
+ | 4096 | 4 | 9 | 17.6% |
137
+ | 8192 | 2 | 5 | 4.9% |
138
+
139
+ The win is at long sequences. At `seq_len=1024`, a window-2 mask still stores
140
+ 40% of blocks — dispatch overhead dominates on Trainium at that size. At
141
+ `seq_len=8192`, 5% density means 95% of the `nc_matmul` calls are skipped and
142
+ the per-block compute becomes the bottleneck, which is where the Tensor Engine
143
+ thrives.
144
+
145
+ ## What's next
146
+
147
+ The current path materializes the full `(seq_len, seq_len)` score matrix to
148
+ compute attention weights. A production path would:
149
+
150
+ 1. Iterate over nonzero blocks in `mask_bsr`.
151
+ 2. For each block `(i, j)`, load `Q[i*b:(i+1)*b]` and `K[j*b:(j+1)*b]` into
152
+ SBUF, compute the score tile, and apply softmax over the block-row.
153
+ 3. Multiply the score tile by `V[j*b:(j+1)*b]` and accumulate into the output.
154
+
155
+ This fused tile-level kernel avoids the `O(seq_len²)` intermediate entirely.
156
+ It's the same architectural opportunity as [on-chip iterative solvers](iterative_solvers.md):
157
+ load A once, iterate on-chip. The NKI building block is available (the BSR
158
+ kernel in `nki/kernels.py`); the row-wise softmax over variable numbers of
159
+ tiles is the authoring challenge.
160
+
161
+ A runnable reference for the current (non-fused) path is in
162
+ [`examples/block_sparse_attention.py`](https://github.com/trnsci/trnsparse/blob/main/examples/block_sparse_attention.py).
@@ -0,0 +1,284 @@
1
+ """Block-sparse attention via BSRMatrix and bsr_spmm.
2
+
3
+ The final matmul in sparse attention — `attn_weights @ V` — is a sparse ×
4
+ dense matrix multiplication. When the attention mask has 128×128 block
5
+ structure (local window, dilated, global tokens), that matmul is exactly
6
+ `bsr_spmm(attn_weights_bsr, V)`. No new kernel; BSRMatrix captures the
7
+ sparsity pattern, and the Tensor Engine sees a sequence of dense 128×128
8
+ tiles with zero gather overhead.
9
+
10
+ Three mask patterns are demonstrated:
11
+
12
+ local — sliding-window (Longformer-style): each token attends to the
13
+ nearest `window` blocks. Block (i, j) is stored iff |i-j| ≤ window.
14
+ dilated — every `stride`-th block column is stored. Covers dilated sparse
15
+ attention variants.
16
+ global — first `n_global` token blocks attend to and are attended to by
17
+ everyone; remaining rows follow a local window.
18
+
19
+ Usage:
20
+ python examples/block_sparse_attention.py --demo
21
+ python examples/block_sparse_attention.py --seq-len 512 --pattern dilated
22
+ python examples/block_sparse_attention.py --seq-len 1024 --head-dim 64 --pattern global
23
+
24
+ Note on materialization: this example computes the full score matrix
25
+ `Q @ K.T` before masking. A production path would compute only the nonzero
26
+ tiles of S = Q @ K.T, avoiding the O(seq_len²) intermediate. That fused
27
+ tile-level score computation is the architectural follow-up; here the claim
28
+ being demonstrated is that `bsr_spmm` handles the attn_weights × V step.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ import torch
39
+
40
+ # Allow running from repo root without install.
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
42
+ import trnsparse
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Mask pattern helpers
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def _local_window_mask(seq_len: int, block_size: int, window: int) -> torch.Tensor:
50
+ """Boolean mask for local-window (Longformer-style) attention.
51
+
52
+ Block (i, j) in block-row i, block-column j is nonzero iff |i - j| ≤ window.
53
+
54
+ Returns:
55
+ bool tensor of shape (seq_len, seq_len).
56
+ """
57
+ assert seq_len % block_size == 0, "seq_len must be a multiple of block_size"
58
+ n_blocks = seq_len // block_size
59
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
60
+ for i in range(n_blocks):
61
+ lo = max(0, i - window)
62
+ hi = min(n_blocks, i + window + 1)
63
+ block_mask[i, lo:hi] = True
64
+ # Expand each block indicator to (block_size, block_size)
65
+ return block_mask.repeat_interleave(block_size, dim=0).repeat_interleave(block_size, dim=1)
66
+
67
+
68
+ def _dilated_mask(seq_len: int, block_size: int, stride: int) -> torch.Tensor:
69
+ """Boolean mask for dilated sparse attention.
70
+
71
+ Block (i, j) is nonzero iff (i - j) % stride == 0 (i.e., block column j
72
+ is a multiple of stride away from block row i, including j == i).
73
+
74
+ Returns:
75
+ bool tensor of shape (seq_len, seq_len).
76
+ """
77
+ assert seq_len % block_size == 0
78
+ n_blocks = seq_len // block_size
79
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
80
+ for i in range(n_blocks):
81
+ for j in range(n_blocks):
82
+ if (i - j) % stride == 0:
83
+ block_mask[i, j] = True
84
+ return block_mask.repeat_interleave(block_size, dim=0).repeat_interleave(block_size, dim=1)
85
+
86
+
87
+ def _global_token_mask(seq_len: int, block_size: int, window: int, n_global: int) -> torch.Tensor:
88
+ """Boolean mask with global tokens + local window (BigBird-style).
89
+
90
+ First `n_global` token-blocks attend to all blocks and are attended to
91
+ by all blocks. Remaining rows follow a local window of `window` blocks.
92
+
93
+ Returns:
94
+ bool tensor of shape (seq_len, seq_len).
95
+ """
96
+ assert seq_len % block_size == 0
97
+ n_blocks = seq_len // block_size
98
+ assert 0 <= n_global <= n_blocks
99
+ block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
100
+ # Global rows: attend everywhere
101
+ block_mask[:n_global, :] = True
102
+ # Global columns: attended to by everyone
103
+ block_mask[:, :n_global] = True
104
+ # Local window for non-global rows
105
+ for i in range(n_global, n_blocks):
106
+ lo = max(0, i - window)
107
+ hi = min(n_blocks, i + window + 1)
108
+ block_mask[i, lo:hi] = True
109
+ return block_mask.repeat_interleave(block_size, dim=0).repeat_interleave(block_size, dim=1)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Attention computation
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def block_sparse_attention(
118
+ Q: torch.Tensor,
119
+ K: torch.Tensor,
120
+ V: torch.Tensor,
121
+ mask_bsr: trnsparse.BSRMatrix,
122
+ ) -> torch.Tensor:
123
+ """Compute sparse attention output using bsr_spmm for the final matmul.
124
+
125
+ Args:
126
+ Q: (seq_len, head_dim) query tensor.
127
+ K: (seq_len, head_dim) key tensor.
128
+ V: (seq_len, head_dim) value tensor.
129
+ mask_bsr: BSRMatrix encoding which attention blocks are nonzero.
130
+ The blocks themselves are ignored — only the sparsity pattern
131
+ (block_row_ptrs, block_col_indices) is used. The actual weights
132
+ come from softmax(Q @ K.T).
133
+
134
+ Returns:
135
+ (seq_len, head_dim) attention output.
136
+
137
+ Note: this materializes the full (seq_len, seq_len) score matrix Q @ K.T
138
+ before masking. See module docstring on the production alternative.
139
+ """
140
+ seq_len, head_dim = Q.shape
141
+ scale = head_dim**-0.5
142
+
143
+ # Full score matrix — O(seq_len²). A production path computes only the
144
+ # nonzero tiles of S to avoid this intermediate.
145
+ scores = (Q @ K.T) * scale # (seq_len, seq_len)
146
+
147
+ # Apply additive mask: positions outside the pattern → -inf before softmax.
148
+ mask_dense = mask_bsr.to_dense().bool()
149
+ masked_scores = scores.masked_fill(~mask_dense, float("-inf"))
150
+
151
+ # Row-wise softmax over all attended positions (positions outside mask → 0).
152
+ attn_weights = torch.softmax(masked_scores, dim=-1) # (seq_len, seq_len)
153
+
154
+ # Convert the post-softmax weights to BSR (same pattern, real values now).
155
+ attn_bsr = trnsparse.BSRMatrix.from_dense(attn_weights, block_size=mask_bsr.block_size)
156
+
157
+ # Final matmul via bsr_spmm: (seq_len, seq_len) sparse × (seq_len, head_dim) dense.
158
+ return trnsparse.bsr_spmm(attn_bsr, V) # (seq_len, head_dim)
159
+
160
+
161
+ def _dense_reference(
162
+ Q: torch.Tensor,
163
+ K: torch.Tensor,
164
+ V: torch.Tensor,
165
+ mask: torch.Tensor,
166
+ ) -> torch.Tensor:
167
+ """Dense reference: full softmax(Q @ K.T) @ V with additive mask."""
168
+ scale = Q.shape[-1] ** -0.5
169
+ scores = (Q @ K.T) * scale
170
+ masked = scores.masked_fill(~mask, float("-inf"))
171
+ attn = torch.softmax(masked, dim=-1)
172
+ return attn @ V
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # CLI
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ def main() -> None:
181
+ parser = argparse.ArgumentParser(description=__doc__)
182
+ parser.add_argument("--demo", action="store_true", help="run a small demo (seq_len=512)")
183
+ parser.add_argument("--seq-len", type=int, default=512)
184
+ parser.add_argument("--head-dim", type=int, default=64)
185
+ parser.add_argument(
186
+ "--pattern",
187
+ choices=["local", "dilated", "global"],
188
+ default="local",
189
+ help="attention mask pattern",
190
+ )
191
+ parser.add_argument(
192
+ "--window", type=int, default=2, help="block window for local/global patterns"
193
+ )
194
+ parser.add_argument("--stride", type=int, default=2, help="block stride for dilated pattern")
195
+ parser.add_argument(
196
+ "--n-global", type=int, default=2, help="global token blocks for global pattern"
197
+ )
198
+ parser.add_argument("--block-size", type=int, default=128, help="BSR block size")
199
+ args = parser.parse_args()
200
+
201
+ if args.demo:
202
+ args.seq_len = 512
203
+ args.head_dim = 64
204
+ args.pattern = "local"
205
+ args.window = 1
206
+
207
+ seq_len = args.seq_len
208
+ head_dim = args.head_dim
209
+ block_size = args.block_size
210
+
211
+ if seq_len % block_size != 0:
212
+ print(f"Error: seq_len ({seq_len}) must be a multiple of block_size ({block_size}).")
213
+ sys.exit(1)
214
+
215
+ torch.manual_seed(42)
216
+ Q = torch.randn(seq_len, head_dim)
217
+ K = torch.randn(seq_len, head_dim)
218
+ V = torch.randn(seq_len, head_dim)
219
+
220
+ # Build the mask.
221
+ if args.pattern == "local":
222
+ mask = _local_window_mask(seq_len, block_size, args.window)
223
+ pattern_desc = f"local window (w={args.window})"
224
+ elif args.pattern == "dilated":
225
+ mask = _dilated_mask(seq_len, block_size, args.stride)
226
+ pattern_desc = f"dilated (stride={args.stride})"
227
+ else:
228
+ mask = _global_token_mask(seq_len, block_size, args.window, args.n_global)
229
+ pattern_desc = f"global tokens (n_global={args.n_global}, w={args.window})"
230
+
231
+ mask_bsr = trnsparse.BSRMatrix.from_dense(mask.float(), block_size=block_size)
232
+ n_blocks_total = (seq_len // block_size) ** 2
233
+ block_density = mask_bsr.n_blocks / n_blocks_total
234
+
235
+ print("Block-sparse attention via bsr_spmm:")
236
+ print(f" seq_len: {seq_len}")
237
+ print(f" head_dim: {head_dim}")
238
+ print(f" block_size: {block_size}")
239
+ print(f" pattern: {pattern_desc}")
240
+ print(
241
+ f" n_blocks: {mask_bsr.n_blocks} / {n_blocks_total} ({block_density:.1%} block density)"
242
+ )
243
+ print()
244
+
245
+ # Dense reference.
246
+ out_dense = _dense_reference(Q, K, V, mask)
247
+
248
+ # BSR path.
249
+ out_bsr = block_sparse_attention(Q, K, V, mask_bsr)
250
+
251
+ max_err = (out_bsr - out_dense).abs().max().item()
252
+ print(f" Numerical agreement: max |Δ| = {max_err:.2e}")
253
+ assert max_err < 1e-3, f"BSR and dense outputs disagree: max_err={max_err}"
254
+
255
+ # Timing: attn_weights @ V, BSR vs dense.
256
+ # Pre-compute attn_weights for a fair comparison of just the matmul step.
257
+ scale = head_dim**-0.5
258
+ masked_scores = (Q @ K.T * scale).masked_fill(~mask, float("-inf"))
259
+ attn_weights = torch.softmax(masked_scores, dim=-1)
260
+ attn_bsr_timed = trnsparse.BSRMatrix.from_dense(attn_weights, block_size=block_size)
261
+
262
+ N_REPS = 20
263
+
264
+ t0 = time.perf_counter()
265
+ for _ in range(N_REPS):
266
+ _ = trnsparse.bsr_spmm(attn_bsr_timed, V)
267
+ t_bsr = (time.perf_counter() - t0) / N_REPS
268
+
269
+ t0 = time.perf_counter()
270
+ for _ in range(N_REPS):
271
+ _ = attn_weights @ V
272
+ t_dense = (time.perf_counter() - t0) / N_REPS
273
+
274
+ print(f" Timing (attn_weights @ V, {N_REPS}-rep mean):")
275
+ print(f" bsr_spmm: {t_bsr * 1e6:8.1f} μs")
276
+ print(f" dense @: {t_dense * 1e6:8.1f} μs")
277
+ print()
278
+ print(" Note: on CPU the PyTorch fallback is used. bsr_spmm wins at")
279
+ print(" large seq_len on Trainium, where dispatch overhead amortizes")
280
+ print(" against the tile-level nc_matmul throughput advantage.")
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
@@ -0,0 +1,164 @@
1
+ """Sparse Fock matrix build with integral screening.
2
+
3
+ Three paths, side by side:
4
+
5
+ 1. **v0.1.x unfused** — `schwarz_bounds` → `screen_quartets` → mask-apply
6
+ → `from_dense` → `spmm`. What the library shipped before v0.4.0.
7
+ Four host passes over an (n, n) mask + a separate CSR build + a
8
+ separate SpMM dispatch.
9
+ 2. **v0.4.0 fused** — `screened_spmm(A, diag_integrals, B, threshold)`.
10
+ One NKI kernel on the NKI backend; explicit mask + matmul on CPU.
11
+ Same numeric result, fewer HBM round-trips, no mask tensor on HBM.
12
+ 3. **Full Fock build** — the coulomb J from path 2 contracted against
13
+ MO coefficients via trnblas: `F_MO = C.T @ J @ C`. Demonstrates the
14
+ suite composition — trnsparse hands off to trnblas's GEMM once the
15
+ sparse step is done.
16
+
17
+ Schwarz bounds here are synthetic (Gaussian-distance decay) — realistic
18
+ enough to show non-trivial sparsity. For real AO integrals from a
19
+ molecule, see `examples/pyscf_bridge.py`.
20
+
21
+ Usage:
22
+ python examples/sparse_fock.py --demo
23
+ python examples/sparse_fock.py --nbasis 200 --threshold 1e-8
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import time
30
+
31
+ import torch
32
+
33
+ import trnsparse
34
+
35
+
36
+ def _synthetic_schwarz_system(n: int, seed: int = 42) -> torch.Tensor:
37
+ """Return synthetic diagonal integrals `(μμ|μμ)` for a 1-D molecular chain.
38
+
39
+ Chemistry convention: the Schwarz bound for `(μν|μν)` factors as
40
+ `Q[μ] * Q[ν]` where `Q[i] = sqrt((ii|ii))`. This demo generates
41
+ per-shell magnitudes that span several orders of magnitude so the
42
+ outer-product bound `Q[i] * Q[j]` produces non-trivial sparsity at
43
+ a realistic threshold.
44
+ """
45
+ torch.manual_seed(seed)
46
+ # Shells arranged in a 1-D chain; Gaussian-like decay in magnitude
47
+ # from a central "heavy" region so there's a tail of small Q values.
48
+ idx = torch.arange(n, dtype=torch.float32)
49
+ center = n / 2.0
50
+ diag_integrals = torch.exp(-((idx - center) ** 2) / (2.0 * (n / 6.0) ** 2)) + 0.01
51
+ return diag_integrals
52
+
53
+
54
+ def _unfused_path(
55
+ integrals_dense: torch.Tensor, diag_integrals: torch.Tensor, P: torch.Tensor, threshold: float
56
+ ):
57
+ """Path 1: explicit Schwarz bound + mask + from_dense + spmm. v0.1.x flow."""
58
+ import math
59
+
60
+ t0 = time.perf_counter()
61
+ Q = trnsparse.schwarz_bounds(diag_integrals) # (n,)
62
+ pair_bound = Q.unsqueeze(-1) * Q.unsqueeze(0) # (n, n)
63
+ mask = pair_bound > math.sqrt(threshold)
64
+ integrals_masked = integrals_dense * mask.to(integrals_dense.dtype)
65
+ integrals_sparse = trnsparse.from_dense(integrals_masked)
66
+ J = trnsparse.spmm(integrals_sparse, P)
67
+ return J, time.perf_counter() - t0
68
+
69
+
70
+ def _fused_path(
71
+ integrals_dense: torch.Tensor, diag_integrals: torch.Tensor, P: torch.Tensor, threshold: float
72
+ ):
73
+ """Path 2: v0.4.0 fused screened_spmm."""
74
+ t0 = time.perf_counter()
75
+ J = trnsparse.screened_spmm(integrals_dense, diag_integrals, P, threshold=threshold)
76
+ return J, time.perf_counter() - t0
77
+
78
+
79
+ def _full_fock_build(J: torch.Tensor, C: torch.Tensor):
80
+ """Path 3: transform the coulomb J into the MO basis via trnblas.
81
+
82
+ F_MO = C.T @ J @ C — two GEMMs. Falls back to torch.matmul if
83
+ trnblas isn't importable (it's an optional suite dep; pure-trnsparse
84
+ users don't need it).
85
+ """
86
+ t0 = time.perf_counter()
87
+ try:
88
+ import trnblas
89
+
90
+ Jt = trnblas.gemm(1.0, C, J, transA=True) # C.T @ J
91
+ F_MO = trnblas.gemm(1.0, Jt, C) # (C.T @ J) @ C
92
+ backend = "trnblas"
93
+ except ImportError:
94
+ F_MO = C.T @ J @ C
95
+ backend = "torch.matmul (trnblas not installed)"
96
+ return F_MO, time.perf_counter() - t0, backend
97
+
98
+
99
+ def main() -> None:
100
+ parser = argparse.ArgumentParser(description=__doc__)
101
+ parser.add_argument("--demo", action="store_true", help="run a small demo")
102
+ parser.add_argument("--nbasis", type=int, default=50)
103
+ parser.add_argument("--threshold", type=float, default=1e-4)
104
+ args = parser.parse_args()
105
+
106
+ if args.demo:
107
+ args.nbasis = 50
108
+
109
+ n = args.nbasis
110
+ print("Sparse Fock build:")
111
+ print(f" Basis functions: {n}")
112
+ print(f" Threshold: {args.threshold:.0e}")
113
+
114
+ diag_integrals = _synthetic_schwarz_system(n)
115
+ Q = trnsparse.schwarz_bounds(diag_integrals) # 1-D Schwarz bounds
116
+
117
+ # Unscreened ERI slice — random, scaled by outer-product Schwarz
118
+ # (chemistry-realistic: integrals tracking the bound).
119
+ torch.manual_seed(0)
120
+ integrals_dense = torch.randn(n, n) * (Q.unsqueeze(-1) * Q.unsqueeze(0)) * 0.01
121
+
122
+ # Density matrix — random SPD.
123
+ M = torch.randn(n, n) * 0.1
124
+ P = M @ M.T
125
+
126
+ # MO coefficients (for the trnblas transform in path 3) — orthonormal.
127
+ U, _ = torch.linalg.qr(torch.randn(n, n))
128
+ C = U
129
+
130
+ # --- Path 1: unfused ---
131
+ J_unfused, t_unfused = _unfused_path(integrals_dense, diag_integrals, P, args.threshold)
132
+
133
+ # --- Path 2: fused ---
134
+ J_fused, t_fused = _fused_path(integrals_dense, diag_integrals, P, args.threshold)
135
+
136
+ # --- Path 3: trnblas MO transform ---
137
+ F_MO, t_transform, backend = _full_fock_build(J_fused, C)
138
+
139
+ # --- Sparsity stats for context ---
140
+ # Build the pair-bound matrix for reporting stats at the matmul scale.
141
+ pair_bound = Q.unsqueeze(-1) * Q.unsqueeze(0)
142
+ stats = trnsparse.sparsity_stats(pair_bound, args.threshold**0.5)
143
+
144
+ print()
145
+ print(" Sparsity statistics:")
146
+ print(f" Total shell pairs: {stats['total_pairs']}")
147
+ print(f" Significant pairs: {stats['significant_pairs']}")
148
+ print(f" Pair sparsity: {stats['pair_sparsity']:.1%}")
149
+ print()
150
+ print(" Coulomb build timings:")
151
+ print(f" Path 1 (unfused, 4-step): {t_unfused * 1e3:8.3f} ms")
152
+ print(
153
+ f" Path 2 (fused screened_spmm): {t_fused * 1e3:8.3f} ms ({t_unfused / t_fused:.2f}x vs unfused)"
154
+ )
155
+ print()
156
+ print(" Full Fock build (trnsparse → trnblas):")
157
+ print(f" MO transform (C.T @ J @ C): {t_transform * 1e3:8.3f} ms via {backend}")
158
+ print()
159
+ print(f" Unfused/fused J agreement: max |ΔJ| = {(J_unfused - J_fused).abs().max().item():.2e}")
160
+ print(f" F_MO shape: {tuple(F_MO.shape)}, mean |F_MO| = {F_MO.abs().mean().item():.3e}")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
@@ -24,6 +24,8 @@ nav:
24
24
  - Architecture: architecture.md
25
25
  - Migration from scipy.sparse: migration_scipy.md
26
26
  - Benchmarks: benchmarks.md
27
+ - Iterative Solvers: iterative_solvers.md
28
+ - Sparse Attention: sparse_attention.md
27
29
  - Deployment:
28
30
  - AWS Setup for Neuron CI: aws_setup.md
29
31
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trnsparse"
7
- version = "0.4.0"
7
+ version = "0.4.2"
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,93 @@
1
+ """Tests for block-sparse attention example and pattern helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import torch
9
+
10
+ import trnsparse
11
+
12
+ EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
13
+ sys.path.insert(0, str(EXAMPLES))
14
+
15
+ import block_sparse_attention as demo # noqa: E402
16
+
17
+
18
+ class TestMaskShapes:
19
+ """Pattern helper sanity checks — block count and coverage."""
20
+
21
+ def test_local_window_mask_shape(self):
22
+ seq_len, block_size, window = 512, 128, 2
23
+ mask = demo._local_window_mask(seq_len, block_size, window)
24
+ assert mask.shape == (seq_len, seq_len)
25
+ assert mask.dtype == torch.bool
26
+ # Each block-row should have at most 2*window+1 nonzero blocks.
27
+ n_blocks = seq_len // block_size
28
+ bsr = trnsparse.BSRMatrix.from_dense(mask.float(), block_size=block_size)
29
+ for i in range(n_blocks):
30
+ row_count = (bsr.block_row_ptrs[i + 1] - bsr.block_row_ptrs[i]).item()
31
+ assert row_count <= 2 * window + 1
32
+
33
+ def test_local_window_mask_symmetric(self):
34
+ mask = demo._local_window_mask(256, 128, 1)
35
+ assert (mask == mask.T).all(), "local window mask should be symmetric"
36
+
37
+ def test_dilated_mask_shape(self):
38
+ seq_len, block_size, stride = 512, 128, 2
39
+ mask = demo._dilated_mask(seq_len, block_size, stride)
40
+ assert mask.shape == (seq_len, seq_len)
41
+ n_blocks = seq_len // block_size
42
+ # For stride=2 and n_blocks=4: blocks 0,2 from row 0; blocks 1,3 from row 1; etc.
43
+ bsr = trnsparse.BSRMatrix.from_dense(mask.float(), block_size=block_size)
44
+ expected_per_row = (n_blocks + stride - 1) // stride
45
+ for i in range(n_blocks):
46
+ row_count = (bsr.block_row_ptrs[i + 1] - bsr.block_row_ptrs[i]).item()
47
+ assert row_count == expected_per_row
48
+
49
+ def test_global_token_mask_global_rows_full(self):
50
+ seq_len, block_size, n_global = 384, 128, 1
51
+ mask = demo._global_token_mask(seq_len, block_size, window=1, n_global=n_global)
52
+ # First n_global*block_size rows must be fully attended.
53
+ assert mask[: n_global * block_size, :].all()
54
+ # First n_global*block_size columns must be fully attended by all rows.
55
+ assert mask[:, : n_global * block_size].all()
56
+
57
+
58
+ class TestAttentionParity:
59
+ """bsr_spmm attention matches dense reference."""
60
+
61
+ def _run_parity(self, seq_len, head_dim, block_size, mask):
62
+ torch.manual_seed(0)
63
+ Q = torch.randn(seq_len, head_dim)
64
+ K = torch.randn(seq_len, head_dim)
65
+ V = torch.randn(seq_len, head_dim)
66
+
67
+ mask_bsr = trnsparse.BSRMatrix.from_dense(mask.float(), block_size=block_size)
68
+ out_bsr = demo.block_sparse_attention(Q, K, V, mask_bsr)
69
+ out_dense = demo._dense_reference(Q, K, V, mask)
70
+
71
+ torch.testing.assert_close(out_bsr, out_dense, atol=1e-4, rtol=1e-4)
72
+ assert out_bsr.shape == (seq_len, head_dim)
73
+
74
+ def test_local_window_parity(self):
75
+ seq_len, head_dim, block_size = 256, 32, 128
76
+ mask = demo._local_window_mask(seq_len, block_size, window=1)
77
+ self._run_parity(seq_len, head_dim, block_size, mask)
78
+
79
+ def test_dilated_parity(self):
80
+ seq_len, head_dim, block_size = 256, 32, 128
81
+ mask = demo._dilated_mask(seq_len, block_size, stride=2)
82
+ self._run_parity(seq_len, head_dim, block_size, mask)
83
+
84
+ def test_global_token_parity(self):
85
+ seq_len, head_dim, block_size = 384, 32, 128
86
+ mask = demo._global_token_mask(seq_len, block_size, window=1, n_global=1)
87
+ self._run_parity(seq_len, head_dim, block_size, mask)
88
+
89
+ def test_full_attention_is_dense(self):
90
+ """A fully-nonzero BSR mask should match plain dense attention."""
91
+ seq_len, head_dim, block_size = 256, 32, 128
92
+ mask = torch.ones(seq_len, seq_len, dtype=torch.bool)
93
+ self._run_parity(seq_len, head_dim, block_size, mask)
@@ -0,0 +1,70 @@
1
+ """Smoke tests for the examples directory.
2
+
3
+ These cover the user-facing integration demos without executing them as
4
+ subprocesses — the tests import the example modules and call their
5
+ entry points directly so failures surface with full tracebacks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+ import torch
16
+
17
+ EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
18
+ sys.path.insert(0, str(EXAMPLES))
19
+
20
+
21
+ class TestSparseFockDemo:
22
+ def test_unfused_path_runs(self):
23
+ """Exercise the v0.1.x multi-step screening path end-to-end."""
24
+ import sparse_fock as demo
25
+
26
+ diag = demo._synthetic_schwarz_system(50)
27
+ import trnsparse
28
+
29
+ Q = trnsparse.schwarz_bounds(diag)
30
+ A = torch.randn(50, 50) * (Q.unsqueeze(-1) * Q.unsqueeze(0)) * 0.01
31
+ P = torch.randn(50, 50)
32
+ J, t = demo._unfused_path(A, diag, P, threshold=1e-4)
33
+ assert J.shape == (50, 50)
34
+ assert torch.isfinite(J).all()
35
+ assert t > 0
36
+
37
+ def test_fused_vs_unfused_parity(self):
38
+ """The two paths should agree up to fp tolerance on the same inputs."""
39
+ import sparse_fock as demo
40
+
41
+ torch.manual_seed(7)
42
+ diag = demo._synthetic_schwarz_system(40)
43
+ import trnsparse
44
+
45
+ Q = trnsparse.schwarz_bounds(diag)
46
+ A = torch.randn(40, 40) * (Q.unsqueeze(-1) * Q.unsqueeze(0)) * 0.01
47
+ P = torch.randn(40, 40)
48
+ threshold = 1e-4
49
+
50
+ J_unfused, _ = demo._unfused_path(A, diag, P, threshold)
51
+ J_fused, _ = demo._fused_path(A, diag, P, threshold)
52
+
53
+ torch.testing.assert_close(J_unfused, J_fused, atol=1e-6, rtol=1e-6)
54
+
55
+
56
+ class TestPyScfBridge:
57
+ """Skips cleanly without PySCF; asserts non-trivial screening on H2O."""
58
+
59
+ def test_h2o_sto3g_screening(self):
60
+ pytest.importorskip("pyscf")
61
+ import pyscf_bridge as demo
62
+
63
+ report = demo.run_demo("h2o", "sto-3g", threshold=1e-8)
64
+ assert report["nao"] > 0
65
+ # Tight tolerance would give no sparsity on a tiny basis; sto-3g
66
+ # H2O has only 7 AOs, so we just assert the API ran and produced
67
+ # a finite result — non-trivial sparsity needs larger bases.
68
+ assert math.isfinite(report["output_norm"])
69
+ assert 0.0 <= report["pair_sparsity"] <= 1.0
70
+ assert report["output_shape"] == (report["nao"], report["nao"])
@@ -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.4.0"
8
+ __version__ = "0.4.2"
9
9
 
10
10
  from .formats import BSRMatrix, COOMatrix, CSRMatrix, eye_sparse, from_dense, from_scipy
11
11
  from .iterative import bsr_diagonal, cg_bsr, jacobi_preconditioner_bsr, power_iteration_bsr
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trnsparse
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -102,3 +102,16 @@ stats = trnsparse.sparsity_stats(Q)
102
102
  ## License
103
103
 
104
104
  Apache 2.0 — Copyright 2026 Scott Friedman
105
+
106
+
107
+ ## Disclaimer
108
+
109
+ trnsci is an **independent open-source project**. It is not sponsored by, endorsed by, or affiliated with Amazon.com, Inc., Amazon Web Services, Inc., or Annapurna Labs Ltd.
110
+
111
+ "AWS", "Amazon", "Trainium", "Inferentia", "NeuronCore", "Neuron SDK", and related identifiers are trademarks of their respective owners and are used here solely for descriptive and interoperability purposes. Use does not imply endorsement, partnership, or any other relationship.
112
+
113
+ All work, opinions, analyses, benchmark results, architectural commentary, and editorial judgments in this repository and on [trnsci.dev](https://trnsci.dev) are those of the project's contributors. They do not represent the views, positions, or commitments of Amazon, AWS, or Annapurna Labs.
114
+
115
+ Feedback directed at the Neuron SDK or Trainium hardware is good-faith ecosystem commentary from independent users. It is not privileged information, is not pre-reviewed by AWS, and should not be read as authoritative about product roadmap, behavior, or quality.
116
+
117
+ For official AWS guidance, see [aws-neuron documentation](https://awsdocs-neuron.readthedocs-hosted.com/) and the [AWS Trainium product page](https://aws.amazon.com/ai/machine-learning/trainium/).
@@ -26,6 +26,8 @@ docs/installation.md
26
26
  docs/iterative_solvers.md
27
27
  docs/migration_scipy.md
28
28
  docs/quickstart.md
29
+ docs/sparse_attention.md
30
+ examples/block_sparse_attention.py
29
31
  examples/sparse_fock.py
30
32
  infra/terraform/.terraform.lock.hcl
31
33
  infra/terraform/README.md
@@ -35,7 +37,9 @@ scripts/run_benchmarks.sh
35
37
  scripts/run_neuron_tests.sh
36
38
  scripts/run_simulator_tests.sh
37
39
  tests/conftest.py
40
+ tests/test_attention.py
38
41
  tests/test_bsr.py
42
+ tests/test_examples.py
39
43
  tests/test_formats.py
40
44
  tests/test_iterative.py
41
45
  tests/test_nki_bsr.py
@@ -1,80 +0,0 @@
1
- """
2
- Sparse Fock matrix build with integral screening.
3
-
4
- Demonstrates how Schwarz screening reduces the Fock build from O(N⁴) to
5
- effectively O(N²) for large molecules. The sparsity pattern is stored
6
- as a CSR matrix, and SpMM handles the screened contraction.
7
-
8
- Usage:
9
- python examples/sparse_fock.py --demo
10
- python examples/sparse_fock.py --nbasis 200
11
- """
12
-
13
- import argparse
14
- import time
15
-
16
- import torch
17
-
18
- import trnsparse
19
-
20
-
21
- def main():
22
- parser = argparse.ArgumentParser()
23
- parser.add_argument("--demo", action="store_true")
24
- parser.add_argument("--nbasis", type=int, default=50)
25
- parser.add_argument("--threshold", type=float, default=1e-10)
26
- args = parser.parse_args()
27
-
28
- if args.demo:
29
- args.nbasis = 50
30
-
31
- n = args.nbasis
32
- print("Sparse Fock build:")
33
- print(f" Basis functions: {n}")
34
- print(f" Threshold: {args.threshold:.0e}")
35
-
36
- torch.manual_seed(42)
37
-
38
- # Simulate Schwarz bounds (decay with distance for realistic sparsity)
39
- positions = torch.rand(n, 3) * 10.0 # Random 3D positions
40
- distances = torch.cdist(positions, positions)
41
- Q = torch.exp(-0.5 * distances) # Gaussian decay
42
-
43
- # Screen
44
- stats = trnsparse.sparsity_stats(Q, args.threshold)
45
- print("\n Sparsity statistics:")
46
- print(f" Total shell pairs: {stats['total_pairs']}")
47
- print(f" Significant pairs: {stats['significant_pairs']}")
48
- print(f" Pair sparsity: {stats['pair_sparsity']:.1%}")
49
- print(f" Quartet sparsity (lower): {stats['quartet_sparsity_lower']:.1%}")
50
-
51
- # Build sparse integral matrix (simulated)
52
- mask = trnsparse.screen_quartets(Q, args.threshold)
53
- integrals_dense = torch.randn(n, n) * Q * 0.01
54
- integrals_dense[~mask] = 0.0
55
- integrals_sparse = trnsparse.from_dense(integrals_dense)
56
- print(f" Integral matrix nnz: {integrals_sparse.nnz} / {n * n}")
57
-
58
- # Density matrix (random SPD for demo)
59
- P = torch.randn(n, n) * 0.1
60
- P = P @ P.T
61
-
62
- # Sparse Fock build: J_μν = Σ_λσ P_λσ * (μν|λσ)
63
- # Approximated here as SpMM: J ≈ sparse_integrals @ P
64
- t0 = time.perf_counter()
65
- J_sparse = trnsparse.spmm(integrals_sparse, P)
66
- t_sparse = time.perf_counter() - t0
67
-
68
- # Dense reference
69
- t0 = time.perf_counter()
70
- J_dense = integrals_dense @ P
71
- t_dense = time.perf_counter() - t0
72
-
73
- error = torch.linalg.norm(J_sparse - J_dense).item()
74
- print(f"\n Sparse SpMM: {t_sparse:.4f}s")
75
- print(f" Dense matmul: {t_dense:.4f}s")
76
- print(f" Error: {error:.2e}")
77
-
78
-
79
- if __name__ == "__main__":
80
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes