Skip to content

Commit d41d92e

Browse files
FrancescAltedclaude
andcommitted
Sort dictionary columns by rank instead of decoded strings
A row's alphabetical rank orders exactly as its decoded value does -- the basis of the FULL rank index -- so _build_lex_keys can hand lexsort an int32 key and skip both the decode and lexsort's string comparisons. The code -> rank map is extracted as _dict_code_to_rank so the sort and the index builder cannot drift apart. 200k rows, cardinality 5000: key construction 106.2 -> 21.4 ms, sort_by(view=True) 246.7 -> 156.9 ms, descending 273.5 -> 156.2 ms (an object key could not be negated, so descending went through a double argsort that an int32 key does not need). _sorted_small_copy_from_live_positions held a second copy of the key builder with the same decode, a narrower dtype list (no StringDType keys) and a KeyError for computed sort columns, and it re-read the column after already gathering the codes. It now calls _build_lex_keys with the arrays it gathered, which removes the duplicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ccd82c4 commit d41d92e

4 files changed

Lines changed: 113 additions & 51 deletions

File tree

‎RELEASE_NOTES.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ XXX version-specific blurb XXX
142142
lexsort-based `sort_by` cost O(N) decompressions. At 1M rows an unindexed
143143
`sort_by` drops from 236 s to 713 ms, and a full column read from 44 s (at
144144
200k rows) to 193 ms.
145+
- **`sort_by` on a dictionary column sorts int32 ranks, not decoded
146+
strings.** A row's alphabetical rank orders exactly as its value does — the
147+
trick the FULL index already used — so the sort key needs neither the decode
148+
nor lexsort's string comparisons. Key construction drops from 106 ms to 21 ms
149+
per 200k rows (cardinality 5000) and `sort_by(view=True)` from 247 ms to
150+
157 ms. The filtered small-copy path, which had its own copy of the key
151+
builder, now shares this one and picks up the same speedup.
145152
- **`kind=BUCKET` indexes no longer cost more than the scan they replace.**
146153
Scattered matches were read one bucket run at a time, re-decompressing the
147154
same blocks many times, and the planner measured selectivity in buckets while

