Skip to content

Commit c851704

Browse files
Merge pull request #676 from Blosc/enhancing-ctable2
Enhancing CTable, phase 2: pandas engine fix, assign()/col() chaining
2 parents e242cbe + b8c56f6 commit c851704

17 files changed

Lines changed: 1517 additions & 20 deletions

RELEASE_NOTES.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@
44

55
XXX version-specific blurb XXX
66

7+
### New features
8+
9+
- `CTable.assign(**named_exprs)`: return a view with additional computed
10+
columns, without mutating the table or copying column data. Pairs with
11+
the new `blosc2.col(name)` — an unbound column expression that defers
12+
operator replay until it's bound to a table (`assign()`, `t[...]`,
13+
`where()`) — to write pandas-3-style chains:
14+
`t.assign(profit=col("revenue") - col("cost"))[col("profit") > 0].sort_by("profit", ascending=False).head(10)`.
15+
16+
### Bug fixes
17+
18+
- Fixed a `@blosc2.dsl_kernel`-decorated function crashing unconditionally
19+
when passed as a groupby UDF aggregation (`g.agg(name=(col,
20+
dsl_kernel_fn))`): it now runs like the equivalent undecorated callable.
21+
- Fixed `CTable.head()`/`tail()` silently discarding row order when called
22+
on a lazily-sorted view (e.g. `t.sort_by("col", ascending=False)` on a
23+
view, or any `.sort_by()` result chained off a prior filter): they
24+
ignored `_cached_live_positions` and built a plain physical-order mask
25+
instead, so `t.where(...).sort_by("x", ascending=False).head(10)` came
26+
back in the wrong order.
27+
- Fixed `engine=blosc2.jit` for `DataFrame.apply` against pandas 3.0.3: with
28+
the default `raw=False`, the engine returned a raw NumPy array instead of
29+
a properly indexed `DataFrame`/`Series`, so results only matched plain
30+
`apply()` by value, never by type. `Series.map(func, engine=blosc2.jit)`
31+
is now implemented (it previously always raised `NotImplementedError`).
32+
Non-numeric columns now raise a clear `ValueError` instead of a deep
33+
`numexpr` error. See the new guide "Using Blosc2 as a pandas engine" and
34+
`bench/bench_pandas_engine.py`.
35+
736
## Changes from 4.8.0 to 4.8.1
837

938
### Improvements

bench/bench_pandas_engine.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
# Benchmark: DataFrame.apply(f, engine=blosc2.jit) vs plain DataFrame.apply(f)
9+
#
10+
# engine=blosc2.jit calls the vectorized function once per column (the
11+
# default axis=0), so the win comes from the Blosc2/numexpr compute engine
12+
# (operator fusion, multi-threading) beating plain NumPy on a
13+
# multi-operation elementwise expression over a large 1D array. This script
14+
# measures that on a 1,000,000-row, 8-column frame.
15+
#
16+
# Note: axis=1 (row-wise) is NOT a good fit for this engine. It still calls
17+
# the function once per row in a Python loop either way, and for a handful
18+
# of columns the wrapping overhead per call (building a compute-engine proxy
19+
# for a tiny array) is larger than the win, so engine=blosc2.jit is actually
20+
# *slower* than plain apply(axis=1) in that case. Use axis=0 (or restructure
21+
# the computation to operate on whole columns) to get the engine's benefit.
22+
#
23+
# Each measurement is the minimum of NRUNS repetitions to reduce noise.
24+
25+
from time import perf_counter
26+
27+
import numpy as np
28+
import pandas as pd
29+
30+
import blosc2
31+
32+
NRUNS = 3
33+
NROWS = 1_000_000
34+
NCOLS = 8
35+
36+
37+
def make_df():
38+
rng = np.random.default_rng(0)
39+
return pd.DataFrame(
40+
{f"c{i}": rng.random(NROWS) for i in range(NCOLS)},
41+
)
42+
43+
44+
def transform(col):
45+
return np.sin(col) * np.cos(col) + col**2 - np.sqrt(np.abs(col)) + np.exp(-col)
46+
47+
48+
def timeit(fn):
49+
best = float("inf")
50+
result = None
51+
for _ in range(NRUNS):
52+
t0 = perf_counter()
53+
result = fn()
54+
best = min(best, perf_counter() - t0)
55+
return best, result
56+
57+
58+
def main():
59+
df = make_df()
60+
61+
t_plain, result_plain = timeit(lambda: df.apply(transform))
62+
t_engine, result_engine = timeit(lambda: df.apply(transform, engine=blosc2.jit))
63+
64+
pd.testing.assert_frame_equal(result_engine, result_plain)
65+
66+
print(f"rows={NROWS}, cols={NCOLS}")
67+
print(f"plain df.apply(f): {t_plain:.4f} s")
68+
print(f"df.apply(f, engine=blosc2.jit): {t_engine:.4f} s")
69+
print(f"speedup: {t_plain / t_engine:.1f}x")
70+
71+
72+
if __name__ == "__main__":
73+
main()

doc/guides/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Topics
1313

1414
optimization_tips
1515
sharing_across_processes
16+
pandas_engine
1617

1718
Command line tools
1819
------------------

