Skip to content

Commit ff87140

Browse files
committed
Record string length bounds for compiled string operations
Record an upper bound on every string's byte length when string columns are loaded from Arrow or cuDF, rounded up to 2**k - 1 bytes, and keep it through copies, views, concatenation, compilation and pickling. Compiled string operations can then size their buffers without reading lengths from the device.
1 parent 084aa7c commit ff87140

4 files changed

Lines changed: 126 additions & 4 deletions

File tree

‎sdm/tensor/string.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class StringTensor(VarLenTensor):
3434
size: The shape of the tensor.
3535
stride: The stride of the tensor.
3636
storage_offset: The offset into the logical ``offset`` storage.
37+
max_length: Upper bound on the byte length of every stored string,
38+
or :obj:`None` if unknown. Lets compiled operations size buffers
39+
without reading lengths from the device. Must not underestimate.
3740
"""
3841

3942
# NOTE Assume that `data` stores valid UTF-8 bytes and do not validate it.
@@ -51,6 +54,7 @@ def _make_var_len_tensor(
5154
size: tuple[int, ...],
5255
stride: tuple[int, ...],
5356
storage_offset: int,
57+
max_length: int | None,
5458
cls: type[VarLenTensor] | None = None,
5559
) -> VarLenTensor:
5660
return VarLenTensor._make_var_len_tensor(
@@ -60,6 +64,7 @@ def _make_var_len_tensor(
6064
size=size,
6165
stride=stride,
6266
storage_offset=storage_offset,
67+
max_length=max_length,
6368
cls=StringTensor if cls is None else cls,
6469
)
6570

@@ -131,6 +136,10 @@ def from_arrow(
131136
valid=valid,
132137
size=size,
133138
storage_offset=storage_offset,
139+
# Inspect CPU offsets before moving buffers.
140+
max_length=_length_bound(
141+
offset[storage_offset : storage_offset + len(array) + 1]
142+
),
134143
)
135144

136145
@override
@@ -203,6 +212,7 @@ def from_cudf(
203212
offset=torch.zeros(1, dtype=torch.int32, device=data.device),
204213
valid=None,
205214
size=size,
215+
max_length=0,
206216
)
207217

208218
offsets = column.children()[0] # (base_size + 1,) INT32/INT64 values
@@ -228,6 +238,10 @@ def from_cudf(
228238
valid=valid,
229239
size=size,
230240
storage_offset=storage_offset,
241+
# Reads one value from the device, once at ingestion.
242+
max_length=_length_bound(
243+
offset[storage_offset : storage_offset + len(ser) + 1]
244+
),
231245
)
232246

233247
def to_cudf(self) -> cudf.Series:
@@ -546,3 +560,20 @@ def _sort(
546560
perm = torch.from_dlpack(perm_ser.astype("int64").to_cupy())
547561

548562
return cast(StringTensor, inp[perm]), perm
563+
564+
565+
def _length_bound(offset: Tensor) -> int:
566+
"""Return an upper bound on the byte length of every stored string.
567+
568+
``torch.compile`` treats the bound as a constant and compiles a separate
569+
graph for each distinct value. Rounding the longest length up to the
570+
next ``2**k - 1`` bytes (1, 3, 7, ..., 127, 255, ...) lets inputs with
571+
similar lengths share one graph, at the cost of a bound up to about
572+
twice the longest length. Pass only the offsets of the tensor's own
573+
strings: views and copies of the tensor cannot reach other strings in
574+
a shared buffer, so the bound stays valid for them.
575+
"""
576+
lengths = offset[1:] - offset[:-1]
577+
longest = int(lengths.max()) if lengths.numel() > 0 else 0
578+
# The smallest 2**k - 1 that is at least `longest`:
579+
return 2 ** longest.bit_length() - 1

‎sdm/tensor/table.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ def __new__(
300300
offset=torch.zeros(1, dtype=torch.int32, device=device),
301301
valid=None,
302302
size=(*size, 0),
303+
max_length=0,
303304
)
304305
if id is None:
305306
id = ColumnarTensor((), size=size, device=device)

‎sdm/tensor/var_len.py‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ class VarLenTensor(Tensor):
6262
size: The shape of the tensor.
6363
stride: The stride of the tensor.
6464
storage_offset: The offset into the logical ``offset`` storage.
65+
max_length: Upper bound on the length of every stored entry, or
66+
:obj:`None` if unknown. Lets compiled operations size buffers
67+
without reading lengths from the device. Must not underestimate.
6568
"""
6669

6770
ALLOWED_DTYPES: ClassVar[tuple[torch.dtype, ...] | None] = None
@@ -72,6 +75,7 @@ class VarLenTensor(Tensor):
7275
_data: Tensor
7376
_offset: Tensor
7477
_valid: Tensor | None
78+
_max_length: int | None
7579
_layout: Tensor
7680

7781
# Constructors ############################################################
@@ -84,6 +88,7 @@ def __init__(
8488
size: Sequence[int],
8589
stride: Sequence[int] | None = None,
8690
storage_offset: int = 0,
91+
max_length: int | None = None,
8792
) -> None:
8893
pass
8994