‎src/blosc2/ctable.py‎

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11645,13 +11645,22 @@ def _build_lex_keys(
1164511645
ascending: list[bool],
1164611646
live_pos: np.ndarray,
1164711647
n: int,
11648+
gathered: dict[str, np.ndarray] | None = None,
1164811649
) -> list[np.ndarray]:
1164911650
"""Build the key list for np.lexsort (innermost = last = primary key).
1165011651

1165111652
For nullable columns a null-indicator key (0=non-null, 1=null) is
1165211653
inserted immediately after the value key, making it more significant.
1165311654
This ensures nulls sort last regardless of ascending/descending order.
11655+
11656+
*gathered* lets a caller that has already read the columns at
11657+
*live_pos* hand them over instead of paying for a second gather; a
11658+
dictionary column is expected there as its **codes**, which is what it
11659+
is cheap to gather.
1165411660
"""
11661+
from blosc2.ctable_indexing import _dict_code_to_rank
11662+
11663+
gathered = gathered or {}
1165511664
lex_keys = []
1165611665
for name, asc in zip(reversed(cols), reversed(ascending), strict=True):
1165711666
cc = self._computed_cols.get(name)
@@ -11663,14 +11672,24 @@ def _build_lex_keys(
1166311672
else:
1166411673
is_dict_col = col_info is not None and self._is_dictionary_column(col_info)
1166511674
if is_dict_col:
11666-
# Sort dictionary columns by decoded string values.
11667-
decoded = self._cols[name][live_pos]
11668-
raw = np.array(decoded, dtype=object)
11669-
# Replace None with placeholder so lexsort never compares None.
11670-
# Null indicator key (below) already places nulls last.
11671-
raw[raw == None] = "" # noqa: E711
11675+
# Sort a dictionary column by the alphabetical rank of each
11676+
# row's code -- the same trick the FULL index plays. Ranks
11677+
# order exactly as the decoded values do, and sorting int32
11678+
# skips both the decode and lexsort's string comparisons.
11679+
dict_col = self._cols[name]
11680+
dictionary = list(dict_col.dictionary)
11681+
code_to_rank = _dict_code_to_rank(dictionary)
11682+
raw_codes = gathered[name] if name in gathered else dict_col.codes[live_pos]
11683+
codes = np.asarray(raw_codes, dtype=np.int32)
11684+
# The null code is reserved (-1), not a dictionary entry, so
11685+
# it cannot be looked up; nulls take the largest rank. The
11686+
# null indicator key below is what actually places them.
11687+
is_null = codes == col_info.spec.null_code
11688+
raw = np.empty(len(codes), dtype=np.int32)
11689+
raw[~is_null] = code_to_rank[codes[~is_null]]
11690+
raw[is_null] = np.int32(len(dictionary))
1167211691
else:
11673-
raw = self._cols[name][live_pos]
11692+
raw = gathered[name] if name in gathered else self._cols[name][live_pos]
1167411693
nv = getattr(col_info.spec, "null_value", None) if col_info else None
1167511694

1167611695
# Value key
@@ -11689,10 +11708,7 @@ def _build_lex_keys(
1168911708
# Null indicator key — more significant than the value key above,
1169011709
# so nulls always sort last (0 before 1 → non-null before null).
1169111710
if is_dict_col and col_info.spec.nullable:
11692-
null_code = col_info.spec.null_code
11693-
codes_at_pos = np.asarray(self._cols[name].codes[live_pos], dtype=np.int32)
11694-
null_ind = (codes_at_pos == null_code).astype(np.intp)
11695-
lex_keys.append(null_ind)
11711+
lex_keys.append(is_null.astype(np.intp))
1169611712
elif nv is not None:
1169711713
if isinstance(nv, float) and np.isnan(nv):
1169811714
null_ind = np.isnan(raw).astype(np.intp)
@@ -11966,43 +11982,9 @@ def _sorted_small_copy_from_live_positions(
1196611982
else:
1196711983
gathered[col.name] = arr[live_pos]
1196811984

11969-
lex_keys = []
11970-
for name, asc in zip(reversed(cols), reversed(ascending), strict=True):
11971-
col_info = self._schema.columns_by_name.get(name)
11972-
is_dict_col = col_info is not None and self._is_dictionary_column(col_info)
11973-
if is_dict_col:
11974-
raw = np.array(self._cols[name][live_pos], dtype=object)
11975-
# Replace None with placeholder so lexsort never compares None.
11976-
raw[raw == None] = "" # noqa: E711
11977-
else:
11978-
raw = gathered[name]
11979-
11980-
if not asc:
11981-
if raw.dtype.kind in "USO":
11982-
rank = np.argsort(np.argsort(raw, kind="stable"), kind="stable")
11983-
lex_keys.append((n - 1 - rank).astype(np.intp))
11984-
elif np.issubdtype(raw.dtype, np.unsignedinteger):
11985-
lex_keys.append(-raw.astype(np.int64))
11986-
else:
11987-
lex_keys.append(-raw)
11988-
else:
11989-
lex_keys.append(raw)
11990-
11991-
if is_dict_col and col_info.spec.nullable:
11992-
null_code = col_info.spec.null_code
11993-
codes_at_pos = np.asarray(self._cols[name].codes[live_pos], dtype=np.int32)
11994-
null_ind = (codes_at_pos == null_code).astype(np.intp)
11995-
lex_keys.append(null_ind)
11996-
else:
11997-
nv = getattr(col_info.spec, "null_value", None) if col_info else None
11998-
if nv is not None:
11999-
if isinstance(nv, float) and np.isnan(nv):
12000-
null_ind = np.isnan(raw).astype(np.intp)
12001-
else:
12002-
null_ind = (raw == nv).astype(np.intp)
12003-
lex_keys.append(null_ind)
12004-
12005-
order = np.lexsort(lex_keys)
11985+
# The gather above already read every column at live_pos, dictionary
11986+
# columns as codes -- exactly what the key builder wants.
11987+
order = np.lexsort(self._build_lex_keys(cols, ascending, live_pos, n, gathered))
1200611988
result = self._empty_copy(capacity=n)
1200711989
for col in self._schema.columns:
1200811990
col_name = col.name

‎src/blosc2/ctable_indexing.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ def __init__(self):
5858
self.vlmeta = _FakeVlMeta()
5959

6060

61+
def _dict_code_to_rank(dictionary) -> np.ndarray:
62+
"""``code -> alphabetical rank`` lookup for a dictionary column.
63+
64+
Sorting by rank is sorting by decoded value, which is what lets an int32
65+
array stand in for the strings — in the FULL index (:class:`_DictRankWrapper`)
66+
and in ``CTable._build_lex_keys``. The reserved null code is *not* a
67+
dictionary entry, so callers assign nulls a rank of their own (``len``,
68+
the largest, so nulls sort last).
69+
"""
70+
n_entries = len(dictionary)
71+
order = np.argsort(dictionary, kind="stable")
72+
code_to_rank = np.empty(n_entries, dtype=np.int32)
73+
code_to_rank[order] = np.arange(n_entries, dtype=np.int32)
74+
return code_to_rank
75+
76+
6177
def _dict_rank_hash(dictionary) -> str:
6278
"""Stable hash of a dictionary's entries (code position + value).
6379
@@ -887,9 +903,7 @@ def create_index( # noqa: C901
887903
n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows)
888904
dictionary = list(dict_col.dictionary)
889905
n_entries = len(dictionary)
890-
order = np.argsort(dictionary, kind="stable")
891-
code_to_rank = np.empty(n_entries, dtype=np.int32)
892-
code_to_rank[order] = np.arange(n_entries, dtype=np.int32)
906+
code_to_rank = _dict_code_to_rank(dictionary)
893907
null_code = dict_col.spec.null_code
894908
null_rank = np.int32(n_entries)
895909
# Hash for staleness detection.

‎tests/ctable/test_dictionary_column.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from dataclasses import dataclass
1212

13+
import numpy as np
1314
import pytest
1415

1516
import blosc2
@@ -686,3 +687,61 @@ def counting_hash(dictionary):
686687
finally:
687688
ci._dict_rank_hash = _dict_rank_hash
688689
assert calls == 0, "value epoch was unchanged, so no hash should have been needed"
690+
691+
692+
def test_sort_by_keys_on_ranks_not_decoded_strings():
693+
"""sort_by must not decode a dictionary column to sort it.
694+
695+
Sorting by alphabetical rank is sorting by decoded value, so the key can
696+
stay int32 -- which skips both the decode and lexsort's string
697+
comparisons. Correctness alone would not notice the difference, so
698+
assert on the key dtype as well as the order.
699+
"""
700+
701+
@dataclass
702+
class Row:
703+
c: str = blosc2.field(blosc2.dictionary())
704+
x: int = blosc2.field(blosc2.int64())
705+
706+
values = ["delta", "alpha", "Zeta", "beta", "alpha"]
707+
t = CTable(Row, new_data={"c": values, "x": list(range(len(values)))})
708+
709+
live = np.arange(len(values))
710+
keys = t._build_lex_keys(["c"], [True], live, len(values))
711+
assert keys[0].dtype == np.int32
712+
713+
# Ranks must order exactly as Python orders the decoded strings.
714+
assert list(t.sort_by("c")["c"][:]) == sorted(values)
715+
assert list(t.sort_by("c", ascending=False)["c"][:]) == sorted(values, reverse=True)
716+
717+
718+
def test_sort_by_dictionary_nulls_and_multiple_keys():
719+
"""Nulls sort last in both directions, and rank keys compose with others."""
720+
721+
@dataclass
722+
class Row:
723+
c: str = blosc2.field(blosc2.dictionary(nullable=True))
724+
x: int = blosc2.field(blosc2.int64())
725+
726+
values = ["b", None, "a", None, "b"]
727+
t = CTable(Row, new_data={"c": values, "x": [0, 1, 2, 3, 4]})
728+
729+
assert list(t.sort_by("c")["c"][:]) == ["a", "b", "b", None, None]
730+
assert list(t.sort_by("c", ascending=False)["c"][:]) == ["b", "b", "a", None, None]
731+
# Secondary key breaks the "b" tie; nulls still trail.
732+
assert list(t.sort_by(["c", "x"], [True, False])["x"][:]) == [2, 4, 0, 3, 1]
733+
734+
735+
def test_sort_by_dictionary_view_and_small_copy_agree():
736+
"""The filtered small-copy path builds its keys the same way sort_by does."""
737+
738+
@dataclass
739+
class Row:
740+
c: str = blosc2.field(blosc2.dictionary(nullable=True))
741+
x: int = blosc2.field(blosc2.int64())
742+
743+
values = ["b", None, "a", "c", "b", None, "a"]
744+
t = CTable(Row, new_data={"c": values, "x": list(range(len(values)))})
745+
filtered = t[t.x > 1] # small enough to take _sorted_small_copy_from_live_positions
746+
assert list(filtered.sort_by("c")["c"][:]) == ["a", "a", "b", "c", None]
747+
assert list(filtered.sort_by("c", ascending=False)["c"][:]) == ["c", "b", "a", "a", None]

0 commit comments

Comments
 (0)