Skip to content

Commit d4e013e

Browse files
committed
Replace cuDF string sorting with native PyTorch on CUDA
Rank the first 21 bytes of each string with stable LSD radix passes, then rank only longer prefix-tied strings by their remaining bytes using block-pair prefix doubling. Eager execution skips groups of identical strings and sizes refinement exactly; compiled execution gates refinement with torch.cond under static bounds and has no graph breaks. CPU sorting keeps Arrow.
1 parent a0e16e9 commit d4e013e

3 files changed

Lines changed: 469 additions & 28 deletions

File tree

‎sdm/tensor/_string_sort.py‎

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Exact stable string sorting with native PyTorch operations.
5+
6+
Seven UTF-8 bytes, each stored as ``byte + 1`` in nine bits, form one word;
7+
zero marks the end of a string, so prefixes sort before their extensions.
8+
An LSD radix sort orders strings by their first ``PREFIX`` bytes while
9+
tracking prefix ties. Longer strings that tie on the prefix are ranked by
10+
their remaining bytes with block-pair prefix doubling. The same static-shape
11+
code runs eagerly and under ``torch.compile``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import functools
17+
import math
18+
from typing import TYPE_CHECKING
19+
20+
import torch
21+
from torch import Tensor
22+
23+
if TYPE_CHECKING:
24+
from sdm.tensor.string import StringTensor
25+
26+
PREFIX = 21
27+
28+
29+
def argsort(values: StringTensor, descending: bool) -> Tensor:
30+
"""Return a stable sort permutation of one-dimensional strings.
31+
32+
Nulls sort last in both directions.
33+
"""
34+
n = values.numel()
35+
if values.stride(0) == 0:
36+
# Every row refers to the same string.
37+
return torch.arange(n, device=values.device)
38+
39+
dtype = _index_dtype(max(values._offset.numel(), values._data.numel() + 1))
40+
rows = torch.arange(n, device=values.device, dtype=dtype)
41+
rows = rows * values.stride(0) + values._logical_storage_offset
42+
start = values._offset[rows].to(dtype)
43+
length = values._offset[rows + 1].to(dtype) - start
44+
valid = None if values._valid is None else values._valid[rows]
45+
if valid is not None:
46+
# Null payloads compare as empty and keep their input order.
47+
length = length.where(valid, 0)
48+
# Masked bytes read any valid position, so empty buffers get one byte.
49+
data = values._data if values._data.numel() else values._data.new_zeros(1)
50+
bound = values._max_length
51+
if bound is None:
52+
bound = values._data.numel()
53+
54+
# LSD radix sort over the prefix, least significant word first.
55+
prefix = min(max(bound, 1), PREFIX)
56+
perm = rank = None
57+
for depth in reversed(range(0, prefix, 7)):
58+
rows = slice(None) if perm is None else perm
59+
key = _word(
60+
data, start[rows], length[rows], depth, min(7, prefix - depth)
61+
)
62+
if depth == 0 and valid is not None:
63+
key = key.where(
64+
valid[rows], -1 if descending else torch.iinfo(key.dtype).max
65+
)
66+
key, order = key.sort(descending=descending, stable=True)
67+
perm = order if perm is None else perm[order]
68+
if bound > prefix:
69+
# Rank the prefix bytes processed so far in sorted order.
70+
changed = torch.ones_like(key, dtype=torch.bool)
71+
changed[1:] = key[1:] != key[:-1]
72+
if rank is not None:
73+
rank = rank[order]
74+
changed[1:] |= rank[1:] != rank[:-1]
75+
rank = changed.cumsum(0)
76+
assert perm is not None # The prefix spans at least one word.
77+
if rank is None:
78+
return perm
79+
80+
# Only longer strings that tie on the prefix need their remaining bytes.
81+
tied = torch.zeros_like(rank, dtype=torch.bool)
82+
tied[1:] = rank[1:] == rank[:-1]
83+
tied[:-1] |= tied[1:].clone()
84+
tied &= length[perm] > prefix
85+
refine = functools.partial(_refine, bound=bound, descending=descending)
86+
if torch.compiler.is_compiling():
87+
# Static bounds: at most total // (PREFIX + 1) strings are longer,
88+
# and their tails span at most total // 7 seven-byte blocks.
89+
total = values._data.numel()
90+
slots = min(n, total // (PREFIX + 1))
91+
blocks = slots * max(1, -(-(bound - PREFIX) // 7))
92+
refine = functools.partial(
93+
refine, slots=slots, blocks=min(total // 7, blocks)
94+
)
95+
operands = (data, start, length, perm, rank, tied)
96+
return torch.cond(tied.any(), refine, _keep, operands)
97+
98+
# Eager: groups of identical strings are already in order.
99+
tied &= _mixed(data, start, length, perm, rank, tied)
100+
if not tied.any():
101+
return perm
102+
blocks = ((length[perm] - PREFIX + 6) // 7)[tied]
103+
return refine(
104+
data,
105+
start,
106+
length,
107+
perm,
108+
rank,
109+
tied,
110+
slots=int(tied.sum()),
111+
blocks=int(blocks.sum()),
112+
)
113+
114+
115+
def _keep(
116+
data: Tensor,
117+
start: Tensor,
118+
length: Tensor,
119+
perm: Tensor,
120+
rank: Tensor,
121+
tied: Tensor,
122+
) -> Tensor:
123+
return perm.clone()
124+
125+
126+
def _refine(
127+
data: Tensor,
128+
start: Tensor,
129+
length: Tensor,
130+
perm: Tensor,
131+
rank: Tensor,
132+
tied: Tensor,
133+
*,
134+
bound: int,
135+
descending: bool,
136+
slots: int,
137+
blocks: int,
138+
) -> Tensor:
139+
"""Order prefix ties by their remaining bytes.
140+
141+
``slots`` and ``blocks`` bound the tied strings and their tail blocks.
142+
"""
143+
n = perm.numel()
144+
index = torch.nonzero_static(tied, size=slots, fill_value=n).squeeze(1)
145+
rows = torch.cat((perm, perm.new_zeros(1)))[index]
146+
used = index < n
147+
tail = _block_rank(
148+
data=data,
149+
start=start[rows] + PREFIX,
150+
length=(length[rows] - PREFIX).where(used, 0),
151+
bound=bound - PREFIX,
152+
capacity=blocks,
153+
)
154+
# Padding slots scatter to their own destinations past the rows.
155+
padding = n + torch.arange(slots, device=data.device)
156+
tail = tail.new_zeros(n + slots).scatter_(
157+
0, index.where(used, padding), tail
158+
)[:n]
159+
if descending:
160+
tail = slots - tail
161+
# Tail ranks are at most ``slots``, so the combined key stays ordered.
162+
key = rank * (slots + 1) + tail
163+
return perm[key.argsort(stable=True)]
164+
165+
166+
def _mixed(
167+
data: Tensor,
168+
start: Tensor,
169+
length: Tensor,
170+
perm: Tensor,
171+
rank: Tensor,
172+
tied: Tensor,
173+
) -> Tensor:
174+
"""Whether each tied position's group holds different strings (eager)."""
175+
position = tied.nonzero().squeeze(1)
176+
group = rank[position]
177+
first = torch.ones_like(group, dtype=torch.bool)
178+
first[1:] = group[1:] != group[:-1]
179+
index = torch.arange(first.numel(), device=first.device)
180+
leader = perm[position[(index * first).cummax(0).values]]
181+
row = perm[position]
182+
count = length[row]
183+
differs = count != length[leader]
184+
differs |= _rows_differ(
185+
data=data,
186+
start=start[row] + PREFIX,
187+
other=start[leader] + PREFIX,
188+
count=(count - PREFIX).where(~differs, 0),
189+
)
190+
# Any difference within a group, via prefix sums over the groups.
191+
bounds = torch.cat((first, first.new_ones(1))).nonzero().squeeze(1)
192+
seen = torch.cat((group.new_zeros(1), differs.cumsum(0)))
193+
mixed = (seen[bounds].diff() > 0)[first.cumsum(0) - 1]
194+
return torch.zeros_like(tied).index_put_((position,), mixed)
195+
196+
197+
def _rows_differ(
198+
data: Tensor,
199+
start: Tensor,
200+
other: Tensor,
201+
count: Tensor,
202+
) -> Tensor:
203+
"""Whether ``count`` bytes at ``start`` differ from those at ``other``.
204+
205+
Bytes are compared in four chunks so temporaries stay near the data size.
206+
"""
207+
dtype = start.dtype
208+
first = torch.cat((count.new_zeros(1), count.cumsum(0, dtype=dtype)))
209+
total = int(first[-1])
210+
differs = torch.zeros_like(count, dtype=torch.bool)
211+
step = max(1, -(-total // 4))
212+
for begin in range(0, total, step):
213+
byte = torch.arange(
214+
begin, min(begin + step, total), device=data.device, dtype=dtype
215+
)
216+
row = torch.searchsorted(
217+
first, byte, right=True, out_int32=dtype == torch.int32
218+
).sub_(1)
219+
byte -= first[row]
220+
mismatch = data[start[row] + byte] != data[other[row] + byte]
221+
differs[row[mismatch]] = True
222+
return differs
223+
224+
225+
def _word(
226+
data: Tensor,
227+
start: Tensor,
228+
length: Tensor,
229+
depth: int,
230+
width: int = 7,
231+
) -> Tensor:
232+
# Up to three nine-bit symbols fit in int32; more need int64.
233+
dtype = torch.int32 if width <= 3 else torch.int64
234+
byte = torch.arange(
235+
depth, depth + width, device=data.device, dtype=start.dtype
236+
)
237+
absent = byte >= length[:, None]
238+
index = start[:, None] + byte # [rows, width]
239+
index.masked_fill_(absent, data.numel() - 1)
240+
symbol = data[index]
241+
del index
242+
symbol = symbol.to(dtype).add_(1).masked_fill_(absent, 0)
243+
symbol <<= torch.arange(9 * (width - 1), -1, -9, device=data.device)
244+
return symbol.sum(-1, dtype=dtype)
245+
246+
247+
def _index_dtype(size: int) -> torch.dtype:
248+
# Positions use int32 unless the indexed extent needs int64.
249+
return torch.int32 if size < torch.iinfo(torch.int32).max else torch.int64
250+
251+
252+
def _layout(count: Tensor, capacity: int) -> tuple[Tensor, Tensor, Tensor]:
253+
"""Map ``capacity`` slots to rows holding ``count`` slots each.
254+
255+
Unused trailing slots belong to a padding row with index ``count.numel()``.
256+
"""
257+
dtype = _index_dtype(capacity + 1)
258+
count = count.to(dtype)
259+
first = torch.cat((count.new_zeros(1), count.cumsum(0, dtype=dtype)))
260+
rows = torch.arange(count.numel() + 1, device=count.device, dtype=dtype)
261+
repeats = torch.cat((count, (capacity - first[-1]).reshape(1)))
262+
owner = rows.repeat_interleave(repeats, output_size=capacity)
263+
local = torch.arange(capacity, device=count.device, dtype=dtype)
264+
return first, owner, local - first[owner]
265+
266+
267+
def _dense_rank(key: Tensor) -> Tensor:
268+
# Rank zero is reserved for key zero, which marks an empty block.
269+
key = torch.cat((key, key.new_zeros(1)))
270+
sorted_key, order = key.sort()
271+
dtype = _index_dtype(key.numel())
272+
step = (sorted_key.diff() != 0).to(dtype).cumsum(0, dtype=dtype)
273+
step = torch.cat((step.new_zeros(1), step))
274+
return torch.empty_like(step).scatter_(0, order, step)[:-1]
275+
276+
277+
def _block_rank(
278+
data: Tensor,
279+
start: Tensor,
280+
length: Tensor,
281+
bound: int,
282+
capacity: int,
283+
) -> Tensor:
284+
"""Rank rows by merging adjacent seven-byte blocks with static shapes.
285+
286+
``capacity`` must cover the total number of blocks.
287+
"""
288+
n = start.numel()
289+
# Rows with an empty tail (padding) own no blocks.
290+
count = (length + 6) // 7
291+
first, owner, local = _layout(count, capacity)
292+
real = owner < n
293+
start = torch.cat((start, start.new_zeros(1)))
294+
length = torch.cat((length, length.new_zeros(1)))
295+
byte = start[owner] + 7 * local.to(start.dtype)
296+
end = (start + length)[owner]
297+
word = torch.zeros(capacity, dtype=torch.int64, device=data.device)
298+
for i in range(7):
299+
present = byte + i < end
300+
symbol = data[(byte + i).where(present, data.numel() - 1)].long()
301+
word |= (symbol + 1).where(present, 0) << (54 - 9 * i)
302+
rank = _dense_rank(word)
303+
304+
# [blocks] -> [ceil(blocks / 2)] per row until one block remains.
305+
for _ in range(math.ceil(math.log2(max(1, -(-bound // 7))))):
306+
merged = (count + 1) // 2
307+
merged_capacity = capacity // 2 + n
308+
merged_first, owner, local = _layout(merged, merged_capacity)
309+
real = owner < n
310+
first = torch.cat((first, first[-1:]))
311+
count = torch.cat((count, count.new_zeros(1)))
312+
left = (first[owner] + 2 * local).where(real, capacity)
313+
has_right = real & (2 * local + 1 < count[owner])
314+
right = (left + 1).where(has_right, capacity)
315+
rank = torch.cat((rank, rank.new_zeros(1)))
316+
rank = _dense_rank(rank[left].long() * (capacity + 1) + rank[right])
317+
count, capacity, first = merged, merged_capacity, merged_first
318+
return rank[first[:-1]]

‎sdm/tensor/string.py‎

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
from typing_extensions import override
1616

1717
from sdm._warnings import warn_once
18-
from sdm.tensor import VarLenTensor
18+
from sdm.tensor import VarLenTensor, _string_sort
1919
from sdm.tensor.io import arrow_as_tensor, combine_arrow_chunks
20+
from sdm.tensor.var_len import _permute_entries
2021

2122
if TYPE_CHECKING:
2223
import cudf
@@ -523,33 +524,29 @@ def _sort(
523524
if inp.dim() != 1:
524525
raise NotImplementedError("'sort' only supports one-dimensional input")
525526

526-
backend: Literal["arrow", "cudf"] = "arrow"
527527
if inp.is_cuda:
528-
if importlib.util.find_spec("cudf") is not None:
529-
backend = "cudf"
530-
else:
531-
warn_once(
532-
key="missing-cudf-sort",
533-
message=(
534-
"Falling back to a CPU-based string sort because cuDF is "
535-
"not installed. Install cuDF to enable faster CUDA-based "
536-
"string sorting without device synchronization."
537-
),
538-
)
528+
return _sort_native(inp, descending=descending)
529+
530+
out = pc.call_function(
531+
"array_sort_indices",
532+
[inp.to_arrow()],
533+
options=pc.ArraySortOptions(
534+
order="descending" if descending else "ascending",
535+
),
536+
)
537+
perm = arrow_as_tensor(out, dtype=torch.int64, device=inp.device)
538+
return cast(StringTensor, inp[perm]), perm
539539

540-
if backend == "arrow":
541-
out = pc.call_function(
542-
"array_sort_indices",
543-
[inp.to_arrow()],
544-
options=pc.ArraySortOptions(
545-
order="descending" if descending else "ascending",
546-
),
547-
)
548-
perm = arrow_as_tensor(out, dtype=torch.int64, device=inp.device)
549-
else:
550-
assert backend == "cudf"
551-
with torch.cuda.device(inp.device):
552-
perm_ser = inp.to_cudf().argsort(ascending=not descending)
553-
perm = torch.from_dlpack(perm_ser.astype("int64").to_cupy())
554540

555-
return cast(StringTensor, inp[perm]), perm
541+
def _sort_native(
542+
inp: StringTensor,
543+
*,
544+
descending: bool,
545+
) -> tuple[StringTensor, Tensor]:
546+
perm = _string_sort.argsort(inp, descending=descending)
547+
out = (
548+
_permute_entries(inp, perm)
549+
if torch.compiler.is_compiling()
550+
else inp[perm]
551+
)
552+
return cast(StringTensor, out), perm

0 commit comments

Comments
 (0)