Skip to content

Add AI.CLASSIFY with document KV reuse across query operations - #178

Merged
shreyashankar merged 177 commits into
mainfrom
claude/beautiful-brown-atl3of
Oct 3, 2026
Merged

shreyashankar merged 177 commits into
mainfrom
claude/beautiful-brown-atl3of

Conversation

@shreyashankar

@shreyashankar shreyashankar commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Problem

Quail could filter and join documents with AI.IF, but it could not assign classification labels within a query. Queries with several model operations could also process the same document repeatedly after its KV was released.

Solution

Add AI.CLASSIFY to SQL and the Python builder so queries can assign labels, filter on labels, and classify rows produced by a join. The PR also adds 12 classification queries to quail-b to evaluate the feature.

Refactor execution to reuse document KV across consecutive model operations. KV is the model's saved attention keys and values for tokens it has already processed. Keeping document KV in GPU memory avoids processing the same document again for each operation.

For example, imagine a table of movie reviews. A query can filter reviews, assign sentiment labels, and join selected reviews with movie aspects while reusing the review KV.

flowchart LR
    scan["Read reviews"] --> filter["Filter reviews with AI.IF"]
    filter -->|"Reuse review KV"| classify["Assign sentiment with AI.CLASSIFY"]
    classify --> labels["Filter on sentiment labels"]
    labels -->|"Reuse review KV"| join["Join reviews with aspects using AI.IF"]
    join --> result["Return selected columns"]
Loading
  • The planner selects a supported label scoring method using estimated GPU time.
  • The Quail backend can return label probabilities. Greedy label decoding and classification of joined rows do not return probabilities. Filters on probabilities are not supported.
  • Supported models are Qwen3 4B FP8, Qwen3 32B FP8, and DiffusionGemma 26B-A4B FP8. Each GPU holds a complete model copy.
  • The PR also separates logical and physical planning rules, updates benchmark execution and token accounting, and rewrites the documentation.

Experimental results

Across the 12 new classification queries, total query time was 2,781 seconds on Quail, compared with 5,676 seconds on stock vLLM. Both used Qwen3 4B FP8 at scale factor 0.5 on one H100. Stock vLLM submitted one operator at a time with prefix caching. Query times exclude model startup. At the configured price of $3.9492 per GPU hour, total GPU cost was $3.05 for Quail and $6.23 for stock vLLM.

No stock vLLM comparison is recorded for DiffusionGemma.

Agreement with the Qwen3 32B reference labels was lower on AGENT-3 at 0.833 for Quail, compared with 0.886 for stock vLLM. On BIO-5 and BIO-6, agreement was 0.720, compared with 0.812. The vLLM calculation includes only rows with a recognized label. The cause remains unresolved and needs comparison across scoring methods.

Measurement sources

Results are on the quail-results volume. The measured commits precede the latest change to join execution. That change has not been measured.

  • Quail engine commit f15c5ca2, rescored with the corrected token minimum. /results/benchmarks/quailb/20261002T061804Z-a6ccc1fe-minfix/quail
  • Stock vLLM engine commit 6deb4fc, rescored with decoded tokens included in fresh tokens. /results/benchmarks/quailb/20261001T010217Z-1e7f7ba8-minfix/stock_vllm
  • Rescoring used quail-b f3ad796. The current dependency pin is 4298e7a, the merge commit for that benchmark change.
Checks
  • CI passes on 954d5f39. Ruff, long string checks, Vulture, and pytest pass. The local suite has 201 tests passed and 9 skipped.
  • Tests cover query syntax, label scoring, result columns, classification answers, mixed execution pipelines, and KV retention. Five duplicate tests were removed while preserving their distinct checks.
  • The documentation production build passes.
  • GPU execution was not repeated for the documentation and test cleanup. Label probabilities have CPU test coverage but no GPU validation in the recorded measurements.

Closes #105

A new cell scores each classification label as the sum of its tokens'
log probabilities under Qwen3 32B fp8 on vLLM 0.26.0. It sends one
request per shared label prefix and reads the next tokens' log
probabilities with logprob_token_ids, which keeps prefix caching; prompt
log probabilities would bypass the cache and recompute each document
once per label. Each document's first request fills the cache before
the others run.

The cell labels sf=1.0 in shards, copies labels by document content to
sf=0.5 and sf=0.1, and extends the published collections, reusing every
filter and join label set. activate_reused_collection gains new_specs
for predicates outside PREDICATES. quail-b moves to the commit that
defines the classification queries.
Quail's runtime has no AI.CLASSIFY yet, so its QUAIL-B runner leaves
classification queries out of default runs and reports an explicitly
requested one as skipped, instead of failing to read its plan.
The classification queries are now in quail-b's default list. Quail's
runner still skips them, and the tests expect them in QUERY_ORDER but
not among the queries Quail plans. The labeling cell's collection
check selects queries by their classifications, since none is pending
any more.
…n-atl3of

