How to run scan jobs¶
This guide shows you how to drive the scan-composition register contract over many batches: choosing a batching strategy, overlapping staging with compute, spreading work across several cards, and reading the failures when a job does not come back.
Everything shell-specific stays yours. Lane output addresses and static
configuration are written before the run, and your collect callback reads each
job’s results. The runner owns the input staging, the trigger, and the wait.
Run a sequence of batches¶
from dau_driver.scan_execution import ScanBatch, run_scan_batches
results = run_scan_batches(
device,
batches,
per_key_state=False,
input_regions=(0x0000_0000, 0x0800_0000),
row_bytes=16,
collect=collect_results,
timeout_seconds=60.0,
)
input_regions must be two distinct device addresses. The runner alternates
between them, so batch i + 1 DMAs into one region while job i computes out of
the other. collect is called once per batch after that job reports DONE, and
its return values come back in batch order.
Each batch’s payload must be exactly row_count * row_bytes. The runner pads
the DMA out to the 64-byte grid while INPUT_LENGTH stays exact.
Your collect reads lane registers and output regions, which are disjoint from
the input staging, so collection overlaps the next batch’s DMA too.
Overlap the encoding as well¶
Pass a generator and the runner pulls each batch just before staging it, so whatever work the iterator does happens concurrently with the running job:
def batches():
for chunk in chunks:
yield ScanBatch(payload=encode(chunk), row_count=len(chunk))
results = run_scan_batches(device, batches(), per_key_state=False, ...)
A failure raised inside the iterator surfaces from the wait rather than hiding behind its timeout.
Choose a batching strategy¶
Strategy |
Use when |
Guarantee |
|---|---|---|
Position slicing (build |
The composition is stateless per batch. |
None beyond batch size. |
|
The composition carries per-key state across a batch. |
Each key lands in exactly one batch. |
|
The composition uses a hash-mode grouped tile with a fixed key capacity. |
Each batch holds at most |
Key-confined batches¶
from dau_driver.scan_execution import key_partitioned_scan_batches
batches = key_partitioned_scan_batches(
records,
batch_count=8,
key=lambda record: record.symbol,
encode=encode_rows,
)
Assignment is longest-processing-time-first: the largest key group goes to the
currently smallest batch. Records must arrive ordered by key: a key
appearing in two runs raises ValueError. Record order within each key is
preserved.
The first iteration consumes one complete grouping pass, because LPT needs every group size before it can assign. After that, each batch’s payload is built only when that batch is requested. Key groups are indivisible, so one hot key can dominate a batch.
Pass per_key_state=True when running these. The runner requires it for a
stateful composition and checks that the batches came from this function. A
position-sliced iterable raises ValueError rather than splitting a key’s state
without telling you.
Capacity-bounded batches¶
from dau_driver.scan_execution import key_range_scan_batches
batches = key_range_scan_batches(
records,
key_capacity=512,
key=lambda record: record.symbol,
encode=encode_rows,
)
Size the batches so the hash table cannot overflow rather than reacting to the
device’s GROUP_CAPACITY_EXCEEDED close-out. The key space is partitioned into
ranges of key_capacity keys and one batch is emitted per range.
Records need not arrive in key order here, and arrival order is preserved within each batch. The hash table needs no key ordering, and imposing one would give up the staging freedom the tile exists to provide. The trade is a counting pass over every key before the first batch, and row counts that may be uneven: key ranges are equal in keys, not in rows.
Because each key lands in exactly one batch, combining the per-batch group records
is a plain concatenation. If your batches came from somewhere else, use
dau_core.operators.merge_hash_grouped for the general additive merge instead.
Query rows that are already on the device¶
When the device already holds the data, such as a resident table queried many times, re-staging every batch is wasted work. The feed is the larger term of the device path, so lift the DMA out of the loop:
from dau_driver.scan_execution import run_resident_scan
results = run_resident_scan(
device,
address=0x0000_0000,
row_count=1024,
row_bytes=8,
collect=collect_results,
repeats=3,
)
Nothing is staged. Each repeat programs the address and length, starts the job,
waits, and calls collect, so you get one result per repeat rather than one
merged answer, so you can check that a rerun returns identical results instead of
assuming it.
The address must be on the 64-byte DMA grid, and it is raw: it outlives the allocation it came from. To name a grant instead, bind the allocator to the shell’s handle table and run through that:
from dau_driver.handle_table import HandleFaultError, HandleTable
table = HandleTable(device, allocator, capacity=4)
table.install(handle)
try:
results = table.run_resident_scan(handle, row_count=1024, row_bytes=8, collect=collect_results)
except HandleFaultError as exc:
print(exc.code, "- the job did not run")
install programs the grant from the allocator’s live record, and capacity is
the number of slots the shell was built with. The job then names
(id, generation). When the device disagrees with a resolution, the shell does
not start the job (the reader never issues a beat), and you get a
HandleFaultError rather than results.
Call table.free(handle) when the region is released, so nothing resolves
through the slot until it is installed again.
Spread batches across several devices¶
from dau_driver.fleet import run_scan_batches_fleet
results = run_scan_batches_fleet(
devices,
batches,
per_key_state=False,
input_regions=(0x0000_0000, 0x0800_0000),
row_bytes=16,
collect=collect_results,
)
for result in results:
print(result.batch_index, result.device.name, result.value)
One independent pipeline per device pulls from one shared lazy iterable. Each
batch is taken exactly once by whichever pipeline demands it, so the fleet
self-balances by actual device throughput without any pre-partitioning. Results
come back in global batch order as FleetScanResult records, each retaining the
exact device object that produced it.
input_regions is reused as device-local addresses on every card, never
shared memory.
Bias the split with weights, one positive, finite value per device, to
pre-weight each shared pull toward the requested relative shares. Batches stay
lazy and are never queued ahead.
per_key_state must be False. A stateful composition cannot run on a fleet at
all: batches go to whichever card is free, so a key’s rows split across cards that
never see each other’s state. Run it on a single device with
key_partitioned_scan_batches instead.
A failure is raised as FleetScanError annotated with the batch index and the
device. Note that cancelling wakes pipelines blocked pulling a batch, but one
already inside its status wait runs to its own completion or timeout first, so
an early failure can surface only after its peers finish.
Report progress¶
run_scan_batches(device, batches, ..., progress=lambda completed: print(f"{completed} batches"))
Called with the running completion count after each batch. On a fleet it counts across all devices.
Handle a job that does not finish¶
Both runners raise PipelineJobError when a job’s status neither reports DONE
before timeout_seconds nor reports an error flag:
scan job 3 timed out (status=0x0)
A device-side error flag is raised the same way, with LAST_ERROR included:
scan job 3 failed: status=0x3 last_error=2
Raise timeout_seconds for large batches; the default is 60 seconds.
If you are running concurrent pipelines against a pure-Python transport, such as
a fake in a test, set poll_yield_seconds to a small value. The real transports release
the GIL in their register read, so pipelines interleave without it and a sleep
would only add latency per poll; a pure-Python transport spinning would hold the
GIL and flatten self-balancing toward an even split regardless of device speed.
Next¶
Allocate device storage: where the input regions and output windows come from.
Execution contracts reference: every runner’s full signature.
Overlapped staging: why the ping-pong and the
per_key_stateguard are shaped this way.