doc/guides/pandas_engine.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Using Blosc2 as a pandas Engine
2+
3+
pandas' `DataFrame.apply` and `Series.map` accept an `engine=` argument: a
4+
callable exposing a `__pandas_udf__` attribute that pandas dispatches to
5+
instead of running the Python-level per-row/per-element loop. `blosc2.jit`
6+
is such an engine.
7+
8+
The contract is different from a plain `apply`/`map` callback: the function
9+
passed to `engine=blosc2.jit` must be **vectorized** — it is called *once*
10+
with a full NumPy array (a column, a row, or the whole array, depending on
11+
`axis`), not once per element. This is the same contract `@blosc2.jit`
12+
already has everywhere else in the library; using it as a pandas engine
13+
just changes who supplies the array.
14+
15+
```python
16+
import numpy as np
17+
import pandas as pd
18+
import blosc2
19+
20+
df = pd.DataFrame(
21+
{
22+
"a": np.arange(1_000_000, dtype=np.float64),
23+
"b": np.arange(1_000_000, dtype=np.float64),
24+
}
25+
)
26+
27+
28+
@blosc2.jit
29+
def add_one(col):
30+
return col + 1
31+
32+
33+
result = df.apply(add_one, engine=blosc2.jit)
34+
```
35+
36+
`axis=0` (the default) calls the function once per column; `axis=1` calls it
37+
once per row. **Use `axis=0`** (or restructure the computation so it works
38+
column-wise): the win comes from the Blosc2/numexpr compute engine (operator
39+
fusion, multi-threading) processing one large 1D array per call, and that
40+
only happens for columns. `axis=1` still calls the function once per row —
41+
same as plain pandas — and for a handful of columns, the overhead of
42+
wrapping each tiny row array for the compute engine outweighs any benefit,
43+
so `engine=blosc2.jit` with `axis=1` is typically *slower* than plain
44+
`apply(axis=1)`. See the benchmark below.
45+
46+
`Series.map(func, engine=blosc2.jit)` works the same way: `func` is called
47+
once with the Series' full underlying array.
48+
49+
## Limitations
50+
51+
- Only numeric dtypes are supported. A non-numeric (e.g. object-dtype or
52+
string) column raises a `ValueError` naming the limitation rather than
53+
attempting the computation.
54+
- `na_action="ignore"` is not supported for `map` and raises
55+
`NotImplementedError` — the vectorized-call contract means there is no
56+
per-element step at which to skip a value.
57+
- `Series.apply(func, engine=...)` and `DataFrame.map(func, engine=...)` do
58+
not reach `blosc2.jit` at all: pandas 3's `Series.apply` does not accept
59+
an `engine` keyword for non-string functions, and `DataFrame.map` doesn't
60+
forward `engine` to a dispatch mechanism at all. These are limitations of
61+
the pandas-side API surface, not of the Blosc2 engine. The two entry
62+
points that do reach the engine are `DataFrame.apply` and `Series.map`.
63+
64+
## Benchmark
65+
66+
`bench/bench_pandas_engine.py` compares `df.apply(f, engine=blosc2.jit)`
67+
against plain `df.apply(f)` (`axis=0`, the default) on a 1,000,000-row,
68+
8-column frame, for a multi-operation elementwise expression
69+
(`sin(x)*cos(x) + x**2 - sqrt(|x|) + exp(-x)`). Measured on the development
70+
machine (Apple M4, conda env with pandas 3.0.3):
71+
72+
```
73+
rows=1000000, cols=8
74+
plain df.apply(f): 0.1114 s
75+
df.apply(f, engine=blosc2.jit): 0.0260 s
76+
speedup: 4.3x
77+
```
78+
79+
Run the script for the numbers on your machine:
80+
81+
```
82+
python bench/bench_pandas_engine.py
83+
```

doc/reference/ctable.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,31 +309,52 @@ When a NumPy structured array is needed, materialize explicitly::
309309

310310
np.asarray(t[:10])
311311

312+
Chained pipelines
313+
~~~~~~~~~~~~~~~~~
314+
315+
:meth:`CTable.assign` returns a view with additional computed columns —
316+
never mutating the table, never copying column data — and :func:`blosc2.col`
317+
builds an unbound column expression that resolves against a table only when
318+
bound (in ``assign()``, ``t[...]``, or :meth:`CTable.where`). Together they
319+
enable pandas-3 style method chains::
320+
321+
from blosc2 import col
322+
323+
result = (
324+
t.assign(profit=col("revenue") - col("cost"))[col("profit") > 0]
325+
.sort_by("profit", ascending=False)
326+
.head(10)
327+
)
328+
312329
.. autosummary::
313330

314331
CTable.where
315332
CTable.dropna
316333
CTable.view
317334
CTable.take
318335
CTable.select
336+
CTable.assign
319337
CTable.head
320338
CTable.tail
321339
CTable.sample
322340
CTable.sort_by
323341
CTable.iter_sorted
324342
CTable.group_by
343+
col
325344

326345
.. automethod:: CTable.where
327346
.. automethod:: CTable.dropna
328347
.. automethod:: CTable.view
329348
.. automethod:: CTable.take
330349
.. automethod:: CTable.select
350+
.. automethod:: CTable.assign
331351
.. automethod:: CTable.head
332352
.. automethod:: CTable.tail
333353
.. automethod:: CTable.sample
334354
.. automethod:: CTable.sort_by
335355
.. automethod:: CTable.iter_sorted
336356
.. automethod:: CTable.group_by
357+
.. autofunction:: col
337358

338359

339360
Group-by reductions

0 commit comments

Comments
 (0)