Skip to content

Commit 2714aa3

Browse files
Merge pull request #677 from Blosc/enhancing-ctable3
Enhancing ctable with a new utf8() string type
2 parents c851704 + 28d5f8f commit 2714aa3

27 files changed

Lines changed: 4761 additions & 113 deletions

CMakeLists.txt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ add_custom_command(
5959
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/blosc2/groupby_ext.pyx"
6060
VERBATIM)
6161

62+
add_custom_command(
63+
OUTPUT utf8_ext.c
64+
COMMAND Python::Interpreter -m cython
65+
"${CMAKE_CURRENT_SOURCE_DIR}/src/blosc2/utf8_ext.pyx" --output-file utf8_ext.c
66+
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/blosc2/utf8_ext.pyx"
67+
VERBATIM)
68+
6269
# ...and add it to the target
6370
Python_add_library(blosc2_ext MODULE blosc2_ext.c WITH_SOABI)
6471
target_sources(blosc2_ext PRIVATE src/blosc2/matmul_kernels.c)
@@ -68,11 +75,17 @@ if(UNIX)
6875
endif()
6976
Python_add_library(indexing_ext MODULE indexing_ext.c WITH_SOABI)
7077
Python_add_library(groupby_ext MODULE groupby_ext.c WITH_SOABI)
78+
Python_add_library(utf8_ext MODULE utf8_ext.c WITH_SOABI)
79+
# NpyString_pack() and friends are part of NumPy's 2.0 C API; opt in
80+
# explicitly since numpy/*.h otherwise targets an older API version by
81+
# default for source compatibility.
82+
target_compile_definitions(utf8_ext PRIVATE NPY_TARGET_VERSION=NPY_2_0_API_VERSION)
7183

7284
# We need to link against NumPy
7385
target_link_libraries(blosc2_ext PRIVATE Python::NumPy)
7486
target_link_libraries(indexing_ext PRIVATE Python::NumPy)
7587
target_link_libraries(groupby_ext PRIVATE Python::NumPy)
88+
target_link_libraries(utf8_ext PRIVATE Python::NumPy)
7689