@@ -95,6 +100,7 @@ def __new__(
95100
size: Sequence[int],
96101
stride: Sequence[int] | None = None,
97102
storage_offset: int = 0,
103+
max_length: int | None = None,
98104
) -> Self:
99105
r"""Create a tensor wrapper."""
100106
size = tuple(size)
@@ -222,6 +228,7 @@ def __new__(
222228
size=size,
223229
stride=stride,
224230
storage_offset=storage_offset,
231+
max_length=max_length,
225232
cls=None if "_make_var_len_tensor" in cls.__dict__ else cls,
226233
)
227234

@@ -235,6 +242,7 @@ def _make_var_len_tensor(
235242
size: tuple[int, ...],
236243
stride: tuple[int, ...],
237244
storage_offset: int,
245+
max_length: int | None,
238246
cls: type[VarLenTensor] | None = None,
239247
) -> VarLenTensor:
240248
cls = VarLenTensor if cls is None else cls
@@ -250,6 +258,7 @@ def _make_var_len_tensor(
250258
out._data = data
251259
out._offset = offset
252260
out._valid = valid
261+
out._max_length = max_length
253262
# Mirror size and stride in an empty tensor's shape so torch.compile
254263
# can make them symbolic. A tensor nested in another one, like a
255264
# categorical vocabulary, would otherwise keep its first traced size.
@@ -292,6 +301,7 @@ def from_tensor(
292301
size=tensor.size(),
293302
stride=tensor.stride(),
294303
storage_offset=int(tensor.storage_offset()),
304+
max_length=min(data.numel(), 1), # Every entry has length 1.
295305
)
296306

297307
@classmethod
@@ -566,7 +576,7 @@ def __tensor_flatten__(self) -> tuple[list[str], tuple[Any, ...]]:
566576
attrs = ["_offset", "_data", "_layout"]
567577
if self._valid is not None:
568578
attrs.append("_valid")
569-
return attrs, (self.__class__, self.storage_offset())
579+
return attrs, (self.__class__, self.storage_offset(), self._max_length)
570580

571581
@staticmethod
572582
def __tensor_unflatten__(
@@ -575,7 +585,7 @@ def __tensor_unflatten__(
575585
outer_size: tuple[int, ...],
576586
outer_stride: tuple[int, ...],
577587
) -> VarLenTensor:
578-
cls, storage_offset = ctx
588+
cls, storage_offset, max_length = ctx
579589
layout = inner_tensors["_layout"]
580590
out = cls(
581591
data=inner_tensors["_data"],
@@ -584,6 +594,7 @@ def __tensor_unflatten__(
584594
size=layout.size()[:-1],
585595
stride=layout.stride()[:-1],
586596
storage_offset=storage_offset,
597+
max_length=max_length,
587598
)
588599
out._layout = layout
589600
return out
@@ -596,6 +607,7 @@ def __reduce_ex__(self, proto: SupportsIndex) -> Any:
596607
tuple(self.size()),
597608
tuple(self.stride()),
598609
int(self.storage_offset()),
610+
self._max_length,
599611
)
600612
return (self.__class__, args)
601613

@@ -740,6 +752,7 @@ def _alias(inp: VarLenTensor) -> VarLenTensor:
740752
size=inp.size(),
741753
stride=inp.stride(),
742754
storage_offset=int(inp.storage_offset()),
755+
max_length=inp._max_length,
743756
)
744757

745758

@@ -849,6 +862,7 @@ def _to_dtype_layout(
849862
if memory_format == torch.preserve_format
850863
else None,
851864
storage_offset=0,
865+
max_length=inp._max_length,
852866
)
853867

854868

@@ -958,6 +972,7 @@ def _detach(inp: VarLenTensor) -> VarLenTensor:
958972
size=inp.size(),
959973
stride=inp.stride(),
960974
storage_offset=int(inp.storage_offset()),
975+
max_length=inp._max_length,
961976
)
962977

963978

@@ -976,6 +991,7 @@ def _pin_memory(inp: VarLenTensor) -> VarLenTensor:
976991
size=inp.size(),
977992
stride=inp.stride(),
978993
storage_offset=int(inp.storage_offset()),
994+
max_length=inp._max_length,
979995
)
980996

981997

@@ -1370,6 +1386,10 @@ def _cat(tensors: Sequence[Tensor], dim: int = 0) -> VarLenTensor:
13701386
tensors = tuple(
13711387
cast(VarLenTensor, tensor.contiguous()) for tensor in tensors
13721388
)
1389+
lengths = tuple(tensor._max_length for tensor in tensors)
1390+
max_length = (
1391+
None if None in lengths else max(cast(tuple[int, ...], lengths))
1392+
)
13731393
data_list, offsets = zip(*(_data_offset(tensor) for tensor in tensors))
13741394
valid: Tensor | None = None
13751395
if any(tensor._valid is not None for tensor in tensors):
@@ -1411,15 +1431,27 @@ def _cat(tensors: Sequence[Tensor], dim: int = 0) -> VarLenTensor:
14111431

