erikkaum commited on
Commit
23d14b7
·
verified ·
1 Parent(s): 5fdc1d1

Uploaded using `kernel-builder`.

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. build/torch210-cxx11-cu126-x86_64-linux/__init__.py +979 -0
  2. build/torch210-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  3. build/torch210-cxx11-cu126-x86_64-linux/_ops.py +9 -0
  4. build/torch210-cxx11-cu126-x86_64-linux/maxsim/__init__.py +26 -0
  5. build/torch210-cxx11-cu126-x86_64-linux/metadata.json +16 -0
  6. build/torch210-cxx11-cu126-x86_64-linux/reference.py +361 -0
  7. build/torch210-cxx11-cu128-x86_64-linux/__init__.py +979 -0
  8. build/torch210-cxx11-cu128-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  9. build/torch210-cxx11-cu128-x86_64-linux/_ops.py +9 -0
  10. build/torch210-cxx11-cu128-x86_64-linux/maxsim/__init__.py +26 -0
  11. build/torch210-cxx11-cu128-x86_64-linux/metadata.json +16 -0
  12. build/torch210-cxx11-cu128-x86_64-linux/reference.py +361 -0
  13. build/torch210-cxx11-cu130-x86_64-linux/__init__.py +979 -0
  14. build/torch210-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  15. build/torch210-cxx11-cu130-x86_64-linux/_ops.py +9 -0
  16. build/torch210-cxx11-cu130-x86_64-linux/maxsim/__init__.py +26 -0
  17. build/torch210-cxx11-cu130-x86_64-linux/metadata.json +16 -0
  18. build/torch210-cxx11-cu130-x86_64-linux/reference.py +361 -0
  19. build/torch211-cxx11-cu126-x86_64-linux/__init__.py +979 -0
  20. build/torch211-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  21. build/torch211-cxx11-cu126-x86_64-linux/_ops.py +9 -0
  22. build/torch211-cxx11-cu126-x86_64-linux/maxsim/__init__.py +26 -0
  23. build/torch211-cxx11-cu126-x86_64-linux/metadata.json +16 -0
  24. build/torch211-cxx11-cu126-x86_64-linux/reference.py +361 -0
  25. build/torch211-cxx11-cu128-x86_64-linux/__init__.py +979 -0
  26. build/torch211-cxx11-cu128-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  27. build/torch211-cxx11-cu128-x86_64-linux/_ops.py +9 -0
  28. build/torch211-cxx11-cu128-x86_64-linux/maxsim/__init__.py +26 -0
  29. build/torch211-cxx11-cu128-x86_64-linux/metadata.json +16 -0
  30. build/torch211-cxx11-cu128-x86_64-linux/reference.py +361 -0
  31. build/torch211-cxx11-cu130-x86_64-linux/__init__.py +979 -0
  32. build/torch211-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  33. build/torch211-cxx11-cu130-x86_64-linux/_ops.py +9 -0
  34. build/torch211-cxx11-cu130-x86_64-linux/maxsim/__init__.py +26 -0
  35. build/torch211-cxx11-cu130-x86_64-linux/metadata.json +16 -0
  36. build/torch211-cxx11-cu130-x86_64-linux/reference.py +361 -0
  37. build/torch212-cxx11-cu126-x86_64-linux/__init__.py +979 -0
  38. build/torch212-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  39. build/torch212-cxx11-cu126-x86_64-linux/_ops.py +9 -0
  40. build/torch212-cxx11-cu126-x86_64-linux/maxsim/__init__.py +26 -0
  41. build/torch212-cxx11-cu126-x86_64-linux/metadata.json +16 -0
  42. build/torch212-cxx11-cu126-x86_64-linux/reference.py +361 -0
  43. build/torch212-cxx11-cu130-x86_64-linux/__init__.py +979 -0
  44. build/torch212-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
  45. build/torch212-cxx11-cu130-x86_64-linux/_ops.py +9 -0
  46. build/torch212-cxx11-cu130-x86_64-linux/maxsim/__init__.py +26 -0
  47. build/torch212-cxx11-cu130-x86_64-linux/metadata.json +16 -0
  48. build/torch212-cxx11-cu130-x86_64-linux/reference.py +361 -0
  49. build/torch212-cxx11-cu132-x86_64-linux/__init__.py +979 -0
  50. build/torch212-cxx11-cu132-x86_64-linux/_maxsim_cuda_bd13740.abi3.so +3 -0
