Skip to content

Re-encoding the support set dominates tabfm_classify: 71-80% of a call, paid again on every call #37

Description

@maxdemarzi

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-rocketscripts/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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions