Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
4598b43
Phase 1f-1g: size string results from miniexpr, not from NumPy
FrancescAlted Jul 26, 2026
9a310e6
Phase 1h (blosc2 side): lower Python string syntax to the DSL grammar
FrancescAlted Jul 26, 2026
0f92c73
Size string DSL kernel output from miniexpr, and check it
FrancescAlted Jul 26, 2026
9d5ec14
docs: release note for string expressions and DSL kernels
FrancescAlted Jul 26, 2026
6f5c9fe
Phase 2 (blosc2 side): route bytes ('S') arrays to miniexpr
FrancescAlted Jul 26, 2026
729a208
Compile row["colname"] kernels that branch, and let strings through
FrancescAlted Jul 26, 2026
cbcb15b
Fix miniexpr prefilter reads for operands wider than 255 bytes
FrancescAlted Jul 27, 2026
cd6317d
Phase 3: string expressions over utf8 columns, plus utf8_array()
FrancescAlted Jul 27, 2026
332ac40
docs: utf8() no longer disclaims string-expression filters
FrancescAlted Jul 27, 2026
2638a0d
Rename the utf8_array module to _utf8_array
FrancescAlted Jul 27, 2026
47b033c
Phase 4: answer utf8 scalar predicates with a raw-byte scan
FrancescAlted Jul 27, 2026
cfd3898
docs: release note for the utf8 scalar-predicate fast path
FrancescAlted Jul 27, 2026
0546293
Fix string indexes returning zero rows at the default column width
FrancescAlted Jul 27, 2026
c8fd3f5
docs: release note for the string-index zero-rows fix
FrancescAlted Jul 27, 2026
393a200
Sweep the capped-typesize family: one helper, plus SChunk.get_slice
FrancescAlted Jul 27, 2026
ff63d7a
docs: release note for the wide-typesize SChunk slice fix
FrancescAlted Jul 27, 2026
6edf058
Link the SChunk wide-typesize workaround to Blosc/c-blosc2#796
FrancescAlted Jul 27, 2026
9bb345d
Drop the wide-typesize get_slice() workaround, fixed upstream
FrancescAlted Jul 27, 2026
7cbd48b
Read chunk sub-ranges via blosc2_getitem_bytes_ctx()
FrancescAlted Jul 27, 2026
e3cd570
Bump internal C-Blosc2 and miniexpr deps
FrancescAlted Jul 27, 2026
9cb490a
utf8 span driver: return a Utf8Array for string results
FrancescAlted Jul 27, 2026
25d9e64
Bump miniexpr to 9e9b8d9 (varlen string output)
FrancescAlted Jul 27, 2026
f6b0643
Add compute_varlen(): Arrow varlen results from string expressions
FrancescAlted Jul 27, 2026
4e36490
Revert "Add compute_varlen(): Arrow varlen results from string expres…
FrancescAlted Jul 27, 2026
615700d
Restore the string benchmark dropped by the compute_varlen revert
FrancescAlted Jul 27, 2026
7ec1751
Bump miniexpr to 5a7de4f (width-preserving upper/lower)
FrancescAlted Jul 27, 2026
bbb94f4
Stop expression results from losing the code-unit shuffle width
FrancescAlted Jul 27, 2026
f78f657
bench: size the string benchmark's blocks for the result, not the ope…
FrancescAlted Jul 27, 2026
df04137
bench: refresh the string-ops results after the three fixes
FrancescAlted Jul 27, 2026
ef9a6cf
bench: use LZ4-5 for the blosc2 string rows
FrancescAlted Jul 27, 2026
2835d60
bench: final string-ops plot (24.3M rows, LZ4-5)
FrancescAlted Jul 27, 2026
eb2ff11
bench: drop SHUFFLE for the string rows too
FrancescAlted Jul 27, 2026
56ed4ff
bench: present the filter choice as the tradeoff it is
FrancescAlted Jul 27, 2026
0ea4720
bench: keep SHUFFLE at the UCS4 width, take throughput from the codec
FrancescAlted Jul 27, 2026
a1473b4
bench: regenerate the plot from a full all-engines run
FrancescAlted Jul 27, 2026
c5dc1d0
Add the regression test for the 4096-element eval-block bug
FrancescAlted Jul 27, 2026
bf9b484
Repoint miniexpr at bd2c602
FrancescAlted Jul 27, 2026
a944684
Cache the dictionary code->value map instead of decoding per row
FrancescAlted Jul 28, 2026
92c39aa
Stop bucket indexes from costing more than the scan they replace
FrancescAlted Jul 28, 2026
12c5629
Assess the string column flavours against measurements
FrancescAlted Jul 28, 2026
64d4a76
Update bucket index tests for the block-selectivity gate
FrancescAlted Jul 28, 2026
3692673
Give Utf8Array real comparison operators
FrancescAlted Jul 28, 2026
174eb7c
Record the Utf8Array comparison fix in the assessment
FrancescAlted Jul 28, 2026
0b486b0
Route bare Utf8Array expressions through the span driver
FrancescAlted Jul 28, 2026
d04badc
Record the G4 fix in the assessment
FrancescAlted Jul 28, 2026
486be88
Give dictionary and varlen scalar columns element-wise comparisons
FrancescAlted Jul 28, 2026
9a9c176
Record the dictionary/vlstring comparison fix in the assessment
FrancescAlted Jul 28, 2026
b1bbc54
Support create_index on utf8 columns via alphabetical ranks
FrancescAlted Jul 29, 2026
3fac526
Record the utf8 rank index in the assessment
FrancescAlted Jul 29, 2026
f132d6d
Answer utf8 scalar predicates from the rank index
FrancescAlted Jul 29, 2026
93b45ac
Record indexed utf8 predicates in the assessment
FrancescAlted Jul 29, 2026
1c22fdf
Serve dictionary equality from the rank index, and fix != while there
FrancescAlted Jul 29, 2026
7174109
Record dictionary index predicates in the assessment
FrancescAlted Jul 29, 2026
e707c91
Update the user-facing docs for the string index work
FrancescAlted Jul 29, 2026
e273ef1
Mark the doc update done in the assessment
FrancescAlted Jul 29, 2026
f8af071
Let add_column() fill a new column from values=
FrancescAlted Jul 29, 2026
5b31abe
Publish the utf8 <-> fixed-width conversion pair
FrancescAlted Jul 29, 2026
2b23b07
Record the conversion pair in the assessment
FrancescAlted Jul 29, 2026
9b5eaba
Fix index-summary min()/max(), and restrict rank indexes to kind=FULL
FrancescAlted Jul 29, 2026
8e3868b
Make the utf8 compute refusals route instead of just refusing
FrancescAlted Jul 29, 2026
c712c95
Record the error-message routing in the assessment
FrancescAlted Jul 29, 2026
93cea90
Refresh the capability matrix for the routed errors
FrancescAlted Jul 29, 2026
0ed3823
Dispatch the array constructors on NumPy's StringDType
FrancescAlted Jul 29, 2026
246dcf5
Record the StringDType dispatch decision in the assessment
FrancescAlted Jul 29, 2026
cacac07
Rename Utf8Array to UTF8Array
FrancescAlted Jul 29, 2026
8d0eca3
Rename Utf8Spec to UTF8Spec, keeping the old name as an alias
FrancescAlted Jul 29, 2026
7eca6f6
Keep the string tests running on NumPy 1.26
FrancescAlted Jul 29, 2026
86d466f
Let nested (dotted) utf8 leaves be filtered
FrancescAlted Jul 29, 2026
eaeaaed
Record the G2/G3/G4 withdrawal in the utf8 parity plan
FrancescAlted Jul 29, 2026
ccd82c4
Close item 5 of the assessment, and refile nested leaves
FrancescAlted Jul 29, 2026
d41d92e
Sort dictionary columns by rank instead of decoded strings
FrancescAlted Jul 29, 2026
f5838d5
Record the dictionary rank sort in the assessment
FrancescAlted Jul 29, 2026
7a322dd
Address the Copilot review on PR #684
FrancescAlted Jul 29, 2026
de99ca8
Keep test names under 50 characters
FrancescAlted Jul 29, 2026
cb28624
Rewrite the string-type decision path around the real trade-off
FrancescAlted Jul 29, 2026
26c7052
Correct what an over-long max_length actually does
FrancescAlted Jul 29, 2026
44fda19
Check add_column(values=) against the declared constraints
FrancescAlted Jul 29, 2026
d673c80
Address the Copilot review on PR #686
FrancescAlted Jul 29, 2026
e517bd7
Size the rank indexes by physical extent, not the live row count
FrancescAlted Jul 29, 2026
2ac13b1
Count blocks, not chunks, when a bucket is as wide as a block
FrancescAlted Jul 30, 2026
9a3efa6
Attach jit hints as notes instead of rebuilding the exception
FrancescAlted Jul 30, 2026
cf45079
Key the string-width cache on the operand dtypes too
FrancescAlted Jul 30, 2026
672c102
Drop the stale nested-utf8-leaf disclaimer from utf8()
FrancescAlted Jul 30, 2026
92788a6
Assert the string fill is flat in n, not under a fixed ceiling
FrancescAlted Jul 30, 2026
e7b336e
Stand the min/max shortcut down on any hole, not just fresh ones
FrancescAlted Jul 30, 2026
c5f5823
Make a string result refuse clashing utf8 null sentinels
FrancescAlted Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ endif()
project(python-blosc2)