7790
# Fetch and build miniexpr library
7891
include(FetchContent)
@@ -110,6 +123,7 @@ endif()
110123
target_compile_features(blosc2_ext PRIVATE c_std_11)
111124
target_compile_features(indexing_ext PRIVATE c_std_11)
112125
target_compile_features(groupby_ext PRIVATE c_std_11)
126+
target_compile_features(utf8_ext PRIVATE c_std_11)
113127
if(WIN32 AND CMAKE_C_COMPILER_ID STREQUAL "Clang")
114128
execute_process(
115129
COMMAND "${CMAKE_C_COMPILER}" -print-resource-dir
@@ -184,7 +198,7 @@ endif()
184198

185199
# Python extension -> site-packages/blosc2
186200
install(
187-
TARGETS blosc2_ext indexing_ext groupby_ext
201+
TARGETS blosc2_ext indexing_ext groupby_ext utf8_ext
188202
LIBRARY DESTINATION ${SKBUILD_PLATLIB_DIR}/blosc2
189203
)
190204

bench/ctable/bench_groupby_keys.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@ class Row:
3232
ikey: int = blosc2.field(blosc2.int64())
3333
skey: str = blosc2.field(blosc2.string(max_length=8))
3434
dkey: str = blosc2.field(blosc2.dictionary())
35+
ukey: str = blosc2.field(blosc2.utf8())
3536
val: float = blosc2.field(blosc2.float64())
3637

3738

3839
print(f"building table ({N:.0e} rows)...", flush=True)
3940
t = CTable(Row)
4041
t.extend(
41-
{"ikey": int_keys, "skey": str_keys, "dkey": [str(s) for s in str_keys], "val": float_vals},
42+
{
43+
"ikey": int_keys,
44+
"skey": str_keys,
45+
"dkey": [str(s) for s in str_keys],
46+
"ukey": [str(s) for s in str_keys],
47+
"val": float_vals,
48+
},
4249
validate=False,
4350
)
4451

@@ -56,7 +63,9 @@ def bench(label, fn, reps=3):
5663
bench("int key, mean", lambda: t.group_by("ikey").agg({"val": "mean"}))
5764
bench("string key, sum", lambda: t.group_by("skey").sum("val"))
5865
bench("dict key, sum", lambda: t.group_by("dkey").sum("val"))
66+
bench("utf8 key, sum", lambda: t.group_by("ukey").sum("val"))
5967
bench("two keys (int+dict), sum", lambda: t.group_by(["ikey", "dkey"]).sum("val"))
68+
bench("two keys (int+utf8), sum", lambda: t.group_by(["ikey", "ukey"]).sum("val"))
6069

6170
try:
6271
import pandas as pd

bench/ctable/bench_string_kinds.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#######################################################################
2+
# Copyright (c) 2019-present, Blosc Development Team <blosc@blosc.org>
3+
# All rights reserved.
4+
#
5+
# SPDX-License-Identifier: BSD-3-Clause
6+
#######################################################################
7+
8+
"""Compare the three plain-string column representations head to head:
9+
``utf8()`` (offsets + bytes, StringDType reads), ``string(max_length=L)``
10+
(fixed-width UTF-32), and ``vlstring()`` (msgpack cells).
11+
12+
Two workloads:
13+
- "taxi company": the real ``company`` column from the Chicago taxi dataset
14+
(medium-length, low-cardinality strings), when the parquet file is present.
15+
- "synthetic free text": high-cardinality random words of 0-60 chars with
16+
some multi-byte values — the workload utf8() is designed for.
17+
18+
For each representation: ingest, storage footprint, full read, equality
19+
filter, groupby-key aggregation, sort, and Arrow export. Operations a
20+
representation does not support are reported as such rather than skipped
21+
silently.
22+
"""
23+
24+
import argparse
25+
import pathlib
26+
import time
27+
from dataclasses import make_dataclass
28+
29+
import numpy as np
30+
31+
import blosc2
32+
from blosc2 import CTable
33+
34+
N_TAXI = 10_000_000
35+
N_SYNTH = 2_000_000 # fixed-width U~130 at 1e7 rows would need a ~5 GB ingest buffer
36+
REPS = 3
37+
TAXI_PARQUET = pathlib.Path(__file__).parent.parent / "chicago-taxi" / "chicago-taxi-flat.parquet"
38+
39+
parser = argparse.ArgumentParser(description=__doc__)
40+
parser.add_argument(
41+
"--ingest-only",
42+
action="store_true",
43+
help="Only measure ingest and storage footprint; skip full read, filter, "
44+
"groupby, sort, and to_arrow (the last of which alone can take over a "
45+
"minute per column kind on the full taxi workload). Meant for fast "
46+
"iteration when only ingest performance is under investigation.",
47+
)
48+
args = parser.parse_args()
49+
50+
rng = np.random.default_rng(42)
51+
52+
53+
def bench(label, fn, reps=REPS):
54+
times = []
55+
result = None
56+
for _ in range(reps):
57+
t0 = time.perf_counter()
58+
result = fn()
59+
times.append(time.perf_counter() - t0)
60+
print(f" {label:34s} {min(times) * 1e3:9.1f} ms")
61+
return result
62+
63+
64+
def load_taxi_company(n):
65+
import pyarrow.parquet as pq
66+
67+
tbl = pq.read_table(TAXI_PARQUET, columns=["company"])
68+
col = tbl.column("company").combine_chunks()
69+
values = col.to_pylist()[:n]
70+
return [v if v is not None else "" for v in values]
71+
72+
73+
def synth_free_text(n):
74+
words = np.array(
75+
["taxi", "río", "航空", "boulevard", "x" * 40, "café", "", "downtown", "zürich", "o'hare"]
76+
)
77+
# 2-6 words per row, high cardinality via a row counter suffix on ~half.
78+
parts = words[rng.integers(0, len(words), (n, 3))]
79+
joined = [" ".join(p) for p in parts]
80+
salt = rng.integers(0, 100_000, n) # ~100k distinct values: high cardinality, sane group count
81+
return [f"{s} #{salt[i]}" if i % 2 else s for i, s in enumerate(joined)]
82+
83+
84+
def run_workload(title, values, filter_value):
85+
n = len(values)
86+
max_len = max(len(v) for v in values)
87+
float_vals = rng.random(n)
88+
print(f"\n=== {title} ({n:.0e} rows, max length {max_len} chars) ===")
89+
90+
specs = {
91+
"utf8": blosc2.utf8(),
92+
"string": blosc2.string(max_length=max_len),
93+
"vlstring": blosc2.vlstring(),
94+
}
95+
for kind, spec in specs.items():
96+
row_cls = make_dataclass(
97+
"Row", [("s", str, blosc2.field(spec)), ("val", float, blosc2.field(blosc2.float64()))]
98+
)
99+
print(f"[{kind}]")
100+
t0 = time.perf_counter()
101+
t = CTable(row_cls)
102+
t.extend({"s": values, "val": float_vals}, validate=False)
103+
t._flush_varlen_columns()
104+
print(f" {'ingest':34s} {(time.perf_counter() - t0) * 1e3:9.1f} ms")
105+
106+
col = t._cols["s"]
107+
nbytes = getattr(col, "nbytes", None)
108+
cbytes = getattr(col, "cbytes", None)
109+
if nbytes is None: # NDArray-backed fixed-width column
110+
nbytes, cbytes = col.schunk.nbytes, col.schunk.cbytes
111+
print(
112+
f" {'storage nbytes -> cbytes':34s} {nbytes / 2**20:7.1f} MB -> {cbytes / 2**20:7.1f} MB (cratio {nbytes / cbytes:.1f}x)"
113+
)
114+
115+
if args.ingest_only:
116+
del t
117+
continue
118+
119+
bench("full column read", lambda t=t: t["s"][:])
120+
try:
121+
bench("filter: count(s == value)", lambda t=t: int((t.s == filter_value)[:].sum()))
122+
except (NotImplementedError, TypeError) as exc:
123+
print(f" {'filter: count(s == value)':34s} unsupported: {str(exc)[:60]}")
124+
try:
125+
bench("groupby key: sum(val)", lambda t=t: t.group_by("s").sum("val"), reps=1)
126+
except (NotImplementedError, TypeError) as exc:
127+
print(f" {'groupby key: sum(val)':34s} unsupported: {str(exc)[:60]}")
128+
try:
129+
bench("sort_by(s) (copy)", lambda t=t: t.sort_by("s"), reps=1)
130+
except (NotImplementedError, TypeError) as exc:
131+
print(f" {'sort_by(s) (copy)':34s} unsupported: {str(exc)[:60]}")
132+
try:
133+
bench("to_arrow()", lambda t=t: t.to_arrow(), reps=1)
134+
except Exception as exc:
135+
print(f" {'to_arrow()':34s} failed: {str(exc)[:60]}")
136+
del t
137+
138+
139+
if TAXI_PARQUET.exists():
140+
print("loading taxi company column...", flush=True)
141+
taxi = load_taxi_company(N_TAXI)
142+
# the most frequent company value as the filter probe
143+
vals, counts = np.unique(np.array(taxi, dtype=np.dtypes.StringDType()), return_counts=True)
144+
run_workload("chicago-taxi company", taxi, str(vals[np.argmax(counts)]))
145+
del taxi
146+
else:
147+
print(f"({TAXI_PARQUET} not found; skipping the real-data workload)")
148+
149+
print("building synthetic free text...", flush=True)
150+
synth = synth_free_text(N_SYNTH)
151+
run_workload("synthetic free text", synth, synth[123])

doc/getting_started/overview.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ types including integers, floats, booleans, and strings:
270270
tips: float = blosc2.field(blosc2.float32())
271271
km: float = blosc2.field(blosc2.float32())
272272
lon: float = blosc2.field(blosc2.float32())
273-
company: str = blosc2.field(blosc2.string(max_length=50))
273+
company: str = blosc2.field(blosc2.utf8()) # variable-length text
274274
275275
276276
t = blosc2.CTable(Row, expected_size=10_000_000)

doc/guides/parquet_to_blosc2.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ Use the ``.b2d`` extension to produce a directory-backed (sparse) store:
5757
Step 4 — Fixed-width string import
5858
------------------------------------
5959

60-
By default, string columns are stored as variable-length strings
61-
(``vlstring``). Pass ``--fixed-str-maxlen`` to pre-scan strings and store
62-
columns whose maximum character length fits within the given limit as
63-
fixed-width, indexable strings:
60+
By default, string columns are stored as variable-length ``utf8`` columns
61+
(Arrow-style offsets + bytes; falls back to ``vlstring`` on NumPy < 2.0).
62+
Pass ``--fixed-str-maxlen`` to pre-scan strings and store columns whose
63+
maximum character length fits within the given limit as fixed-width,
64+
indexable strings:
6465

6566
.. code-block:: console
6667

doc/reference/ctable.rst

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,15 +938,109 @@ Text & binary
938938
.. autosummary::
939939

940940
string
941+
utf8
941942
bytes
942943
vlstring
943944
vlbytes
944945

945946
.. autoclass:: string
947+
.. autofunction:: utf8
946948
.. autoclass:: bytes
947949
.. autofunction:: vlstring
948950
.. autofunction:: vlbytes
949951

952+
.. _ChoosingStringType:
953+
954+
Choosing a string column type
955+
-----------------------------
956+
957+
CTable offers four ways to store strings. As a quick decision path:
958+
959+
* Low-cardinality strings (categories, enumerations, repeated labels):
960+
use :func:`dictionary` — repeated values are stored once as integer codes.
961+
* Everything else (names, free text, high-cardinality values):
962+
use :func:`utf8` — the recommended default for variable-length text.
963+
* Short codes of near-uniform length, or columns that need
964+
:meth:`CTable.create_index`: use :class:`string` (fixed-width).
965+
* NumPy < 2.0, or nullable columns where any string value can legally occur
966+
(native ``None`` nulls, no sentinel): use :func:`vlstring`.
967+
968+
.. list-table::
969+
:header-rows: 1
970+
:stub-columns: 1
971+
972+
* -
973+
- :class:`string`
974+
- :func:`utf8`
975+
- :func:`dictionary`
976+
- :func:`vlstring`
977+
* - Storage layout
978+
- fixed-width UTF-32 NDArray
979+
- int64 offsets + UTF-8 bytes (Arrow-style)
980+
- integer codes + unique values
981+
- msgpack batches
982+
* - Per-row cost (pre-compression)
983+
- 4 × ``max_length`` bytes
984+
- exact UTF-8 length + 8-byte offset
985+
- one integer code
986+
- value + msgpack framing
987+
* - Length limit
988+
- ``max_length`` characters
989+
- none
990+
- none
991+
- none
992+
* - Bulk read returns
993+
- NumPy ``U`` array
994+
- NumPy ``StringDType`` array
995+
- decoded strings
996+
- Python list of ``str``
997+
* - Nulls
998+
- sentinel
999+
- sentinel
1000+
- native
1001+
- native ``None``
1002+
* - Filters (``==``, ``<``, …)
1003+
- ✓ (incl. string expressions)
1004+
- ✓ operators only [#utf8expr]_
1005+
- ``==`` / ``isin()`` only
1006+
- ✗
1007+
* - :meth:`CTable.group_by` key / :meth:`CTable.sort_by`
1008+
- ✓
1009+
- ✓
1010+
- ✓
1011+
- ✗
1012+
* - :meth:`CTable.create_index`
1013+
- ✓
1014+
- not yet
1015+
- ✓ (rank-based)
1016+
- ✗
1017+
* - Arrow / Parquet
1018+
- ✓
1019+
- ✓ (``large_string``)
1020+
- ✓
1021+
- ✓
1022+
* - NumPy requirement
1023+
- any
1024+
- >= 2.0
1025+
- any
1026+
- any
1027+
* - Best for
1028+
- short, near-uniform codes
1029+
- **general text (recommended)**
1030+
- low-cardinality categories
1031+
- NumPy < 2.0; native-``None`` nulls
1032+
1033+
.. [#utf8expr] utf8 columns support the operator form ``t[t.name == "x"]``
1034+
(also ``!=``, ``<``, ``<=``, ``>``, ``>=``), but not the string-expression
1035+
form ``t.where("name == 'x'")`` yet. :meth:`CTable.create_index` on utf8
1036+
columns is not supported yet either; both raise ``NotImplementedError``
1037+
with a clear message.
1038+
1039+
Note that a plain ``str`` annotation without an explicit :func:`field` spec
1040+
still maps to fixed-width ``string(max_length=32)`` for backward
1041+
compatibility; opt in to variable-length storage with
1042+
``blosc2.field(blosc2.utf8())``.
1043+
9501044
Array, encoded, and compound specs
9511045
----------------------------------
9521046

doc/reference/misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public objects into the appropriate reference section.
7474
uint16,
7575
uint32,
7676
uint64,
77+
utf8,
7778
vlbytes,
7879
vlstring,
7980
Array,

examples/ctable/arrow_interop.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ class Stock:
5959
t2 = blosc2.CTable.from_arrow(at2.schema, at2.to_batches())
6060
print("CTable from Arrow (inferred schema):")
6161
print(t2)
62-
print(f" label dtype: {t2['label'].dtype} (max_length inferred from data)")
62+
# Arrow string columns import as variable-length utf8() columns (StringDType
63+
# reads); pass string_max_length= to from_arrow() for fixed-width instead.
64+
print(f" label dtype: {t2['label'].dtype}")
6365

6466
# -- pandas round-trip ------------------------------------------------------
6567
try:

0 commit comments

Comments
 (0)