Skip to content

Commit 7a2cce5

Browse files
FrancescAltedclaude
andcommitted
New dictionary() section for optimization tips
Backed by bench/optim_tips/tip_14_dictionary.py: utf8() vs dictionary() over the same 1 Mrow free-text column as the utf8 tip, at three levels of repetition (100 distinct, 20k distinct, near-unique). Three plots: grouping (log scale, since the near-unique group is two orders of magnitude above the rest), membership tests, and storage plus the full-column read. The operations that do not win are in the figures rather than in a footnote -- where(==) sits beside isin(), and the near-unique group appears in all three. Also links the utf8 tip's on-disk note to the new section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6e5f746 commit 7a2cce5

5 files changed

Lines changed: 393 additions & 1 deletion

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
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+
# Tip 14: when a text column's values repeat, store it as dictionary()
9+
# rather than utf8().
10+
#
11+
# A dictionary column is one int32 code per row plus one copy of each distinct
12+
# value, so grouping and membership tests run over integers instead of over
13+
# strings, and the stored column is a fraction of the size. The win is large
14+
# but it is *not* uniform, and this script measures the parts that do not win
15+
# as carefully as the parts that do:
16+
#
17+
# (a) tip_14a_dict_groupby.png -- group_by: time and peak memory
18+
# (b) tip_14b_dict_membership.png -- isin() wins; where(== ) is a wash
19+
# (c) tip_14c_dict_storage.png -- on disk, and the full-read trade
20+
#
21+
# Three cardinalities are measured throughout (100 distinct, 20k distinct,
22+
# near-unique) because the deciding question for this flavour is how often the
23+
# values repeat. The near-unique group is where every advantage reverses, for
24+
# one reason: opening the column builds a value->code cache by decoding the
25+
# whole dictionary, which at ~1M distinct values costs half a second and
26+
# hundreds of MB before any work starts.
27+
#
28+
# Same synthetic free-text catalogue as tip 13, so a reader moving between the
29+
# two sections is looking at the same column in a different flavour. The
30+
# vocabulary and generator are copied rather than imported: importing that
31+
# module would run its table-existence check (and possibly a multi-minute
32+
# rebuild) inside every measurement subprocess this one spawns.
33+
34+
import shutil
35+
import time
36+
from dataclasses import dataclass
37+
from pathlib import Path
38+
39+
import matplotlib.pyplot as plt
40+
import numpy as np
41+
42+
import blosc2
43+
from common import COLOR_NAIVE, COLOR_TIP, GRID, INK, MUTED, OUT_DIR, fmt_bytes, measure
44+
45+
N = 1_000_000
46+
EXTEND_ROWS = 250_000
47+
HERE = Path(__file__).parent
48+
49+
# One call per measurement, as in tip 13: repeating a read inflates its peak
50+
# RSS through allocator reuse, and peak memory is half of what two of these
51+
# three figures are about.
52+
BENCH_REPS = 1
53+
54+
# Same vocabulary as tip 13 (accented and CJK entries included).
55+
VOCAB = [
56+
"camino", "río", "montaña", "señalización", "überlandfahrt", "straßenbahn",
57+
"京都議定書", "東京湾岸", "sakura", "quietude", "amberglow", "hollowware",
58+
"lanternlight", "driftwood", "emberfall", "northbound", "saltmarsh", "cedarwood",
59+
"café", "niño", "vórtice", "光合成", "水平線", "風車小屋", "marinescape",
60+
"copperplate", "velveteen", "ashlar", "harbourside", "meridiano",
61+
] # fmt: skip
62+
63+
64+
@dataclass
65+
class RowUTF8:
66+
title: str = blosc2.field(blosc2.utf8())
67+
price: float = blosc2.field(blosc2.float64())
68+
69+
70+
@dataclass
71+
class RowDict:
72+
title: str = blosc2.field(blosc2.dictionary())
73+
price: float = blosc2.field(blosc2.float64())
74+
75+
76+
FLAVOURS = {"utf8": RowUTF8, "dict": RowDict}
77+
CARDS = {"100": 100, "20k": 20_000, "uniq": N}
78+
COMBOS = [(f, c) for f in FLAVOURS for c in CARDS]
79+
80+
81+
def path_for(flavour, card):
82+
return str(HERE / f"tip_14_{flavour}_{card}.b2d")
83+
84+
85+
def _make_titles(n, rng):
86+
"""n free-text titles: 2-22 vocabulary words, right-tailed word count."""
87+
nwords = np.clip(rng.lognormal(1.6, 0.6, n) + 2, 2, 22).astype(np.int32)
88+
words = rng.integers(0, len(VOCAB), int(nwords.sum()))
89+
titles, pos = [], 0
90+
for k in nwords:
91+
titles.append(" ".join(VOCAB[i] for i in words[pos : pos + k])[:200])
92+
pos += k
93+
return np.array(titles, dtype=np.dtypes.StringDType())
94+
95+
96+
def _is_built(urlpath):
97+
if not Path(urlpath).is_dir():
98+
return False
99+
try:
100+
return len(blosc2.CTable.open(urlpath)) == N
101+
except Exception:
102+
return False
103+
104+
105+
def _build_all():
106+
"""(Re)build every table. Only runs when something is missing."""
107+
rng = np.random.default_rng(42)
108+
price = rng.random(N) * 100.0
109+
ingest = {}
110+
111+
for card, ndistinct in CARDS.items():
112+
pool = _make_titles(ndistinct, rng)
113+
titles = pool if ndistinct == N else pool[rng.integers(0, ndistinct, N)]
114+
del pool
115+
print(f"[{card}] {len(np.unique(titles)):,} distinct titles over {N:,} rows")
116+
117+
for flavour, Row in FLAVOURS.items():
118+
base = path_for(flavour, card)
119+
shutil.rmtree(base, ignore_errors=True)
120+
t0 = time.perf_counter()
121+
with blosc2.CTable(Row, urlpath=base, mode="w", expected_size=N) as t:
122+
for i in range(0, N, EXTEND_ROWS):
123+
sl = slice(i, min(i + EXTEND_ROWS, N))
124+
t.extend({"title": titles[sl], "price": price[sl]}, validate=False)
125+
ingest[flavour, card] = time.perf_counter() - t0
126+
del titles
127+
128+
for card in CARDS:
129+
u, d = ingest["utf8", card], ingest["dict", card]
130+
print(f"ingest {card:>4}: utf8 {u:.2f}s dict {d:.2f}s ({u / d:.2f}x)")
131+
132+
133+
def du_title(urlpath):
134+
"""Just the text column -- its codes/offsets plus its values.
135+
136+
Not the whole directory (tip 13's figure): `price` would be more than half
137+
of it here, and this tip is about what the *text* costs.
138+
"""
139+
return sum(f.stat().st_size for f in (Path(urlpath) / "_cols").glob("title*"))
140+
141+
142+
if not all(_is_built(path_for(f, c)) for f, c in COMBOS):
143+
_build_all()
144+
145+
# Needles read back from the tables themselves, so they survive the
146+
# module-level build being skipped. Both flavours get the same values.
147+
_titles = {c: blosc2.CTable.open(path_for("utf8", c))["title"][7:12] for c in CARDS}
148+
NEEDLE = {c: str(v[0]) for c, v in _titles.items()}
149+
ISIN = {c: [str(x) for x in v] for c, v in _titles.items()}
150+
151+
152+
def _groupby_sum(flavour, card):
153+
t = blosc2.CTable.open(path_for(flavour, card))
154+
return len(t.group_by("title").sum("price"))
155+
156+
157+
def _groupby_size(flavour, card):
158+
t = blosc2.CTable.open(path_for(flavour, card))
159+
return len(t.group_by("title").size())
160+
161+
162+
def _isin(flavour, card):
163+
t = blosc2.CTable.open(path_for(flavour, card))
164+
return int(t["title"].isin(ISIN[card]).sum())
165+
166+
167+
def _where(flavour, card):
168+
t = blosc2.CTable.open(path_for(flavour, card))
169+
return len(t.where(f"title == {NEEDLE[card]!r}")[:])
170+
171+
172+
def _read(flavour, card):
173+
return blosc2.CTable.open(path_for(flavour, card))["title"][:]
174+
175+
176+
# fmt: off
177+
def gbysum_utf8_100(): return _groupby_sum("utf8", "100")
178+
def gbysum_dict_100(): return _groupby_sum("dict", "100")
179+
def gbysum_utf8_20k(): return _groupby_sum("utf8", "20k")
180+
def gbysum_dict_20k(): return _groupby_sum("dict", "20k")
181+
def gbysum_utf8_uniq(): return _groupby_sum("utf8", "uniq")
182+
def gbysum_dict_uniq(): return _groupby_sum("dict", "uniq")
183+
184+
def gbysize_utf8_100(): return _groupby_size("utf8", "100")
185+
def gbysize_dict_100(): return _groupby_size("dict", "100")
186+
def gbysize_utf8_20k(): return _groupby_size("utf8", "20k")
187+
def gbysize_dict_20k(): return _groupby_size("dict", "20k")
188+
def gbysize_utf8_uniq(): return _groupby_size("utf8", "uniq")
189+
def gbysize_dict_uniq(): return _groupby_size("dict", "uniq")
190+
191+
def isin_utf8_100(): return _isin("utf8", "100")
192+
def isin_dict_100(): return _isin("dict", "100")
193+
def isin_utf8_20k(): return _isin("utf8", "20k")
194+
def isin_dict_20k(): return _isin("dict", "20k")
195+
def isin_utf8_uniq(): return _isin("utf8", "uniq")
196+
def isin_dict_uniq(): return _isin("dict", "uniq")
197+
198+
def where_utf8_100(): return _where("utf8", "100")
199+
def where_dict_100(): return _where("dict", "100")
200+
def where_utf8_20k(): return _where("utf8", "20k")
201+
def where_dict_20k(): return _where("dict", "20k")
202+
def where_utf8_uniq(): return _where("utf8", "uniq")
203+
def where_dict_uniq(): return _where("dict", "uniq")
204+
205+
def read_utf8_100(): return _read("utf8", "100")
206+
def read_dict_100(): return _read("dict", "100")
207+
def read_utf8_20k(): return _read("utf8", "20k")
208+
def read_dict_20k(): return _read("dict", "20k")
209+
def read_utf8_uniq(): return _read("utf8", "uniq")
210+
def read_dict_uniq(): return _read("dict", "uniq")
211+
# fmt: on
212+
213+
214+
def grouped_bars(ax, title, groups, series, values, fmt, ylabel="Time (s)", legend_cols=1, log=False):
215+
"""One panel: len(groups) clusters of len(series) direct-labeled bars.
216+
217+
`log` for the grouping panels only, where the near-unique group is two
218+
orders of magnitude above the rest and a linear axis would flatten the
219+
comparison the tip is actually about into an invisible sliver.
220+
"""
221+
x = np.arange(len(groups), dtype=float)
222+
width = 0.8 / len(series)
223+
top = max(max(v) for v in values)
224+
bottom = min(min(v) for v in values)
225+
for i, (label, color) in enumerate(series):
226+
heights = [v[i] for v in values]
227+
offs = x + (i - (len(series) - 1) / 2) * width
228+
for xi, h in zip(offs, heights, strict=True):
229+
ax.bar(xi, h, width=width * 0.9, color=color, label=label if xi == offs[0] else None)
230+
y = h * 1.12 if log else h + top * 0.03
231+
ax.text(xi, y, fmt(h), ha="center", va="bottom", fontsize=7.5, color=INK)
232+
ax.set_xticks(x, groups, fontsize=8)
233+
ax.set_title(title, fontsize=9.5, color=INK)
234+
ax.set_ylabel(ylabel, color=INK, fontsize=9)
235+
ax.spines[["top", "right"]].set_visible(False)
236+
ax.spines[["left", "bottom"]].set_color(GRID)
237+
# Values are direct-labeled on the bars; labelleft=False rather than
238+
# set_yticklabels([]) so it also holds once a log scale re-formats the axis.
239+
ax.tick_params(colors=MUTED, labelsize=8, labelleft=False)
240+
ax.yaxis.grid(True, color=GRID, linewidth=0.8)
241+
ax.set_axisbelow(True)
242+
# Headroom for the legend, which sits over the bars.
243+
ax.set_ylim(*((bottom / 3, top * 8) if log else (0, top * 1.5)))
244+
if log:
245+
ax.set_yscale("log")
246+
ax.legend(fontsize=8, frameon=False, loc="upper left", ncol=legend_cols)
247+
248+
249+
def save(fig, name, rect=(0, 0, 1, 0.9)):
250+
fig.tight_layout(rect=rect)
251+
out_path = OUT_DIR / name
252+
fig.savefig(out_path, dpi=150)
253+
plt.close(fig)
254+
print(f"plot saved to {out_path}")
255+
256+
257+
if __name__ == "__main__":
258+
ops = ("gbysum", "gbysize", "isin", "where", "read")
259+
t, m = {}, {}
260+
for op in ops:
261+
for card in CARDS:
262+
for flavour in FLAVOURS:
263+
name = f"{op}_{flavour}_{card}"
264+
t[name], m[name] = measure(__file__, name)
265+
print(f"{name:<20} {t[name]:8.4f}s peak {fmt_bytes(m[name])}")
266+
267+
label = {
268+
"gbysum": "group_by.sum",
269+
"gbysize": "group_by.size",
270+
"isin": "isin(5 values)",
271+
"where": "where ==",
272+
"read": "full read",
273+
}
274+
print("\nratios are utf8 / dict, so > 1 means dictionary() wins")
275+
for op in ops:
276+
print(f"\n--- {label[op]} ---")
277+
for card in CARDS:
278+
u, d = t[f"{op}_utf8_{card}"], t[f"{op}_dict_{card}"]
279+
mu, md = m[f"{op}_utf8_{card}"], m[f"{op}_dict_{card}"]
280+
print(
281+
f" {card:>4}: utf8 {u:8.4f}s dict {d:8.4f}s ({u / d:5.2f}x) "
282+
f"peak utf8 {fmt_bytes(mu):>10} dict {fmt_bytes(md):>10} ({mu / md:5.2f}x)"
283+
)
284+
285+
print("\ntitle column on disk (codes/offsets + values, no price, no index):")
286+
for card in CARDS:
287+
u, d = du_title(path_for("utf8", card)), du_title(path_for("dict", card))
288+
print(f" {card:>4}: utf8 {fmt_bytes(u):>10} dict {fmt_bytes(d):>10} ({u / d:5.2f}x)")
289+
290+
series = (("utf8()", COLOR_NAIVE), ("dictionary()", COLOR_TIP))
291+
groups = ("100 different\ntitles", "20k different\ntitles", "titles nearly\nall different")
292+
secs = lambda v: f"{v:.2f}s" # noqa: E731
293+
msecs = lambda v: f"{v * 1000:.1f}ms" if v < 0.01 else f"{v * 1000:.0f}ms" # noqa: E731
294+
vals = lambda key: [[t[f"{key}_utf8_{c}"], t[f"{key}_dict_{c}"]] for c in CARDS] # noqa: E731
295+
mems = lambda key: [[m[f"{key}_utf8_{c}"], m[f"{key}_dict_{c}"]] for c in CARDS] # noqa: E731
296+
297+
# (a) Grouping -- the headline.
298+
fig, axes = plt.subplots(1, 2, figsize=(9.5, 3.7))
299+
fig.suptitle(
300+
f"Grouping a text column — {N // 1_000_000} Mrow, group_by('title').sum('price')",
301+
fontsize=11.5, color=INK,
302+
) # fmt: skip
303+
grouped_bars(
304+
axes[0], "Time", groups, series, vals("gbysum"), secs,
305+
ylabel="Time (s, log scale)", log=True,
306+
) # fmt: skip
307+
grouped_bars(
308+
axes[1], "Peak memory of that call", groups, series, mems("gbysum"), fmt_bytes,
309+
ylabel="Peak memory (log scale)", log=True,
310+
) # fmt: skip
311+
save(fig, "tip_14a_dict_groupby.png")
312+
313+
# (b) Membership: one win, one wash. Both, so the wash is visible.
314+
fig, axes = plt.subplots(1, 2, figsize=(9.5, 3.7))
315+
fig.suptitle(
316+
f"Selecting rows by value — {N // 1_000_000} Mrow", fontsize=11.5, color=INK
317+
) # fmt: skip
318+
grouped_bars(
319+
axes[0], "isin(5 values)", groups, series, vals("isin"), msecs, ylabel="Time (ms)"
320+
) # fmt: skip
321+
grouped_bars(
322+
axes[1], "where('title == ...')", groups, series, vals("where"), msecs, ylabel="Time (ms)"
323+
) # fmt: skip
324+
save(fig, "tip_14b_dict_membership.png")
325+
326+
# (c) What it costs to store, and what a full read trades.
327+
fig, axes = plt.subplots(1, 3, figsize=(12.5, 3.7))
328+
fig.suptitle(
329+
f"Storing and reading the column — {N // 1_000_000} Mrow", fontsize=11.5, color=INK
330+
) # fmt: skip
331+
grouped_bars(
332+
axes[0], "Text column on disk", groups, series,
333+
[[du_title(path_for("utf8", c)), du_title(path_for("dict", c))] for c in CARDS],
334+
fmt_bytes, ylabel="On disk",
335+
) # fmt: skip
336+
grouped_bars(
337+
axes[1], "Full read t['title'][:]", groups, series, vals("read"), msecs,
338+
ylabel="Time (ms)",
339+
) # fmt: skip
340+
grouped_bars(
341+
axes[2], "Peak memory of that read", groups, series, mems("read"), fmt_bytes,
342+
ylabel="Peak memory",
343+
) # fmt: skip
344+
save(fig, "tip_14c_dict_storage.png")
49.5 KB
Loading
35.3 KB
Loading
52.9 KB
Loading

0 commit comments

Comments
 (0)