Skip to content

Commit 25fc76d

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): ns/call bench per §10 — boundary baseline, four engines, raw-compute isolation (TASK-44 stretch 4)
scripts/bench_specializer.py reports p50/p99 ns/call at n in {1,8,64,1024} for the no-op boundary baseline, cranelift, the interpreter control (SPECIALIZER_FORCE_INTERP env knob, also a debugging escape hatch), and the existing native + codegen engines. Measured on this machine: the pydantic boundary dominates end-to-end (a no-op passthrough costs within ~20% of a full join), cranelift and interp are at parity through Python, and both beat native/codegen by ~1.5-2x at larger n. The ignored backend_compute_bench isolates raw compute without the boundary: cranelift 13.2 ns/row vs interpreter 37.6 ns/row (2.8x) — the JIT win is real and waiting behind the boundary, which is M-boundary's job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fed084d commit 25fc76d

3 files changed

Lines changed: 233 additions & 18 deletions

File tree

scripts/bench_specializer.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Specializer ns/call bench — the design doc §10 measurement discipline.
2+
3+
Reports p50/p99 ns per call at n in {1, 8, 64, 1024} for:
4+
boundary — a no-op passthrough (SELECT a FROM __THIS__) through the
5+
generic pydantic path: the floor every engine pays today
6+
cranelift — the specializer's JIT backend (default)
7+
interp — the same programs on the interpreter backend (the control;
8+
SPECIALIZER_FORCE_INTERP=1 in a subprocess)
9+
native — the existing DataFusion-semantics InferFn
10+
codegen — the existing Python codegen engine
11+
12+
Run: `uv run python scripts/bench_specializer.py [--json out.json]`
13+
The interp control re-execs this script in a subprocess so the env knob is
14+
set before the module builds its functions.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import os
21+
import subprocess
22+
import sys
23+
import time
24+
25+
import pyarrow as pa
26+
from pydantic import create_model
27+
28+
NS = [1, 8, 64, 1024]
29+
CASES = {
30+
"noop": "SELECT a FROM __THIS__",
31+
"arith+where": "SELECT a * 2 + 1 AS x, b / 2 AS h FROM __THIS__ WHERE a % 3 <> 0",
32+
"strings": "SELECT upper(s) || '-' || a AS t, substr(s, 2, 3) AS m FROM __THIS__",
33+
"join": "SELECT a, name FROM __THIS__ JOIN dim ON a = dim.id",
34+
}
35+
36+
37+
def rows_of(n):
38+
return [{"a": i % 60, "b": (i % 7) / 3.0, "s": f"item{i}"} for i in range(n)]
39+
40+
41+
def quantiles(samples_ns):
42+
s = sorted(samples_ns)
43+
return s[len(s) // 2], s[min(len(s) - 1, int(len(s) * 0.99))]
44+
45+
46+
def bench_fn(f, n):
47+
iters = max(30, min(3000, 3_000_000 // max(n, 1)))
48+
f() # warm
49+
out = []
50+
for _ in range(iters):
51+
t0 = time.perf_counter_ns()
52+
f()
53+
out.append(time.perf_counter_ns() - t0)
54+
return quantiles(out)
55+
56+
57+
def build_specializer(sql, model, dim):
58+
from sql_transform._interpreter import DuckDBInferFn
59+
60+
return DuckDBInferFn(
61+
sql, row_tables={"__THIS__": model}, static_tables={"dim": dim}
62+
)
63+
64+
65+
def build_native(sql, model, dim):
66+
from sql_transform._interpreter import InferFn
67+
68+
return InferFn(sql, row_tables={"__THIS__": model}, static_tables={"dim": dim})
69+
70+
71+
def build_codegen(sql, model, dim):
72+
from sql_transform._codegen import CodegenFn
73+
74+
return CodegenFn(sql, row_tables={"__THIS__": model}, static_tables={"dim": dim})
75+
76+
77+
def main():
78+
model = create_model("Row", a=(int, ...), b=(float, ...), s=(str, ...))
79+
dim = pa.table({"id": list(range(60)), "name": [f"n{i}" for i in range(60)]})
80+
engine = os.environ.get("BENCH_ENGINE", "cranelift")
81+
82+
builders = {"cranelift": build_specializer, "interp": build_specializer}
83+
if engine in ("native", "codegen"):
84+
builders = {"native": build_native, "codegen": build_codegen}
85+
86+
results = {}
87+
for case, sql in CASES.items():
88+
try:
89+
fn = builders[engine](sql, model, dim)
90+
except Exception as e: # noqa: BLE001 -- engines differ in coverage
91+
results[case] = {"error": str(e)[:120]}
92+
continue
93+
if engine in ("cranelift", "interp"):
94+
want = "interpreter" if engine == "interp" else "cranelift"
95+
assert fn.backend == want, f"{case}: backend is {fn.backend}, wanted {want}"
96+
per_n = {}
97+
for n in NS:
98+
objs = [model(**r) for r in rows_of(n)]
99+
p50, p99 = bench_fn(lambda f=fn, o=objs: f.infer({"__THIS__": o}), n)
100+
per_n[n] = {"p50_ns": p50, "p99_ns": p99}
101+
results[case] = per_n
102+
print(json.dumps({engine: results}))
103+
104+
105+
def orchestrate():
106+
"""Run every engine (interp in a subprocess for the env knob), merge."""
107+
merged = {}
108+
for engine in ("cranelift", "interp", "native", "codegen"):
109+
env = os.environ.copy()
110+
env["BENCH_ENGINE"] = engine
111+
if engine == "interp":
112+
env["SPECIALIZER_FORCE_INTERP"] = "1"
113+
out = subprocess.run( # noqa: S603 -- fixed argv
114+
[sys.executable, __file__, "--engine-run"],
115+
env=env,
116+
capture_output=True,
117+
text=True,
118+
check=True,
119+
)
120+
merged.update(json.loads(out.stdout.strip().splitlines()[-1]))
121+
122+
# Human table: p50 ns/call (p99 in parens), one row per case+engine.
123+
hdr = f"{'case':<12} {'engine':<10}" + "".join(f"{f'n={n}':>16}" for n in NS)
124+
print(hdr)
125+
print("-" * len(hdr))
126+
for case in CASES:
127+
for engine, per_case in merged.items():
128+
r = per_case.get(case)
129+
if r is None:
130+
continue
131+
if "error" in r:
132+
print(f"{case:<12} {engine:<10} unsupported: {r['error'][:60]}")
133+
continue
134+
cells = "".join(
135+
f"{r[str(n)]['p50_ns'] if str(n) in r else r[n]['p50_ns']:>13,}ns"
136+
for n in NS
137+
)
138+
print(f"{case:<12} {engine:<10}{cells}")
139+
if "--json" in sys.argv:
140+
path = sys.argv[sys.argv.index("--json") + 1]
141+
with open(path, "w", encoding="utf-8") as f:
142+
json.dump(merged, f, indent=2)
143+
print(f"wrote {path}")
144+
145+
146+
if __name__ == "__main__":
147+
if "--engine-run" in sys.argv:
148+
main()
149+
else:
150+
orchestrate()

src/duckdb/mod.rs

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -324,25 +324,33 @@ impl DuckDBInferFn {
324324
data.push(materialize_map(py, table, spec, keys, values)?);
325325
}
326326

327-
let fun = match cranelift::compile(&prepared.program, data) {
328-
Ok(f) => Backend::Cranelift(f),
329-
// The failed attempt consumed the static data; rematerialize on
330-
// this cold path and fall back to the interpreter.
331-
Err(_) => {
332-
let mut data = Vec::with_capacity(prepared.statics.len());
333-
for (spec, sty) in prepared.statics.iter().zip(&prepared.program.statics) {
334-
let StaticTy::Map { keys, values } = sty else {
335-
return Err(build_err("internal: v0 lowering emits only map statics"));
336-
};
337-
let table = static_tables
338-
.get(&spec.table)
339-
.expect("spec names come from the catalog");
340-
data.push(materialize_map(py, table, spec, keys, values)?);
327+
// SPECIALIZER_FORCE_INTERP pins the interpreter — the bench control
328+
// and a debugging escape hatch.
329+
let force_interp = std::env::var_os("SPECIALIZER_FORCE_INTERP").is_some();
330+
let fun = match (force_interp, data) {
331+
(true, data) => Backend::Interp(
332+
compile(&prepared.program, data).map_err(|e| build_err(e.to_string()))?,
333+
),
334+
(false, data) => match cranelift::compile(&prepared.program, data) {
335+
Ok(f) => Backend::Cranelift(f),
336+
// The failed attempt consumed the static data; rematerialize
337+
// on this cold path and fall back to the interpreter.
338+
Err(_) => {
339+
let mut data = Vec::with_capacity(prepared.statics.len());
340+
for (spec, sty) in prepared.statics.iter().zip(&prepared.program.statics) {
341+
let StaticTy::Map { keys, values } = sty else {
342+
return Err(build_err("internal: v0 lowering emits only map statics"));
343+
};
344+
let table = static_tables
345+
.get(&spec.table)
346+
.expect("spec names come from the catalog");
347+
data.push(materialize_map(py, table, spec, keys, values)?);
348+
}
349+
Backend::Interp(
350+
compile(&prepared.program, data).map_err(|e| build_err(e.to_string()))?,
351+
)
341352
}
342-
Backend::Interp(
343-
compile(&prepared.program, data).map_err(|e| build_err(e.to_string()))?,
344-
)
345-
}
353+
},
346354
};
347355
let output_model = match output_model {
348356
// Supplied models are trusted as-is in v0 (no shape validation).

src/specializer/exec/tests.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,3 +850,60 @@ fn fuzz_cranelift_agrees_with_interpreter() {
850850
}
851851
}
852852
}
853+
854+
/// Raw compute, no Python boundary: `cargo test --release backend_compute_
855+
/// bench -- --ignored --nocapture`. Informational, not a gate assertion.
856+
#[test]
857+
#[ignore = "bench, run explicitly"]
858+
fn backend_compute_bench() {
859+
use super::cranelift;
860+
use std::time::Instant;
861+
let p = parse(
862+
r#"fn f(in: batch{a: i64, b: f64}, out: batch{x: i64, h: f64, k: i1}) {
863+
entry:
864+
%a = load in.a
865+
%b = load in.b
866+
%two = const.i64 2
867+
%one = const.i64 1
868+
%m = imul %a, %two
869+
%x = iadd %m, %one
870+
%hf = const.f64 0.5
871+
%h = fmul %b, %hf
872+
%z = const.f64 10.0
873+
%k = fcmp.gt %h, %z
874+
store out.x, %x
875+
store out.h, %h
876+
store out.k, %k
877+
emit
878+
}"#,
879+
)
880+
.unwrap();
881+
let n = 100_000usize;
882+
let a: Vec<Option<i64>> = (0..n).map(|i| Some(i as i64 % 1000)).collect();
883+
let b: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64 / 7.0)).collect();
884+
let input = batch(n, vec![c_i64(&a), c_f64(&b)]);
885+
886+
let fi = compile(&p, vec![]).unwrap();
887+
let fc = cranelift::compile(&p, vec![]).unwrap();
888+
let mut sti = fi.new_state();
889+
let mut stc = fc.new_state();
890+
for (name, run) in [
891+
(
892+
"interp",
893+
&mut (|| fi.run(&input, &mut sti).unwrap()) as &mut dyn FnMut(),
894+
),
895+
("cranelift", &mut (|| fc.run(&input, &mut stc).unwrap())),
896+
] {
897+
run(); // warm
898+
let mut best = u128::MAX;
899+
for _ in 0..20 {
900+
let t = Instant::now();
901+
run();
902+
best = best.min(t.elapsed().as_nanos());
903+
}
904+
println!(
905+
"{name:>10}: {:>6.2} ns/row (best of 20, n={n})",
906+
best as f64 / n as f64
907+
);
908+
}
909+
}

0 commit comments

Comments
 (0)