# Conflicts:
#	tests/test_quailb.py
fsdatalab/quail-bench#31 was squash-merged as b47d212, with the same
tree as the branch commit 3738036 pinned before.
A classification is a column-producing model call, planned and run
like AI.SCORE: each document is a join anchor whose prompt head and
document stay in KV, the classification question and category list
are written once after it, and one suffix per label-trie node reads
the next token's log probabilities over the whole vocabulary. Each
label's score is the sum over its tokens; a tie goes to the earlier
label. A label filter keeps documents whose label is accepted.

Quail keeps the full output head only when it is tied to the
embedding, so Qwen3 4B classifies and Qwen3 32B refuses. Joins,
AI.IF filters beside a classification, diffusion models, and the
request backends refuse for now. The QUAIL-B runner reads ai_classify
plans and label filters, and runs IMDB-11, IMDB-14, BIO-5, FEV-11,
and AGENT-4.
The labeling cell's judge takes a model, and quail_check labels the
documents of a QUAIL-B run with stock vLLM on Qwen3 4B, then reports
which labels differ from Quail's and by how much vLLM's scores
separate them.
The cell scores the documents where a Quail run and stock vLLM chose
different labels, and a sample where they agreed, four ways: Quail's
frame path, Quail with the question in the anchor prefix, Quail with
float32 logits, and vLLM's full-sequence prompt log probabilities.
@shreyashankar shreyashankar changed the title Label QUAIL-B classification queries with stock vLLM and pin quail-b Run one-table AI.CLASSIFY on Quail and pin quail-b's classification queries Sep 28, 2026
Add the label_chains scoring rule: one request per label, holding the
answer cue and all but the label's last token, with every row's log
probabilities read. The trie_nodes rule stays selectable, so the two
can be compared on the same query.

- The executor's pack_chunk takes read_all_rows and records
  rows_per_answer; AsyncLabelLogprobs pads each suffix's rows into one
  fixed-size record, so JoinAdmission.report needs no change.
- ClassifySpec names its scoring rule; EngineConfig.label_scoring
  forces one for a session, and the planner records it in the plan
  settings. The planner costs each rule by its own suffix lengths.
- The classify planner is a plain loop over steps with a small table
  record, without the closure it had.
- The bench runner and the parallel Modal runner take --label-scoring.
When every label is one token the readout holds one row per chain and
returns (labels, targets); reshape it before chain scoring. IMDB-11's
sentiment labels hit this on the first label_chains run.
score_in_batches replaced the extension metrics with the output name,
aliases, and input rows, so label_tokens never reached the report.
Sum it over batches for AiClassify nodes.
The prefix_sharing rule now fires for a classification as it does for
a join: its documents' KV is written anyway, so any whole shared page
pays. The classifier builds the prefix tree over its document
prefixes and hands it to run_join, which packs a child from its shared
length on. A classification that shares prefixes scores every row in
one call, so parents and children meet in the same tree. Borrowed
tokens are reported per node as borrowed_prefix_tokens.
Add the trie_paths scoring rule, now the default: one chain per
deepest proper prefix of the label trie, every row read and every
label token returned. The answer cue's row scores all one-token labels
at once, so a one-token label set costs one request per document, and
labels sharing leading tokens share the rows after them. The rules'
requests and scoring are one LabelRequests record per rule.
@shreyashankar
shreyashankar marked this pull request as draft September 28, 2026 19:26
The compile and touch passes now run a classification-shaped join
chunk: the question as each document's frame, then 26 suffixes of one
to six tokens. WARMUP_VERSION moves to 5 so the marker forces one
compile pass. The bench records the file count under each kernel
cache directory before and after every Quail query, so a kernel
compiled inside a query's time shows as growth.
…bench

The classifier now turns its forward chunks' CUDA events into gpu_s
and chunks on the node's metrics when the session asks for GPU
timing, as filters and joins do. The bench CLIs take --gpu-timing to
ask for it.
A classify node now carries later stages: classifications of the same
documents, each gated by the accepted labels of the label filter
before it. The executor runs every stage in one join call, so a
document goes on to the next stage while its head and document are
still in KV, packing only that stage's frame and label suffixes; the
join admission takes an advance predicate that reads the gate from
the stage's label. The stages share one readout over every stage's
label tokens. Later labels come back as extra columns of the scores
table, and each stage gets its own classify answer table.

The planner folds a classification into the node before it when both
run on the same alias with the same prompt head and at most one label
filter, on the earlier label, lies between them. Its estimate streams
a later stage's frame and suffixes against a resident document.

