|
| 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]] |
0 commit comments