The transport boundary: why a driver owns almost nothing¶
dau-driver moves bytes and runs jobs. It does not know what a register means,
what a batch header contains, or what an opcode computes. All of that comes from
dau-core. This page is about where that line sits, why it is drawn there, and
what it makes possible.
Two things that must not be the same package¶
A device’s semantics and a host’s transport change for completely different reasons. Register offsets, stream framing, and capability words change when the hardware contract changes: a new register map version, a new operator, a wider capability advertisement. Transport code changes when the host changes: a new OS, a new devnode layout, a DMA quirk on one bitstream.
Putting both in one package means every hardware-contract change touches the driver, and every host quirk risks changing the contract. The contract would also have one consumer, this driver, and a contract with one consumer is in practice an implementation detail. The compiler, the simulator, and the golden models all need the same offsets.
So dau-core owns the semantics and dau-driver imports them. In practice, a
hardware quirk gets fixed in the backend layer and stays there. When one bitstream’s AXI path corrupted whole-transfer C2H reads above a
threshold, the fix was a chunking field on the device handle, applied inside
read_dma. It did not become a rule in the protocol contract, because it was
never true of the protocol, only of that image. It is also why the field could
be deleted once no such image remained, with nothing else to change.
DeviceTransport is four methods¶
The entire device-touching surface is a Protocol with read_u32, write_u32,
read_dma, and write_dma. Everything above it (register decoding, transfer
validation, the job runners, the stream codec) is written against those four
calls.
The interface is kept that narrow on purpose. Two real transports implement it: Linux XDMA character devices via os.pread and
os.pwrite, and a macOS DriverKit system extension via IOKit user-client calls.
They share nothing but the protocol, and the layers above cannot tell them apart.
It also makes testing cheap: a fake transport is fifty lines. FakeTransport
records DMA traffic and register writes in dictionaries, and every Python
execution path in the package (the scan runner’s ping-pong staging, the fleet’s
demand-driven scheduling, the bar shell’s DDR layout, capability checks) runs
against it in CI, on machines with no FPGA. What sits below the boundary (the kernel module, the DriverKit extension,
the native extension) is tested on hardware.
This is the package’s testing policy stated as an architectural constraint:
every Python execution path must run against a fake, and hardware-only
behaviour sits behind DeviceTransport. If a code path cannot be reached without a board, it
is in the wrong layer.
The fake lives in dau_driver.testing, shipped rather than kept in the test
directory, because the driver’s consumers test against it too. One definition
means the fake device’s semantics cannot drift between packages, as they would if
each package wrote its own.
Discovery skips anything that is not a DAU shell¶
discover_devices will not return an XDMA endpoint just because it is an XDMA
endpoint. It reads the register window, looks for the DAU magic word, and decodes
a capability advertisement. Anything else is skipped.
discover_devices and identify_device treat a mismatch differently on purpose.
Discovery scans a machine that may hold other people’s cards, so a non-DAU
endpoint is not an error, just not a match, and raising would make a mixed
machine undiscoverable. identify_device takes a path you named, so a bad
answer there is an error, and it raises WrongBitstreamError or
UnhealthyBarError by name.
The all-ones case has its own exception because it does not look like a failure.
A dead AXI-lite path returns 0xFFFFFFFF for every read, which is a valid 32-bit
number. It decodes as a version, a bitmap or a counter, all of them plausible. UnhealthyBarError exists so that a dead path is a
named failure rather than a strange number four layers later, and
check_bar_health() exists so you can assert it before and after anything you
care about.
The magic probe retries a few times, with a short delay. The first AXI-lite reads after a PCIe rescan can transiently return stale data before the link settles, and a single-shot probe would report a healthy DAU shell as the wrong bitstream. This is the kind of fact that only transport code should know.
Validate the shape before opening the channel¶
Every DMA transfer is checked against the device’s own advertised constraints (alignment, maximum size, channel availability) before a channel is opened. The errors are typed and say which rule was broken.
Checking against the advertisement rather than a constant is what lets the
driver work on a new board. dma.max_aligned_transfer_bytes is the value to use
for a maximum-sized loopback, not a hard-coded descriptor limit, because the
limit is a property of the device in front of you.
Two related guards also stop rather than proceed.
write_address always writes both halves of an address pair, including a zero
high half, because a design whose master is wider than 32 bits keeps the high half
across a LOW write, so writing only LOW reuses whatever the previous job left there
and addresses the wrong region. And when the handle knows the device’s
memory size, an address past it raises rather than wrapping to the bottom of
memory, which is what the hardware would otherwise do.
The memory_bytes field defaults to None, because a guard is only as good as
the fact behind it. Inventing a default size would either
refuse valid addresses on a larger board or give false confidence on a smaller
one.
Where the model bends¶
Two places do not fit this picture.
write_address is a module-level function rather than a method on DauDevice,
because devices in this codebase are duck-typed. Test fakes and alternative
transports implement write_register without subclassing DauDevice, and making
this a method broke every one of them. The runners take device untyped for the
same reason.
KeyedPipelineConfig is a plain dataclass while the rest of the DAU stack uses
frozen pydantic models. It mirrors the PIPELINE_* register encodings (it is
wire packing, not configuration), and the rule the codebase follows is that
register mirrors and DMA structs stay dataclasses while anything authored or
composed follows the model patterns.