The score node's inputs now carry the query's gpu_timing choice, so
classify nodes report gpu_s and chunks like filters and joins.
@shreyashankar shreyashankar changed the title Run one-table AI.CLASSIFY on Quail and pin quail-b's classification queries Run one-table AI.CLASSIFY on Quail: label scoring rules, prefix sharing, and chained stages Sep 28, 2026
A Stage is what one model operator asks of each document at one step:
its frame, its request suffixes, the readout for its answers, which
suffixes each document sends, and the decision that lets a document
go on. run_stages drives a list of stages over documents with one
admission, so a document's KV is computed once and stays resident
while it moves through every operator's stage. Each stage has its own
readout; a frame the stage before already wrote is not written again;
a survivor limit ends admission; an unpaged mode runs a lone
single-suffix stage without arena pages.

run_join is now a wrapper that builds one Stage per join stage, so
joins, AI.SCORE, and classify chains run on the scheduler unchanged.
filter_stages builds one Stage per question: the preamble the
questions share is the frame, written once at the first stage, and
each stage's suffix is its question past the preamble. A single-suffix
stage packs the frame and its suffix as one entry written straight
into the document's pages, and under tree attention a borrowing
document reads its parent's pages stacked with its siblings, as the
filter loop did. run_filter delegates to run_stages: the unpaged path
for a lone stage, the survivor limit, retention through on_settled,
and the per-chunk finished-document callback through on_chunk.
A filter chain that feeds a join now runs as leading stages of the
join's own run: every document of the chain is an anchor, its
questions are the first stages, and a survivor goes on to the join's
stages with its KV resident. The join's per-document partner lists
are asked when the document reaches the stage, so a per-batch
transform between the chain and the join runs on each survivor there
and can drop it. The join's anchors are the documents that reached
it, in the chain's order.

The filter stream and the filter admission are gone: run_stages with
JoinAdmission is the scheduler for filters, joins, AI.SCORE, and
classifications, on the worker's child-join path as well. Tests that
built the stream now build stages.
A family whose queries Quail cannot run yet, such as the classify
and join queries, left run_suite with no query ids and quail-b
refused the run. The suite now returns a record with no queries and
the skipped ids, in the shape the parallel runner merges.

Add experiments/cells/profile_query.py: one QUAIL-B query under
cProfile on Modal, to read the host time between forward passes.
The cell shares the parallel runner's Modal app, whose entrypoint is
already called main.
- The bench counted kernel-cache files inside the timed window, and
  walking the caches on the volume took seconds. The count now runs
  before the query's clock starts and counts top-level entries only.
- The readout head is kept in bf16: the fp32 head made every
  full-vocabulary matmul an fp32 GEMM, 2.8 of BIO-5's 3.5 GPU
  seconds. Logits are computed in bf16, as vLLM computes them, and
  normalized in float32.
- A chunk's token parts are joined on the host and copied into pinned
  memory once, instead of one copy per suffix: 41,636 copies on
  BIO-5.
The Quail physical planner chose each table's AI.IF filter order, the
joins' stage order and anchors, and which KV stays resident while it
built the graph, so EXPLAIN could not name those decisions and no
extension could see them. They are rules now.

Logical rules, in order: projection_pushdown, filter_pushdown,
classify_placement, filter_order, and join_order. filter_order
records each SemanticFilter's execution order on the node;
join_order runs the left-deep search once per plan and records each
SemanticJoin's execution position and anchor (exec_idx, exec_anchor),
leaving the written anchor as the constraint it was. The search
result is memoized on the planning context, so classify_placement's
candidate pricing and the registered rule share one search.

Physical rules, in order: kv_retention, label_scoring, prefix_sharing,
and tree_attention. kv_retention computes the retention schedule from
the decided stages, sets keep_kv, arena_writes, and keep_anchor_kv,
and puts the schedule in the plan's retention setting through the
context; it fires once, so a plan refined on exact tokens keeps the
schedule it was made with, as before.

plan_quail now only translates the decided logical plan into physical
nodes, with its refusals. The filter-order cost code moved to
quail.planner.filters and the shared statistics to
quail.planner.statistics. Physical plans are unchanged for every
QUAIL-B query at sf 0.5 on qwen3-4b-fp8 and
diffusion-gemma-26b-a4b-fp8, first and refined, except for the rule
remarks.
A plan carried remarks: a line for each rule that changed it, and
advice such as a forced join anchor that priced higher than a free one.
EXPLAIN printed them under its settings. Every plan named
projection_pushdown, so the lines said little, and Spark keeps rule
changes out of a plan too (spark.sql.planChangeLog.level is an opt-in
log). PhysicalPlan, PlanningContext, and LogicalPlanningContext lose
their remarks, plan_query its remarks argument, and join_order no
longer runs a second search with forced anchors released, which only
priced that advice. A run report's remarks keep the session's notes.

