torch-linear-assignment 0.1.0rc0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """Public exports for batched linear assignment."""
2
+
3
+ from .assignment import assignment_to_indices, batch_linear_assignment
4
+
5
+ __all__ = ["assignment_to_indices", "batch_linear_assignment"]
@@ -0,0 +1,309 @@
1
+ """Triton implementation of batched rectangular linear assignment.
2
+
3
+ Purpose:
4
+ Provide the private CUDA implementation selected by ``assignment.py`` on
5
+ supported Linux NVIDIA systems. The module owns no public dispatch policy;
6
+ importing it is deliberately deferred until the caller has established that
7
+ CUDA, the Torch version, and the GPU capability are eligible.
8
+
9
+ Scope:
10
+ One Triton program solves one batch item. The program is intentionally
11
+ correctness-first: outer augmenting-path steps remain sequential while each
12
+ current set of candidate columns is processed as a masked lane vector.
13
+ Per-search state remains program-local. The correctness launch currently
14
+ keeps its loop-carried vectors within one warp; wider launch configurations
15
+ remain gated on exact GPU parity. Its ordering reproduces the legacy CUDA
16
+ and SciPy rectangular-LSAP algorithm.
17
+
18
+ Usage:
19
+ ``batch_linear_assignment(cuda_cost)`` accepts a three-dimensional CUDA
20
+ cost tensor and returns the stable public ``(B, W)`` long assignment shape.
21
+ The optional validation setting is private benchmark instrumentation; normal
22
+ dispatch always uses the default full validation.
23
+
24
+ Outputs:
25
+ The kernel writes assignment and temporary Torch workspaces on the input
26
+ device. The wrapper returns worker-to-task indices, using ``-1`` for an
27
+ unmatched worker, and raises ordinary ``ValueError`` exceptions for invalid
28
+ numeric values or infeasible matrices.
29
+
30
+ Failure:
31
+ NaN and negative infinity are rejected by one host reduction before launch.
32
+ The kernel records one infeasibility flag per batch item instead of asserting
33
+ on device; the normal wrapper synchronizes once to turn any flag into an
34
+ exception. Triton import, compilation, and GPU execution failures are not
35
+ swallowed because they do not mean the optional dependency is merely absent.
36
+
37
+ Used by:
38
+ ``torch_linear_assignment.assignment.batch_linear_assignment_cuda``. The
39
+ module is private so its benchmark validation switch and workspace layout do
40
+ not become part of the package API.
41
+ """
42
+
43
+ from typing import Literal
44
+
45
+ import torch
46
+ import triton
47
+ import triton.language as tl
48
+
49
+ ValidationMode = Literal["full", "off", "nonfinite_only", "infeasibility_flag_only"]
50
+ _VALIDATION_MODES: frozenset[str] = frozenset({"full", "off", "nonfinite_only", "infeasibility_flag_only"})
51
+
52
+
53
+ @triton.jit
54
+ def _linear_assignment_kernel(
55
+ cost,
56
+ u,
57
+ v,
58
+ col4row,
59
+ row4col,
60
+ infeasible,
61
+ NUM_ROWS: tl.constexpr,
62
+ NUM_COLUMNS: tl.constexpr,
63
+ BLOCK_N: tl.constexpr,
64
+ IS_FP64: tl.constexpr,
65
+ ):
66
+ """Solve one rectangular assignment problem in a single Triton program."""
67
+ batch_index = tl.program_id(0)
68
+ lane = tl.arange(0, BLOCK_N)
69
+ column_mask = lane < NUM_COLUMNS
70
+ row_mask = lane < NUM_ROWS
71
+
72
+ cost = cost + batch_index * NUM_ROWS * NUM_COLUMNS
73
+ u = u + batch_index * NUM_ROWS
74
+ v = v + batch_index * NUM_COLUMNS
75
+ col4row = col4row + batch_index * NUM_ROWS
76
+ row4col = row4col + batch_index * NUM_COLUMNS
77
+
78
+ for current_row in tl.range(0, NUM_ROWS, num_stages=1):
79
+ scanned_rows = tl.zeros((BLOCK_N,), tl.int1)
80
+ scanned_columns = tl.zeros((BLOCK_N,), tl.int1)
81
+ active_columns = column_mask
82
+ scan_positions = NUM_COLUMNS - lane - 1
83
+ path = tl.full((BLOCK_N,), -1, tl.int32)
84
+ if IS_FP64:
85
+ shortest_path_costs = tl.full((BLOCK_N,), float("inf"), tl.float64)
86
+ scanned_row_costs = tl.zeros((BLOCK_N,), tl.float64)
87
+ else:
88
+ shortest_path_costs = tl.full((BLOCK_N,), float("inf"), tl.float32)
89
+ scanned_row_costs = tl.zeros((BLOCK_N,), tl.float32)
90
+
91
+ sink = -1
92
+ current_potential = tl.load(u + current_row)
93
+ # A workspace-derived zero keeps this loop-carried value FP32 or FP64.
94
+ min_value = current_potential - current_potential
95
+ search_row = current_row
96
+ num_remaining = NUM_COLUMNS
97
+
98
+ # Wide matrices usually reach an unmatched sink early; do not execute
99
+ # the remaining masked column-search iterations after that point.
100
+ searching = True
101
+ while searching:
102
+ scanned_rows = scanned_rows | (row_mask & (lane == search_row) & searching)
103
+ candidate_mask = active_columns & searching
104
+ previous_cost = shortest_path_costs
105
+ reduced_cost = (
106
+ min_value
107
+ - tl.load(u + search_row)
108
+ + tl.load(
109
+ cost + search_row * NUM_COLUMNS + lane,
110
+ mask=candidate_mask,
111
+ other=float("inf"),
112
+ )
113
+ - tl.load(v + lane, mask=candidate_mask, other=0.0)
114
+ )
115
+ improved = candidate_mask & (reduced_cost < previous_cost)
116
+ candidate_cost = tl.where(improved, reduced_cost, previous_cost)
117
+ shortest_path_costs = candidate_cost
118
+ path = tl.where(improved, search_row, path)
119
+
120
+ # The legacy solver scans a swap-removed ``remaining`` array. Track
121
+ # each column's current scan position locally to preserve its exact
122
+ # last-unmatched/first-matched tie rule without shared scratch writes.
123
+ lowest = tl.min(
124
+ tl.where(candidate_mask, candidate_cost, float("inf")),
125
+ axis=0,
126
+ )
127
+ has_candidate = lowest != float("inf")
128
+ step_valid = searching & has_candidate
129
+ tied = candidate_mask & (candidate_cost == lowest)
130
+ matched_rows = tl.load(row4col + lane, mask=candidate_mask, other=0)
131
+ last_unmatched_position = tl.max(
132
+ tl.where(tied & (matched_rows == -1), scan_positions, -1),
133
+ axis=0,
134
+ )
135
+ first_tied_position = tl.min(
136
+ tl.where(tied, scan_positions, NUM_COLUMNS),
137
+ axis=0,
138
+ )
139
+ selected_position = tl.where(
140
+ last_unmatched_position >= 0,
141
+ last_unmatched_position,
142
+ first_tied_position,
143
+ )
144
+ selected_column = tl.max(
145
+ tl.where(candidate_mask & (scan_positions == selected_position), lane, -1),
146
+ axis=0,
147
+ )
148
+ selected_column = tl.where(step_valid, selected_column, 0)
149
+ selected_row = tl.load(row4col + selected_column)
150
+
151
+ # A matched row enters the search tree through ``selected_column``.
152
+ # Save that column's shortest cost by row now, avoiding a dynamic
153
+ # gather from the loop-carried column vector after the search.
154
+ matched_step = step_valid & (selected_row != -1)
155
+ scanned_row_costs = tl.where(
156
+ row_mask & (lane == selected_row) & matched_step,
157
+ lowest,
158
+ scanned_row_costs,
159
+ )
160
+ sink = tl.where(step_valid & (selected_row == -1), selected_column, sink)
161
+ search_row = tl.where(
162
+ matched_step,
163
+ selected_row,
164
+ search_row,
165
+ )
166
+ scanned_columns = scanned_columns | (column_mask & (lane == selected_column) & step_valid)
167
+ last_index = num_remaining - 1
168
+ last_column = tl.max(
169
+ tl.where(active_columns & (scan_positions == last_index), lane, -1),
170
+ axis=0,
171
+ )
172
+ scan_positions = tl.where(
173
+ active_columns & (lane == last_column) & step_valid,
174
+ selected_position,
175
+ scan_positions,
176
+ )
177
+ active_columns = active_columns & ~((lane == selected_column) & step_valid)
178
+ num_remaining = tl.where(step_valid, num_remaining - 1, num_remaining)
179
+ min_value = tl.where(step_valid, lowest, min_value)
180
+ tl.store(infeasible + batch_index, 1, mask=searching & ~has_candidate)
181
+ searching = matched_step
182
+
183
+ solved = sink != -1
184
+ tl.store(infeasible + batch_index, 1, mask=~solved)
185
+
186
+ tl.store(u + current_row, current_potential + min_value, mask=solved)
187
+ visited_rows = scanned_rows
188
+ update_row = solved & row_mask & visited_rows & (lane != current_row)
189
+ tl.store(
190
+ u + lane,
191
+ tl.load(u + lane, mask=row_mask, other=0.0) + min_value - scanned_row_costs,
192
+ mask=update_row,
193
+ )
194
+
195
+ update_column = solved & column_mask & scanned_columns
196
+ tl.store(
197
+ v + lane,
198
+ tl.load(v + lane, mask=column_mask, other=0.0) - min_value + shortest_path_costs,
199
+ mask=update_column,
200
+ )
201
+
202
+ augmenting = solved
203
+ augmenting_column = sink
204
+ augmentation_steps = 0
205
+ # Recover only the actual path while retaining the row-count safety cap.
206
+ while augmenting & (augmentation_steps < NUM_ROWS):
207
+ safe_column = tl.where(augmenting, augmenting_column, 0)
208
+ augmenting_row = tl.max(
209
+ tl.where(column_mask & (lane == safe_column), path, -1),
210
+ axis=0,
211
+ )
212
+ safe_row = tl.where(augmenting, augmenting_row, 0)
213
+ previous_column = tl.load(col4row + safe_row)
214
+ tl.store(row4col + safe_column, augmenting_row, mask=augmenting)
215
+ tl.store(col4row + safe_row, augmenting_column, mask=augmenting)
216
+ augmenting = augmenting & (augmenting_row != current_row)
217
+ augmenting_column = tl.where(augmenting, previous_column, augmenting_column)
218
+ augmentation_steps += 1
219
+
220
+ # The next row consumes the assignments and potentials written above.
221
+ tl.debug_barrier()
222
+
223
+
224
+ def _validate_mode(validation: ValidationMode) -> None:
225
+ """Reject an unsupported private validation configuration."""
226
+ if validation not in _VALIDATION_MODES:
227
+ raise ValueError(f"Unknown Triton validation mode: {validation!r}.")
228
+
229
+
230
+ def _reject_invalid_numeric_entries(cost: torch.Tensor) -> None:
231
+ """Raise the SciPy-compatible error for NaN or negative-infinite costs."""
232
+ if not cost.is_floating_point():
233
+ return
234
+ invalid = torch.logical_or(torch.isnan(cost), torch.isneginf(cost)).any()
235
+ if bool(invalid.item()):
236
+ raise ValueError("matrix contains invalid numeric entries")
237
+
238
+
239
+ def _solver_cost_dtype(cost: torch.Tensor) -> torch.Tensor:
240
+ """Preserve float64 costs and promote every other solver input to float32."""
241
+ if cost.dtype == torch.float64:
242
+ return cost
243
+ return cost.to(torch.float32)
244
+
245
+
246
+ def _empty_assignment(cost: torch.Tensor) -> torch.Tensor:
247
+ """Return the public assignment shape without launching a zero-size kernel."""
248
+ batch_size, workers, _ = cost.shape
249
+ return torch.full((batch_size, workers), -1, dtype=torch.long, device=cost.device)
250
+
251
+
252
+ def _solve(cost: torch.Tensor, validation: ValidationMode) -> tuple[torch.Tensor, torch.Tensor]:
253
+ """Launch the one-program-per-batch kernel and return both assignment views."""
254
+ batch_size, rows, columns = cost.shape
255
+ block_n = triton.next_power_of_2(columns)
256
+ workspace_options = {"device": cost.device}
257
+ scalar_options = {**workspace_options, "dtype": cost.dtype}
258
+ integer_options = {**workspace_options, "dtype": torch.int32}
259
+ u = torch.zeros((batch_size, rows), **scalar_options)
260
+ v = torch.zeros((batch_size, columns), **scalar_options)
261
+ col4row = torch.full((batch_size, rows), -1, **integer_options)
262
+ row4col = torch.full((batch_size, columns), -1, **integer_options)
263
+ infeasible = torch.zeros((batch_size,), **integer_options)
264
+
265
+ _linear_assignment_kernel[(batch_size,)](
266
+ cost,
267
+ u,
268
+ v,
269
+ col4row,
270
+ row4col,
271
+ infeasible,
272
+ NUM_ROWS=rows,
273
+ NUM_COLUMNS=columns,
274
+ BLOCK_N=block_n,
275
+ IS_FP64=cost.dtype == torch.float64,
276
+ # Multi-warp loop-carried state produces false infeasibility on the
277
+ # saved 512-lane L4 case; keep one warp until a wider design is exact.
278
+ num_warps=1,
279
+ )
280
+ if validation in {"full", "infeasibility_flag_only"} and bool(infeasible.any().item()):
281
+ raise ValueError("cost matrix is infeasible")
282
+ return col4row, row4col
283
+
284
+
285
+ def batch_linear_assignment(
286
+ cost: torch.Tensor,
287
+ *,
288
+ validation: ValidationMode = "full",
289
+ ) -> torch.Tensor:
290
+ """Solve a CUDA batch with full validation unless private benchmarks opt out."""
291
+ _validate_mode(validation)
292
+ if cost.ndim != 3:
293
+ raise ValueError("Need 3-dimensional tensor with shape (B, W, T).")
294
+ if not cost.is_cuda:
295
+ raise ValueError("Triton linear assignment requires a CUDA tensor.")
296
+ if validation in {"full", "nonfinite_only"}:
297
+ _reject_invalid_numeric_entries(cost)
298
+
299
+ cost = _solver_cost_dtype(cost)
300
+ batch_size, workers, tasks = cost.shape
301
+ if batch_size == 0 or workers == 0 or tasks == 0:
302
+ return _empty_assignment(cost)
303
+
304
+ with torch.amp.autocast("cuda", enabled=False):
305
+ if tasks < workers:
306
+ _, row4col = _solve(cost.transpose(1, 2).contiguous(), validation)
307
+ return row4col.to(torch.long)
308
+ col4row, _ = _solve(cost.contiguous(), validation)
309
+ return col4row.to(torch.long)
@@ -0,0 +1,163 @@
1
+ """Public linear-assignment API with optional CUDA acceleration."""
2
+
3
+ import importlib
4
+ import re
5
+ import sys
6
+ import warnings
7
+ from types import ModuleType
8
+
9
+ import torch
10
+ from scipy.optimize import linear_sum_assignment
11
+
12
+ _CUDA_FALLBACK_WARNING_EMITTED = False
13
+ _TRITON_MINIMUM_TORCH_VERSION = (2, 4)
14
+
15
+
16
+ def _prepare_solver_cost(cost: torch.Tensor) -> torch.Tensor:
17
+ """Apply the documented solver dtype without discarding complex values."""
18
+ if cost.dtype in {torch.float32, torch.float64} or cost.is_complex():
19
+ return cost
20
+ return cost.to(torch.float32)
21
+
22
+
23
+ def batch_linear_assignment_cpu(cost: torch.Tensor) -> torch.Tensor:
24
+ """Solve batched assignment with SciPy on the cost tensor's CPU device."""
25
+ cost = _prepare_solver_cost(cost)
26
+ batch_size, workers, _ = cost.shape
27
+ matching = torch.full([batch_size, workers], -1, dtype=torch.long, device=cost.device)
28
+ for batch_index in range(batch_size):
29
+ row_indices, column_indices = linear_sum_assignment(cost[batch_index].numpy(), maximize=False)
30
+ matching[batch_index].scatter_(
31
+ 0,
32
+ torch.from_numpy(row_indices),
33
+ torch.from_numpy(column_indices),
34
+ )
35
+ return matching
36
+
37
+
38
+ def _torch_supports_triton() -> bool:
39
+ """Return whether the installed Torch version meets the Triton-path floor."""
40
+ version = re.match(r"(\d+)\.(\d+)", torch.__version__)
41
+ return version is not None and tuple(map(int, version.groups())) >= _TRITON_MINIMUM_TORCH_VERSION
42
+
43
+
44
+ def _load_triton_backend() -> ModuleType | None:
45
+ """Load the optional Triton module without hiding nested import failures."""
46
+ try:
47
+ return importlib.import_module("torch_linear_assignment._triton")
48
+ except ModuleNotFoundError as error:
49
+ if error.name in {"torch_linear_assignment._triton", "triton"}:
50
+ return None
51
+ raise
52
+
53
+
54
+ def _load_legacy_backend() -> ModuleType | None:
55
+ """Load the optional legacy extension for private benchmark comparisons only."""
56
+ try:
57
+ return importlib.import_module("torch_linear_assignment._backend")
58
+ except ModuleNotFoundError as error:
59
+ if error.name == "torch_linear_assignment._backend":
60
+ return None
61
+ raise
62
+
63
+
64
+ def _cuda_uses_triton(cost: torch.Tensor) -> bool:
65
+ """Return whether this CUDA cost tensor can use the supported Triton path."""
66
+ if (
67
+ sys.platform != "linux"
68
+ or not _torch_supports_triton()
69
+ or not torch.cuda.is_available()
70
+ or torch.version.cuda is None
71
+ ):
72
+ return False
73
+ return torch.cuda.get_device_capability(cost.device) >= (8, 0) and _load_triton_backend() is not None
74
+
75
+
76
+ def _warn_cuda_fallback() -> None:
77
+ """Warn once when a CUDA input must use the SciPy CPU fallback."""
78
+ global _CUDA_FALLBACK_WARNING_EMITTED
79
+ if _CUDA_FALLBACK_WARNING_EMITTED:
80
+ return
81
+ _CUDA_FALLBACK_WARNING_EMITTED = True
82
+ warnings.warn(
83
+ "Triton linear-assignment support is unavailable for this CUDA input; using SciPy on CPU.",
84
+ RuntimeWarning,
85
+ stacklevel=3,
86
+ )
87
+
88
+
89
+ def _batch_linear_assignment_cuda_legacy(cost: torch.Tensor) -> torch.Tensor:
90
+ """Run the legacy extension for private benchmark comparison when installed."""
91
+ backend = _load_legacy_backend()
92
+ if backend is None:
93
+ raise RuntimeError("The legacy CUDA extension is not installed.")
94
+
95
+ cost = _prepare_solver_cost(cost)
96
+ _, workers, tasks = cost.shape
97
+ if tasks < workers:
98
+ _, row4col = backend.batch_linear_assignment(cost.transpose(1, 2).contiguous())
99
+ return row4col.long()
100
+ col4row, _ = backend.batch_linear_assignment(cost.contiguous())
101
+ return col4row.long()
102
+
103
+
104
+ def batch_linear_assignment_cuda(cost: torch.Tensor) -> torch.Tensor:
105
+ """Solve a CUDA batch through the private Triton implementation."""
106
+ backend = _load_triton_backend()
107
+ if backend is None:
108
+ raise RuntimeError("Triton is unavailable for CUDA linear assignment.")
109
+ return backend.batch_linear_assignment(cost)
110
+
111
+
112
+ def batch_linear_assignment(cost: torch.Tensor) -> torch.Tensor:
113
+ """Solve a batch of linear assignment problems.
114
+
115
+ The method minimizes the cost.
116
+
117
+ Args:
118
+ cost: Cost matrix with shape (B, W, T), where W is the number of workers
119
+ and T is the number of tasks.
120
+
121
+ Returns:
122
+ Matching tensor with shape (B, W), with assignments for each worker. If the
123
+ task was not assigned, the corresponding index will be -1.
124
+ """
125
+ if cost.ndim != 3:
126
+ raise ValueError("Need 3-dimensional tensor with shape (B, W, T).")
127
+ if cost.is_cuda and _cuda_uses_triton(cost):
128
+ return batch_linear_assignment_cuda(cost)
129
+
130
+ device = cost.device
131
+ if cost.is_cuda:
132
+ _warn_cuda_fallback()
133
+ cost = cost.cpu()
134
+ return batch_linear_assignment_cpu(cost).to(device)
135
+
136
+
137
+ def assignment_to_indices(
138
+ assignment: torch.Tensor,
139
+ ) -> tuple[torch.Tensor, torch.Tensor]:
140
+ """Convert assignment to the SciPy format.
141
+
142
+ Args:
143
+ assignment: The assignment with shape (B, W).
144
+
145
+ Returns:
146
+ row_ind, col_ind: An array of row indices and one of corresponding column indices
147
+ giving the optimal assignment, each with shape (B, K).
148
+
149
+ Raises:
150
+ ValueError if batch assignments have different sizes.
151
+ """
152
+ batch_size = assignment.shape[0]
153
+ if batch_size == 0:
154
+ indices = torch.zeros(0, 0, dtype=torch.long, device=assignment.device)
155
+ return indices, indices
156
+ mask = assignment >= 0
157
+ n_matches = mask.sum(1)
158
+ if (n_matches[1:] != n_matches[0]).any():
159
+ raise ValueError("Inconsistent matching sizes.")
160
+ n_matches = n_matches[0].item()
161
+ row_ind = mask.nonzero()[:, 1].reshape(batch_size, n_matches)
162
+ col_ind = assignment.masked_select(mask).reshape(batch_size, n_matches)
163
+ return row_ind, col_ind
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: torch-linear-assignment
3
+ Version: 0.1.0rc0
4
+ Summary: Batched linear assignment with PyTorch and CUDA.
5
+ Author: Ivan Karpukhin
6
+ Author-email: karpuhini@yandex.ru
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: torch>=1.12.0
11
+ Requires-Dist: scipy>=1.6.0
12
+ Requires-Dist: triton; platform_system == "Linux"
13
+ Dynamic: author
14
+ Dynamic: author-email
15
+ Dynamic: description
16
+ Dynamic: description-content-type
17
+ Dynamic: license-file
18
+ Dynamic: requires-dist
19
+ Dynamic: requires-python
20
+ Dynamic: summary
21
+
22
+ # Batch linear assignment for PyTorch
23
+
24
+ [![PyPI version](https://badge.fury.io/py/torch-linear-assignment.svg)](https://badge.fury.io/py/torch-linear-assignment) [![Build Status](https://github.com/ivan-chai/torch-linear-assignment/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/ivan-chai/torch-linear-assignment/actions) [![Downloads](https://img.shields.io/pypi/dm/torch-linear-assignment)](https://pepy.tech/project/torch-linear-assignment) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
25
+
26
+ <h4 align="left">
27
+ <p>
28
+ <a href="#install">Installation</a> |
29
+ <a href="#backend-and-release-policy">Backend policy</a> |
30
+ <a href="#example">Usage</a> |
31
+ <a href="#support">Support</a> |
32
+ <a href="#legacy-cuda-implementation-00x">Legacy CUDA</a> |
33
+ <a href="#acknowledgments">Acknowledgments</a> |
34
+ <a href="#citation">Citation</a>
35
+ <p>
36
+ </h4>
37
+ Batch computation of the linear assignment problem with PyTorch. The active
38
+ `0.1.0+` release line is pure source: CPU calls use SciPy, and supported CUDA
39
+ calls use the lazy Triton backend.
40
+
41
+ ## Backend and release policy
42
+
43
+ The active release line is `0.1.0+`. It uses Triton on validated Linux NVIDIA GPUs with compute capability 8.0 or newer and SciPy everywhere else. Unsupported CUDA inputs are solved by the SciPy CPU fallback and returned to the input device. The compiled CUDA extension belongs to the maintenance-only `0.0.x` line and is documented separately in [Legacy CUDA implementation (`0.0.x`)](#legacy-cuda-implementation-00x). If that backend needs a compatibility or correctness fix, the project can issue another `0.0.x` release without adopting the Triton overhaul.
44
+
45
+ ## Install
46
+
47
+ Python 3.10 or newer is required. Install the `0.1.0` release candidate from PyPI:
48
+
49
+ ```bash
50
+ python -m pip install "torch-linear-assignment==0.1.0rc0"
51
+ ```
52
+
53
+ No editable install, local CUDA compilation, or `--no-build-isolation` flag is needed for normal use. On Linux, the package declares Triton through a platform marker and imports it lazily only for an eligible CUDA input. macOS and Windows installs remain usable through SciPy without requiring Triton.
54
+
55
+ ## Example
56
+
57
+ ```python
58
+ import torch
59
+ from torch_linear_assignment import batch_linear_assignment
60
+
61
+ cost = torch.tensor([
62
+ 8, 4, 7,
63
+ 5, 2, 3,
64
+ 9, 6, 7,
65
+ 9, 4, 8,
66
+ ]).reshape(1, 4, 3)
67
+
68
+ assignment = batch_linear_assignment(cost)
69
+ print(assignment)
70
+ ```
71
+
72
+ The output is:
73
+
74
+ ```py
75
+ tensor([[ 0, 2, -1, 1]])
76
+ ```
77
+
78
+ To get indices in the SciPy's format:
79
+
80
+ ```py
81
+ from torch_linear_assignment import assignment_to_indices
82
+
83
+ row_ind, col_ind = assignment_to_indices(assignment)
84
+ print(row_ind)
85
+ print(col_ind)
86
+ ```
87
+
88
+ The output is:
89
+
90
+ ```py
91
+ tensor([[0, 1, 3]])
92
+ tensor([[0, 2, 1]])
93
+ ```
94
+
95
+ ## Support
96
+
97
+ The public API is `batch_linear_assignment(cost)` with cost shape `(B, W, T)`. Assignments have shape `(B, W)`, use `torch.long`, and contain `-1` for an unmatched worker.
98
+
99
+ | Input and runtime | Backend and behavior |
100
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
101
+ | CPU input on any supported OS | SciPy on CPU. This remains the reference path. |
102
+ | Linux NVIDIA GPU with compute capability 8.0 or newer, Torch >= 2.4, and Triton available | Lazy Triton CUDA backend. The first call can include Triton compilation. |
103
+ | CUDA input on an unsupported OS; non-NVIDIA Linux accelerator; NVIDIA GPU below compute capability 8.0 (including T4); or missing/ineligible Triton/Torch | Warn once, solve with SciPy on CPU, and return the assignment on the input device. |
104
+
105
+ The solver promotes `float16`, `bfloat16`, and integer costs to `float32`; `float64` costs remain `float64`. Triton execution disables autocast, so precision already lost before entry cannot be recovered.
106
+
107
+ Non-finite costs follow the SciPy contract. `NaN` and `-inf` raise `ValueError("matrix contains invalid numeric entries")`. `+inf` represents a forbidden edge when a perfect matching still exists; if the matrix is infeasible, the call raises `ValueError("cost matrix is infeasible")` instead of terminating the process with a device assertion.
108
+
109
+ ## Validation and performance status
110
+
111
+ GPU validation is author-run rather than hosted CI. The validation targets are Linux NVIDIA GPUs with compute capability 8.0 or newer (for example, L4, A100, H100, or RTX PRO 6000 Blackwell):
112
+
113
+ ```bash
114
+ make validate # correctness once; prints GPU metadata when available
115
+ make benchmark # SciPy CPU and installed public CUDA; writes JSONL and a cold/warm table
116
+ ```
117
+
118
+ `make validate` prints Python/platform, Torch/CUDA, Triton, and GPU compute capability metadata when a CUDA device is visible. `make benchmark` separately measures the SciPy CPU reference and installed public CUDA backend, writes complete JSONL evidence, and prints a compact cold/warm table. A clean GPU metadata skip on macOS or a machine without CUDA is expected, but is not GPU evidence.
119
+
120
+ The first Triton call may be slower because it includes cold compilation; measure cold compilation separately from warm execution. Do not generalize performance across GPU models or workloads. The active `0.1.0+` line requires the correctness, AMP/dtype, fallback, packaging, GPU, and warm-performance gates to pass on each advertised GPU class.
121
+
122
+ Author-run evidence captured on 2026-08-20 at commit `eee95ffc` (`perf(triton): terminate completed augmentations early`) supports this matrix:
123
+
124
+ | GPU | SciPy reference comparison | Triton validation |
125
+ | ---------------------------------------- | ------------------------------- | ------------------ |
126
+ | NVIDIA L4 (`sm_89`) | Exact in all 30 benchmark cases | `78/78` tests pass |
127
+ | NVIDIA A100 (`sm_80`) | Exact in all 30 benchmark cases | `78/78` tests pass |
128
+ | NVIDIA H100 (`sm_90`) | ? | ? |
129
+ | NVIDIA RTX PRO 6000 Blackwell (`sm_120`) | Exact in all 30 benchmark cases | `78/78` tests pass |
130
+
131
+ Representative batch-208 float32 performance is shown below. Each timing cell is `transpose / square / direct` in milliseconds for `300 x 100`, `300 x 300`, and `300 x 600`. Ratios above `1.0x` mean Triton is faster. Values are rounded to one decimal; `~` marks ratios derived from the displayed timings.
132
+
133
+ | GPU / timing | SciPy CPU (ms) | Triton (ms) | SciPy / Triton |
134
+ | ------------------ | -------------------- | ---------------- | ----------------------- |
135
+ | L4 (first\*) | 71.8 / 788.6 / 421.9 | 1.0 / 7.6 / 4.3 | ~71.8x / 103.8x / 98.1x |
136
+ | L4 (warm) | 71.3 / 782.5 / 422.8 | 1.0 / 7.6 / 4.4 | 71.3x / 103.5x / 96.4x |
137
+ | A100 (first\*) | 74.8 / 788.6 / 430.3 | 1.3 / 11.6 / 4.3 | ~57.5x / 68.0x / 100.1x |
138
+ | A100 (warm) | 73.9 / 788.4 / 431.9 | 1.3 / 11.6 / 4.4 | 56.7x / 67.7x / 99.1x |
139
+ | H100 (first\*) | ? | ? | ? |
140
+ | H100 (warm) | ? | ? | ? |
141
+ | RTX 6000 (first\*) | 32.1 / 409.8 / 204.8 | 0.7 / 5.5 / 2.2 | ~45.9x / 74.5x / 93.1x |
142
+ | RTX 6000 (warm) | 32.3 / 410.1 / 204.8 | 0.5 / 5.4 / 2.2 | 60.8x / 75.8x / 94.4x |
143
+
144
+ The Triton implementation parallelizes batch items and vectorizes each current candidate-column scan. Commit `eee95ffc` also terminates the shortest-path search when an augmentation finds an unmatched sink. This removes the post-completion iterations that dominated direct matrices. Against the same-machine SciPy CPU reference, representative warm Triton speedups span `56.7x`--`103.5x` while retaining exact assignment parity.
145
+
146
+ `first*` means the first call for that case in the shared benchmark process. It is not an isolated compilation measurement: earlier shapes and dtypes can populate driver, process, and Triton disk caches. The three notebooks also predate the fresh-process benchmark-integrity harness, so these measurements are provisional author-run evidence rather than the final cold-performance record. Publication-grade cold claims require a source-bound, five-process rerun.
147
+
148
+ Do not publish a Triton-only timing as a speedup. Each performance report pairs the same seeded workload with its same-machine SciPy CPU baseline.
149
+
150
+ For direct benchmark-script options, the underlying command writes complete JSONL evidence to a run-specific file and prints a compact table containing the backend, validation mode, shape, status, cold/warm latency, and exact-parity result:
151
+
152
+ ```bash
153
+ make benchmark
154
+ python tests/benchmark.py --backends scipy,public_cuda --output benchmark-results.jsonl
155
+ ```
156
+
157
+ The command exits nonzero if any invocation fails exact parity. The JSONL file retains replay diagnostics that the console table intentionally omits.
158
+
159
+ ## Legacy CUDA implementation (`0.0.x`)
160
+
161
+ Release `0.0.6` contains the frozen, compiled plain-CUDA implementation. It is not the active default and should be pinned only when that legacy GPU path is explicitly required, including T4 (`sm_75`) execution:
162
+
163
+ ```bash
164
+ python -m pip install "torch-linear-assignment==0.0.6"
165
+ ```
166
+
167
+ The extension needs a compatible CUDA build environment and must include `sm_75`; the project does not promise a portable T4 wheel. Pinning it is also workload-dependent: in the recorded T4 comparison, SciPy was faster through batch 50, while legacy CUDA was materially faster for the tested batch-208 transpose and direct cases.
168
+
169
+ For a private contributor comparison from a current checkout, the legacy extension can instead be built explicitly:
170
+
171
+ ```bash
172
+ TLA_BUILD_LEGACY_CUDA=1 python -m pip install -e . --no-build-isolation
173
+ ```
174
+
175
+ This opt-in is not required for normal `0.1.0+` use and does not expose a public backend selector. For a clean cross-version benchmark, copy `tests/benchmark.py` outside the checkout first. In a separate baseline environment, `make install-legacy` installs the newest PyPI release matching `torch-linear-assignment<0.1.0`; the copied runner then measures its installed public CUDA backend. Pair those results with the same shapes, dtypes, seed, GPU, and validation mode from the current Triton run.
176
+
177
+ The following author-run batch-208 FP32 comparison uses the same `transpose / square / direct` ordering as the main table. Ratios above `1.0x` mean Triton is faster; values are rounded to one decimal, and `~` marks ratios derived from separately displayed legacy and current-package timings.
178
+
179
+ | GPU / timing | Legacy 0.0.6 (ms) | Triton (ms) | Legacy / Triton |
180
+ | ------------------ | -------------------- | ---------------- | ------------------------ |
181
+ | T4 (first\*) | 37.0 / 879.5 / 273.9 | — | — |
182
+ | T4 (warm) | 37.7 / 738.6 / 272.6 | — | — |
183
+ | L4 (first\*) | 37.9 / 648.5 / 274.5 | 1.0 / 7.6 / 4.3 | ~37.9x / 85.3x / 63.8x |
184
+ | L4 (warm) | 37.8 / 652.5 / 273.3 | 1.0 / 7.6 / 4.4 | ~37.8x / 85.9x / 62.1x |
185
+ | A100 (first\*) | 36.2 / 755.5 / 271.7 | 1.3 / 11.6 / 4.3 | ~27.8x / 65.1x / 63.2x |
186
+ | A100 (warm) | 36.2 / 731.3 / 271.6 | 1.3 / 11.6 / 4.4 | ~27.8x / 63.0x / 61.7x |
187
+ | H100 (first\*) | ? | ? | ? |
188
+ | H100 (warm) | ? | ? | ? |
189
+ | RTX 6000 (first\*) | 35.4 / 607.1 / 248.0 | 0.7 / 5.5 / 2.2 | ~50.6x / 110.4x / 112.7x |
190
+ | RTX 6000 (warm) | 35.4 / 607.6 / 249.3 | 0.5 / 5.4 / 2.2 | ~70.8x / 112.5x / 113.3x |
191
+
192
+ Triton is faster in all 30 paired warm cases on each measured supported GPU; the weakest displayed ratio is about `6.0x`. T4 has no Triton timing because it is below the supported compute capability, and H100 remains unmeasured. The `first*` caveat from the main table applies equally here.
193
+
194
+ ## Acknowledgments
195
+
196
+ The `0.1.0+` pure-source Triton backend, GPU validation workflow, and benchmark revamp were implemented by [@Borda](https://github.com/Borda).
197
+
198
+ ## Citation
199
+
200
+ The code was originally developed for the [HoTPP Benchmark](https://github.com/ivan-chai/hotpp-benchmark). If you use this code in your research project, please cite one of the following papers:
201
+
202
+ ```
203
+ @article{karpukhin2024hotppbenchmark,
204
+ title={HoTPP Benchmark: Are We Good at the Long Horizon Events Forecasting?},
205
+ author={Karpukhin, Ivan and Shipilov, Foma and Savchenko, Andrey},
206
+ journal={arXiv preprint arXiv:2406.14341},
207
+ year={2024},
208
+ url ={https://arxiv.org/abs/2406.14341}
209
+ }
210
+
211
+ @article{karpukhin2024detpp,
212
+ title={DeTPP: Leveraging Object Detection for Robust Long-Horizon Event Prediction},
213
+ author={Karpukhin, Ivan and Savchenko, Andrey},
214
+ journal={arXiv preprint arXiv:2408.13131},
215
+ year={2024},
216
+ url ={https://arxiv.org/abs/2408.13131}
217
+ }
218
+ ```
@@ -0,0 +1,8 @@
1
+ torch_linear_assignment/__init__.py,sha256=Dkb0lPgPrrtzMmsKxGpZsVhjKIwtUnoDdT71LArxBYM,188
2
+ torch_linear_assignment/_triton.py,sha256=0EPQpI793Lkq9mtJtcoDqVHjf4m6zCOsx3sp6dWpHlM,13154
3
+ torch_linear_assignment/assignment.py,sha256=MkeY2kGZNW7RPLePBA07RefwT_lWBczszeGXzJiENl0,5816
4
+ torch_linear_assignment-0.1.0rc0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
5
+ torch_linear_assignment-0.1.0rc0.dist-info/METADATA,sha256=YWaTB-VCM02uZq2iipe0fvE7KET93xnx5-42Mh42VQA,13963
6
+ torch_linear_assignment-0.1.0rc0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ torch_linear_assignment-0.1.0rc0.dist-info/top_level.txt,sha256=eV_iv2yNOwDzuo6EUQVtdx-8Ar-HX03wRmnl6Ge1vGA,24
8
+ torch_linear_assignment-0.1.0rc0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ torch_linear_assignment