Skip to content

Commit ef4b269

Browse files
committed
Rewrite the jit optimization tip around a worked Mandelbrot benchmark
The tip previously described the control-flow DSL route in prose with a code sketch that did not actually compile (its tuple assignment is not valid DSL, so jit silently fell back to tracing and raised at call time). Rewrite it around bench/optim_tips/tip_15_jit_control_flow.py, which measures the same escape-time kernel across plain Python, a vectorized NumPy mask loop, the default @blosc2.jit, and @blosc2.jit(jit_backend="cc"), plus the elementwise tracing contrast and the strict=False failure mode, and adds two plots. Document the DSL-form rules (simple assignments only, no docstrings in the kernel body) and the tradeoffs of forcing the system compiler.
1 parent b1e5c90 commit ef4b269

3 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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 15: @blosc2.jit with control flow. The same Mandelbrot escape-time
9+
# computation run four ways -- a plain Python per-element loop, a vectorized
10+
# NumPy mask iteration, @blosc2.jit (auto-detects the loop/break and compiles
11+
# the whole kernel as DSL), and @blosc2.jit(strict=True) (forces that route) --
12+
# plus the elementwise contrast that explains why functions *without* control
13+
# flow still trace: tracing is faster than the forced DSL route for pure
14+
# elementwise expressions. A final pair measures the same DSL kernels compiled
15+
# with jit_backend="cc" (system C compiler) instead of the bundled tcc.
16+
#
17+
# Peak memory is the same story for every variant here (the result array plus
18+
# a few temporaries), so the plot is time-only. The script prints a
19+
# correctness check of every mode against the pure-Python reference, and
20+
# shows that strict=False cannot even trace this function: the loop condition
21+
# depends on the traced arrays, so tracing raises instead of recording one
22+
# path for every element.
23+
24+
import numpy as np
25+
from common import COLOR_NAIVE, COLOR_TIP, GRID, INK, MUTED, OUT_DIR, measure
26+
27+
import blosc2
28+
29+
W = H = 512
30+
MAX_ITER = 64
31+
32+
# The grid. float32, matching what a real image pipeline would use.
33+
_ys = np.linspace(-1.2, 1.2, H, dtype=np.float32)
34+
_xs = np.linspace(-2.0, 0.6, W, dtype=np.float32)
35+
CR, CI = np.meshgrid(_xs, _ys)
36+
37+
38+
def mandel_py(cr, ci, max_iter):
39+
"""Reference: one Python loop per pixel. Correct but slow."""
40+
zr = zi = 0.0
41+
n = 0
42+
while zr * zr + zi * zi <= 4.0 and n < max_iter:
43+
zr, zi = zr * zr - zi * zi + cr, 2 * zr * zi + ci
44+
n += 1
45+
return n
46+
47+
48+
def mandel_numpy(cr, ci, max_iter):
49+
"""Vectorized alternative without jit: one masked iteration per step.
50+
51+
The fiddly bits are exactly what the jit kernel below gets for free: the
52+
alive/escaped bookkeeping, and the overflow trap (zr/zi keep growing after
53+
escape and go inf/nan in float32, so the mask loop must not rely on their
54+
values -- and nan would keep `alive` true forever if the bookkeeping ever
55+
missed an element).
56+
"""
57+
zr = np.zeros_like(cr)
58+
zi = np.zeros_like(cr)
59+
out = np.zeros(cr.shape, dtype=np.int32)
60+
alive = np.ones(cr.shape, dtype=bool)
61+
with np.errstate(over="ignore", invalid="ignore"):
62+
for k in range(max_iter):
63+
zr2 = zr * zr - zi * zi + cr
64+
zi2 = 2 * zr * zi + ci
65+
zr, zi = zr2, zi2
66+
escaped = (zr * zr + zi * zi) > 4.0
67+
newly = escaped & alive
68+
out[newly] = k + 1
69+
alive &= ~escaped
70+
if not alive.any():
71+
break
72+
out[alive] = max_iter
73+
return out
74+
75+
76+
@blosc2.jit
77+
def mandel_jit(cr, ci, max_iter):
78+
# Same computation, written the natural way. jit detects the control flow
79+
# at decoration time and compiles the whole function as a DSL kernel.
80+
# DSL-form rules: simple assignments only (a tuple assignment like
81+
# `zr, zi = ...` silently falls back to tracing, which then raises when
82+
# the branch is reached), and no docstring inside the kernel body.
83+
zr = 0.0
84+
zi = 0.0
85+
n = 0
86+
for _ in range(max_iter):
87+
if zr * zr + zi * zi > 4.0:
88+
break
89+
zr2 = zr * zr - zi * zi + cr
90+
zi = 2 * zr * zi + ci
91+
zr = zr2
92+
n += 1
93+
return n
94+
95+
96+
@blosc2.jit(strict=True)
97+
def mandel_strict(cr, ci, max_iter):
98+
zr = 0.0
99+
zi = 0.0
100+
n = 0
101+
for _ in range(max_iter):
102+
if zr * zr + zi * zi > 4.0:
103+
break
104+
zr2 = zr * zr - zi * zi + cr
105+
zi = 2 * zr * zi + ci
106+
zr = zr2
107+
n += 1
108+
return n
109+
110+
111+
@blosc2.jit(jit_backend="cc")
112+
def mandel_cc(cr, ci, max_iter):
113+
# Same kernel, but compiled with the system C compiler (clang/gcc) instead
114+
# of the bundled tcc: slower one-time compile, faster generated code.
115+
zr = 0.0
116+
zi = 0.0
117+
n = 0
118+
for _ in range(max_iter):
119+
if zr * zr + zi * zi > 4.0:
120+
break
121+
zr2 = zr * zr - zi * zi + cr
122+
zi = 2 * zr * zi + ci
123+
zr = zr2
124+
n += 1
125+
return n
126+
127+
128+
def py_loop():
129+
return np.array(
130+
[[mandel_py(CR[y, x], CI[y, x], MAX_ITER) for x in range(W)] for y in range(H)],
131+
dtype=np.int32,
132+
)
133+
134+
135+
def numpy_masked():
136+
return mandel_numpy(CR, CI, MAX_ITER)
137+
138+
139+
def jit_default():
140+
return mandel_jit(CR, CI, MAX_ITER)
141+
142+
143+
def jit_strict():
144+
return mandel_strict(CR, CI, MAX_ITER)
145+
146+
147+
def jit_cc():
148+
return mandel_cc(CR, CI, MAX_ITER)
149+
150+
151+
# --- Elementwise contrast: why functions without control flow still trace ---
152+
153+
X = np.random.default_rng(0).random(8_000_000, dtype=np.float32)
154+
155+
156+
@blosc2.jit
157+
def elementwise(x):
158+
return (
159+
np.sin(x)
160+
+ np.cos(x * 2)
161+
+ np.exp(x * 0.5) * np.sin(x * 3)
162+
+ np.sqrt(np.abs(x))
163+
+ np.log1p(np.abs(x))
164+
)
165+
166+
167+
@blosc2.jit(strict=True)
168+
def elementwise_dsl(x):
169+
return (
170+
np.sin(x)
171+
+ np.cos(x * 2)
172+
+ np.exp(x * 0.5) * np.sin(x * 3)
173+
+ np.sqrt(np.abs(x))
174+
+ np.log1p(np.abs(x))
175+
)
176+
177+
178+
@blosc2.jit(strict=True, jit_backend="cc")
179+
def elementwise_dsl_cc(x):
180+
return (
181+
np.sin(x)
182+
+ np.cos(x * 2)
183+
+ np.exp(x * 0.5) * np.sin(x * 3)
184+
+ np.sqrt(np.abs(x))
185+
+ np.log1p(np.abs(x))
186+
)
187+
188+
189+
def trace_route():
190+
return elementwise(X)
191+
192+
193+
def dsl_route():
194+
return elementwise_dsl(X)
195+
196+
197+
def dsl_route_cc():
198+
return elementwise_dsl_cc(X)
199+
200+
201+
def bars(ax, title, labels, values, fmt, log=False, colors=None):
202+
"""One cluster of direct-labeled bars, tip-14 style.
203+
204+
`colors`: per-bar colors; default is the 2-bar naive/tip convention
205+
(first two bars naive-blue, the rest tip-aqua).
206+
"""
207+
x = np.arange(len(labels), dtype=float)
208+
top = max(values)
209+
bottom = min(values)
210+
for i, h in enumerate(values):
211+
color = colors[i] if colors is not None else (COLOR_NAIVE if i < 2 else COLOR_TIP)
212+
ax.bar(i, h, width=0.55, color=color)
213+
y = h * 1.12 if log else h + top * 0.03
214+
ax.text(i, y, fmt(h), ha="center", va="bottom", fontsize=8.5, color=INK)
215+
ax.set_xticks(x, labels, fontsize=8)
216+
ax.set_title(title, fontsize=9.5, color=INK)
217+
ax.set_ylabel("Time (s, log scale)" if log else "Time (s)", color=INK, fontsize=9)
218+
ax.spines[["top", "right"]].set_visible(False)
219+
ax.spines[["left", "bottom"]].set_color(GRID)
220+
ax.tick_params(colors=MUTED, labelsize=8, labelleft=False)
221+
ax.yaxis.grid(True, color=GRID, linewidth=0.8)
222+
ax.set_axisbelow(True)
223+
ax.set_ylim(*((bottom / 3, top * 8) if log else (0, top * 1.5)))
224+
if log:
225+
ax.set_yscale("log")
226+
227+
228+
def save(fig, name):
229+
fig.tight_layout()
230+
out_path = OUT_DIR / name
231+
fig.savefig(out_path, dpi=150)
232+
plt.close(fig)
233+
print(f"plot saved to {out_path}")
234+
235+
236+
if __name__ == "__main__":
237+
import matplotlib.pyplot as plt
238+
239+
# --- Correctness: every mode against the pure-Python reference ---
240+
ref = py_loop()
241+
for name, fn in (
242+
("numpy_masked", numpy_masked),
243+
("jit_default", jit_default),
244+
("jit_strict", jit_strict),
245+
("jit_cc", jit_cc),
246+
):
247+
ok = np.array_equal(fn(), ref)
248+
print(f"{name:<13} correct: {ok}")
249+
assert ok, name
250+
251+
@blosc2.jit(strict=False)
252+
def mandel_trace(cr, ci, max_iter):
253+
zr = 0.0
254+
zi = 0.0
255+
n = 0
256+
for _ in range(max_iter):
257+
if zr * zr + zi * zi > 4.0:
258+
break
259+
zr2 = zr * zr - zi * zi + cr
260+
zi = 2 * zr * zi + ci
261+
zr = zr2
262+
n += 1
263+
return n
264+
265+
try:
266+
mandel_trace(CR, CI, MAX_ITER)
267+
raise AssertionError("strict=False should not be able to trace this function")
268+
except ValueError as e:
269+
print("strict=False raises at call time as expected:", str(e)[:70], "...")
270+
271+
# Same expression, two engines: results agree to float32 precision (1 ulp)
272+
# even though trace and DSL associate the arithmetic differently.
273+
eq = np.allclose(elementwise(X), elementwise_dsl(X), rtol=1e-6, atol=1e-7)
274+
print(f"elementwise trace/dsl agree: {eq}")
275+
assert eq
276+
eq_cc = np.allclose(elementwise_dsl_cc(X), elementwise_dsl(X), rtol=1e-6, atol=1e-7)
277+
print(f"elementwise tcc/cc agree: {eq_cc}")
278+
assert eq_cc
279+
280+
# --- Timings ---
281+
mandel_names = ("py_loop", "numpy_masked", "jit_default", "jit_strict", "jit_cc")
282+
t = {}
283+
for name in mandel_names + ("trace_route", "dsl_route", "dsl_route_cc"):
284+
t[name], rss = measure(__file__, name)
285+
print(f"{name:<13} {t[name]:8.4f}s peak {rss / 1e6:6.1f} MB")
286+
287+
print("\nmandelbrot speedups vs py_loop:")
288+
for name in mandel_names[1:]:
289+
print(f" {name:<13} {t['py_loop'] / t[name]:6.1f}x faster than py_loop")
290+
print(f" elementwise: trace {t['dsl_route'] / t['trace_route']:.2f}x faster than forced DSL")
291+
print("\njit_backend='cc' vs default tcc (steady state):")
292+
print(f" mandel: {t['jit_default'] / t['jit_cc']:.2f}x faster with cc")
293+
print(f" elementwise: {t['dsl_route'] / t['dsl_route_cc']:.2f}x faster with cc")
294+
295+
msecs = lambda v: f"{v * 1000:.0f}ms" # noqa: E731
296+
297+
fig, ax = plt.subplots(figsize=(6.5, 3.2))
298+
fig.suptitle(
299+
f"Mandelbrot escape times, {W}×{H} grid, max_iter={MAX_ITER}",
300+
fontsize=10.5, color=INK,
301+
) # fmt: skip
302+
bars(
303+
ax, "Time",
304+
("Python\nper-element", "NumPy\nmask loop", "@jit\n(default)", '@jit\n(jit_backend="cc")'),
305+
[t[n] for n in ("py_loop", "numpy_masked", "jit_default", "jit_cc")], msecs, log=True,
306+
) # fmt: skip
307+
save(fig, "tip_15a_jit_control_flow.png")
308+
309+
fig, ax = plt.subplots(figsize=(6.5, 3.2))
310+
fig.suptitle(
311+
"Elementwise expression, 8M float32 — no control flow, so jit traces",
312+
fontsize=10.5, color=INK,
313+
) # fmt: skip
314+
bars(
315+
ax, "Time",
316+
("@jit\n(default)", '@jit(strict=True)\n(jit_backend="tcc")', '@jit(strict=True)\n(jit_backend="cc")'),
317+
[t["trace_route"], t["dsl_route"], t["dsl_route_cc"]], msecs,
318+
colors=(COLOR_NAIVE, COLOR_TIP, COLOR_TIP),
319+
) # fmt: skip
320+
save(fig, "tip_15b_jit_elementwise.png")
28.3 KB
Loading
23.8 KB
Loading

0 commit comments

Comments
 (0)