Compute a compilation break-even
Measure your machineNeeds Python with NumPy, numba
Problem
Vectorization makes each boundary cheap. Compilation removes the boundaries altogether: the engine generates one tight loop for the whole pipeline. But compiling takes time before the first row is processed. That is a fixed cost paid per query, not per row, so compilation has a break-even data volume: break-even = compile time ÷ time saved per row. Below it, the interpreter wins outright.
Turn "compilation has a break-even data volume" into a measured number. Use Python with numba. Compare an interpreted Python loop with a compiled one.
The loop is a filter and a sum over 1 million values, timed in plain Python and compiled with numba (a compiler for Python functions), for a simple expression and for one with five more arithmetic operations. You will measure the compile time, the time per row each way, and the break-even row count they imply.
The question. How many rows must a query process before compiling it is faster than interpreting it, and how does that change when the expression gets more complex?
Predict
Expect a compile time of a few tenths of a second to about a second, a saving of about 30 to 100 ns per tuple, and a break-even of a few million to tens of millions of tuples. Repeat with a more complex expression (add five arithmetic operations): the saving per tuple rises and the break-even falls.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
compilation-break-even.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "numba"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Compute a compilation break-even. An interpreted Python loop against the same loop compiled with numba: the compile cost, the saving per tuple, and the data volume at which compiling pays. """ import argparse, json, statistics, time import numpy as np from numba import njit p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1_000_000, help="Tuples per timed run.") p.add_argument("--expressions", type=lambda s: s.split(","), default=["simple", "complex"], help="Which loop bodies to time: simple (one compare) and/or complex (five more arithmetic operations).") p.add_argument("--repeats", type=int, default=3, help="Runs per timing; the median is reported.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def py_sum(a, thr): s = 0.0 for v in a: if v > thr: s += v return s def py_complex(a, thr): s = 0.0 for v in a: w = (v * 1.5 + 0.25) * v - v / 3.0 # five arithmetic operations if w > thr: s += w return s kernels = {"simple": py_sum, "complex": py_complex} def median_time(fn, a): runs = [] for _ in range(args.repeats): t = time.perf_counter(); fn(a, 0.5); runs.append(time.perf_counter() - t) return statistics.median(runs) a = np.random.default_rng(0).random(args.rows) # numba's own start-up (loading LLVM and its type system) is paid once per process, on the first # compile; time it on a throwaway function so each expression below shows its compile cost alone t = time.perf_counter(); njit(lambda v: v + 1)(1.0); startup = time.perf_counter() - t print(f"numba start-up (once per process): {startup:.2f} s") results, curve = [], [] for name in args.expressions: py_fn = kernels[name] nb_fn = njit(py_fn) # a fresh compiled copy t = time.perf_counter(); nb_fn(a, 0.5); compile_cost = time.perf_counter() - t # first call = compile + run py = median_time(py_fn, a) / len(a) nb = median_time(nb_fn, a) / len(a) saving = py - nb break_even, break_even_cold = compile_cost / saving, (startup + compile_cost) / saving results.append({"expression": name, "startup_s": startup, "compile_s": compile_cost, "interpreted_ns": py * 1e9, "compiled_ns": nb * 1e9, "saving_ns": saving * 1e9, "break_even_tuples": break_even, "break_even_cold_tuples": break_even_cold}) print(f"{name:>8}: compile {compile_cost:.2f} s · saving {saving * 1e9:.1f} ns/tuple · " f"break-even {break_even / 1e6:.1f} M tuples ({break_even_cold / 1e6:.1f} M counting start-up)") for n in (10 ** k for k in range(3, 11)): # total time to process n tuples each way curve.append({"expression": name, "tuples": n, "interpreted_s": py * n, "compiled_s": compile_cost + nb * n, "compiled_cold_s": startup + compile_cost + nb * n}) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"results": results, "curve": curve}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run compilation-break-even.py uv run compilation-break-even.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1,000,000 | Tuples per timed run. |
| --expressionssimple, complex | Which loop bodies to time: simple (one compare) and/or complex (five more arithmetic operations). |
| --repeats3 | Runs per timing; the median is reported. |
Our run
Data table
| Tuples | Interpreted | Compiled | Compiled, first compile in the process |
|---|---|---|---|
| 1K | 51.2 µs | 28.7 ms | 106 ms |
| 10K | 512 µs | 28.7 ms | 106 ms |
| 100K | 5.12 ms | 28.8 ms | 106 ms |
| 1M | 51.2 ms | 30.1 ms | 107 ms |
| 10M | 512 ms | 43.4 ms | 121 ms |
| 100M | 5.12 s | 176 ms | 253 ms |
| 1B | 51.2 s | 1.5 s | 1.58 s |
| 10B | 512 s | 14.8 s | 14.8 s |
Measured
| Expression | numba start-up | Compile | Saving per tuple (ns) | Break-even (tuples) | Break-even with start-up |
|---|---|---|---|---|---|
| simple | 77.3 ms | 28.7 ms | 49.8 | 576K | 2.13M |
| complex | 77.3 ms | 27.6 ms | 197 | 140K | 532K |
numba start-up (once per process): 0.08 s
simple: compile 0.03 s · saving 49.8 ns/tuple · break-even 0.6 M tuples (2.1 M counting start-up)
complex: compile 0.03 s · saving 197.3 ns/tuple · break-even 0.1 M tuples (0.5 M counting start-up)What we found
numba compiled the simple loop in 28.7 ms and saved 49.8 ns per row, in the expected 30 to 100 ns. The break-even came out at 576K rows, below the expected millions, because the compile itself was much faster than "a few tenths of a second". The first compile in a process also pays numba's one-time start-up (77.3 ms); counting it, as timing the first call does, the break-even is 2.13M rows. Both are shown on the chart and in the table.
The complex expression behaved as predicted: it saved 197 ns per row for about the same compile time, so its break-even fell to 140K rows. A real engine compiles a whole pipeline through a full compiler, so its compile time, and its break-even, are larger than this small loop's.
Read it
This is why engines start by interpreting and compile in the background. Start interpreting at once, compile in the background, and switch when the compiled code is ready and the query is still running: short queries never pay the compile, long ones get the compiled speed for most of their rows.
On the chart, the interpreted line rises with the row count from almost nothing; each compiled line starts at its compile cost and rises much more slowly. Where they cross is the break-even, and the more work each row needs, the earlier they cross.