Skip to content

Commit 05a88d9

Browse files
authored
Support compiled string vocabulary comparison and alignment (#1230)
# Summary > Fix three compilation failures in string-category comparison and alignment. # Description of the change > Found while compiling KumoTabular's default recipe on PyTorch 2.10–2.14: ```python model.fit(x, y) predict = torch.compile(model.predict, backend="inductor", fullgraph=False) predict(x_query) ``` > | Failure point | Error | Fix | > |---|---|---| > | String equality reads `valid` / `data_offset` properties during tracing | `DataDependentOutputException: aten._local_scalar_dense.default` | Call SDM's equality implementation directly and use shared functions instead of property access. | > | String concatenation reads `data_offset` during tracing | Same scalar-extraction error | Call the same data/offset function directly. | > | Tracing reaches the external vocabulary join | `RuntimeError: .numpy() is not supported for tensor subclasses.` | Register the existing lookup as one custom operation, with output shape defined for compilation. | > > Packing/unpacking is supporting code: pass ordinary tensors into the custom operation, then reconstruct string views inside it. StringTensor does not dispatch this operation directly. Existing Arrow/cuDF joins, public properties, missing values and unknown-category code `-1` are preserved. # Validation > CPU Inductor, Python 3.12; default recipe with four estimators, mixed Iris data, reduced model and synthetic weights. > > | Check | Result | > |---|---| > | Focused suites, PyTorch 2.10–2.14 | Passed | > | Compiled default `fit` / `predict` / uncached `model()`, fresh caches | Pass on all five; maximum eager difference 2.98e-8 | > | Strict string lookup | Covered cases pass on 2.11–2.14; 2.10 sliced inputs remain blocked | > > Equality still requires graph breaks; this does not establish full-method `fullgraph=True` support. Separate 2.13 compiler-cache reuse checks can raise `RecursionError`. > > Additional PyTorch 2.14/L4 testing uses the full pretrained model and default recipe: compiled `fit`/`predict` execute, but BF16 numerical parity is not established. Uncached `model()` also needs the separate metadata-guard fix. cuDF was unavailable; joins used Arrow.
1 parent baba30e commit 05a88d9

4 files changed

Lines changed: 228 additions & 76 deletions

File tree

‎sdm/processing/categorical/align.py‎

Lines changed: 132 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from sdm.processing import EnsembleProcessor
2020
from sdm.relational.join import join_index
2121
from sdm.tensor.categorical import _iter_categories
22+
from sdm.tensor.var_len import _cat, _contiguous
2223

2324
_UNSIGNED_DTYPES = frozenset({torch.uint16, torch.uint32, torch.uint64})
2425

@@ -339,56 +340,19 @@ def _string_category_lookups(
339340
) -> tuple[Tensor, ...]:
340341
if not input_categories:
341342
return ()
342-
343-
# Pair IDs for each input category: (total_input_categories,).
344-
left_pair = torch.cat(
345-
[
346-
codes.new_full((categories.numel(),), pair_index)
347-
for pair_index, categories in enumerate(input_categories)
348-
]
349-
)
350-
# Pair IDs for each fitted category: (total_fitted_categories,).
351-
right_pair = torch.cat(
352-
[
353-
codes.new_full((categories.numel(),), pair_index)
354-
for pair_index, categories in enumerate(fitted_categories)
355-
]
356-
)
357-
left_index, right_index = join_index(
358-
left_table=TableTensor(
359-
columns={"id": ("pair", "value")},
360-
id=ColumnarTensor(
361-
(left_pair, torch.cat(input_categories)),
362-
),
363-
),
364-
right_table=TableTensor(
365-
columns={"id": ("pair", "value")},
366-
id=ColumnarTensor(
367-
(right_pair, torch.cat(fitted_categories)),
368-
),
369-
),
370-
left_keys=["pair", "value"],
371-
right_keys=["pair", "value"],
372-
dtype=codes.dtype,
373-
)
374-
fitted_codes = torch.cat(
375-
[
376-
torch.arange(
377-
categories.numel(),
378-
dtype=codes.dtype,
379-
device=codes.device,
380-
)
381-
for categories in fitted_categories
382-
]
383-
)
384-
# Fitted code for each input category: (total_input_categories,).
385-
lookup = codes.new_full(
386-
(sum(categories.numel() for categories in input_categories),),
387-
-1,
343+
# StringTensor does not handle this custom op. Pass its underlying
344+
# tensors and layout without copying the string data.
345+
inputs, input_layout = _flatten_strings(input_categories)
346+
fitted, fitted_layout = _flatten_strings(fitted_categories)
347+
lookup = _string_category_lookup(
348+
inputs=inputs,
349+
input_layout=input_layout,
350+
fitted=fitted,
351+
fitted_layout=fitted_layout,
352+
codes=codes,
388353
)
389-
lookup[left_index] = fitted_codes[right_index]
390354
return lookup.split(
391-
[categories.numel() for categories in input_categories]
355+
[category.numel() for category in input_categories]
392356
)
393357

394358
@staticmethod
@@ -535,3 +499,123 @@ def __repr__(self, *, indent: int = 0) -> str:
535499
return (
536500
f"{' ' * indent}{self.__class__.__name__}({', '.join(arguments)})"
537501
)
502+
503+
504+
def _flatten_strings(
505+
categories: tuple[Tensor, ...],
506+
) -> tuple[list[Tensor | None], list[int]]:
507+
"""Pack vocabulary tensors and their (length, stride, offset) metadata."""
508+
tensors: list[Tensor | None] = []
509+
layout: list[int] = []
510+
for category in categories:
511+
category = cast(StringTensor, category)
512+
tensors.extend((category._data, category._offset, category._valid))
513+
layout.extend(
514+
(
515+
category.numel(),
516+
category.stride(0),
517+
category._logical_storage_offset,
518+
)
519+
)
520+
return tensors, layout
521+
522+
523+
def _unflatten_strings(
524+
tensors: list[Tensor | None], layout: list[int]
525+
) -> tuple[StringTensor, ...]:
526+
"""Rebuild string views over the same tensors for the join."""
527+
return tuple(
528+
StringTensor(
529+
data=cast(Tensor, tensors[3 * i]),
530+
offset=cast(Tensor, tensors[3 * i + 1]),
531+
valid=tensors[3 * i + 2],
532+
size=(layout[3 * i],),
533+
stride=(layout[3 * i + 1],),
534+
storage_offset=layout[3 * i + 2],
535+
)
536+
for i in range(len(layout) // 3)
537+
)
538+
539+
540+
def _concatenate_strings(categories: tuple[StringTensor, ...]) -> StringTensor:
541+
# Calling torch.cat here fails on StringTensor during compiled execution.
542+
# Call SDM's concatenation implementation directly.
543+
contiguous = tuple(
544+
category if category.is_contiguous() else _contiguous(category)
545+
for category in categories
546+
)
547+
return cast(StringTensor, _cat(contiguous))
548+
549+
550+
@torch.library.custom_op("sdm::string_category_lookup", mutates_args=())
551+
def _string_category_lookup(
552+
inputs: list[Tensor | None],
553+
input_layout: list[int],
554+
fitted: list[Tensor | None],
555+
fitted_layout: list[int],
556+
codes: Tensor,
557+
) -> Tensor:
558+
"""Keep the backend join's dynamic row pairs inside a fixed-size lookup."""
559+
input_categories = _unflatten_strings(inputs, input_layout)
560+
fitted_categories = _unflatten_strings(fitted, fitted_layout)
561+
# Pair IDs for each input category: (total_input_categories,).
562+
left_pair = torch.cat(
563+
[
564+
codes.new_full((categories.numel(),), pair_index)
565+
for pair_index, categories in enumerate(input_categories)
566+
]
567+
)
568+
# Pair IDs for each fitted category: (total_fitted_categories,).
569+
right_pair = torch.cat(
570+
[
571+
codes.new_full((categories.numel(),), pair_index)
572+
for pair_index, categories in enumerate(fitted_categories)
573+
]
574+
)
575+
left_index, right_index = join_index(
576+
left_table=TableTensor(
577+
columns={"id": ("pair", "value")},
578+
id=ColumnarTensor(
579+
(left_pair, _concatenate_strings(input_categories)),
580+
),
581+
),
582+
right_table=TableTensor(
583+
columns={"id": ("pair", "value")},
584+
id=ColumnarTensor(
585+
(right_pair, _concatenate_strings(fitted_categories)),
586+
),
587+
),
588+
left_keys=["pair", "value"],
589+
right_keys=["pair", "value"],
590+
dtype=codes.dtype,
591+
)
592+
fitted_codes = torch.cat(
593+
[
594+
torch.arange(
595+
categories.numel(),
596+
dtype=codes.dtype,
597+
device=codes.device,
598+
)
599+
for categories in fitted_categories
600+
]
601+
)
602+
# Fitted code for each input category: (total_input_categories,).
603+
lookup = codes.new_full(
604+
(sum(categories.numel() for categories in input_categories),),
605+
-1,
606+
)
607+
lookup[left_index] = fitted_codes[right_index]
608+
return lookup
609+
610+
611+
@_string_category_lookup.register_fake
612+
def _string_category_lookup_fake(
613+
inputs: list[Tensor | None],
614+
input_layout: list[int],
615+
fitted: list[Tensor | None],
616+
fitted_layout: list[int],
617+
codes: Tensor,
618+
) -> Tensor:
619+
# E.g. inputs ["red", "blue", "unknown"], fitted ["blue", "red"]:
620+
# real lookup [1, 0, -1]; the fake result only describes its shape (3,).
621+
return codes.new_empty((sum(input_layout[::3]),))

‎sdm/tensor/var_len.py‎

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -499,28 +499,21 @@ def flatten(value: Any) -> tuple[int, ...]:
499499

500500
# Properties ##############################################################
501501

502+
@property
503+
def _logical_storage_offset(self) -> int:
504+
"""Expose the logical view offset as traceable Python metadata."""
505+
# torch.compile can read this integer as an attribute, but rejects
506+
# the direct storage_offset() call on this tensor subclass.
507+
return cast(int, self.storage_offset())
508+
502509
@property
503510
def data_offset(self) -> tuple[Tensor, Tensor]:
504511
r"""Return contiguous data and normalized offsets.
505512
506513
Returns:
507514
``(data, offset)`` tuple.
508515
"""
509-
if not self.is_contiguous():
510-
raise RuntimeError(
511-
f"Can't access 'data_offset' for non-contiguous "
512-
f"{self.__class__.__name__!r}"
513-
)
514-
515-
start = int(self.storage_offset())
516-
offset = self._offset[start : start + self.numel() + 1]
517-
if self._data.numel() == 0:
518-
# An empty payload has only zero offsets, already normalized.
519-
return self._data, offset
520-
data = self._data[offset[0] : offset[-1]]
521-
if offset.is_cpu and int(offset[0]) == 0:
522-
return data, offset
523-
return data, offset - offset[0]
516+
return _data_offset(self)
524517

525518
@property
526519
def valid(self) -> Tensor | None:
@@ -530,15 +523,7 @@ def valid(self) -> Tensor | None:
530523
Boolean mask with shape ``self.size()`` indicating valid,
531524
non-null tensor elements, or ``None`` when all elements are valid.
532525
"""
533-
if self._valid is None:
534-
return None
535-
536-
return torch.as_strided(
537-
self._valid,
538-
size=self.size(),
539-
stride=self.stride(),
540-
storage_offset=int(self.storage_offset()),
541-
)
526+
return _valid(self)
542527

543528
@property
544529
def is_nullable(self) -> bool:
@@ -611,6 +596,13 @@ def __torch_function__(
611596
args: tuple[Any, ...] = (),
612597
kwargs: dict[str, Any] | None = None,
613598
) -> Any:
599+
if (
600+
(func is torch.equal or func is Tensor.equal)
601+
and not kwargs
602+
and len(args) == 2
603+
and type(args[0]) is type(args[1])
604+
):
605+
return cls.HANDLED_FUNCTIONS[aten.equal.default](*args)
614606
if func is torch.isfinite or func is Tensor.isfinite:
615607
assert isinstance(args[0], VarLenTensor)
616608
return _isfinite(args[0])
@@ -1007,14 +999,44 @@ def _isfinite(inp: VarLenTensor) -> Tensor:
1007999
return valid.clone()
10081000

10091001

1002+
def _valid(inp: VarLenTensor) -> Tensor | None:
1003+
if inp._valid is None:
1004+
return None
1005+
1006+
return torch.as_strided(
1007+
inp._valid,
1008+
size=inp.size(),
1009+
stride=inp.stride(),
1010+
storage_offset=int(inp.storage_offset()),
1011+
)
1012+
1013+
1014+
def _data_offset(inp: VarLenTensor) -> tuple[Tensor, Tensor]:
1015+
if not inp.is_contiguous():
1016+
raise RuntimeError(
1017+
f"Can't access 'data_offset' for non-contiguous "
1018+
f"{inp.__class__.__name__!r}"
1019+
)
1020+
1021+
start = int(inp.storage_offset())
1022+
offset = inp._offset[start : start + inp.numel() + 1]
1023+
if inp._data.numel() == 0:
1024+
# An empty payload has only zero offsets, already normalized.
1025+
return inp._data, offset
1026+
data = inp._data[offset[0] : offset[-1]]
1027+
if offset.is_cpu and int(offset[0]) == 0:
1028+
return data, offset
1029+
return data, offset - offset[0]
1030+
1031+
10101032
@VarLenTensor.implements(aten.equal.default)
10111033
def _equal(inp: VarLenTensor, other: Tensor) -> bool:
10121034
if inp.__class__ is not other.__class__:
10131035
return False
10141036
if inp.size() != other.size():
10151037
return False
10161038

1017-
valid1, valid2 = inp.valid, other.valid
1039+
valid1, valid2 = _valid(inp), _valid(other)
10181040
if valid1 is not None and valid2 is not None:
10191041
if not valid1.equal(valid2):
10201042
return False
@@ -1032,8 +1054,8 @@ def _equal(inp: VarLenTensor, other: Tensor) -> bool:
10321054
other.contiguous() if valid2 is None else other[valid2],
10331055
)
10341056

1035-
data1, offset1 = inp.data_offset
1036-
data2, offset2 = other.data_offset
1057+
data1, offset1 = _data_offset(inp)
1058+
data2, offset2 = _data_offset(other)
10371059

10381060
if not offset1.equal(offset2):
10391061
return False
@@ -1332,7 +1354,7 @@ def _cat(tensors: Sequence[Tensor], dim: int = 0) -> VarLenTensor:
13321354
tensors = tuple(
13331355
cast(VarLenTensor, tensor.contiguous()) for tensor in tensors
13341356
)
1335-
data_list, offsets = zip(*(tensor.data_offset for tensor in tensors))
1357+
data_list, offsets = zip(*(_data_offset(tensor) for tensor in tensors))
13361358
valid: Tensor | None = None
13371359
if any(tensor._valid is not None for tensor in tensors):
13381360
valid_views = tuple(

‎test/processing/categorical/test_align.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,3 +504,39 @@ def test_align_categories_ensemble_matches_member_fits(
504504
expected_query = reference.transform(query)
505505
assert query_output[member_id].equal(expected_query)
506506
assert fitted_query_output[member_id].equal(expected_query)
507+
508+
509+
def test_string_category_lookups_compile(compilation: None) -> None:
510+
fitted = (
511+
StringTensor.from_list(["a", "b"]),
512+
StringTensor.from_list(["a", "x"]),
513+
)
514+
codes = torch.empty((), dtype=torch.int32)
515+
516+
def lookups(categories: StringTensor) -> tuple[torch.Tensor, ...]:
517+
return AlignCategories._string_category_lookups(
518+
input_categories=(categories, categories),
519+
fitted_categories=fitted,
520+
codes=codes,
521+
)
522+
523+
compiled = torch.compile(lookups, backend="inductor", fullgraph=True)
524+
first, second = compiled(StringTensor.from_list(["b", "z", "a"]))
525+
torch.testing.assert_close(
526+
actual=first,
527+
expected=torch.tensor([1, -1, 0], dtype=codes.dtype),
528+
)
529+
torch.testing.assert_close(
530+
actual=second,
531+
expected=torch.tensor([-1, -1, 0], dtype=codes.dtype),
532+
)
533+
534+
first, second = compiled(StringTensor.from_list(["x", "z", "q"]))
535+
torch.testing.assert_close(
536+
actual=first,
537+
expected=torch.tensor([-1, -1, -1], dtype=codes.dtype),
538+
)
539+
torch.testing.assert_close(
540+
actual=second,
541+
expected=torch.tensor([1, -1, -1], dtype=codes.dtype),
542+
)

‎test/tensor/test_var_len.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,16 @@ def test_equal_allclose() -> None:
484484
assert torch.allclose(tensor, other[:, ::2])
485485

486486

487+
def test_compiled_equal_independent_views(compilation: None) -> None:
488+
values = [[1], [2, 3]]
489+
tensor = VarLenTensor.from_list(values)
490+
other = VarLenTensor.from_list([[0], *values, [0]])[1:-1]
491+
equal = torch.compile(torch.equal, backend="eager", fullgraph=False)
492+
493+
assert equal(tensor, other)
494+
assert not equal(tensor, VarLenTensor.from_list([[99], [2, 3]]))
495+
496+
487497
def test_isnan_isfinite() -> None:
488498
tensor = VarLenTensor.from_list([[1, 2], None, [3]])
489499
assert tensor.is_nullable

0 commit comments

Comments
 (0)