build/torch210-cxx11-cu126-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch210-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:87acba677b07d2d3345639030702b887a1186d9dedf31bfe997f3b9741723184
3
+ size 4157360
build/torch210-cxx11-cu126-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch210-cxx11-cu126-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch210-cxx11-cu126-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch210-cxx11-cu126-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch210-cxx11-cu128-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch210-cxx11-cu128-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c7892c2a5d2aa96730ed84c9f871e9960cce8777cc5b4782d84c0f928d8439cd
3
+ size 4208032
build/torch210-cxx11-cu128-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch210-cxx11-cu128-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch210-cxx11-cu128-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch210-cxx11-cu128-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch210-cxx11-cu130-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch210-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dda7e16afdaf04829fa79b4795d8d9e6a8a7e17283ee3f4fc5ed7d8cbafdbbc5
3
+ size 4184328
build/torch210-cxx11-cu130-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch210-cxx11-cu130-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch210-cxx11-cu130-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch210-cxx11-cu130-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch211-cxx11-cu126-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch211-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b82fb2f1f8e2afefe02757c48f283d97267afef8503360daac352752e298c2d
3
+ size 4146200
build/torch211-cxx11-cu126-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch211-cxx11-cu126-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch211-cxx11-cu126-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch211-cxx11-cu126-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch211-cxx11-cu128-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch211-cxx11-cu128-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3bcc18f53d7aa8e0e51e7f0c06a019657847fef17179c019dee40d84b0a3c7f5
3
+ size 4192776
build/torch211-cxx11-cu128-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch211-cxx11-cu128-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch211-cxx11-cu128-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch211-cxx11-cu128-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch211-cxx11-cu130-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch211-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7aea18c09688aee2dec9137888df4b644f4b0cf8b3c37b1bdd6a271cf00eb7f
3
+ size 4173168
build/torch211-cxx11-cu130-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch211-cxx11-cu130-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch211-cxx11-cu130-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch211-cxx11-cu130-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch212-cxx11-cu126-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch212-cxx11-cu126-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:104aa541527ccece6130096807f5f5a8e314e6a1835c6e8793dfdf155381c610
3
+ size 4143584
build/torch212-cxx11-cu126-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch212-cxx11-cu126-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch212-cxx11-cu126-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch212-cxx11-cu126-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch212-cxx11-cu130-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch212-cxx11-cu130-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:595359d19b1523673b01592bd42655fa8317b2b5d1567d4f0df819179fb3c536
3
+ size 4170504
build/torch212-cxx11-cu130-x86_64-linux/_ops.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from . import _maxsim_cuda_bd13740
3
+ ops = torch.ops._maxsim_cuda_bd13740
4
+
5
+ def add_op_namespace_prefix(op_name: str):
6
+ """
7
+ Prefix op by namespace.
8
+ """
9
+ return f"_maxsim_cuda_bd13740::{op_name}"
build/torch212-cxx11-cu130-x86_64-linux/maxsim/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ctypes
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _import_from_path(file_path: Path) -> ModuleType:
9
+ # We cannot use the module name as-is, after adding it to `sys.modules`,
10
+ # it would also be used for other imports. So, we make a module name that
11
+ # depends on the path for it to be unique using the hex-encoded hash of
12
+ # the path.
13
+ path_hash = "{:x}".format(ctypes.c_size_t(hash(file_path.absolute())).value)
14
+ module_name = path_hash
15
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
16
+ if spec is None:
17
+ raise ImportError(f"Cannot load spec for {module_name} from {file_path}")
18
+ module = importlib.util.module_from_spec(spec)
19
+ if module is None:
20
+ raise ImportError(f"Cannot load module {module_name} from spec")
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module) # type: ignore
23
+ return module
24
+
25
+
26
+ globals().update(vars(_import_from_path(Path(__file__).parent.parent / "__init__.py")))
build/torch212-cxx11-cu130-x86_64-linux/metadata.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "maxsim",
3
+ "id": "_maxsim_cuda_bd13740",
4
+ "version": 2,
5
+ "license": "Apache-2.0",
6
+ "python-depends": [],
7
+ "backend": {
8
+ "type": "cuda",
9
+ "archs": [
10
+ "8.0",
11
+ "8.6",
12
+ "8.9",
13
+ "9.0+PTX"
14
+ ]
15
+ }
16
+ }
build/torch212-cxx11-cu130-x86_64-linux/reference.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch reference implementations of MaxSim.
2
+
3
+ These are intentionally simple and slow (they materialize the full
4
+ `[Lq, Ld]` similarity matrix). They exist so that:
5
+
6
+ * tests can compare the kernel against an obviously-correct baseline, and
7
+ * benchmarks can show the speed and memory wins of the kernel against the
8
+ natural way someone would write MaxSim in PyTorch.
9
+
10
+ The public function is `maxsim_reference`, which mirrors the formula
11
+
12
+ score(q, d) = sum_i max_j dot(q_i, d_j)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+
20
+ def maxsim_reference(q: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
21
+ """Reference MaxSim score for a single (query, document) pair.
22
+
23
+ Args:
24
+ q: ``[Lq, dim]``
25
+ d: ``[Ld, dim]``
26
+
27
+ Returns:
28
+ Scalar fp32 score tensor on the same device.
29
+ """
30
+ sim = (q.float() @ d.float().transpose(-1, -2)) # [Lq, Ld]
31
+ return sim.max(dim=-1).values.sum()
32
+
33
+
34
+ def maxsim_reference_with_argmax(
35
+ q: torch.Tensor, d: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor]:
37
+ """Like :func:`maxsim_reference` but also returns the argmax document index
38
+ per query token, with PyTorch's first-index-wins tiebreak semantics.
39
+
40
+ Returns:
41
+ ``(score, argmax)`` where ``score`` is a scalar fp32 tensor and
42
+ ``argmax`` is an int32 tensor of shape ``[Lq]``.
43
+ """
44
+ sim = q.float() @ d.float().transpose(-1, -2) # [Lq, Ld]
45
+ # torch.max returns first occurrence on ties — matches what we want.
46
+ vals, idx = sim.max(dim=-1)
47
+ return vals.sum(), idx.to(torch.int32)
48
+
49
+
50
+ def score_pairs_packed_reference(
51
+ queries: torch.Tensor,
52
+ query_offsets: torch.Tensor,
53
+ documents: torch.Tensor,
54
+ document_offsets: torch.Tensor,
55
+ pair_query_ids: torch.Tensor,
56
+ pair_document_ids: torch.Tensor,
57
+ ) -> torch.Tensor:
58
+ """Reference implementation of :func:`score_pairs_packed`.
59
+
60
+ Loops over pairs and calls :func:`maxsim_reference`. Always returns fp32.
61
+ """
62
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
63
+ doff = document_offsets.to(torch.int64).cpu().tolist()
64
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
65
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
66
+
67
+ out = torch.empty(len(qids), dtype=torch.float32, device=queries.device)
68
+ for k, (qi, di) in enumerate(zip(qids, dids)):
69
+ q = queries[qoff[qi] : qoff[qi + 1]]
70
+ d = documents[doff[di] : doff[di + 1]]
71
+ out[k] = maxsim_reference(q, d)
72
+ return out
73
+
74
+
75
+ def score_pairs_packed_with_argmax_reference(
76
+ queries: torch.Tensor,
77
+ query_offsets: torch.Tensor,
78
+ documents: torch.Tensor,
79
+ document_offsets: torch.Tensor,
80
+ pair_query_ids: torch.Tensor,
81
+ pair_document_ids: torch.Tensor,
82
+ max_q_len: int,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ """Like :func:`score_pairs_packed_reference` but also returns argmax
85
+ positions per query token. Out-of-range slots (q_tok >= Lq for this pair)
86
+ are filled with 0 so the buffer has a uniform shape.
87
+
88
+ Returns:
89
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
90
+ is int32 ``[num_pairs, max_q_len]``. Tiebreak is PyTorch's
91
+ first-index-wins.
92
+ """
93
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
94
+ doff = document_offsets.to(torch.int64).cpu().tolist()
95
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
96
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
97
+
98
+ n = len(qids)
99
+ scores = torch.empty(n, dtype=torch.float32, device=queries.device)
100
+ argmax = torch.zeros(
101
+ (n, max_q_len), dtype=torch.int32, device=queries.device
102
+ )
103
+ for k, (qi, di) in enumerate(zip(qids, dids)):
104
+ q = queries[qoff[qi] : qoff[qi + 1]]
105
+ d = documents[doff[di] : doff[di + 1]]
106
+ s, a = maxsim_reference_with_argmax(q, d)
107
+ scores[k] = s
108
+ argmax[k, : a.numel()] = a
109
+ return scores, argmax
110
+
111
+
112
+ def score_candidates_padded_reference(
113
+ queries: torch.Tensor,
114
+ documents: torch.Tensor,
115
+ query_lengths: torch.Tensor,
116
+ doc_lengths: torch.Tensor,
117
+ ) -> torch.Tensor:
118
+ """Reference implementation of :func:`score_candidates_padded`.
119
+
120
+ Args:
121
+ queries: ``[B, Lq, dim]``
122
+ documents: ``[B, C, Ld, dim]``
123
+ query_lengths: ``[B]``
124
+ doc_lengths: ``[B, C]``
125
+
126
+ Returns:
127
+ ``[B, C]`` fp32 tensor on the same device as ``queries``.
128
+ """
129
+ B, C = doc_lengths.shape
130
+ out = torch.empty((B, C), dtype=torch.float32, device=queries.device)
131
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
132
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
133
+ for b in range(B):
134
+ q = queries[b, : qlen[b]]
135
+ for c in range(C):
136
+ d = documents[b, c, : dlen[b][c]]
137
+ out[b, c] = maxsim_reference(q, d)
138
+ return out
139
+
140
+
141
+ def score_candidates_padded_backward_reference(
142
+ dscore: torch.Tensor, # [B, C] fp32, incoming gradient
143
+ queries: torch.Tensor, # [B, Lq, dim] - forward input
144
+ documents: torch.Tensor, # [B, C, Ld, dim] - forward input
145
+ query_lengths: torch.Tensor, # [B]
146
+ doc_lengths: torch.Tensor, # [B, C]
147
+ argmax: torch.Tensor, # [B, C, Lq] int32 - from forward
148
+ ) -> tuple[torch.Tensor, torch.Tensor]:
149
+ """Reference backward for ``score_candidates_padded``.
150
+
151
+ Routes ``g = dscore[b, c]`` to ``dq[b, q] += g * d[b, c, j]`` and
152
+ ``dd[b, c, j] += g * q[b, q]`` where ``j = argmax[b, c, q]`` for each
153
+ valid (b, c, q).
154
+
155
+ Always returns fp32 gradients regardless of input dtype (matches the
156
+ kernel's behavior; downstream can cast).
157
+ """
158
+ B, C = doc_lengths.shape
159
+ Lq = queries.shape[1]
160
+ Ld = documents.shape[2]
161
+
162
+ dq = torch.zeros_like(queries, dtype=torch.float32)
163
+ dd = torch.zeros_like(documents, dtype=torch.float32)
164
+
165
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
166
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
167
+ argmax_cpu = argmax.to(torch.int64).cpu()
168
+
169
+ q_f = queries.float()
170
+ d_f = documents.float()
171
+ g_f = dscore.float()
172
+
173
+ for b in range(B):
174
+ for c in range(C):
175
+ g = g_f[b, c].item()
176
+ for i in range(qlen[b]):
177
+ j = int(argmax_cpu[b, c, i].item())
178
+ if j < 0 or j >= dlen[b][c]:
179
+ continue
180
+ dq[b, i] += g * d_f[b, c, j]
181
+ dd[b, c, j] += g * q_f[b, i]
182
+
183
+ return dq, dd
184
+
185
+
186
+ def score_pairs_packed_backward_reference(
187
+ dscore: torch.Tensor, # [num_pairs] fp32 incoming gradient
188
+ queries: torch.Tensor, # [total_q_tokens, dim]
189
+ query_offsets: torch.Tensor, # [num_queries + 1]
190
+ documents: torch.Tensor, # [total_d_tokens, dim]
191
+ document_offsets: torch.Tensor, # [num_documents + 1]
192
+ pair_query_ids: torch.Tensor, # [num_pairs]
193
+ pair_document_ids: torch.Tensor, # [num_pairs]
194
+ argmax: torch.Tensor, # [num_pairs, max_q_len] int32 from forward
195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
196
+ """Reference backward for the packed maxsim.
197
+
198
+ Routes ``g = dscore[k]`` to ``dq[q_start + i] += g * d[d_start + j]`` and
199
+ ``dd[d_start + j] += g * q[q_start + i]`` where ``j = argmax[k, i]``
200
+ and (q_start, d_start) are derived from the offset arrays.
201
+
202
+ Returns fp32 gradients matching the kernel.
203
+ """
204
+ qoff = query_offsets.to(torch.int64).cpu().tolist()
205
+ doff = document_offsets.to(torch.int64).cpu().tolist()
206
+ qids = pair_query_ids.to(torch.int64).cpu().tolist()
207
+ dids = pair_document_ids.to(torch.int64).cpu().tolist()
208
+ argmax_cpu = argmax.to(torch.int64).cpu()
209
+ q_f = queries.float()
210
+ d_f = documents.float()
211
+ g_f = dscore.float()
212
+
213
+ dq = torch.zeros_like(queries, dtype=torch.float32)
214
+ dd = torch.zeros_like(documents, dtype=torch.float32)
215
+
216
+ for k, (qi, di) in enumerate(zip(qids, dids)):
217
+ q_start, q_end = qoff[qi], qoff[qi + 1]
218
+ d_start, d_end = doff[di], doff[di + 1]
219
+ Lq_i = q_end - q_start
220
+ Ld_i = d_end - d_start
221
+ g = g_f[k].item()
222
+ for i in range(Lq_i):
223
+ j = int(argmax_cpu[k, i].item())
224
+ if j < 0 or j >= Ld_i:
225
+ continue
226
+ dq[q_start + i] += g * d_f[d_start + j]
227
+ dd[d_start + j] += g * q_f[q_start + i]
228
+
229
+ return dq, dd
230
+
231
+
232
+ def score_contrastive_reference(
233
+ queries: torch.Tensor, # [Nq, Lq, dim]
234
+ documents: torch.Tensor, # [total_d_toks, dim] (packed)
235
+ document_offsets: torch.Tensor, # [Nb + 1] int32 (CSR offsets)
236
+ ) -> torch.Tensor:
237
+ """Reference for the contrastive maxsim: every query scored against
238
+ every doc.
239
+
240
+ Returns:
241
+ ``[Nq, Nb]`` fp32 on the same device as ``queries``.
242
+ """
243
+ Nq = queries.shape[0]
244
+ Nb = document_offsets.numel() - 1
245
+ out = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
246
+ offs = document_offsets.to(torch.int64).cpu().tolist()
247
+ for qi in range(Nq):
248
+ q = queries[qi]
249
+ for di in range(Nb):
250
+ d = documents[offs[di] : offs[di + 1]]
251
+ out[qi, di] = maxsim_reference(q, d)
252
+ return out
253
+
254
+
255
+ def score_contrastive_with_argmax_reference(
256
+ queries: torch.Tensor, # [Nq, Lq, dim]
257
+ documents: torch.Tensor, # [total_d_toks, dim]
258
+ document_offsets: torch.Tensor, # [Nb + 1] int32
259
+ ) -> tuple[torch.Tensor, torch.Tensor]:
260
+ """Like :func:`score_contrastive_reference` but also returns the
261
+ argmax positions per query token.
262
+
263
+ Returns:
264
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
265
+ is int32 ``[Nq, Nb, Lq]`` with first-index-wins tiebreak.
266
+ """
267
+ Nq, Lq, _ = queries.shape
268
+ Nb = document_offsets.numel() - 1
269
+ scores = torch.empty((Nq, Nb), dtype=torch.float32, device=queries.device)
270
+ argmax = torch.zeros(
271
+ (Nq, Nb, Lq), dtype=torch.int32, device=queries.device
272
+ )
273
+ offs = document_offsets.to(torch.int64).cpu().tolist()
274
+ for qi in range(Nq):
275
+ q = queries[qi]
276
+ for di in range(Nb):
277
+ d = documents[offs[di] : offs[di + 1]]
278
+ s, a = maxsim_reference_with_argmax(q, d)
279
+ scores[qi, di] = s
280
+ argmax[qi, di] = a
281
+ return scores, argmax
282
+
283
+
284
+ def score_contrastive_backward_reference(
285
+ dscore: torch.Tensor, # [Nq, Nb] fp32, incoming gradient
286
+ queries: torch.Tensor, # [Nq, Lq, dim]
287
+ documents: torch.Tensor, # [total_d_toks, dim]
288
+ document_offsets: torch.Tensor, # [Nb + 1] int32
289
+ argmax: torch.Tensor, # [Nq, Nb, Lq] int32 from forward
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Reference backward for the contrastive maxsim.
292
+
293
+ Routes ``g = dscore[qi, di]`` to ``dq[qi, i] += g * d[di, j]`` and
294
+ ``dd[d_offset + j] += g * q[qi, i]`` where ``j = argmax[qi, di, i]``.
295
+
296
+ Both gradients are fp32 (matches kernel).
297
+ """
298
+ Nq, Lq, _ = queries.shape
299
+ Nb = document_offsets.numel() - 1
300
+
301
+ dq = torch.zeros_like(queries, dtype=torch.float32)
302
+ dd = torch.zeros_like(documents, dtype=torch.float32)
303
+
304
+ offs = document_offsets.to(torch.int64).cpu().tolist()
305
+ argmax_cpu = argmax.to(torch.int64).cpu()
306
+ q_f = queries.float()
307
+ d_f = documents.float()
308
+ g_f = dscore.float()
309
+
310
+ for qi in range(Nq):
311
+ for di in range(Nb):
312
+ g = g_f[qi, di].item()
313
+ d_start = offs[di]
314
+ d_end = offs[di + 1]
315
+ Ld_i = d_end - d_start
316
+ for i in range(Lq):
317
+ j = int(argmax_cpu[qi, di, i].item())
318
+ if j < 0 or j >= Ld_i:
319
+ continue
320
+ dq[qi, i] += g * d_f[d_start + j]
321
+ dd[d_start + j] += g * q_f[qi, i]
322
+
323
+ return dq, dd
324
+
325
+
326
+ def score_candidates_padded_with_argmax_reference(
327
+ queries: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ query_lengths: torch.Tensor,
330
+ doc_lengths: torch.Tensor,
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Like :func:`score_candidates_padded_reference` but also returns argmax
333
+ positions per query token. Tiebreak is PyTorch's first-index-wins.
334
+
335
+ Args:
336
+ queries: ``[B, Lq, dim]``
337
+ documents: ``[B, C, Ld, dim]``
338
+ query_lengths: ``[B]``
339
+ doc_lengths: ``[B, C]``
340
+
341
+ Returns:
342
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
343
+ int32 ``[B, C, Lq]``. Slots beyond ``query_lengths[b]`` are filled
344
+ with 0.
345
+ """
346
+ B, C = doc_lengths.shape
347
+ Lq = queries.shape[1]
348
+ scores = torch.empty((B, C), dtype=torch.float32, device=queries.device)
349
+ argmax = torch.zeros(
350
+ (B, C, Lq), dtype=torch.int32, device=queries.device
351
+ )
352
+ qlen = query_lengths.to(torch.int64).cpu().tolist()
353
+ dlen = doc_lengths.to(torch.int64).cpu().tolist()
354
+ for b in range(B):
355
+ q = queries[b, : qlen[b]]
356
+ for c in range(C):
357
+ d = documents[b, c, : dlen[b][c]]
358
+ s, a = maxsim_reference_with_argmax(q, d)
359
+ scores[b, c] = s
360
+ argmax[b, c, : a.numel()] = a
361
+ return scores, argmax
build/torch212-cxx11-cu132-x86_64-linux/__init__.py ADDED
@@ -0,0 +1,979 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Python wrapper around the compiled MaxSim kernel.
2
+
3
+ Three scoring surfaces, each with an inference function, a ``_with_argmax``
4
+ variant (also returns the winning document-token index per query token), and a
5
+ ``_train`` variant wired into PyTorch autograd:
6
+
7
+ * :func:`score_candidates_padded` -- padded reranking. Reads ``[B, Lq, D]``
8
+ queries and ``[B, K, Ld, D]`` candidates directly; the common inference path.
9
+ * :func:`score_contrastive` -- all-pairs ``[Nq, Nb]`` scoring with packed
10
+ documents; what in-batch contrastive training losses consume.
11
+ * :func:`score_pairs_packed` -- the lowest-level, kernel-facing API over
12
+ arbitrary ``(query, document)`` pair grids on ragged inputs.
13
+
14
+ Pure-PyTorch references (:func:`maxsim_reference`, ``score_*_reference``) are
15
+ also exported for tests and benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Tuple
21
+
22
+ import torch
23
+
24
+ from ._ops import ops
25
+ from .reference import (
26
+ maxsim_reference,
27
+ maxsim_reference_with_argmax,
28
+ score_candidates_padded_reference,
29
+ score_candidates_padded_with_argmax_reference,
30
+ score_contrastive_backward_reference,
31
+ score_contrastive_reference,
32
+ score_contrastive_with_argmax_reference,
33
+ score_pairs_packed_backward_reference,
34
+ score_pairs_packed_reference,
35
+ score_pairs_packed_with_argmax_reference,
36
+ )
37
+
38
+ __all__ = [
39
+ "score_pairs_packed",
40
+ "score_pairs_packed_with_argmax",
41
+ "score_pairs_packed_train",
42
+ "score_candidates_padded",
43
+ "score_candidates_padded_with_argmax",
44
+ "score_candidates_padded_train",
45
+ "score_contrastive",
46
+ "score_contrastive_with_argmax",
47
+ "score_contrastive_train",
48
+ "maxsim_reference",
49
+ "maxsim_reference_with_argmax",
50
+ "score_pairs_packed_reference",
51
+ "score_pairs_packed_with_argmax_reference",
52
+ "score_candidates_padded_reference",
53
+ "score_candidates_padded_with_argmax_reference",
54
+ "score_contrastive_reference",
55
+ "score_contrastive_with_argmax_reference",
56
+ ]
57
+
58
+
59
+ _FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
60
+ _INDEX_DTYPES = (torch.int32, torch.int64)
61
+
62
+
63
+ def _check_float(name: str, t: torch.Tensor) -> None:
64
+ if t.dtype not in _FLOAT_DTYPES:
65
+ raise TypeError(
66
+ f"{name} must be float32, float16, or bfloat16; got {t.dtype}"
67
+ )
68
+
69
+
70
+ def _check_index(name: str, t: torch.Tensor) -> None:
71
+ if t.dtype not in _INDEX_DTYPES:
72
+ raise TypeError(f"{name} must be int32 or int64; got {t.dtype}")
73
+
74
+
75
+ def _check_same_device(tensors: dict) -> None:
76
+ devices = {name: t.device for name, t in tensors.items()}
77
+ first_name, first_dev = next(iter(devices.items()))
78
+ for name, dev in devices.items():
79
+ if dev != first_dev:
80
+ raise RuntimeError(
81
+ f"all tensors must be on the same device; {first_name} is on "
82
+ f"{first_dev} but {name} is on {dev}"
83
+ )
84
+
85
+
86
+ def _validate_length_bounds(
87
+ lengths: torch.Tensor,
88
+ *,
89
+ max_len: int,
90
+ name: str,
91
+ ) -> None:
92
+ values = lengths.detach().to(device="cpu", dtype=torch.int64)
93
+ if (values <= 0).any().item():
94
+ raise ValueError(f"{name} must contain values > 0")
95
+ if (values > max_len).any().item():
96
+ raise ValueError(
97
+ f"{name} values must be <= padded length {max_len}"
98
+ )
99
+
100
+
101
+ def _check_padded_shapes(
102
+ queries: torch.Tensor,
103
+ documents: torch.Tensor,
104
+ query_lengths: torch.Tensor,
105
+ doc_lengths: torch.Tensor,
106
+ ) -> tuple[int, int, int, int, int]:
107
+ if queries.dim() != 3:
108
+ raise ValueError(
109
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
110
+ )
111
+ if documents.dim() != 4:
112
+ raise ValueError(
113
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
114
+ )
115
+ B, Lq_max, D = queries.shape
116
+ Bd, C, Ld_max, Dd = documents.shape
117
+ if B != Bd:
118
+ raise ValueError(
119
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
120
+ )
121
+ if D != Dd:
122
+ raise ValueError(
123
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
124
+ )
125
+ if query_lengths.shape != (B,):
126
+ raise ValueError(
127
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
128
+ )
129
+ if doc_lengths.shape != (B, C):
130
+ raise ValueError(
131
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
132
+ )
133
+ if queries.dtype != documents.dtype:
134
+ raise TypeError(
135
+ "queries and documents must have the same dtype; got "
136
+ f"{queries.dtype} vs {documents.dtype}"
137
+ )
138
+ _check_float("queries", queries)
139
+ _check_float("documents", documents)
140
+ _check_index("query_lengths", query_lengths)
141
+ _check_index("doc_lengths", doc_lengths)
142
+ _check_same_device(
143
+ dict(
144
+ queries=queries,
145
+ documents=documents,
146
+ query_lengths=query_lengths,
147
+ doc_lengths=doc_lengths,
148
+ )
149
+ )
150
+ _validate_length_bounds(query_lengths, max_len=Lq_max, name="query_lengths")
151
+ _validate_length_bounds(doc_lengths, max_len=Ld_max, name="doc_lengths")
152
+ return B, C, Lq_max, Ld_max, D
153
+
154
+
155
+ def _check_pair_ids(
156
+ pair_query_ids: torch.Tensor,
157
+ pair_document_ids: torch.Tensor,
158
+ ) -> None:
159
+ if pair_query_ids.shape != pair_document_ids.shape:
160
+ raise ValueError(
161
+ "pair_query_ids and pair_document_ids must have the same shape; "
162
+ f"got {tuple(pair_query_ids.shape)} vs {tuple(pair_document_ids.shape)}"
163
+ )
164
+ if pair_query_ids.dim() != 1:
165
+ raise ValueError(
166
+ f"pair_query_ids must be 1-D; got shape {tuple(pair_query_ids.shape)}"
167
+ )
168
+
169
+
170
+ def _validate_packed_layout(
171
+ queries: torch.Tensor,
172
+ query_offsets: torch.Tensor,
173
+ documents: torch.Tensor,
174
+ document_offsets: torch.Tensor,
175
+ pair_query_ids: torch.Tensor,
176
+ pair_document_ids: torch.Tensor,
177
+ ) -> int:
178
+ """Validate packed offsets and ids, returning max query segment length.
179
+
180
+ This is intentionally a host-side sync so public APIs fail clearly before
181
+ launching a native kernel with invalid layout metadata.
182
+ """
183
+
184
+ def _validate_offsets(
185
+ offsets: torch.Tensor,
186
+ total_tokens: int,
187
+ name: str,
188
+ ) -> tuple[list[int], int]:
189
+ values = offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
190
+ if len(values) < 2:
191
+ raise RuntimeError(f"{name} must have length >= 2")
192
+ if values[0] != 0:
193
+ raise RuntimeError(f"{name}[0] must equal 0, got {values[0]}")
194
+ if values[-1] != total_tokens:
195
+ raise RuntimeError(
196
+ f"{name}[-1] ({values[-1]}) must equal total token count "
197
+ f"({total_tokens})"
198
+ )
199
+ max_len = 0
200
+ for i, (start, end) in enumerate(zip(values, values[1:])):
201
+ diff = end - start
202
+ if diff <= 0:
203
+ raise RuntimeError(f"empty segment in {name} at index {i}")
204
+ max_len = max(max_len, diff)
205
+ return values, max_len
206
+
207
+ def _validate_ids(ids: torch.Tensor, upper: int, name: str) -> None:
208
+ values = ids.detach().to(device="cpu", dtype=torch.int64).tolist()
209
+ for i, value in enumerate(values):
210
+ if value < 0 or value >= upper:
211
+ raise RuntimeError(
212
+ f"{name}[{i}] = {value} out of range [0, {upper})"
213
+ )
214
+
215
+ q_offsets, max_q_len = _validate_offsets(
216
+ query_offsets, queries.shape[0], "query_offsets"
217
+ )
218
+ d_offsets, _ = _validate_offsets(
219
+ document_offsets, documents.shape[0], "document_offsets"
220
+ )
221
+ _validate_ids(pair_query_ids, len(q_offsets) - 1, "pair_query_ids")
222
+ _validate_ids(pair_document_ids, len(d_offsets) - 1, "pair_document_ids")
223
+ return max_q_len
224
+
225
+
226
+ def score_pairs_packed(
227
+ queries: torch.Tensor,
228
+ query_offsets: torch.Tensor,
229
+ documents: torch.Tensor,
230
+ document_offsets: torch.Tensor,
231
+ pair_query_ids: torch.Tensor,
232
+ pair_document_ids: torch.Tensor,
233
+ *,
234
+ max_q_len: int | None = None,
235
+ ) -> torch.Tensor:
236
+ """Compute MaxSim scores for a packed ragged batch of (query, document) pairs.
237
+
238
+ Args:
239
+ queries: ``[total_q_tokens, dim]`` (fp32 / fp16 / bf16).
240
+ query_offsets: ``[num_queries + 1]`` (int32 / int64). Must start at 0,
241
+ end at ``queries.shape[0]``, be strictly monotonically increasing
242
+ (no empty query segments).
243
+ documents: ``[total_d_tokens, dim]`` with the same dtype as ``queries``.
244
+ document_offsets: ``[num_documents + 1]`` (int32 / int64), same rules
245
+ as ``query_offsets``.
246
+ pair_query_ids: ``[num_pairs]`` of query ids in ``[0, num_queries)``.
247
+ pair_document_ids: ``[num_pairs]`` of document ids in
248
+ ``[0, num_documents)``.
249
+ max_q_len: optional pre-computed maximum query segment length. When
250
+ provided it is checked against ``query_offsets`` before launch; it
251
+ must be at least the actual maximum query segment length.
252
+
253
+ Returns:
254
+ ``[num_pairs]`` fp32 tensor of MaxSim scores on the same device.
255
+ """
256
+ if queries.dim() != 2:
257
+ raise ValueError(
258
+ f"queries must be 2-D [total_q_tokens, dim]; got shape {tuple(queries.shape)}"
259
+ )
260
+ if documents.dim() != 2:
261
+ raise ValueError(
262
+ f"documents must be 2-D [total_d_tokens, dim]; got shape {tuple(documents.shape)}"
263
+ )
264
+ if queries.shape[1] != documents.shape[1]:
265
+ raise ValueError(
266
+ "queries.dim and documents.dim must match; got "
267
+ f"{queries.shape[1]} vs {documents.shape[1]}"
268
+ )
269
+ if queries.dtype != documents.dtype:
270
+ raise TypeError(
271
+ "queries and documents must have the same dtype; got "
272
+ f"{queries.dtype} vs {documents.dtype}"
273
+ )
274
+
275
+ _check_float("queries", queries)
276
+ _check_float("documents", documents)
277
+ _check_index("query_offsets", query_offsets)
278
+ _check_index("document_offsets", document_offsets)
279
+ _check_index("pair_query_ids", pair_query_ids)
280
+ _check_index("pair_document_ids", pair_document_ids)
281
+
282
+ _check_same_device(
283
+ dict(
284
+ queries=queries,
285
+ query_offsets=query_offsets,
286
+ documents=documents,
287
+ document_offsets=document_offsets,
288
+ pair_query_ids=pair_query_ids,
289
+ pair_document_ids=pair_document_ids,
290
+ )
291
+ )
292
+
293
+ _check_pair_ids(pair_query_ids, pair_document_ids)
294
+
295
+ actual_max_q_len = _validate_packed_layout(
296
+ queries,
297
+ query_offsets,
298
+ documents,
299
+ document_offsets,
300
+ pair_query_ids,
301
+ pair_document_ids,
302
+ )
303
+ if max_q_len is None:
304
+ mql = actual_max_q_len
305
+ else:
306
+ if max_q_len <= 0:
307
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
308
+ if max_q_len < actual_max_q_len:
309
+ raise ValueError(
310
+ f"max_q_len ({max_q_len}) must be >= actual max query "
311
+ f"segment length ({actual_max_q_len})"
312
+ )
313
+ mql = int(max_q_len)
314
+
315
+ return ops.maxsim_forward(
316
+ queries.contiguous(),
317
+ query_offsets.contiguous(),
318
+ documents.contiguous(),
319
+ document_offsets.contiguous(),
320
+ pair_query_ids.contiguous(),
321
+ pair_document_ids.contiguous(),
322
+ mql,
323
+ )
324
+
325
+ def _check_packed_shapes_for_argmax(
326
+ queries: torch.Tensor,
327
+ query_offsets: torch.Tensor,
328
+ documents: torch.Tensor,
329
+ document_offsets: torch.Tensor,
330
+ pair_query_ids: torch.Tensor,
331
+ pair_document_ids: torch.Tensor,
332
+ ) -> None:
333
+ if queries.dim() != 2:
334
+ raise ValueError(
335
+ f"queries must be 2-D; got shape {tuple(queries.shape)}"
336
+ )
337
+ if documents.dim() != 2:
338
+ raise ValueError(
339
+ f"documents must be 2-D; got shape {tuple(documents.shape)}"
340
+ )
341
+ if queries.shape[1] != documents.shape[1]:
342
+ raise ValueError(
343
+ f"queries.D ({queries.shape[1]}) must match documents.D "
344
+ f"({documents.shape[1]})"
345
+ )
346
+ if queries.dtype != documents.dtype:
347
+ raise TypeError(
348
+ f"queries and documents must share dtype; got {queries.dtype} "
349
+ f"vs {documents.dtype}"
350
+ )
351
+ _check_float("queries", queries)
352
+ _check_float("documents", documents)
353
+ _check_index("query_offsets", query_offsets)
354
+ _check_index("document_offsets", document_offsets)
355
+ _check_index("pair_query_ids", pair_query_ids)
356
+ _check_index("pair_document_ids", pair_document_ids)
357
+ _check_same_device(dict(
358
+ queries=queries, query_offsets=query_offsets,
359
+ documents=documents, document_offsets=document_offsets,
360
+ pair_query_ids=pair_query_ids,
361
+ pair_document_ids=pair_document_ids,
362
+ ))
363
+ _check_pair_ids(pair_query_ids, pair_document_ids)
364
+
365
+
366
+ def score_pairs_packed_with_argmax(
367
+ queries: torch.Tensor,
368
+ query_offsets: torch.Tensor,
369
+ documents: torch.Tensor,
370
+ document_offsets: torch.Tensor,
371
+ pair_query_ids: torch.Tensor,
372
+ pair_document_ids: torch.Tensor,
373
+ *,
374
+ max_q_len: int | None = None,
375
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
376
+ """Like :func:`score_pairs_packed` but also returns the per-q-tok argmax
377
+ positions.
378
+
379
+ Returns:
380
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[num_pairs]``; ``argmax``
381
+ is int32 ``[num_pairs, max_q_len]``. Slots beyond a pair's Lq are 0.
382
+ First-index-wins tiebreak.
383
+ """
384
+ _check_packed_shapes_for_argmax(
385
+ queries, query_offsets, documents, document_offsets,
386
+ pair_query_ids, pair_document_ids,
387
+ )
388
+ actual_max_q_len = _validate_packed_layout(
389
+ queries, query_offsets, documents, document_offsets,
390
+ pair_query_ids, pair_document_ids,
391
+ )
392
+ if max_q_len is None:
393
+ mql = actual_max_q_len
394
+ else:
395
+ if max_q_len <= 0:
396
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
397
+ if max_q_len < actual_max_q_len:
398
+ raise ValueError(
399
+ f"max_q_len ({max_q_len}) must be >= actual max query "
400
+ f"segment length ({actual_max_q_len})"
401
+ )
402
+ mql = int(max_q_len)
403
+ return ops.maxsim_packed_forward_with_argmax(
404
+ queries.contiguous(),
405
+ query_offsets.contiguous(),
406
+ documents.contiguous(),
407
+ document_offsets.contiguous(),
408
+ pair_query_ids.contiguous(),
409
+ pair_document_ids.contiguous(),
410
+ mql,
411
+ )
412
+
413
+
414
+ class _ScorePairsPacked(torch.autograd.Function):
415
+ """Differentiable wrapper for the packed forward+argmax / backward
416
+ pair. Same fp32-grad convention as the padded and contrastive autograd
417
+ Functions."""
418
+
419
+ @staticmethod
420
+ def forward(
421
+ ctx,
422
+ queries: torch.Tensor,
423
+ query_offsets: torch.Tensor,
424
+ documents: torch.Tensor,
425
+ document_offsets: torch.Tensor,
426
+ pair_query_ids: torch.Tensor,
427
+ pair_document_ids: torch.Tensor,
428
+ max_q_len: int,
429
+ ) -> torch.Tensor:
430
+ q_c = queries.contiguous()
431
+ d_c = documents.contiguous()
432
+ qoff = query_offsets.contiguous()
433
+ doff = document_offsets.contiguous()
434
+ qids = pair_query_ids.contiguous()
435
+ dids = pair_document_ids.contiguous()
436
+ mql = int(max_q_len)
437
+
438
+ scores, argmax = ops.maxsim_packed_forward_with_argmax(
439
+ q_c, qoff, d_c, doff, qids, dids, mql
440
+ )
441
+ ctx.save_for_backward(q_c, qoff, d_c, doff, qids, dids, argmax)
442
+ ctx.max_q_len = mql
443
+ return scores
444
+
445
+ @staticmethod
446
+ def backward(ctx, dscore: torch.Tensor):
447
+ q_c, qoff, d_c, doff, qids, dids, argmax = ctx.saved_tensors
448
+ dscore_f32 = dscore.contiguous().to(torch.float32)
449
+ dq, dd = ops.maxsim_packed_backward(
450
+ dscore_f32, q_c, qoff, d_c, doff, qids, dids, argmax,
451
+ ctx.max_q_len,
452
+ )
453
+ # Only queries/documents are differentiable.
454
+ return dq, None, dd, None, None, None, None
455
+
456
+
457
+ def score_pairs_packed_train(
458
+ queries: torch.Tensor,
459
+ query_offsets: torch.Tensor,
460
+ documents: torch.Tensor,
461
+ document_offsets: torch.Tensor,
462
+ pair_query_ids: torch.Tensor,
463
+ pair_document_ids: torch.Tensor,
464
+ *,
465
+ max_q_len: int | None = None,
466
+ ) -> torch.Tensor:
467
+ """Differentiable packed MaxSim — the training entry point.
468
+
469
+ Same forward semantics as :func:`score_pairs_packed` but plugged into
470
+ PyTorch autograd. Gradients are fp32 (cast at the call site if needed).
471
+
472
+ Args:
473
+ queries: ``[total_q_tokens, dim]``. ``requires_grad=True`` to
474
+ receive grads.
475
+ documents: ``[total_d_tokens, dim]``. Same.
476
+ query_offsets / document_offsets / pair_query_ids / pair_document_ids:
477
+ non-differentiable layout tensors.
478
+ max_q_len: optional pre-computed max query segment length. When
479
+ provided it is checked against ``query_offsets`` before launch; it
480
+ must be at least the actual maximum query segment length.
481
+
482
+ Returns:
483
+ ``[num_pairs]`` fp32 tensor of MaxSim scores, end-to-end
484
+ differentiable.
485
+ """
486
+ _check_packed_shapes_for_argmax(
487
+ queries, query_offsets, documents, document_offsets,
488
+ pair_query_ids, pair_document_ids,
489
+ )
490
+ actual_max_q_len = _validate_packed_layout(
491
+ queries, query_offsets, documents, document_offsets,
492
+ pair_query_ids, pair_document_ids,
493
+ )
494
+ if max_q_len is None:
495
+ mql = actual_max_q_len
496
+ else:
497
+ if max_q_len <= 0:
498
+ raise ValueError(f"max_q_len must be > 0; got {max_q_len}")
499
+ if max_q_len < actual_max_q_len:
500
+ raise ValueError(
501
+ f"max_q_len ({max_q_len}) must be >= actual max query "
502
+ f"segment length ({actual_max_q_len})"
503
+ )
504
+ mql = int(max_q_len)
505
+ return _ScorePairsPacked.apply(
506
+ queries, query_offsets, documents, document_offsets,
507
+ pair_query_ids, pair_document_ids, mql,
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Padded -> packed conversion + ergonomic wrapper.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _pack_padded(
517
+ queries: torch.Tensor,
518
+ documents: torch.Tensor,
519
+ query_lengths: torch.Tensor,
520
+ doc_lengths: torch.Tensor,
521
+ *,
522
+ validate: bool = False,
523
+ ) -> Tuple[
524
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor,
525
+ torch.Tensor, torch.Tensor, int,
526
+ ]:
527
+ """Convert padded ``[B, Lq, D]`` / ``[B, C, Ld, D]`` inputs to packed form.
528
+
529
+ Returns:
530
+ ``(queries_packed, query_offsets, documents_packed, document_offsets,
531
+ pair_query_ids, pair_document_ids, max_q_len_int)``.
532
+ ``max_q_len_int`` is a Python int suitable for passing through to
533
+ :func:`score_pairs_packed`'s ``max_q_len`` argument. The public API
534
+ still checks it against ``query_offsets`` before launching the native
535
+ kernel.
536
+
537
+ Pair ordering is row-major ``(batch, candidate)`` so the output of the
538
+ packed call can be reshaped to ``[B, C]``.
539
+
540
+ ``validate=False`` (default) skips the ``.any().item()`` length checks --
541
+ those would force a device->host sync on every call. Pass
542
+ ``validate=True`` to enable them (e.g. for first-time / debug usage).
543
+ """
544
+ if queries.dim() != 3:
545
+ raise ValueError(
546
+ f"queries must be 3-D [B, Lq, D]; got shape {tuple(queries.shape)}"
547
+ )
548
+ if documents.dim() != 4:
549
+ raise ValueError(
550
+ f"documents must be 4-D [B, C, Ld, D]; got shape {tuple(documents.shape)}"
551
+ )
552
+ B, Lq_max, D = queries.shape
553
+ Bd, C, Ld_max, Dd = documents.shape
554
+ if B != Bd:
555
+ raise ValueError(
556
+ f"batch dim mismatch: queries B={B} but documents B={Bd}"
557
+ )
558
+ if D != Dd:
559
+ raise ValueError(
560
+ f"embedding dim mismatch: queries D={D} but documents D={Dd}"
561
+ )
562
+ if query_lengths.shape != (B,):
563
+ raise ValueError(
564
+ f"query_lengths must have shape [B={B}]; got {tuple(query_lengths.shape)}"
565
+ )
566
+ if doc_lengths.shape != (B, C):
567
+ raise ValueError(
568
+ f"doc_lengths must have shape [B={B}, C={C}]; got {tuple(doc_lengths.shape)}"
569
+ )
570
+
571
+ device = queries.device
572
+ qlen = query_lengths.to(device=device, dtype=torch.int32)
573
+ dlen = doc_lengths.to(device=device, dtype=torch.int32)
574
+
575
+ if validate:
576
+ # These three checks each force a device->host sync.
577
+ if (qlen <= 0).any().item():
578
+ raise ValueError("query_lengths must all be > 0")
579
+ if (dlen <= 0).any().item():
580
+ raise ValueError("doc_lengths must all be > 0")
581
+ if (qlen > Lq_max).any().item() or (dlen > Ld_max).any().item():
582
+ raise ValueError("a length exceeds the padded extent")
583
+
584
+ # Vectorised pack: build a boolean mask of valid (non-padded) positions
585
+ # and gather them in row-major order. The same row-major order is used
586
+ # for the offsets so they stay consistent.
587
+ q_arange = torch.arange(Lq_max, device=device, dtype=torch.int32)
588
+ q_mask = q_arange[None, :] < qlen[:, None] # [B, Lq_max]
589
+ queries_packed = queries.reshape(B * Lq_max, D)[q_mask.reshape(-1)]
590
+
591
+ d_arange = torch.arange(Ld_max, device=device, dtype=torch.int32)
592
+ d_mask = d_arange[None, None, :] < dlen[:, :, None] # [B, C, Ld_max]
593
+ documents_packed = documents.reshape(B * C * Ld_max, D)[d_mask.reshape(-1)]
594
+
595
+ query_offsets = torch.zeros(B + 1, dtype=torch.int32, device=device)
596
+ query_offsets[1:] = qlen.cumsum(0)
597
+
598
+ document_offsets = torch.zeros(B * C + 1, dtype=torch.int32, device=device)
599
+ document_offsets[1:] = dlen.reshape(-1).cumsum(0)
600
+
601
+ # Pair ordering: (b, c) -> pair index b * C + c, query id b, doc id b * C + c.
602
+ pair_indices = torch.arange(B * C, dtype=torch.int32, device=device)
603
+ pair_query_ids = (pair_indices // C).contiguous()
604
+ pair_document_ids = pair_indices.contiguous()
605
+
606
+ # One sync to pull max query length to CPU so the kernel can skip its own
607
+ # validation pass. This is unavoidable today because we need an int to
608
+ # size threadgroup memory; doing it here amortises one sync against the
609
+ # four the C++ kernel would otherwise do.
610
+ max_q_len_int = int(qlen.max().item())
611
+
612
+ return (
613
+ queries_packed,
614
+ query_offsets,
615
+ documents_packed,
616
+ document_offsets,
617
+ pair_query_ids,
618
+ pair_document_ids,
619
+ max_q_len_int,
620
+ )
621
+
622
+
623
+ def score_candidates_padded(
624
+ queries: torch.Tensor,
625
+ documents: torch.Tensor,
626
+ query_lengths: torch.Tensor,
627
+ doc_lengths: torch.Tensor,
628
+ ) -> torch.Tensor:
629
+ """Compute MaxSim scores directly on padded inputs.
630
+
631
+ This is the recommended high-throughput entry point: it dispatches to a
632
+ dedicated Metal kernel that reads ``queries`` and ``documents`` in place
633
+ via padded strides, so there's no pack/gather or ``cumsum``. The Python
634
+ wrapper validates that the real lengths fit inside the padded extents
635
+ before launching the kernel.
636
+
637
+ Args:
638
+ queries: ``[B, Lq, dim]``.
639
+ documents: ``[B, C, Ld, dim]``.
640
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
641
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
642
+
643
+ Returns:
644
+ ``[B, C]`` fp32 tensor of MaxSim scores on the same device.
645
+ """
646
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
647
+ queries, documents, query_lengths, doc_lengths
648
+ )
649
+
650
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
651
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
652
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
653
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
654
+
655
+ flat = ops.maxsim_padded_forward(
656
+ queries_flat,
657
+ qlen,
658
+ documents_flat,
659
+ dlen,
660
+ int(Lq_max),
661
+ int(Ld_max),
662
+ int(C),
663
+ )
664
+ return flat.view(B, C)
665
+
666
+
667
+ def score_candidates_padded_with_argmax(
668
+ queries: torch.Tensor,
669
+ documents: torch.Tensor,
670
+ query_lengths: torch.Tensor,
671
+ doc_lengths: torch.Tensor,
672
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
673
+ """Like :func:`score_candidates_padded` but also returns argmax positions
674
+ per query token. This is the forward pass kernel needed for backward /
675
+ training: the int32 argmax buffer is the small saved-for-backward
676
+ payload (95-205x smaller than materialising ``[Lq, Ld]``).
677
+
678
+ Args:
679
+ queries: ``[B, Lq, dim]``.
680
+ documents: ``[B, C, Ld, dim]``.
681
+ query_lengths: ``[B]`` (int32 / int64). Each value in ``(0, Lq]``.
682
+ doc_lengths: ``[B, C]`` (int32 / int64). Each value in ``(0, Ld]``.
683
+
684
+ Returns:
685
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[B, C]``; ``argmax`` is
686
+ int32 ``[B, C, Lq]`` with the document-token index that won the
687
+ max per query token. PyTorch's first-index-wins tiebreak applies;
688
+ slots beyond ``query_lengths[b]`` are 0.
689
+ """
690
+ B, C, Lq_max, Ld_max, D = _check_padded_shapes(
691
+ queries, documents, query_lengths, doc_lengths
692
+ )
693
+
694
+ queries_flat = queries.reshape(B * Lq_max, D).contiguous()
695
+ documents_flat = documents.reshape(B * C * Ld_max, D).contiguous()
696
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
697
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
698
+
699
+ flat_scores, flat_argmax = ops.maxsim_padded_forward_with_argmax(
700
+ queries_flat,
701
+ qlen,
702
+ documents_flat,
703
+ dlen,
704
+ int(Lq_max),
705
+ int(Ld_max),
706
+ int(C),
707
+ )
708
+ return flat_scores.view(B, C), flat_argmax.view(B, C, Lq_max)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Training-mode (differentiable) entry point.
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ class _ScoreCandidatesPadded(torch.autograd.Function):
717
+ """Differentiable wrapper around the padded forward+argmax / backward
718
+ kernel pair. Forward computes scores and saves (q_flat, d_flat, qlen,
719
+ dlen, argmax_flat, dims) for backward; backward routes the incoming
720
+ grad via the saved argmax.
721
+
722
+ Gradient dtype is always fp32 (the kernel accumulates atomically in
723
+ fp32 to avoid the bf16-atomic-only-on-Hopper hardware gap). Returning
724
+ fp32 grads lines up with how AMP / mixed-precision training stacks
725
+ expect to receive gradients.
726
+ """
727
+
728
+ @staticmethod
729
+ def forward(
730
+ ctx,
731
+ queries: torch.Tensor, # [B, Lq, D]
732
+ documents: torch.Tensor, # [B, C, Ld, D]
733
+ query_lengths: torch.Tensor, # [B]
734
+ doc_lengths: torch.Tensor, # [B, C]
735
+ ) -> torch.Tensor:
736
+ B, Lq, D = queries.shape
737
+ _, C, Ld, _ = documents.shape
738
+ q_flat = queries.reshape(B * Lq, D).contiguous()
739
+ d_flat = documents.reshape(B * C * Ld, D).contiguous()
740
+ qlen = query_lengths.to(dtype=torch.int32).contiguous()
741
+ dlen = doc_lengths.reshape(B * C).to(dtype=torch.int32).contiguous()
742
+
743
+ scores_flat, argmax_flat = ops.maxsim_padded_forward_with_argmax(
744
+ q_flat, qlen, d_flat, dlen, int(Lq), int(Ld), int(C)
745
+ )
746
+
747
+ # Save for backward.
748
+ ctx.save_for_backward(q_flat, d_flat, qlen, dlen, argmax_flat)
749
+ ctx.shapes = (B, C, Lq, Ld, D)
750
+ return scores_flat.view(B, C)
751
+
752
+ @staticmethod
753
+ def backward(ctx, dscore: torch.Tensor):
754
+ B, C, Lq, Ld, D = ctx.shapes
755
+ q_flat, d_flat, qlen, dlen, argmax_flat = ctx.saved_tensors
756
+ dscore_flat = dscore.reshape(B * C).contiguous().to(torch.float32)
757
+
758
+ dq_flat, dd_flat = ops.maxsim_padded_backward(
759
+ dscore_flat,
760
+ q_flat,
761
+ d_flat,
762
+ qlen,
763
+ dlen,
764
+ argmax_flat,
765
+ int(Lq),
766
+ int(Ld),
767
+ int(C),
768
+ )
769
+ # Return one grad per forward input (None for non-tensor / non-
770
+ # differentiable inputs query_lengths and doc_lengths).
771
+ return dq_flat.view(B, Lq, D), dd_flat.view(B, C, Ld, D), None, None
772
+
773
+
774
+ def score_candidates_padded_train(
775
+ queries: torch.Tensor,
776
+ documents: torch.Tensor,
777
+ query_lengths: torch.Tensor,
778
+ doc_lengths: torch.Tensor,
779
+ ) -> torch.Tensor:
780
+ """Differentiable padded MaxSim — the training entry point.
781
+
782
+ Same forward semantics as :func:`score_candidates_padded` but plugged
783
+ into PyTorch autograd so ``scores.sum().backward()`` (or any other
784
+ downstream loss) propagates gradients back to ``queries`` and
785
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
786
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
787
+
788
+ Args:
789
+ queries: ``[B, Lq, dim]``. Must have ``requires_grad=True`` to
790
+ receive gradients.
791
+ documents: ``[B, C, Ld, dim]``. Same.
792
+ query_lengths: ``[B]`` (int32 / int64). Non-differentiable.
793
+ doc_lengths: ``[B, C]`` (int32 / int64). Non-differentiable.
794
+
795
+ Returns:
796
+ ``[B, C]`` fp32 tensor of MaxSim scores, end-to-end differentiable.
797
+ """
798
+ _check_padded_shapes(queries, documents, query_lengths, doc_lengths)
799
+ return _ScoreCandidatesPadded.apply(
800
+ queries, documents, query_lengths, doc_lengths
801
+ )
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Contrastive (all-pairs) API: every query in `queries[Nq, Lq, D]` is scored
806
+ # against every doc in `documents[total_d_tokens, D]` packed via document_offsets.
807
+ # This is the in-batch contrastive layout standard ColBERT-style training
808
+ # loops use (flash-maxsim's killer feature).
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def _check_contrastive_shapes(
813
+ queries: torch.Tensor,
814
+ documents: torch.Tensor,
815
+ document_offsets: torch.Tensor,
816
+ ) -> None:
817
+ _check_float("queries", queries)
818
+ _check_float("documents", documents)
819
+ _check_index("document_offsets", document_offsets)
820
+ if queries.dtype != documents.dtype:
821
+ raise TypeError(
822
+ f"queries and documents must share dtype; "
823
+ f"got {queries.dtype} and {documents.dtype}"
824
+ )
825
+ if queries.dim() != 3:
826
+ raise ValueError(
827
+ f"queries must be [Nq, Lq, D]; got shape {tuple(queries.shape)}"
828
+ )
829
+ if documents.dim() != 2:
830
+ raise ValueError(
831
+ f"documents must be [total_d_tokens, D] (packed); got shape "
832
+ f"{tuple(documents.shape)}"
833
+ )
834
+ if document_offsets.dim() != 1:
835
+ raise ValueError(
836
+ f"document_offsets must be 1-D [Nb+1]; got shape {tuple(document_offsets.shape)}"
837
+ )
838
+ if queries.shape[2] != documents.shape[1]:
839
+ raise ValueError(
840
+ f"queries.D ({queries.shape[2]}) must match documents.D "
841
+ f"({documents.shape[1]})"
842
+ )
843
+ if queries.shape[1] <= 0:
844
+ raise ValueError(f"Lq must be > 0; got {queries.shape[1]}")
845
+ if queries.shape[2] <= 0:
846
+ raise ValueError(f"dim must be > 0; got {queries.shape[2]}")
847
+ if document_offsets.numel() < 2:
848
+ raise ValueError(
849
+ f"document_offsets must have length >= 2 (at least one doc); "
850
+ f"got {document_offsets.numel()}"
851
+ )
852
+ _check_same_device(
853
+ dict(queries=queries, documents=documents, document_offsets=document_offsets)
854
+ )
855
+ # Invariant checks on document_offsets. These pay one host sync, but catching
856
+ # bad offsets here gives a clear error instead of a kernel crash or
857
+ # silent wrong-answer.
858
+ cu_cpu = document_offsets.detach().to(device="cpu", dtype=torch.int64).tolist()
859
+ total_d_tokens = documents.shape[0]
860
+ if cu_cpu[0] != 0:
861
+ raise ValueError(
862
+ f"document_offsets[0] must equal 0 (CSR offset convention); "
863
+ f"got {cu_cpu[0]}"
864
+ )
865
+ if cu_cpu[-1] != total_d_tokens:
866
+ raise ValueError(
867
+ f"document_offsets[-1] ({cu_cpu[-1]}) must equal total doc tokens "
868
+ f"({total_d_tokens}). The packed documents tensor and the "
869
+ f"offsets must agree on the total length."
870
+ )
871
+ for i in range(len(cu_cpu) - 1):
872
+ if cu_cpu[i + 1] <= cu_cpu[i]:
873
+ raise ValueError(
874
+ f"document_offsets must be strictly increasing; "
875
+ f"got document_offsets[{i + 1}] ({cu_cpu[i + 1]}) <= "
876
+ f"document_offsets[{i}] ({cu_cpu[i]})"
877
+ )
878
+
879
+
880
+ def score_contrastive(
881
+ queries: torch.Tensor, # [Nq, Lq, D]
882
+ documents: torch.Tensor, # [total_d_tokens, D]
883
+ document_offsets: torch.Tensor, # [Nb + 1]
884
+ ) -> torch.Tensor:
885
+ """Contrastive MaxSim: score every query against every doc.
886
+
887
+ Args:
888
+ queries: ``[Nq, Lq, dim]`` fp16 / bf16.
889
+ documents: ``[total_d_tokens, dim]`` packed, same dtype as queries.
890
+ document_offsets: ``[Nb + 1]`` int32 cumulative doc-length offsets.
891
+
892
+ Returns:
893
+ ``[Nq, Nb]`` fp32 score tensor.
894
+ """
895
+ _check_contrastive_shapes(queries, documents, document_offsets)
896
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
897
+ return ops.maxsim_contrastive_forward(
898
+ queries.contiguous(),
899
+ documents.contiguous(),
900
+ document_offsets_i32,
901
+ )
902
+
903
+
904
+ def score_contrastive_with_argmax(
905
+ queries: torch.Tensor,
906
+ documents: torch.Tensor,
907
+ document_offsets: torch.Tensor,
908
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
909
+ """Like :func:`score_contrastive` but also returns the per-q-tok argmax
910
+ positions.
911
+
912
+ Returns:
913
+ ``(scores, argmax)`` — ``scores`` is fp32 ``[Nq, Nb]``; ``argmax``
914
+ is int32 ``[Nq, Nb, Lq]``. First-index-wins tiebreak.
915
+ """
916
+ _check_contrastive_shapes(queries, documents, document_offsets)
917
+ document_offsets_i32 = document_offsets.to(dtype=torch.int32).contiguous()
918
+ return ops.maxsim_contrastive_forward_with_argmax(
919
+ queries.contiguous(),
920
+ documents.contiguous(),
921
+ document_offsets_i32,
922
+ )
923
+
924
+
925
+ class _ScoreContrastive(torch.autograd.Function):
926
+ """Differentiable wrapper around the contrastive forward+argmax /
927
+ backward kernel pair. The native kernels accumulate gradients in fp32.
928
+ """
929
+
930
+ @staticmethod
931
+ def forward(
932
+ ctx,
933
+ queries: torch.Tensor, # [Nq, Lq, D]
934
+ documents: torch.Tensor, # [total_d_tokens, D]
935
+ document_offsets: torch.Tensor, # [Nb + 1]
936
+ ) -> torch.Tensor:
937
+ q_c = queries.contiguous()
938
+ d_c = documents.contiguous()
939
+ cu = document_offsets.to(dtype=torch.int32).contiguous()
940
+
941
+ scores, argmax = ops.maxsim_contrastive_forward_with_argmax(
942
+ q_c, d_c, cu
943
+ )
944
+ ctx.save_for_backward(q_c, d_c, cu, argmax)
945
+ return scores
946
+
947
+ @staticmethod
948
+ def backward(ctx, dscore: torch.Tensor):
949
+ q_c, d_c, cu, argmax = ctx.saved_tensors
950
+ dscore_f32 = dscore.contiguous().to(torch.float32)
951
+ dq, dd = ops.maxsim_contrastive_backward(
952
+ dscore_f32, q_c, d_c, cu, argmax
953
+ )
954
+ return dq, dd, None
955
+
956
+
957
+ def score_contrastive_train(
958
+ queries: torch.Tensor,
959
+ documents: torch.Tensor,
960
+ document_offsets: torch.Tensor,
961
+ ) -> torch.Tensor:
962
+ """Differentiable contrastive MaxSim — the training entry point.
963
+
964
+ Same forward semantics as :func:`score_contrastive` but plugged into
965
+ PyTorch autograd so ``scores.sum().backward()`` (or any downstream
966
+ loss like InfoNCE / triplet) propagates gradients to ``queries`` and
967
+ ``documents``. The kernel accumulates gradients in fp32; PyTorch stores
968
+ ``.grad`` in the input tensor's dtype for fp16/bf16 inputs.
969
+
970
+ Args:
971
+ queries: ``[Nq, Lq, dim]``. ``requires_grad=True`` to receive grads.
972
+ documents: ``[total_d_tokens, dim]`` packed. Same.
973
+ document_offsets: ``[Nb + 1]`` int32. Non-differentiable.
974
+
975
+ Returns:
976
+ ``[Nq, Nb]`` fp32 tensor of contrastive MaxSim scores.
977
+ """
978
+ _check_contrastive_shapes(queries, documents, document_offsets)
979
+ return _ScoreContrastive.apply(queries, documents, document_offsets)
build/torch212-cxx11-cu132-x86_64-linux/_maxsim_cuda_bd13740.abi3.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b924b6b5d503bdf171e064c7e0a479b1e2622a09129bb56691846c62429cefb3
3
+ size 4205112