|
| 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") |
0 commit comments