# blosc2_ext.pyx calls blosc2_schunk_lock()/unlock(), added in c-blosc2 3.2.x
set(BLOSC2_MIN_VERSION 3.2.1)
set(BLOSC2_BUNDLED_VERSION v3.2.3)
set(BLOSC2_MIN_VERSION 3.3.0)
set(BLOSC2_BUNDLED_VERSION v3.3.0)
# set(BLOSC2_BUNDLED_VERSION bc074b228968d6121b3c8c1a38c0afc0bbf923f6)

if(WIN32 AND NOT CMAKE_C_COMPILER_ID STREQUAL "Clang")
message(FATAL_ERROR "Windows builds require clang-cl. Set CC/CXX to clang-cl or configure CMake with -T ClangCL.")
Expand Down Expand Up @@ -109,7 +110,7 @@ endif()

FetchContent_Declare(miniexpr
GIT_REPOSITORY https://github.com/Blosc/miniexpr.git
GIT_TAG 58d2d0b4a3aee3d1ac84b213712cf982744196c8
GIT_TAG bd2c602a652c50b306def625c5fe5491cbd13f76
# SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr
)
FetchContent_MakeAvailable(miniexpr)
Expand Down
226 changes: 226 additions & 0 deletions RELEASE_NOTES.md

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions bench/chicago-taxi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,154 @@ yourself if flushing manually):
| `select-pandas-flat.py` | the query via pandas (parquet read + NumPy filter/sort) |
| `select-polars-flat.py` | the query via polars lazy scan over parquet |
| `select-blosc2.py` | the query via `blosc2.open()` + `CTable.where()` over `.b2z` |
| `string-ops.py` | a separate benchmark: *string* kernels over the same dataset (see below) |

