Skip to content

bench: restore the duckdb arm, and price a return to it - #391

Closed
FBumann wants to merge 2 commits into
mainfrom
bench/duckdb-engine-spike
Closed

bench: restore the duckdb arm, and price a return to it#391
FBumann wants to merge 2 commits into
mainfrom
bench/duckdb-engine-spike

Conversation

@FBumann

@FBumann FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Draft. This half is meant to merge — the engine port that answers it is stacked on top in #392 and is not.

Why

The polars/duckdb decision has already flipped once (#189, three days ago), and the reasoning did not survive on its own: I had a stored note from the day before #189 landed that still said "keep duckdb". A decision that reverses on a 24-hour boundary needs provenance rather than memory, and this repo already has the machinery for that — #189's harness had a duckdb foreign arm, and it was deleted along with the engine.

What

The arm is back. duckdb runs this tree's harness, models, ladder and parquet cache under another checkout's interpreter; only the engine package comes from there. That is what keeps fleet, sector and the xl/2xl rungs — all added since — covered rather than silently skipped.

Two gaps had opened since it was deleted:

  • The package was renamed (refactor: rename the package to lpspec, drop the farkas branding #336), so _engine() absorbs farkas under its old name.
  • The expression: surface replaced equations:, so today's models are a validation error to that engine. _dialect rewrites the declaration on the way in, before the clock starts — a rewrite rather than a second copy of each model, because copies drift and the drift stays invisible until it changes an optimum.

The gate is what makes that checkable: it agrees to 0.0e+00 relative on all six cases, including the two the duckdb checkout has never seen.

bench.report --arms SUBJECT AGAINST renders the comparison through the same tool as the published ladder rather than a second renderer that drifts.

What the numbers say

Six cases, xs..xl, both sinks, best of two. The duckdb arm ran at its own default memory_limit='1GB'fk.build defaults to it, so this is the engine as it shipped against the engine as it ships.

The write path is the column #189 never published, because its headline compares to a loaded solver where the build is ~9% of peak:

case vars wall: polars wall: duckdb peak: polars peak: duckdb lighter
dispatch l 10M 2.34 s 7.67 s 2.05 GB 0.76 GB 2.69×
dispatch xl 40M 17.99 s 28.23 s 4.38 GB 1.39 GB 3.15×
profiled xl 48M 36.73 s 86.68 s 7.48 GB 1.78 GB 4.20×

The peak gap widens with the model (1.11 / 1.25 / 1.63 / 2.69 / 3.15 up dispatch's ladder). On the solver path it collapses to 1.15–1.35×, and at profiled/l polars is lighter (0.95×) — kept in the table rather than smoothed.

fleet/xl failed on the duckdb arm, OutOfMemoryException at 953 MiB. That is a 1 GB budget being too tight for 48M variables, not the engine breaking — and it is what a ceiling is for. Recorded rather than dropped.

What this is not

Not a proposal to switch back. duckdb-spike.md prices the reversal honestly (~2,300 of 3,317 engine lines; primal()'s return type the one public break) and its conclusion is that this is a product question about lps.write, not an engine question — and that if the write path matters, a duckdb-backed lp_file sink is the cheaper move, since a sink is a module and not an engine.

Reviewing

  • bench/duckdb-spike.md is the argument; everything else is machinery.
  • 709 passed, 4 skipped, 1 xfailed; ruff clean.
  • Two things found along the way that are independent of any duckdb decision: hard rule 2 says engine-internal naming encodes neither "polars" nor "yaml", but PolarsCompiler/PolarsExecutor violate it in 44 places and test_architecture.py does not enforce that clause. Not fixed here — flagged.

🤖 Generated with Claude Code

FBumann and others added 2 commits July 31, 2026 11:59
#189 replaced the duckdb engine with polars and deleted the harness machinery
that had measured the two against each other. Pricing that decision again needs
the engine it replaced, and the only honest version of that number comes from
the engine as it shipped rather than from a fresh port written to lose.

So `duckdb` is a foreign arm again: this tree's harness, models, ladder and
parquet cache, run under another checkout's interpreter. Only the engine
package comes from there, which is what keeps `fleet`, `sector` and the
`xl`/`2xl` rungs — all added since — covered rather than silently skipped.

Two gaps had opened since the arm was deleted. The package was renamed
(#336), so `_engine()` absorbs `farkas` under its old name; and the
`expression:` surface replaced `equations:`, so today's models are a validation
error to that engine. `_dialect` rewrites the declaration on the way in, before
the clock starts — a rewrite rather than a second copy of each model, because
copies drift and the drift stays invisible until it changes an optimum. The
parity gate is what makes that checkable: it agrees to 0.0e+00 relative on all
six cases, including the two the duckdb checkout has never seen.

`bench.report --arms SUBJECT AGAINST` renders the comparison through the same
tool as the published ladder, rather than a second renderer that drifts.

`duckdb-spike.md` carries the costing: ~2,300 of the engine's 3,317 lines
rewritten, ~6,300 above the plan untouched, `primal()`'s return type the one
public break. `sql_tax.py` measures what the hardest operators cost in SQL,
against the polars compiler on live data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six cases, xs..xl, both sinks, both engines, best of two. The duckdb arm ran at
its own default `memory_limit='1GB'` — not a choice made here, `fk.build`
defaults to it, so this is the engine as it shipped against the engine as it
ships. That default is also the whole difference in kind: one arm has a ceiling
and spills, the other's peak tracks the model.

The write path is the column #189 never published, because its headline
compares to a loaded solver where the build is ~9% of peak and any build-side
difference is diluted. Measured directly it is not small: duckdb is 2.69x
lighter at dispatch/l, 3.15x at dispatch/xl and 4.20x at profiled/xl, and the
gap *widens* up the ladder (1.11 / 1.25 / 1.63 / 2.69 / 3.15). The wall gap does
not widen with it — 3.27x at l but 1.57x at xl — because polars does more work
per byte once the model stops fitting comfortably.

`fleet/xl` failed on the duckdb arm, OutOfMemoryException at 953 MiB, twice.
That is a 1 GB budget being too tight for 48M variables rather than the engine
breaking, and it is the behaviour a ceiling exists for: it fails instead of
taking the machine with it. Recorded rather than dropped — a failure is a
result here.

On the solver path the engines converge to 1.15-1.35x, and at profiled/l polars
is *lighter* (0.95x). Kept in the table rather than smoothed: the gap is a
consequence of how much of the build survives to the hand-off, not a law.

So the trade is not "slower and lighter, plus a knob" — it is a bounded build
worth 1.6-2.4x wall clock on the write path and almost nothing on the solve
path. That makes it a product question about `lps.write`, not an engine
question, and §9 now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f497f4e6-d760-48ce-b599-dc5b5c261f51

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@FBumann

FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #399. The valuable half moved there; the rest was made obsolete by the engine landing in-tree.

Kept, in #399: bench/duckdb-spike.md, the ladder results (bench/results/duckdb-spike.jsonl), and bench.report --arms SUBJECT AGAINST. Those had to move — five modules and the [duckdb] extra cite the spike doc for numbers they claim, so leaving it here meant #399 asserting "2.1-4.2x lighter" with the provenance on another branch.

Dropped: the foreign-arm machinery (FOREIGN_ARMS, --duckdb-root, _dialect, _engine()). It existed to reach an engine that was not in the tree, through another checkout's interpreter, translating today's expression: models back to equations: on the way. With the engine in-tree, --arms lpspec duckdb sets LPSPEC_ENGINE for the child and none of that is needed — one process per measurement is exactly what makes an environment variable the right tool.

The committed results are still the old shipped engine measured that way, and the doc now says so rather than quietly re-baselining against the port.

@FBumann FBumann closed this Jul 31, 2026
@FBumann
FBumann deleted the bench/duckdb-engine-spike branch July 31, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant