Skip to content

Commit 5b9045f

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): slot-fill output + boundary bench with generic baseline (TASK-45 stretch 3)
Measurement killed an assumption: pydantic v2's model_construct is pure-Python and SLOWER than model_validate (1432ns vs 882ns per row, pydantic 2.13). The marshaller now builds output rows the way the design doc meant — object.__new__ + direct slot writes (__dict__, __pydantic_fields_set__, extra, private), 491ns measured — and the bench gained a 'generic' engine (SPECIALIZER_GENERIC_BOUNDARY) as the AC #2 baseline. Results: marshaller beats the generic boundary 1.9-2.5x on the no-op f; the specializer beats the shipping native engine 3.7-4.2x end-to-end at n=1024 and 1.5-2.8x at n=1; the JIT-vs- interp compute gap is finally visible through Python. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a811005 commit 5b9045f

2 files changed

Lines changed: 59 additions & 16 deletions

File tree

scripts/bench_specializer.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
"""Specializer ns/call bench — the design doc §10 measurement discipline.
22
33
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)
4+
cranelift — the specializer's JIT backend through the generated row
5+
marshaller (the default production path)
76
interp — the same programs on the interpreter backend (the control;
8-
SPECIALIZER_FORCE_INTERP=1 in a subprocess)
7+
SPECIALIZER_FORCE_INTERP=1 in a subprocess), also marshalled
8+
generic — the JIT backend behind the PRE-marshaller generic boundary
9+
(SPECIALIZER_GENERIC_BOUNDARY=1): per-cell getattr with
10+
fresh name strings, per-call buffers, model_validate per
11+
output row. generic vs cranelift on the noop case IS the
12+
marshaller's win (TASK-45 AC #2).
913
native — the existing DataFusion-semantics InferFn
1014
codegen — the existing Python codegen engine
1115
1216
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
17+
The env-knob engines re-exec this script in a subprocess so the knob is
1418
set before the module builds its functions.
1519
"""
1620

@@ -79,7 +83,11 @@ def main():
7983
dim = pa.table({"id": list(range(60)), "name": [f"n{i}" for i in range(60)]})
8084
engine = os.environ.get("BENCH_ENGINE", "cranelift")
8185

82-
builders = {"cranelift": build_specializer, "interp": build_specializer}
86+
builders = {
87+
"cranelift": build_specializer,
88+
"interp": build_specializer,
89+
"generic": build_specializer,
90+
}
8391
if engine in ("native", "codegen"):
8492
builders = {"native": build_native, "codegen": build_codegen}
8593

