Quail
Architecture

Physical plans

The planner converts the logical plan into a physical plan: a graph of typed nodes that specifies exactly what the GPU and CPU execute. Each node has typed input and output ports, so the graph can be validated, serialized, and edited before execution.

Structure

Nodes and ports

Each node has typed input and output ports. An InputPort reads from one OutputPort of another node. Value types include DOCUMENT_IDS, FILTER_ANSWERS, JOIN_ANSWERS, and ROWS. The graph validates that connected ports have matching types.

@dataclass(frozen=True)
class PhysicalNode:
    node_id: str
    inputs: tuple[InputPort, ...] = ()

    type_name: ClassVar[str]
    runtime_key: ClassVar[str]
    location: ClassVar[ExecutionLocation]

Node types

All node types are defined in physical/nodes.py.

Generic nodes (used by all backends):

NodeWhat it does
ScanNames one tokenized input by alias.
BarrierMaterializes survivor ids between join steps.
ExchangeRoutes documents across GPUs.
RecombineJoins TRUE answer tables using Arrow.
ProjectReads the selected columns for surviving rows.
LimitTruncates the result.

Quail backend nodes:

NodeWhat it does
AiFilterRuns filter stages on the GPU.
AiJoinRuns join stages on the GPU.

Node ids

Node ids are readable names like ai_filter:r, ai_join:r, scan:r, barrier:e, apply:same_page, project.

Editing a plan

Physical plans are inspectable and editable. You get the plan, modify it, and run the modified version. Every edit returns a new immutable plan; the original is unchanged.

This is useful for:

  • Database integration. Another query engine can call Quail's planner, inspect the physical plan, insert or remove nodes, and then execute the modified plan. The plan is a typed graph with a serialization format, so it can cross process boundaries.
  • Custom operators. You can insert a user-defined Foreign node between two operators, or remove one that the planner added.
  • Debugging. Inspect the plan with explain(), edit it, and compare the execution of different plans.

API

plan = query.plan()

Get the physical plan without running it.

plan2 = plan.insert(node, between=("ai_filter:c", "ai_join:e"))

Insert a node on the edge between two existing nodes. The new node must have one output of the same value type as the edge.

plan3 = plan2.remove("apply:same_page")

Remove a node and rewire its consumers to its producer. Only Foreign, Barrier, and Exchange nodes can be removed — removing a Scan, AiFilter, or AiJoin would change what the query computes.

plan4 = plan3.move("apply:same_page", between=("barrier:e", "ai_join:e"))

Move a node from its current position to a new edge.

result = query.run(plan=plan4)

Run the edited plan. After each edit, Quail re-validates the graph (ports, types, cycles) and re-estimates every node. An illegal edit raises PlanEditError.

Example: inserting a custom operator

from quail.physical.nodes import Foreign

plan = query.plan()

custom = Foreign(
    node_id="apply:my_filter",
    function="my_filter",
    kind="per_batch",
)
edited = plan.insert(custom, between=("ai_filter:r", "ai_join:r"))
result = query.run(plan=edited)

See Execution for how the plan runs on the GPU.

Serialization

Each node type has a NodeCodec for encoding and decoding across the process boundary. plan_envelope wraps the encoded graph with the backend, model, device, and worker count.

On this page