Physical plans for every QUAIL-B query at sf 0.5 on Qwen3 4B and
DiffusionGemma are identical to 3b0946a. Tests that read remarks now
check what each rule set on the plan.
A filter on an AI.CLASSIFY label makes no model call, so the logical
plan now holds it as a Filter node with an InList condition on the
column the SemanticClassify adds, instead of a LabelIn predicate inside
a SemanticFilter. SemanticFilter holds model calls only. LabelIn is
removed.

The planner reads label filters through Operators.label_filters
(LabelFilter: the call, values, selectivity, and written position after
the table's AI.IF predicates), so physical node ids are unchanged.
Logical EXPLAIN prints the condition, such as
"Filter: m.organ_class IN [...]", where it printed the classification's
prompt before.

Physical plans of the 45 quail-b queries, planned twice each, and of
12 synthetic classification cases are identical before and after.
Moves the quail-b pin to fsdatalab/quail-bench#35, which adds IMDB-16
and its five guideline checks as labels-pending filter predicates.
experiments/cells/imdb16_labels.py labels each check over the sf=1.0
reviews on its own H100 with Qwen3 32B through Quail, copies the labels
to sf=0.5 and sf=0.1 by review content, and builds collections that
reuse every other published label set.

The IMDB filter group now has eight predicates, so its label parts
hold fewer rows; the labeling test derives the part size from the
group instead of assuming three filters.
run_stages took its progress unit as a string that also chose what to
count: any unit but "anchors" counted answers. The unit is now only
the label, "documents" by default, and count_answers chooses the
count; AI.SCORE over pairs still counts scores.
Moves the quail-b pin back to b82b14f and removes
experiments/cells/imdb16_labels.py. IMDB-16 (fsdatalab/quail-bench#35)
is dropped: its five checks repeat one guidelines text only to create
a shared prefix, which no analyst would write. This reverts 4508215.
The decoding baseline feeds every generated token but the last back
through the model, so those tokens are fresh work. They were recorded
as generated_tokens but left out of fresh_tokens, which made the
baseline's KV regret too low.
A family split into groups (one BIOdex query per container) saves each
group in its own folder, such as biodex-bio-5. The merged run.json named
the family folder instead, so rescoring the merged run did not find the
query files.
The measured time is wall-clock time and is over 1 ms on a busy
machine.
… starts

fsdatalab/quail-bench at f3ad796 counts the partner label once per
anchor and the partner documents of one anchor as a prefix trie.
Every pair of one anchor has the same partner label directly after the
frame, so the label is now part of the frame. Each pair writes only its
partner document and the answer cue. The token sequence is the same, so
the answers do not change. The joined-row classification, the join
cost, and the speed of light estimate count the label the same way.
Describe what Quail does instead of giving commands, keep the details a
user or an implementer needs, and bring the logical plan and planning
pages up to date: the label Filter with InList, the five logical rules,
the planner steps, and the physical rule order.
- Docstrings follow Google style and describe current behavior; history,
  run results, and PR references are removed.
- Comments that repeat the code are removed.
- Comments and docstrings that contradicted the code are corrected.
- Classification text uses "label" and the rule names letters,
  trie_tree, and trie_decode.
- A request-backend test name matches what it checks: one decode
  request per document.
- Architecture follows a query: logical plans, optimizer, physical
  plans, cost model, execution, KV.
- Optimizer lists all nine rules in run order, with one section per
  cost-based rule. It replaces the rule sections of Planning and
  Logical plans.
- Cost model describes the roofline model at a high level and links to
  the blog post for the filter derivation.
- KV replaces Prefix caching and covers paged KV, KV rewind, prefix
  sharing, and KV retention.
- Extending > Rules shows a logical and a physical rule, registration,
  the planning contexts, where to put a rule, and how to test it.
- Extending > Overview describes load_extension.
…nto claude/join-label-in-anchor-kv

# Conflicts:
#	docs/content/docs/architecture/planning.mdx
@shreyashankar shreyashankar changed the title Run AI.CLASSIFY on Quail: pipelines over the stage scheduler, label scoring rules, and classification beside filters and joins Add AI.CLASSIFY with document KV reuse across query operations Oct 3, 2026
@shreyashankar
shreyashankar merged commit 0386c5c into main Oct 3, 2026
1 check passed
@shreyashankar
shreyashankar deleted the claude/beautiful-brown-atl3of branch October 4, 2026 00:19
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.

Add AI.CLASSIFY and constrained decode support

1 participant