Skip to content

Commit 9be6d3b

Browse files
authored
Add sparse_logsumexp: numerically stable sparse log-sum-exp reduction (#87)
* feat: add sparse_logsumexp reduction Numerically-stable log-sum-exp over the nonzero values of a 2-D sparse tensor (COO/CSR/CSC), mirroring torch.logsumexp. Reads CSR/CSC indices directly (no COO coalesce) and reuses the compressed-axis nnz counts to avoid a redundant bincount. The max-shift is detached so gradients equal softmax over the participating entries. An include_zeros flag selects whether structural zeros contribute exp(0)=1 or are treated as -inf. * test: cover sparse_logsumexp against dense reference Parametrized over COO/CSR layouts, devices, dtypes, all dim modes and both include_zeros settings; cross-checks against torch.logsumexp on the densified tensor. Adds keepdim, CSC, empty row/column, error-path and gradient tests, and registers the module for doctest execution. * bench: add sparse_logsumexp benchmark Compares sparse_logsumexp against a dense torch.logsumexp baseline and a segment_reduce variant across sizes/densities, on CPU and CUDA, with graceful handling of dense out-of-memory at large sizes. * docs: document sparse_logsumexp in API reference and README * feat: support batched 3-D sparse_logsumexp Extend the reduction to batched 3-D (batch, rows, cols) inputs, matching the batched convention of sparse_mm and the solvers. Each slice is reduced independently by folding the batch index into the scatter segment id, so a single scatter handles the whole batch; the batch axis itself is not reducible. Batched inputs go through COO (batched CSR/CSC require equal nnz per slice in PyTorch). Adds batched tests across dims/include_zeros/keepdim. * refactor: rename segment->scatter in logsumexp helper The helper reduces via Tensor.scatter_reduce_ with an arbitrary per-value output index, so 'segment' (which implies contiguous runs, as in torch.segment_reduce) was misleading. Rename to _scatter_logsumexp / scatter_index and note in the docstring that group members need not be contiguous. * refactor: use torch.logsumexp directly for scalar reduction For dim=[0,1] every value falls in one group, so torch.logsumexp does the reduction directly. The structural-zero mass (n_zeros entries of exp(0)=1) equals a single entry of value log(n_zeros), appended so the reduction stays one numerically stable call whose gradient still accounts for the zeros. * docs: note why scatter is used over a segmented reduction for CSR/CSC * docs: clarify 'slice' means one batch matrix input[k] * bench: cover >1M-dim and SuiteSparse matrices for sparse_logsumexp Extend the random benchmark to 2**16 and 2**20 (>1M rows/cols) where the dense baseline OOMs, and add sparse_logsumexp_suitesparse.py running on Rothberg/cfd2 (123k x 123k) and Williams/webbase-1M (1M x 1M). Register both in benchmark_suite.py and track their result CSVs. * fix: correct stability shift for structural zeros in sparse_logsumexp With include_zeros=True the structural zeros (value 0) participate in the reduction, but the max-shift was computed only from the explicit values. When every explicit value in a group is negative this overflowed (exp(-shift) -> inf), and a fully dense group (no structural zeros) hit 0 * inf -> nan. Include 0 in the per-group max where structural zeros are present, and contribute the zero term only where the count is nonzero. Adds regression tests. Reported-by: theo-barfoot * fix: handle +inf values in sparse_logsumexp shift An explicit +inf made the per-group max +inf, so value - shift computed inf - inf = nan. Replace any non-finite shift (both +inf and the empty-group -inf) with 0, so a +inf value correctly yields +inf. Adds regression test. Reported-by: theo-barfoot * fix: validate dim before normalising in sparse_logsumexp dim was reduced modulo ndim before validation, so out-of-range dims silently wrapped (2 -> 0), a set() dropped duplicates ([0, 0] -> [0]), and an empty sequence fell through to a wrong path. Validate raw dims first: IndexError for out-of-range, RuntimeError for empty or repeated dims, matching torch.logsumexp. Adds regression tests; valid negative dims are unaffected. Reported-by: theo-barfoot * test: parametrize sparse_logsumexp tests over index dtype Add an index_dtype fixture (int32/int64) matching the repo convention and thread it through the dense-reference test so the CSR/CSC index paths are covered for both dtypes. COO indices are int64-only in PyTorch, so the fixture varies only the compressed layouts. Suggested-by: theo-barfoot * refactor: guard coalesce with is_coalesced in sparse_logsumexp Skip the redundant coalesce() call when the COO input is already coalesced, matching the guard pattern used elsewhere in the repo and at the CSR/CSC fallback in this same file (line 138). Suggested-by: tvercaut
1 parent 6839ddd commit 9be6d3b

11 files changed

Lines changed: 979 additions & 0 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ A comprehensive collection of utility functions to work with PyTorch sparse tens
1414
- Workaround for [PyTorch issue #41128](https://github.com/pytorch/pytorch/issues/41128)
1515
- Supports both COO and CSR formats with optional batching
1616

17+
**Numerically-Stable Sparse Reductions**
18+
- `sparse_logsumexp`: Sparse-aware `log-sum-exp` reduction mirroring `torch.logsumexp`
19+
- Operates directly on the nonzero values (no dense materialisation), with a numerically stable max-shift
20+
- Supports COO/CSR/CSC layouts (unbatched 2-D and batched 3-D) and an `include_zeros` flag for structural-zero semantics
21+
- Fills the gap of [PyTorch issue #31394](https://github.com/pytorch/pytorch/issues/31394) (no native `scatter_logsumexp`)
22+
1723
**Sparse Linear System Solvers**
1824
- `sparse_triangular_solve`: Sparse triangular solver with batch support
1925
- Discussion reference: [PyTorch issue #87358](https://github.com/pytorch/pytorch/issues/87358)

docs/source/api/core.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ Indexed Matrix Multiplication
2525

2626
.. autofunction:: segment_mm
2727

28+
Sparse Reductions
29+
-----------------
30+
31+
.. currentmodule:: torchsparsegradutils.sparse_logsumexp
32+
33+
.. autofunction:: sparse_logsumexp
34+
2835
Sparse Linear Solvers
2936
----------------------
3037

torchsparsegradutils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .indexed_matmul import gather_mm, segment_mm
2+
from .sparse_logsumexp import sparse_logsumexp
23
from .sparse_lstsq import sparse_generic_lstsq
34
from .sparse_matmul import sparse_mm
45
from .sparse_solve import sparse_generic_solve, sparse_triangular_solve
@@ -10,4 +11,5 @@
1011
"sparse_triangular_solve",
1112
"sparse_generic_solve",
1213
"sparse_generic_lstsq",
14+
"sparse_logsumexp",
1315
]

torchsparsegradutils/benchmarks/benchmark_suite.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"sparse_triangular_solve_rand.py",
2020
"sparse_triangular_solve_suitesparse.py",
2121
"batched_sparse_mm_rand.py",
22+
"sparse_logsumexp_rand.py",
23+
"sparse_logsumexp_suitesparse.py",
2224
]
2325

2426

torchsparsegradutils/benchmarks/results/sparse_logsumexp_rand_results.csv

Lines changed: 81 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
matrix,N,M,nnz,index_dt,value_dt,layout,algo,fwd_time_us,fwd_time_std_us,fwd_mem_MB,fwd_mem_std_MB,bwd_time_us,bwd_time_std_us,bwd_mem_MB,bwd_mem_std_MB,python_version,pytorch_version,cuda_version,cuda_device_name
2+
Rothberg/cfd2,123440,123440,3087898,int32,float32,coo,sparse_logsumexp,418.5277514985388,4.05087693996108,192.20275200000003,2.842170943040401e-14,734.4205446912898,114.7157104775913,201.82272000000003,2.842170943040401e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
3+
Rothberg/cfd2,123440,123440,3087898,int32,float32,coo,dense.logsumexp,,,58132.48,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
4+
Rothberg/cfd2,123440,123440,3087898,int32,float32,csr,sparse_logsumexp,501.66498763149883,7.649678428248083,194.17804800000005,5.684341886080802e-14,770.3902083449066,107.41563711558506,194.17804800000005,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
5+
Rothberg/cfd2,123440,123440,3087898,int32,float32,csr,dense.logsumexp,,,58132.48,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
6+
Rothberg/cfd2,123440,123440,3087898,int32,float64,coo,sparse_logsumexp,507.07297932897364,4.405871818295325,257.58515200000005,5.684341886080802e-14,825.7217025170482,105.87526976135317,277.81376000000006,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
7+
Rothberg/cfd2,123440,123440,3087898,int32,float64,coo,dense.logsumexp,,,116254.72,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
8+
Rothberg/cfd2,123440,123440,3087898,int32,float64,csr,sparse_logsumexp,564.1494516136223,6.06369109543913,259.56044800000006,5.684341886080802e-14,859.5567787415348,88.63302570224444,259.56044800000006,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
9+
Rothberg/cfd2,123440,123440,3087898,int32,float64,csr,dense.logsumexp,,,116254.72,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
10+
Rothberg/cfd2,123440,123440,3087898,int64,float32,coo,sparse_logsumexp,416.6980009661832,9.744676566465959,217.59999999999988,1.1368683772161603e-13,711.014287009341,116.1010905243,227.21996799999997,2.842170943040401e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
11+
Rothberg/cfd2,123440,123440,3087898,int64,float32,coo,dense.logsumexp,,,58132.48,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
12+
Rothberg/cfd2,123440,123440,3087898,int64,float32,csr,sparse_logsumexp,451.92705289719953,6.678284499637928,218.651136,0.0,710.8004911060561,122.89434886626724,218.651136,0.0,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
13+
Rothberg/cfd2,123440,123440,3087898,int64,float32,csr,dense.logsumexp,,,58132.48,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
14+
Rothberg/cfd2,123440,123440,3087898,int64,float64,coo,sparse_logsumexp,508.09487109528044,5.7692700070140654,282.2886399999999,1.1368683772161603e-13,844.6509094974684,101.03218358009538,302.51724800000005,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
15+
Rothberg/cfd2,123440,123440,3087898,int64,float64,coo,dense.logsumexp,,,116254.72,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
16+
Rothberg/cfd2,123440,123440,3087898,int64,float64,csr,sparse_logsumexp,575.4054993461898,7.326336114688565,284.72627199999994,5.684341886080802e-14,874.650359187352,85.90047695500046,284.72627199999994,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
17+
Rothberg/cfd2,123440,123440,3087898,int64,float64,csr,dense.logsumexp,,,116254.72,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
18+
Williams/webbase-1M,1000005,1000005,3105536,int32,float32,coo,sparse_logsumexp,481.70156716220475,4.23519804752169,220.52249600000007,8.526512829121202e-14,557.1488393198183,67.37703219108285,220.52249600000007,8.526512829121202e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
19+
Williams/webbase-1M,1000005,1000005,3105536,int32,float32,coo,dense.logsumexp,,,3814737.92,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
20+
Williams/webbase-1M,1000005,1000005,3105536,int32,float32,csr,sparse_logsumexp,552.8516274817447,3.7897933778308235,239.30112000000005,5.684341886080802e-14,676.7854728662458,65.88588450101828,239.30112000000005,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
21+
Williams/webbase-1M,1000005,1000005,3105536,int32,float32,csr,dense.logsumexp,,,3814737.92,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
22+
Williams/webbase-1M,1000005,1000005,3105536,int32,float64,coo,sparse_logsumexp,626.365046395841,5.4730458120651555,307.17798400000004,5.684341886080802e-14,881.5396050899054,84.99292983227919,307.17798400000004,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
23+
Williams/webbase-1M,1000005,1000005,3105536,int32,float64,coo,dense.logsumexp,,,7629475.84,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
24+
Williams/webbase-1M,1000005,1000005,3105536,int32,float64,csr,sparse_logsumexp,669.604426245011,5.613909730105387,324.66483199999993,5.684341886080802e-14,846.7558345728321,49.59094487783846,324.66483199999993,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
25+
Williams/webbase-1M,1000005,1000005,3105536,int32,float64,csr,dense.logsumexp,,,7629475.84,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
26+
Williams/webbase-1M,1000005,1000005,3105536,int64,float32,coo,sparse_logsumexp,528.8523874090364,4.086195664051818,248.271872,0.0,670.9825494752256,56.541783149895586,248.271872,0.0,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
27+
Williams/webbase-1M,1000005,1000005,3105536,int64,float32,coo,dense.logsumexp,,,3814737.92,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
28+
Williams/webbase-1M,1000005,1000005,3105536,int64,float32,csr,sparse_logsumexp,581.2928689548605,3.7917376836134387,264.46694399999996,5.684341886080802e-14,767.8863043882743,87.77875094499424,264.46694399999996,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
29+
Williams/webbase-1M,1000005,1000005,3105536,int64,float32,csr,dense.logsumexp,,,3814737.92,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
30+
Williams/webbase-1M,1000005,1000005,3105536,int64,float64,coo,sparse_logsumexp,652.1855060371661,4.739637246101876,332.66534400000006,5.684341886080802e-14,841.7154034028387,66.40554727399672,332.66534400000006,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
31+
Williams/webbase-1M,1000005,1000005,3105536,int64,float64,coo,dense.logsumexp,,,7629475.84,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
32+
Williams/webbase-1M,1000005,1000005,3105536,int64,float64,csr,sparse_logsumexp,665.9346197367362,5.687582394801057,349.8306559999999,5.684341886080802e-14,927.1953889282838,107.85090915821453,349.8306559999999,5.684341886080802e-14,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
33+
Williams/webbase-1M,1000005,1000005,3105536,int64,float64,csr,dense.logsumexp,,,7629475.84,,,,,,3.12.3,2.11.0+cu128,12.8,NVIDIA GeForce RTX 5090
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Sparse Log-Sum-Exp Benchmark - Random Matrices
4+
5+
Benchmarks ``sparse_logsumexp`` against a dense ``torch.logsumexp`` baseline on
6+
randomly generated sparse matrices of various sizes, sparsity patterns, layouts
7+
and dtypes. The reduction is over ``dim=1`` (one value per row); forward and
8+
backward time and peak memory are measured. The dense baseline materialises
9+
``A.to_dense()`` and is the memory ceiling the sparse path exists to avoid.
10+
"""
11+
12+
import os
13+
import sys
14+
15+
# Add the parent directory to sys.path to allow importing torchsparsegradutils
16+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
17+
18+
import numpy as np
19+
import torch
20+
from benchmark_utils import (
21+
measure_op,
22+
print_benchmark_header,
23+
print_result_row,
24+
print_results_table_header,
25+
save_benchmark_results,
26+
)
27+
from tqdm import tqdm
28+
29+
from torchsparsegradutils import sparse_logsumexp
30+
from torchsparsegradutils.utils import rand_sparse
31+
32+
REPEATS = 100
33+
WARMUP_RUNS = 10
34+
35+
# Only run on CUDA
36+
device = torch.device("cuda")
37+
assert torch.cuda.is_available(), "This benchmark requires a CUDA GPU"
38+
39+
# problem sizes: (label, N, M, nnz)
40+
SIZES = [
41+
("small", 2**10, 2**10, 2**12),
42+
("medium", 2**12, 2**12, 2**14),
43+
("large", 2**14, 2**14, 2**16),
44+
("xlarge", 2**16, 2**16, 2**18),
45+
("million", 2**20, 2**20, 2**22),
46+
]
47+
48+
INDEX_DTYPES = [torch.int32, torch.int64]
49+
VALUE_DTYPES = [torch.float32, torch.float64]
50+
LAYOUTS = [torch.sparse_coo, torch.sparse_csr]
51+
52+
# reduce over dim=1; measure_op takes a binary op, so B is an unused placeholder.
53+
ALGORITHMS = [
54+
("sparse_logsumexp", lambda A, B: sparse_logsumexp(A, dim=1)),
55+
("dense.logsumexp", lambda A, B: torch.logsumexp(A.to_dense(), dim=1)),
56+
]
57+
58+
59+
def run_sparse_logsumexp_benchmark():
60+
"""Run the sparse log-sum-exp benchmark suite."""
61+
62+
print_benchmark_header("Sparse Log-Sum-Exp Benchmark - Random Matrices")
63+
64+
records = []
65+
66+
for size_label, N, M, nnz in tqdm(SIZES, desc="Problem sizes"):
67+
print(f"\n🔍 Testing size: {size_label} (N={N}, M={M}, nnz={nnz})")
68+
69+
A_shape = (N, M)
70+
B = torch.zeros(1, device=device) # unused placeholder for measure_op's binary signature
71+
72+
for idx_dt in tqdm(INDEX_DTYPES, desc="Index dtypes", leave=False):
73+
for val_dt in tqdm(VALUE_DTYPES, desc="Value dtypes", leave=False):
74+
for layout in tqdm(LAYOUTS, desc="Layouts", leave=False):
75+
layout_name = "coo" if layout == torch.sparse_coo else "csr"
76+
print(f"\n 📊 Configuration: idx_dtype={idx_dt}, val_dtype={val_dt}, layout={layout_name}")
77+
78+
print_results_table_header()
79+
80+
for alg_name, alg_fn in ALGORITHMS:
81+
try:
82+
print(f" 🧮 Testing {alg_name} ({layout_name})...")
83+
84+
A_sparse = rand_sparse(
85+
A_shape, nnz, layout, indices_dtype=idx_dt, values_dtype=val_dt, device=device
86+
)
87+
88+
(
89+
t_fwd,
90+
std_fwd,
91+
mem_fwd,
92+
std_mem_fwd,
93+
t_bwd,
94+
std_bwd,
95+
mem_bwd,
96+
std_mem_bwd,
97+
) = measure_op(
98+
alg_fn,
99+
A_sparse,
100+
B,
101+
repeats=REPEATS,
102+
device=device,
103+
desc=f"{alg_name} ({layout_name})",
104+
warmup_runs=WARMUP_RUNS,
105+
remove_outliers=True,
106+
)
107+
108+
print_result_row(
109+
f"{alg_name} ({layout_name})",
110+
(N, M),
111+
t_fwd,
112+
std_fwd,
113+
mem_fwd,
114+
std_mem_fwd,
115+
t_bwd,
116+
std_bwd,
117+
mem_bwd,
118+
std_mem_bwd,
119+
)
120+
121+
records.append(
122+
{
123+
"size": size_label,
124+
"layout": layout_name,
125+
"algo": alg_name,
126+
"index_dt": str(idx_dt).split(".")[-1],
127+
"value_dt": str(val_dt).split(".")[-1],
128+
"N": N,
129+
"M": M,
130+
"nnz": nnz,
131+
"fwd_time_us": t_fwd,
132+
"fwd_time_std_us": std_fwd,
133+
"fwd_mem_MB": mem_fwd,
134+
"fwd_mem_std_MB": std_mem_fwd,
135+
"bwd_time_us": t_bwd,
136+
"bwd_time_std_us": std_bwd,
137+
"bwd_mem_MB": mem_bwd,
138+
"bwd_mem_std_MB": std_mem_bwd,
139+
}
140+
)
141+
142+
except Exception as e:
143+
print(f" ❌ {alg_name} ({layout_name}) failed: {e}")
144+
145+
records.append(
146+
{
147+
"size": size_label,
148+
"layout": layout_name,
149+
"algo": alg_name,
150+
"index_dt": str(idx_dt).split(".")[-1],
151+
"value_dt": str(val_dt).split(".")[-1],
152+
"N": N,
153+
"M": M,
154+
"nnz": nnz,
155+
"fwd_time_us": np.nan,
156+
"fwd_time_std_us": np.nan,
157+
"fwd_mem_MB": np.nan,
158+
"fwd_mem_std_MB": np.nan,
159+
"bwd_time_us": np.nan,
160+
"bwd_time_std_us": np.nan,
161+
"bwd_mem_MB": np.nan,
162+
"bwd_mem_std_MB": np.nan,
163+
"error": str(e),
164+
}
165+
)
166+
167+
# Save results
168+
if records:
169+
save_benchmark_results(records, "sparse_logsumexp_rand")
170+
171+
print("\n✅ Sparse log-sum-exp benchmark completed!")
172+
173+
174+
if __name__ == "__main__":
175+
run_sparse_logsumexp_benchmark()

0 commit comments

Comments
 (0)