14121432
if math.prod(tensors[0].size()[:dim]) == 1: # Contiguous path:
14131433
offset[-1] = storage_offset
1414-
return tensor_cls(data=data, offset=offset, valid=valid, size=size)
1434+
return tensor_cls(
1435+
data=data,
1436+
offset=offset,
1437+
valid=valid,
1438+
size=size,
1439+
max_length=max_length,
1440+
)
14151441

14161442
end = torch.cat(end_views, dim=dim)
14171443
start = torch.as_strided(start, size=(start.numel(),), stride=(1,))
14181444
end = torch.as_strided(end, size=(end.numel(),), stride=(1,))
14191445

14201446
offset, index = _compact(start, end)
14211447

1422-
return tensor_cls(data=data[index], offset=offset, valid=valid, size=size)
1448+
return tensor_cls(
1449+
data=data[index],
1450+
offset=offset,
1451+
valid=valid,
1452+
size=size,
1453+
max_length=max_length,
1454+
)
14231455

14241456

14251457
@VarLenTensor.implements(aten.stack.default)
@@ -1469,6 +1501,7 @@ def _from_layout_view(inp: VarLenTensor, view: Tensor) -> VarLenTensor:
14691501
size=view.size(),
14701502
stride=view.stride(),
14711503
storage_offset=0 if view.numel() == 0 else int(storage_offset),
1504+
max_length=inp._max_length,
14721505
)
14731506

14741507

@@ -1558,4 +1591,5 @@ def _materialize(
15581591
size=size,
15591592
stride=stride,
15601593
storage_offset=0,
1594+
max_length=inp._max_length,
15611595
)

‎test/tensor/test_string.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import pickle
45
from typing import cast
56

67
import pyarrow as pa
@@ -159,6 +160,8 @@ def test_from_cudf_sliced_values() -> None:
159160
assert tensor._offset.equal(
160161
torch.tensor([0, 1, 3, 5, 5], device=tensor.device)
161162
)
163+
# The bound covers the slice, rounded up to 2**k - 1 bytes.
164+
assert tensor._max_length == 3
162165

163166

164167
@onlyCUDA
@@ -184,6 +187,7 @@ def test_from_cudf_all_empty_values() -> None:
184187
assert tensor.tolist() == ["", ""]
185188
assert tensor._data.numel() == 0
186189
assert tensor._offset.equal(torch.tensor([0, 0, 0], device=tensor.device))
190+
assert tensor._max_length == 0
187191

188192

189193
@onlyCUDA
@@ -198,6 +202,7 @@ def test_from_cudf_empty_values() -> None:
198202
assert tensor.tolist() == []
199203
assert tensor._data.numel() == 0
200204
assert tensor._offset.equal(torch.tensor([0], device=tensor.device))
205+
assert tensor._max_length == 0
201206

202207

203208
@pytest.mark.parametrize(
@@ -406,3 +411,54 @@ def test_registers_as_a_pytorch_buffer() -> None:
406411
module.register_buffer("value", tensor)
407412

408413
assert tuple(module.buffers()) == (tensor,)
414+
415+
416+
@pytest.mark.parametrize(
417+
("length", "bound"),
418+
[(0, 0), (1, 1), (3, 3), (4, 7), (127, 127), (128, 255)],
419+
)
420+
def test_bound_rounds_up(length: int, bound: int) -> None:
421+
values = StringTensor.from_list(["a" * length, ""])
422+
assert values._max_length == bound
423+
424+
425+
@withCUDA
426+
@pytest.mark.parametrize("bound", [None, 0, 15])
427+
def test_bound_provenance(
428+
compilation: None,
429+
device: torch.device,
430+
bound: int | None,
431+
) -> None:
432+
texts = ["", None] if bound == 0 else ["abcdefgh", "é"]
433+
# A non-null Arrow slice shares storage with a longer, unreachable string.
434+
array = pa.array(texts)
435+
if bound == 15:
436+
array = pa.array(["x" * 40, *texts]).slice(1)
437+
values = StringTensor.from_arrow(array, device=device)
438+
if bound is None:
439+
values = StringTensor(
440+
data=values._data,
441+
offset=values._offset,
442+
valid=values._valid,
443+
size=values.size(),
444+
)
445+
compiled = torch.compile(lambda value: value.detach(), fullgraph=True)
446+
for copied, expected in (
447+
(values[:], texts),
448+
(values[::2], texts[::2]),
449+
(values[torch.tensor([1, 0], device=device)], texts[::-1]),
450+
(values.clone(), texts),
451+
(values.detach(), texts),
452+
(values.to(device, copy=True), texts),
453+
(compiled(values), texts),
454+
(pickle.loads(pickle.dumps(values)), texts),
455+
):
456+
copied = cast(StringTensor, copied)
457+
assert copied._max_length == bound
458+
assert copied.tolist() == expected
459+
for suffix in ("", "x" * 16):
460+
extra = StringTensor.from_list([suffix], device=device)
461+
joined = cast(StringTensor, torch.cat((values, extra)))
462+
new_bound = None if bound is None else max(bound, 31 if suffix else 0)
463+
assert joined._max_length == new_bound
464+
assert joined.tolist() == [*texts, suffix]

0 commit comments

Comments
 (0)