Each `select-*.py` prints the result, then `open:`/`compute:`/`print:`/`total:`
timings; the driver parses the `total:` line (query time, excluding interpreter
and import startup) alongside `/usr/bin/time`'s wall clock and peak memory.

## String ops (`string-ops.py`)

The numeric benchmark above is I/O-bound. `string-ops.py` is the opposite: it
loads the two *string* columns — `company` (`<U44`) and `payment.type`
(`<U11`) — into memory once and times three string workloads on each engine.
All engines must produce identical results or the run fails.

```bash
python string-ops.py # whole 24.3 M-row table, best of 3
python string-ops.py --nrows 1000000 --apply
python string-ops.py --engines blosc2,numpy --nrows 1000000
```

| task | expression |
|---|---|
| `filter` | `startswith(company, 'Taxi') & (payment_type != 'Cash')` → bool |
| `transform` | `'co=' + company + '\|pay=' + lower(payment_type)` → str |
| `kernel` | the same, branching on whether the company is a cab company |

All three are timed; only `kernel` is plotted. It is the point of the exercise:
the shape of the
[pandas-3 blog kernel](https://datapythonista.me/blog/whats-new-in-pandas-3),
row-wise control flow rather than one expression. blosc2 runs it as a
`@blosc2.dsl_kernel`; the other engines have to rewrite it as a mask plus two
fully-evaluated branches. `--apply` adds the row-wise pandas spelling, which is
what you would write first and is ~70x slower than everything else.

**blosc2 uses LZ4 at `clevel=5`**, rather than the stock ZSTD-5 — a
throughput-for-ratio trade, and the result is still 12x smaller than what the
Arrow-backed engines hold. SHUFFLE stays on at the `<U` code-unit width; see
below. **`blosc2 (raw)` is the identical path at
`clevel=0`** — same container, same kernel, same (empty) filter pipeline,
operands and result both uncompressed. Compression is the only variable between
the two blosc2 bars.

Results on an Apple M-series laptop (8 cores, 24 GB), full table, warm
(see `string-ops.png`):

| | filter | transform | kernel | kernel result |
|---|---|---|---|---|
| **blosc2** | 315 ms | **1.27 s** | 3.08 s | **68 MB** |
| blosc2 (raw) | 192 ms | 1.31 s | 3.57 s | 5 766 MB |
| pandas | 191 ms | 2.00 s | 5.11 s | 932 MB |
| polars | 92 ms | 1.74 s | 3.53 s | 932 MB |
| duckdb | 344 ms | 2.01 s | 2.97 s | 842 MB |

blosc2 is **fastest of all five on `transform`** (1.58x DuckDB), ahead of DuckDB
on `filter`, and within 1.04x on `kernel` — while holding the result in **12x
less memory** than any of them. Only polars' `filter` is faster.

Quote ratios rather than absolutes: `transform` moved between 1.27 and 1.48 s
across full-table runs, and blosc2 alone in the process gives 1.24–1.26 s.

**Compression is close to free.** Compare the two blosc2 rows: the compressed
run is *faster* than the uncompressed one on `kernel` (3.09 s vs 3.62), because
a compressed block is less memory traffic than a 5.8 GB uncompressed result. It
also stores 85x smaller.

### Filters and codec — time/ratio trades, not fixes

blosc2's default for `<U` is SHUFFLE with `filters_meta` 4: shuffle by the UCS4
code unit, which separates the ASCII payload byte from the three mostly-zero
high bytes. That is a good default and it wins on **ratio**. It costs time,
which is what this benchmark optimizes for. 1 M rows, blosc2 alone in the
process:

| codec | filters | filter | transform | kernel | result | operand cratio |
|---|---|---|---|---|---|---|
| ZSTD-5 | none | 10.0 ms | 80.3 ms | 149.3 ms | 1.08 MB | 860x |
| ZSTD-5 | **SHUFFLE meta=4** (default) | 15.9 | 80.4 | 155.5 | **0.75 MB** | **1321x** |
| LZ4-5 | none | 7.2 | 43.9 | 118.1 | 2.76 MB | 198x |
| LZ4-5 | SHUFFLE meta=4 | 12.4 | 50.5 | 124.8 | 2.70 MB | 225x |

Shuffle buys 1.4x ratio under ZSTD and ~2 % under LZ4, where the codec already
handles the zero runs. `filter` pays the most for it — that task writes 1 byte
per row, so the un-shuffle on the operand side has nothing on the output side
to offset it. This benchmark **keeps shuffle** (the ratio is the point of using
blosc2 at all) and takes its throughput from the codec instead.

One trap regardless of which you pick: `filters_meta` is SHUFFLE's element
width, and a `<U` container picks 4 for itself only when you *don't* build a
`CParams`. Constructing one for any reason resets it to 0 — "shuffle by the
whole item" — which is strictly worse than both rows above: 6.6 MB and 75.8 ms
on the LZ4 `transform`.

Note the numbers above are lower than the table's: timing blosc2 in a process
that also runs the other engines costs it ~40 % even though it goes first. The
comparison table keeps every engine in one process, as it always has; use
`--engines blosc2` when tuning.

Four things got this from an earlier 8.49 s `kernel`, and two were bugs rather
than tuning:

1. **`upper`/`lower` stopped reserving a 3x/2x case-expansion bound** (miniexpr
`5a7de4f`). NumPy does not reserve either — it truncates — so the result went
`<U101` → `<U54`, halving every byte moved.
2. **Expression results were losing SHUFFLE's code-unit width.** Constructing a
`CParams` defaults `filters_meta` to all zeros, i.e. "shuffle by the whole
item", which scatters characters across the slot; left alone the container
picks 4 for `<U` (the UCS4 code unit). Identical bytes compressed 3.2x worse
on the expression path than through `asarray()`.
3. **Blocks are now sized for the result, not the operands** (see `BLOCKS` in
the script). The result inherits the operands' block shape in *rows* and is
much wider per row, so a row count tuned for `<U36` operands gave 1.7 MB
blocks for the `<U54` result — out of cache on every task.

4. **LZ4-5 instead of the ZSTD-5 default**, as described below.

### The `<U` dtype used to cost 3.2x here (mostly fixed — see above)

The same kernel over the *same* blosc2 code path, with `S` (bytes) operands
instead of `<U`, at 1 M rows:

| | time | output |
|---|---|---|
| blosc2 `<U` | 300 ms | 404 B/row |
| **blosc2 `S`** | **114 ms** | **54 B/row** |
| duckdb | 111 ms | 35.9 B/row |
| polars | 137 ms | 39.7 B/row |

On `S`, blosc2 is at DuckDB parity and ahead of polars — with the result still
compressed to 2 MB. Three multiplicative factors inflate `<U`:

1. **UCS4 — 4 bytes per codepoint.** The others hold UTF-8, ~1 B/char here.
2. **The `lower()` width bound — 2x.** On `<U` it must reserve for Unicode
full-case expansion (`ß`→`SS`), so `<U36`.lower() → `<U72` and the result is
`<U101` where 54 suffices. On `S`, case mapping is ASCII-only and 1:1, so the
bound is exact — that is most of the `S` win.
3. **Fixed-width padding.** Mean result length is 31.7 chars in a 101-char slot.

Even on `S`, blosc2 holds 54 B/row (the compile-time max, on every row) against
DuckDB's 35.9 (31.7 data + 4 offset + 0.1 validity) — they pay the mean plus an
offset. That residual 1.4x is what native variable-width output would remove.

Everything else measured small: operand decompression 23 ms; per-op interpreter
cost ~10 ns/row/op (~50 ms of the 300 for this 5-op kernel); `lazyudf`
construction ~0. Thread scaling is 3.9x on 8 cores, consistent with being
bandwidth-bound on the wide output.

**Practical advice: use `S` for ASCII/Latin-1 string columns.** It is available
today and already reaches DuckDB parity on this workload.

NumPy is implemented but **off by default**: its `kernel` builds five full-width
`<U` temporaries, ~10 GB each at 24 M rows. Run it with `--engines` at a smaller
`--nrows` — at 1 M rows it is 718 ms and 417 MB, losing on both counts.
Binary file added bench/chicago-taxi/string-ops.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading