Skip to content
About
Main content

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 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

For the simple expression, how many rows must a query process before compiling it is faster than interpreting it?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    compilation-break-even.py75 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Total time to process n tuplesApple M2 · 8 cores · 8 GB
InterpretedCompiledCompiled, first compile in the process
Expression
Data table
Total time to process n tuples
TuplesInterpretedCompiledCompiled, first compile in the process
1K51.2 µs28.7 ms106 ms
10K512 µs28.7 ms106 ms
100K5.12 ms28.8 ms106 ms
1M51.2 ms30.1 ms107 ms
10M512 ms43.4 ms121 ms
100M5.12 s176 ms253 ms
1B51.2 s1.5 s1.58 s
10B512 s14.8 s14.8 s
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · numba 0.68.0 · run 2026-10-20

Measured

Measured
Expressionnumba start-upCompileSaving per tuple (ns)Break-even (tuples)Break-even with start-up
simple77.3 ms28.7 ms49.8576K2.13M
complex77.3 ms27.6 ms197140K532K
Output · uv run compilation-break-even.py
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.