Measured while costing a two-model routing system built on this extension. Posting the numbers
because they make a fairly strong case for a reusable support-set context, and because the shape of
the cost is not obvious from the API.
What was measured
One idle 16-core AMD EPYC 4564P, 124 GB, nothing else running. tabicl-v2, CPU EP, e=1,
--test-chunk 128, 500 features per call. Two UCR datasets, each run at two query-batch sizes in
the same process invocation, so the two points differ only in the number of query rows:
| dataset |
support rows |
query rows |
seconds per call |
| Herring |
64 |
14 |
1.525 |
| Herring |
64 |
64 |
1.978 |
| ScreenType |
375 |
22 |
5.265 |
| ScreenType |
375 |
128 |
6.289 |
Solving seconds = a + b * query_rows on each pair:
| dataset |
fixed per call |
marginal per query row |
fixed share of a 128-row call |
| Herring |
1.398 s |
9.0 ms |
71% |
| ScreenType |
5.053 s |
9.7 ms |
80% |
The fixed term scales with the support set: 1.40 s at 64 support rows and 5.05 s at 375, about
11.8 ms per support row across the two. The marginal term is stable at ~9 ms per query row and
agrees with a third estimate — a regression of per-call seconds on (support rows, query rows) across
51 archived runs gives 9.3 ms, independently of the two fits above.
These are read from the per-call timings rather than wall clock, so process startup (0.5 s of a
63 s call) and feature computation are excluded and not attributed to the model.
Why this is worth a feature
The support set does not change between calls in any deployment I can think of. It is the training
data. But tabfm_classify(train, y, test := ...) re-encodes it on every call, so:
- Chunking is punished twice.
--test-chunk is the documented way to bound memory, and each
chunk re-sends the whole support set. Measured on ItalyPowerDemand: chunk-128 took 1074 s against
493 s unchunked, 2.18x, for byte-identical predictions (1029/1029 rows). The fit above,
which never saw that run, predicts 2.04x for it from the support/query split alone — so most of
that factor is the repeated support pass.
- Serving a small batch is nearly as expensive as serving everything. Routing — run a cheap
model everywhere, escalate only the rows it is unsure of — should be the natural way to use an
expensive in-context model. Measured, escalating 22% of a Herring batch cost 77% of running
the teacher on every row, and 17% of a ScreenType batch cost 83%. The row count falls and the
cost does not, because the fixed pass is most of it.
- It gets worse as the training set grows, which is the opposite of the usual expectation, and
it is the fixed term that grows.
An ensemble over feature groups multiplies all of this: 40 groups against one dataset is 40 support
passes over the same rows in different feature subsets, plus another factor per chunk.
Why it cannot be done downstream
The tensor contract is a single forward pass over support and query concatenated:
inputs: x [1, T, H] support and query rows in one tensor
y [1, T]
train_size the index that splits support from query
outputs: logits [1, T, C]
Nothing intermediate is exposed, so an extension-level or caller-level cache has nothing to hold.
Reusing the encoded support would need the exported graph split at the support/query boundary, with
the encoded support as an output of the first stage and an input of the second — which is export
work in tools/export_*, per architecture, and not something to send as an outside patch. Hence an
issue rather than a PR.
What a user-facing shape might look like
Not a design proposal, just the smallest thing that would capture the win:
-- encode once
CREATE TABLE ctx AS SELECT tabfm_prepare('train_tbl', 'y', model := 'tabicl-v2', features := [...]);
-- reuse across calls; support rows never re-encoded
FROM tabfm_classify(context := ctx, test := 'batch_1');
FROM tabfm_classify(context := ctx, test := 'batch_2');
Invalidation is easy to state: the handle is keyed by (model, feature list, support contents), and
any change to those is a new handle.
Where the numbers come from
All reproducible from https://github.com/maxdemarzi/duckdb-rocket — scripts/route_serve.py serve --compare produces the two-batch-size fit and prints the decomposition; reference/RESULTS.md
section "What routing actually costs" has the full working, and the chunking measurement is under
"Where the wall clock goes".
Happy to test a branch against the 28-dataset harness here if that is useful — it covers CPU and
CUDA and would give you an accuracy-identity check as well as timings.
Measured while costing a two-model routing system built on this extension. Posting the numbers
because they make a fairly strong case for a reusable support-set context, and because the shape of
the cost is not obvious from the API.
What was measured
One idle 16-core AMD EPYC 4564P, 124 GB, nothing else running.
tabicl-v2, CPU EP, e=1,--test-chunk 128, 500 features per call. Two UCR datasets, each run at two query-batch sizes inthe same process invocation, so the two points differ only in the number of query rows:
Solving
seconds = a + b * query_rowson each pair:The fixed term scales with the support set: 1.40 s at 64 support rows and 5.05 s at 375, about
11.8 ms per support row across the two. The marginal term is stable at ~9 ms per query row and
agrees with a third estimate — a regression of per-call seconds on (support rows, query rows) across
51 archived runs gives 9.3 ms, independently of the two fits above.
These are read from the per-call timings rather than wall clock, so process startup (0.5 s of a
63 s call) and feature computation are excluded and not attributed to the model.
Why this is worth a feature
The support set does not change between calls in any deployment I can think of. It is the training
data. But
tabfm_classify(train, y, test := ...)re-encodes it on every call, so:--test-chunkis the documented way to bound memory, and eachchunk re-sends the whole support set. Measured on ItalyPowerDemand: chunk-128 took 1074 s against
493 s unchunked, 2.18x, for byte-identical predictions (1029/1029 rows). The fit above,
which never saw that run, predicts 2.04x for it from the support/query split alone — so most of
that factor is the repeated support pass.
model everywhere, escalate only the rows it is unsure of — should be the natural way to use an
expensive in-context model. Measured, escalating 22% of a Herring batch cost 77% of running
the teacher on every row, and 17% of a ScreenType batch cost 83%. The row count falls and the
cost does not, because the fixed pass is most of it.
it is the fixed term that grows.
An ensemble over feature groups multiplies all of this: 40 groups against one dataset is 40 support
passes over the same rows in different feature subsets, plus another factor per chunk.
Why it cannot be done downstream
The tensor contract is a single forward pass over support and query concatenated:
Nothing intermediate is exposed, so an extension-level or caller-level cache has nothing to hold.
Reusing the encoded support would need the exported graph split at the support/query boundary, with
the encoded support as an output of the first stage and an input of the second — which is export
work in
tools/export_*, per architecture, and not something to send as an outside patch. Hence anissue rather than a PR.
What a user-facing shape might look like
Not a design proposal, just the smallest thing that would capture the win:
Invalidation is easy to state: the handle is keyed by (model, feature list, support contents), and
any change to those is a new handle.
Where the numbers come from
All reproducible from https://github.com/maxdemarzi/duckdb-rocket —
scripts/route_serve.py serve --compareproduces the two-batch-size fit and prints the decomposition;reference/RESULTS.mdsection "What routing actually costs" has the full working, and the chunking measurement is under
"Where the wall clock goes".
Happy to test a branch against the 28-dataset harness here if that is useful — it covers CPU and
CUDA and would give you an accuracy-identity check as well as timings.