Computer Architecture · Accelerators

Shared Virtual Addressing and Peer-to-Peer Transfers

What changes when a device uses the process page table directly, why a PASID is needed, and what must happen before one device writes into another device's memory.

With an IOMMU in place, a device has its own address space, mapped by the driver and disjoint from any process. That solves protection and contiguity, but it leaves a copy in the path: data produced by a process must be copied into a device-mapped buffer, and results must be copied back. For a device that consumes a large structure — a page table, a graph, a set of pointers — copying is not merely wasteful, it is impossible without flattening the structure. Shared virtual addressing removes the copy by letting the device use the process’s page table directly, so a pointer the process computed is a pointer the device can follow.

The two things that must be added

The first addition is a way to say which address space a transaction belongs to. A device in a private-domain configuration has one page table and needs no identifier, because every transaction it issues is interpreted in that one space. A device using process page tables must be able to issue transactions that are interpreted in different spaces, because several processes may be using the device at once and each has its own table. The identifier that selects the space is carried with the transaction.

In PCI Express this identifier is the Process Address Space ID, a field in the transaction prefix. It is not part of the address and not part of the descriptor; it is transaction metadata that the device attaches to every request it makes on behalf of a particular context. A device that supports $k$ simultaneous contexts carries $k$ distinct identifiers and tags each request accordingly. The IOMMU maintains a table indexed by identifier, each entry pointing at a page table root.

The second addition is a fault path. With a private address space, a device address that is not mapped is a driver bug and the driver can be blamed. With a shared address space, an unmapped address may be legitimate: the process may have paged the data out, or the device may have followed a pointer into a region that has not been touched yet. The device cannot resolve this itself, so the IOMMU reports the fault and the system responds as it would to a processor page fault — by bringing the page in and retrying the transaction. This is the piece that makes shared addressing usable for arbitrary data structures rather than only for pinned buffers, and it is also the piece that makes latency unpredictable.

#include <linux/iommu.h>

/* Binding is a host operation; the device only learns the identifier. */
static int bind_queue(struct device *dev, struct mm_struct *mm)
{
    struct iommu_sva *handle;

    /* one binding per device queue, against one process's page tables */
    handle = iommu_sva_bind_device(dev, mm);
    if (IS_ERR(handle))
        return PTR_ERR(handle);

    /* the handle carries the PASID the device must tag its requests with;
       program the queue with that value here */

    /* unbind only after the device has been quiesced: requests already in
       flight carry the PASID and must drain before the context is freed */
    iommu_sva_unbind_device(handle);
    return 0;
}

The binding is per process and per queue, and its lifetime is the intersection of the process’s lifetime and the device’s use. Unbinding must wait for every in-flight transaction carrying that identifier to complete, which is the same invalidation problem described in Device DMA and the IOMMU Boundary, now applied per context rather than per device.

What changes for the programmer

Without shared addressing, the programming model is explicit about placement. The host allocates a buffer in device-accessible memory, copies data in, submits work that refers to device addresses, and copies results out. The device never sees a host pointer, and the host never sees a device address.

With shared addressing, the device can be given a pointer that the process itself would use. A kernel launched with a pointer to a linked structure can follow the links, because the same page table that resolves the pointer for the processor resolves it for the device. This removes the flattening step that otherwise dominates the setup cost of small kernels: a kernel that does a microsecond of work on a tree with thousands of nodes spends far longer preparing a flat copy than executing.

The model also removes the need to pin. A buffer does not have to be resident for the device to use it, because a fault can bring it in. Pinning remains useful when the cost of faults is unacceptable, and most implementations expose it as an option rather than a requirement.

The cost is that the device now depends on the process’s memory state. If the process exits while the device is still working, the page table is torn down and the device’s subsequent accesses fault with no owner. The driver must therefore quiesce the device before the address space is destroyed, and the ordering is not optional: the device’s work must be complete, or cancelled in a way the device acknowledges, before the teardown begins.

Peer-to-peer, and the topology that decides

A peer-to-peer transfer moves data directly between two devices without routing it through host memory. The motivation is bandwidth and latency: a copy through host memory consumes host memory bandwidth twice, once for the write and once for the read, and it adds a round trip through the processor’s memory controller. A direct transfer consumes the interconnect once.

Whether a direct transfer is possible is a question about topology, not about software. Two devices can transfer directly if there is a path between them that supports the transaction and if the interconnect’s root complex allows peer-to-peer routing between the relevant downstream ports. On many systems that routing is disabled or unsupported, and a request from one device to another’s address range is either rejected or silently routed to host memory. Systems that support it usually expose the capability per root port, and the driver must check rather than assume.

  CPU ---- root complex ---- switch ---- device A
                                  \------ device B

  path A -> B through the switch: peer-to-peer, no host memory
  path A -> B through the root complex: often unsupported

Even with a path, two further conditions apply. The target device’s memory must be mapped into the source device’s address space, which means the two devices must share an IOMMU domain or the mapping must be established in both. And the target must be able to accept writes from a device rather than only from the host, which is a property of the target’s memory aperture rather than of the interconnect.

Coherence, and the case where it is absent

