Quail
User guide

Explain plans and runs

Preview the plan

Call explain() to see the query plan without running it:

query = session.sql("...", dialect="snowflake")
print(query.explain())

Quail runs the query planner and produces the full execution plan, but does not load the model or run any GPU work.

The output has two parts.

Logical plan shows the query structure. Read it bottom-up: scans are at the leaves, the projection is at the root. Each predicate appears under the operator that evaluates it.

Project: r.id, a.aspect
  SemanticJoin (full)
    PROMPT('...', r.body, a.aspect) (selectivity=15%)
    Join
      cross
      SemanticFilter
        PROMPT('...', r.body) (selectivity=60%)
        Scan reviews as r [body, id]
      Scan aspects as a [aspect]

Physical plan shows what the execution engine will do: the model and token budgets, then one line per operator with the planner's estimates in columns to the right. Each AI.IF or join predicate is listed under the operator that runs it, numbered by its position in the query text (predicate 2 is the second AI.IF you wrote). When the planner reorders them, the output says which runs first.

physical: backend=quail, model=qwen3-4b-fp8, workers=1
  KV=bf16, chunk budget=110,376 tokens, admission budget=362,250 tokens

                                                            est. rows   est. pass   est. time
  Limit: 1                                                          1
    Project: r.id                                                 1.5
      AiFilter: r                                                 1.5                 1.09 ms
        KV rewind=on
        1st: predicate 2  PROMPT('DOCUMENT:\n{0}\n\nq2:')           6         50%
        2nd: predicate 1  PROMPT('DOCUMENT:\n{0}\n\nq1:')           3         50%
        Scan reviews as r                                           6
          tokens=12, mean_doc_tokens=2

The time estimates assume peak GPU throughput with no host overhead. Actual runtimes are longer, for many reasons, such as kernel launch latency and low MFU. Use explain(analyze=True) on a GPU to see measured times next to the estimates. We are working on better latency estimates.

On an operator line, est. rows is how many rows the planner expects it to produce. On a predicate line, est. rows is how many documents or pairs the planner expects to reach that predicate, and est. pass is the selectivity it planned with. A selectivity you did not set shows as 20%*, with a footnote.

explain(verbose=True) adds node ids, port connections, KV retention decisions, and planner remarks.

Measure the plan

explain(analyze=True) works like EXPLAIN ANALYZE in a database: it runs the query, then prints actual rows, time, and tokens next to each estimate, followed by the query time, throughput, and GPU cost.

print(query.explain(analyze=True))
                                                            est. rows   rows   est. pass    pass   est. time    time   fresh tokens
  Limit: 1                                                          1      1                                   <1 ms
    Project: r.id                                                 1.5      2                                   <1 ms
      AiFilter: r                                                 1.5      2                         1.09 ms   <1 ms             60
        KV rewind=on
        1st: predicate 2  PROMPT('DOCUMENT:\n{0}\n\nq2:')           6      6         50%   66.7%
        2nd: predicate 1  PROMPT('DOCUMENT:\n{0}\n\nq1:')           3      4         50%     50%
        Scan reviews as r                                           6      6                                   <1 ms
          tokens=12, mean_doc_tokens=2

run:
  query time   1 s (model startup excluded)
  startup      500 ms
  throughput   6 documents/second over 6 input documents
  tokens       1,234 fresh
  GPU cost     $0.0011 per query (1 GPU at $3.9492/hour, startup excluded)

Here is how to read the table:

  • Each est. column is what the planner predicted. The column next to it is what actually happened. Compare est. rows with rows, est. pass with pass, est. time with time.
  • rows on a filter or join line is how many documents or pairs the model evaluated. pass is the fraction that returned TRUE.
  • When est. pass and pass are far apart, the planner's selectivity guess was wrong. That is the most common reason a plan takes longer than its estimate.
  • fresh tokens is the total input tokens the model processed in forward passes for that operator.

explain(analyze=True, verbose=True) adds more detail per operator, including KV hits, KV misses, and GPU seconds.

The query runs once for the measurement. To keep the result rows too, run the query yourself and print the plan from the result:

result = query.run()
rows = result.collect()
print(result.explain())

Refusals

If the planner cannot execute the query, explain() prints the reason instead of a plan, and run() raises quail.RefusalError.

For example, if a document is too long to fit in one forward pass:

Refused: a document in 'r' needs 131,072 tokens with its prompt,
but the forward pass budget is 110,376 tokens

Other common refusals:

  • the model needs 4 GPUs of memory, but Quail loads one complete copy per GPU
  • per-batch apply() requires one GPU; use apply_table() or set gpus=1
  • one join pair anchored on 'r' needs 150,000 tokens, but the forward pass budget is 110,376 tokens

Running a query

session.sql compiles the statement in this process and returns a Query. Running it executes the plan on the GPU. On Modal, collect the rows before the function returns, because a QueryResult reads its rows when you ask for them.

result = query.run()

A session created with endpoint returns a RemoteQuery from sql().

Class

Query

A compiled query bound to the session that parsed it.

Attributes

Prop

Type

Methods

plan
MethodQuery.plan()

Build the physical plan. The first call plans the query. Later calls return that same plan.

Returns

PhysicalPlan. The plan the executor will run, including the model, the worker count, and the estimated seconds.

explain
MethodQuery.explain(*, verbose=False, analyze=False)

Print the plan. The shape of the text is the one in Preview the plan and Measure the plan.

Parameters

Prop

Type

Returns

str.

run
MethodQuery.run(plan=None)

Execute the query in this process.

Parameters

Prop

Type

Returns

QueryResult.

collect
MethodQuery.collect(limit=None, batch_rows=65536)

Execute the query and return one Arrow table. This calls run(), then QueryResult.collect.

Parameters

Prop

Type

Returns

pyarrow.Table.

execute_stream
MethodQuery.execute_stream(batch_rows=65536, limit=None)

Execute the query and stream the rows.

Parameters

Prop

Type

Returns

pyarrow.RecordBatchReader.

Class

QueryResult

The rows of a finished query, plus the report and the answer tables. The rows stay unread until collect, to_rows, or execute_stream asks for them.

Attributes

Prop

Type

Methods

collect
MethodQueryResult.collect(limit=None, batch_rows=65536)

Read the result into one Arrow table.

Parameters

Prop

Type

Returns

pyarrow.Table.

execute_stream
Method

QueryResult.execute_stream(batch_rows=65536, limit=None)

Stream the result as Arrow record batches.

Parameters

Prop

Type

Returns

pyarrow.RecordBatchReader.

to_rows
MethodQueryResult.to_rows(limit=None)

Read the result as a list of tuples, one tuple per row, in column order.

Parameters

Prop

Type

Returns

list[tuple].

count
MethodQueryResult.count()

Return the number of rows. len(result) calls this.

Returns

int.

explain
MethodQueryResult.explain(*, verbose=False)

Print the executed plan with measured rows and time beside the operators.

Parameters

Prop

Type

Returns

str. The text is no physical plan was executed when plan is None.

Execution report

result.report is a dict with query time, token counts, and per-stage metrics. See Metrics for every field.

On this page