@@ -90,9 +98,11 @@ def main():
9098
except Exception as e: # noqa: BLE001 -- engines differ in coverage
9199
results[case] = {"error": str(e)[:120]}
92100
continue
93-
if engine in ("cranelift", "interp"):
101+
if engine in ("cranelift", "interp", "generic"):
94102
want = "interpreter" if engine == "interp" else "cranelift"
95103
assert fn.backend == want, f"{case}: backend is {fn.backend}, wanted {want}"
104+
wantb = "generic" if engine == "generic" else "marshaller"
105+
assert fn.boundary == wantb, f"{case}: boundary {fn.boundary} != {wantb}"
96106
per_n = {}
97107
for n in NS:
98108
objs = [model(**r) for r in rows_of(n)]
@@ -105,11 +115,13 @@ def main():
105115
def orchestrate():
106116
"""Run every engine (interp in a subprocess for the env knob), merge."""
107117
merged = {}
108-
for engine in ("cranelift", "interp", "native", "codegen"):
118+
for engine in ("cranelift", "interp", "generic", "native", "codegen"):
109119
env = os.environ.copy()
110120
env["BENCH_ENGINE"] = engine
111121
if engine == "interp":
112122
env["SPECIALIZER_FORCE_INTERP"] = "1"
123+
if engine == "generic":
124+
env["SPECIALIZER_GENERIC_BOUNDARY"] = "1"
113125
out = subprocess.run( # noqa: S603 -- fixed argv
114126
[sys.executable, __file__, "--engine-run"],
115127
env=env,

src/duckdb/mod.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use std::collections::HashMap;
1111

1212
use pyo3::prelude::*;
13-
use pyo3::types::{PyDict, PyString};
13+
use pyo3::types::{PyDict, PySet, PyString};
1414

1515
use crate::error::InterpError;
1616
use crate::schema;
@@ -209,10 +209,21 @@ impl Backend {
209209
struct Marshaller {
210210
in_names: Vec<Py<PyString>>,
211211
out_names: Vec<Py<PyString>>,
212-
/// `output_model.model_construct`, resolved at build. Outputs come out
213-
/// of the typed engine already conformant; re-validating them per row
214-
/// was the single largest boundary cost.
215-
construct: Py<PyAny>,
212+
/// Output rows are built by filling pydantic v2's instance slots
213+
/// directly (`object.__new__` + `object.__setattr__` of `__dict__`,
214+
/// `__pydantic_fields_set__`, `__pydantic_extra__`,
215+
/// `__pydantic_private__`) — what `model_construct` does, minus its
216+
/// pure-Python per-field loop (measured 2026-07-26 on pydantic 2.13:
217+
/// construct 1432ns > validate 882ns > slot fill 491ns per row).
218+
/// Assumes a plain model: no `extra="allow"`, no private attrs —
219+
/// true of synthesized output models; supplied ones are v0-trusted.
220+
model: Py<PyAny>,
221+
object_new: Py<PyAny>,
222+
object_setattr: Py<PyAny>,
223+
s_dict: Py<PyString>,
224+
s_fields_set: Py<PyString>,
225+
s_extra: Py<PyString>,
226+
s_private: Py<PyString>,
216227
cols: Vec<ColData>,
217228
state: RunState,
218229
}
@@ -225,6 +236,7 @@ impl Marshaller {
225236
output_model: &Py<PyAny>,
226237
fun: &Backend,
227238
) -> PyResult<Marshaller> {
239+
let object = PyModule::import(py, "builtins")?.getattr("object")?;
228240
Ok(Marshaller {
229241
in_names: in_cols
230242
.iter()
@@ -234,7 +246,13 @@ impl Marshaller {
234246
.iter()
235247
.map(|c| PyString::intern(py, &c.name).unbind())
236248
.collect(),
237-
construct: output_model.bind(py).getattr("model_construct")?.unbind(),
249+
model: output_model.clone_ref(py),
250+
object_new: object.getattr("__new__")?.unbind(),
251+
object_setattr: object.getattr("__setattr__")?.unbind(),
252+
s_dict: PyString::intern(py, "__dict__").unbind(),
253+
s_fields_set: PyString::intern(py, "__pydantic_fields_set__").unbind(),
254+
s_extra: PyString::intern(py, "__pydantic_extra__").unbind(),
255+
s_private: PyString::intern(py, "__pydantic_private__").unbind(),
238256
cols: in_cols.iter().map(|c| ColData::new(c.ty.ty)).collect(),
239257
state: fun.new_state(),
240258
})
@@ -314,7 +332,14 @@ impl Marshaller {
314332
self.cols = batch.cols;
315333
res.map_err(|t| PyErr::from(InterpError::Eval(t.0)))?;
316334

317-
let construct = self.construct.bind(py);
335+
let model = self.model.bind(py);
336+
let object_new = self.object_new.bind(py);
337+
let object_setattr = self.object_setattr.bind(py);
338+
let s_dict = self.s_dict.bind(py);
339+
let s_fields_set = self.s_fields_set.bind(py);
340+
let s_extra = self.s_extra.bind(py);
341+
let s_private = self.s_private.bind(py);
342+
let none = py.None();
318343
let mut out = Vec::with_capacity(self.state.emitted);
319344
for r in 0..self.state.emitted {
320345
let d = PyDict::new(py);
@@ -339,7 +364,13 @@ impl Marshaller {
339364
}
340365
}
341366
}
342-
out.push(construct.call((), Some(&d))?.unbind());
367+
let fields_set = PySet::new(py, self.out_names.iter().map(|n| n.bind(py)))?;
368+
let inst = object_new.call1((model,))?;
369+
object_setattr.call1((&inst, s_dict, &d))?;
370+
object_setattr.call1((&inst, s_fields_set, fields_set))?;
371+
object_setattr.call1((&inst, s_extra, &none))?;
372+
object_setattr.call1((&inst, s_private, &none))?;
373+
out.push(inst.unbind());
343374
}
344375
Ok(out)
345376
}

0 commit comments

Comments
 (0)