The hardest question in shared addressing is what happens when the device and the processor access the same cache line. If the device’s accesses are coherent — participating in the same protocol as the processors — the hardware keeps the two views consistent and the programming model is the ordinary one. If they are not, the driver must manage cache state explicitly, flushing before the device reads and invalidating after it writes, and the granularity of that management is the cache line.

Devices that participate in coherence through the interconnect’s cache protocol avoid the management entirely, at the cost of the protocol’s overhead on every access and the complexity of implementing it in the device. Devices that do not participate are simpler and cheaper, and they push the problem onto software. The two cases have different correctness arguments, and a driver written for one is not correct for the other.

The practical consequence is that shared addressing does not by itself guarantee coherence. It guarantees translation. Whether the device’s view and the processor’s view agree is a separate property, advertised separately, and the driver must know which case it is in before deciding whether to flush.

The fault path is the expensive part

A device following a pointer into unmapped memory produces an I/O page fault, and the recovery is a dialogue rather than a retry. The IOMMU records the faulting address, the identifier of the context, and the nature of the access, and notifies the host. The host must then determine whether the address is legitimately absent — paged out, not yet populated, part of a lazily grown region — or whether it is genuinely invalid. In the first case the page is brought in and the device’s access is retried; in the second the device is told that the access will never succeed.

The dialogue has a cost the processor’s own page fault does not: the device is stalled for the duration, and a device with many outstanding accesses may accumulate many faults against the same page before the first is resolved. Implementations therefore coalesce faults, recording one entry per distinct address and dropping duplicates, and resume the device only once every outstanding request for that address has been answered. A workload that touches new pages at a high rate spends its time in this loop, and the rate of faults rather than the rate of arithmetic becomes the limit.

Recovery also has to be repeatable. A device that faults on a read can retry it once the page is present. A device that faults in the middle of a multi-step operation — a read-modify-write, a scatter-gather chain, an atomic sequence — may not be able to restart from the point of the fault, and must either buffer enough state to resume or restart the whole operation. Devices that promise restartability advertise it; devices that do not must have their entire working set resident before they start, which is pinning under another name.

The two mapping mechanisms compared

It is worth placing the two arrangements side by side, because the differences determine which kinds of work each supports.

PropertyPrivate device address spaceShared process address space
Mapped bydriver, explicitly, per bufferthe process’s own page tables
Granularitycontiguous ranges assembled from pagesindividual pages, as the process uses them
Fault on unmappeddriver bug, DMA errorrecoverable I/O page fault
Pointer-followingimpossible without flatteningsupported
Lifetime boundexplicit unmap after completionprocess lifetime, quiesced before teardown
Cost of setupone mapping per bufferone binding per context, then free
Cost per accessone translationone translation, plus fault if absent

The table makes the choice obvious for two extremes and ambiguous for the middle. A device that performs a bulk copy of a flat buffer gains nothing from shared addressing and pays for the fault machinery it will never use. A device that traverses a linked structure cannot work with a private space at all without the host flattening the structure first, and flattening may cost more than the traversal. The middle — a device that reads a set of buffers the host has already arranged — can use either, and the decision turns on whether the buffers are long-lived enough to amortize a mapping.

Legacy compatibility, and why identifiers are scarce

The temptation when adding context support is to let existing devices keep working unchanged. A device that predates the identifier field can still be used with shared addressing if the host is willing to bind its single context to one address space at a time and rebind on every switch. The rebinding is a mapping change, so it costs an invalidation and a wait, and the wait is measured in microseconds rather than cycles. A device that switches contexts frequently therefore pays the invalidation cost on every switch, which is why context-capable devices carry the identifier rather than relying on rebinding.

A related constraint appears in virtualized systems. A guest’s driver manages its own page tables, and a device assigned to the guest must resolve addresses using those tables. The host cannot allow the guest to program page table roots directly, because a guest could point at a table it does not own. The nesting of two levels of translation — guest virtual to guest physical, then guest physical to machine — is the standard solution, and it doubles the walk depth for every translation miss. Systems that expect a device to be used with nested translation usually provide a cache for the intermediate level, or a way to pin the relevant subset for the duration of a workload.

The result is a design space rather than a single mechanism, and the driver must know which corner it is operating in: private or shared, flat or nested, coherent or not, faulting or pinned. The combinations are not arbitrary — each is chosen to support a particular class of use — and the failure modes of one combination do not always resemble the next.

Where this leaves the boundary

Shared virtual addressing moves the boundary rather than removing it. The device can follow host pointers, but the mapping is still per-context, the fault path still exists, and the teardown ordering still has to be enforced. What it removes is the copy, and with it the requirement that data be contiguous or pinned or pre-arranged.

The remaining costs are the ones that do not go away: a translation lookup per access, a fault protocol for the cases the translation cannot resolve, and the coherence question that depends on the device’s capabilities rather than on the software’s wishes. Devices that use the same page tables as the processor are easier to program and harder to reason about, because their failure modes now include every failure mode of the process’s own memory management. The scheduling consequences of letting many contexts share one device are worked out in Scheduling and Preemption on Accelerator Queues.

References

  1. PCI Express Base Specification, Revision 5.0 — Process Address Space ID — PCI-SIG
  2. Intel Virtualization Technology for Directed I/O Architecture Specification — Intel
  3. Linux kernel documentation — Shared Virtual Addressing (SVA) with ENQCMD — Linux kernel project