Home › Hardware & OS › Virtualization
algorithm low

DMA and IOMMU

Problem A device that reads and writes memory without the CPU can reach any address, so a faulty or hostile device, or a guest handed one, can corrupt the host. Operators must confine it to its own buffers at low cost.

How devices move data by direct memory access, and how an IOMMU gives each device its own address space, faults on stray accesses and lets user space or guests own a device safely.

translate O(1) on an IOTLB hit, one table walk on a miss; unmap costs one invalidation (strict) or one per batch (lazy)
dmaiommuvfioiovaiotlbvt-dsmmupassthroughbounce-buffer
Overview

A device doing DMA writes to memory addresses on its own; nothing stops it hitting kernel data. An IOMMU sits between device and RAM and translates the device's addresses (IOVAs) through a per-device page table, so a device sees only the pages mapped for it and any other access faults. The same trick lets a user-space driver or a VM own a device without trusting it. The price is map/unmap work and cached-translation (IOTLB) invalidation.

Key facts

Use when

  • Handing a device to a VM or an unprivileged user-space driver (cloud GPUs and NICs, DPDK, SPDK).
  • Untrusted ports or DMA-capable external peripherals (laptops, SoC peripherals).

Avoid when

  • The device is trusted and the load is a per-packet map/unmap storm: use passthrough mode or long-lived mappings.
  • The only problem is a device with narrow addressing: swiotlb bounce buffers suffice.

Deep dive

How it works

In: a buffer (pages, direction) and a device · Out: an IOVA the device may use; each device access then completes or faults · State: per-domain page table, IOTLB, IOVA allocator, queued invalidations · Cost: one table walk per IOTLB miss; one invalidation per unmap (strict) or per batch (lazy); not measured on hardware here.

  1. Allocate IOVA: pick a free range in the domain's I/O address space. Kernel drivers call dma_map_single()/dma_map_page(), which return the DMA address (DMA API); user space gives the IOVA itself in VFIO_IOMMU_MAP_DMA.
  2. Map: write page-table entries IOVA page → physical frame + read/write permission. User-space mappings pin the pages (iommufd says a page is pinned only once).
  3. Program: put the IOVA into the device's descriptors or registers.
  4. Translate: every device access passes the IOMMU, which checks the IOTLB and on a miss walks the device's table.
  5. Fault: no entry or wrong permission blocks the access; the IOMMU may raise an interrupt (Arm SMMU binding).
  6. Hand back: for streaming mappings on non-coherent systems call dma_sync_single_for_cpu/_device before the CPU reads or the device reuses the buffer (DMA API HOWTO). Coherent buffers (dma_alloc_coherent) need no syncs.
  7. Unmap: remove entries and invalidate the IOTLB: synchronously (strict) or queued (lazy).
  8. Free IOVA: reuse only after the invalidation, or a stale entry reaches the new owner's mapping.

User space reaches this through /dev/vfio/$GROUP plus a container, or through iommufd, whose I/O address space object replaces the container (VFIO, iommufd).

Code

A model, not an IOMMU: a per-domain table (IOVA page → frame + permission), an 8-entry FIFO IOTLB with hit/miss counters, a first-fit IOVA allocator, a DMA engine that translates page by page, and strict versus lazy (batched) invalidation. Test 4 asserts the lazy stale window.

use std::collections::{HashMap, VecDeque};
const PAGE: u64 = 4096;
#[derive(Clone, Copy)] struct Pte { frame: u64, write: bool } // IOVA page -> frame + permission
#[derive(Debug, PartialEq)] enum Fault { Unmapped(u64), ReadOnly(u64) }
struct Iommu {
    table: HashMap<u64, Pte>,           // the domain's page table (one per device/domain)
    iotlb: HashMap<u64, Pte>,           // small cache of recent translations
    order: VecDeque<u64>,               // FIFO eviction order of the IOTLB
    free: Vec<(u64, u64)>,              // IOVA allocator: free ranges (start page, pages)
    pending: Vec<(u64, u64)>,           // lazy mode: unmapped ranges awaiting invalidation
    strict: bool, batch: usize, hits: u64, misses: u64, flushes: u64,
}
impl Iommu {
    fn new(strict: bool, batch: usize) -> Self {
        Iommu { table: HashMap::new(), iotlb: HashMap::new(), order: VecDeque::new(), pending: vec![],
                free: vec![(1, 1 << 20)], // page 0 stays unmapped, so IOVA 0 faults
                strict, batch, hits: 0, misses: 0, flushes: 0 }
    }
    /// Allocate IOVA space (first fit) and install PTEs. Returns the IOVA.
    fn map(&mut self, frames: &[u64], write: bool) -> u64 {
        let n = frames.len() as u64;
        let i = self.free.iter().position(|&(_, len)| len >= n).expect("IOVA space exhausted");
        let (start, len) = self.free[i];
        if len == n { self.free.remove(i); } else { self.free[i] = (start + n, len - n); }
        for (k, &f) in frames.iter().enumerate() { self.table.insert(start + k as u64, Pte { frame: f, write }); }
        start * PAGE
    }
    /// Remove PTEs. Strict: invalidate the IOTLB now. Lazy: queue, invalidate when the batch is full.
    fn unmap(&mut self, iova: u64, n: u64) {
        let p = iova / PAGE;
        for k in 0..n { self.table.remove(&(p + k)).expect("not mapped"); }
        self.pending.push((p, n));
        if self.strict || self.pending.len() >= self.batch { self.flush(); }
    }
    /// One invalidation request covering every queued range; only now may the IOVAs be reused.
    fn flush(&mut self) {
        if self.pending.is_empty() { return; }
        self.flushes += 1;
        for (p, n) in std::mem::take(&mut self.pending) {
            for k in 0..n { self.iotlb.remove(&(p + k)); }
            self.order.retain(|x| !(p..p + n).contains(x));
            self.free.push((p, n)); // no coalescing: enough for a model
        }
    }
    fn translate(&mut self, iova: u64, write: bool) -> Result<u64, Fault> {
        let p = iova / PAGE;
        let pte = match self.iotlb.get(&p) {
            Some(&e) => { self.hits += 1; e }
            None => {
                self.misses += 1; // page walk
                let e = *self.table.get(&p).ok_or(Fault::Unmapped(iova))?;
                if self.order.len() == 8 { let old = self.order.pop_front().unwrap(); self.iotlb.remove(&old); }
                self.iotlb.insert(p, e); self.order.push_back(p);
                e
            }
        };
        if write && !pte.write { return Err(Fault::ReadOnly(iova)); }
        Ok(pte.frame * PAGE + iova % PAGE)
    }
    /// The device's DMA engine: every access goes through the IOMMU, page by page.
    fn dma(&mut self, iova: u64, buf: &mut [u8], write: bool, mem: &mut [u8]) -> Result<(), Fault> {
        let mut done = 0;
        while done < buf.len() {
            let a = iova + done as u64;
            let phys = self.translate(a, write)? as usize;
            let n = (PAGE - a % PAGE).min((buf.len() - done) as u64) as usize;
            let (m, b) = (&mut mem[phys..phys + n], &mut buf[done..done + n]);
            if write { m.copy_from_slice(b) } else { b.copy_from_slice(m) }
            done += n;
        }
        Ok(())
    }
}
fn main() {
    let mut mem = vec![0u8; 64 * PAGE as usize];
    // 1. a write spanning two non-contiguous frames lands in both; unmapped/read-only accesses fault
    let (mut m, mut data) = (Iommu::new(true, 1), [7u8; 100]);
    let a = m.map(&[10, 3], true);
    m.dma(a + PAGE - 50, &mut data, true, &mut mem).unwrap();
    assert_eq!((mem[10 * 4096 + 4046], mem[3 * 4096 + 49], mem[3 * 4096 + 50]), (7, 7, 0));
    assert_eq!(m.dma(0, &mut data, true, &mut mem), Err(Fault::Unmapped(0)));
    let ro = m.map(&[20], false);
    assert_eq!(m.dma(ro, &mut data, true, &mut mem), Err(Fault::ReadOnly(ro)));
    let (h, ms) = (m.hits, m.misses); // 2. IOTLB: a second access to the same page hits
    m.dma(a, &mut data[..8], true, &mut mem).unwrap();
    assert_eq!((m.hits - h, m.misses - ms), (1, 0));
    // 3. strict: access after unmap faults at once
    m.unmap(a, 2);
    assert_eq!(m.dma(a, &mut data, true, &mut mem), Err(Fault::Unmapped(a)));
    // 4. lazy: the cached translation survives unmap until the batch flushes (the stale window)
    let mut l = Iommu::new(false, 4);
    let b = l.map(&[5], true);
    l.dma(b, &mut data, true, &mut mem).unwrap();             // fills the IOTLB
    l.unmap(b, 1);
    assert!(l.dma(b, &mut data, true, &mut mem).is_ok() && l.flushes == 0); // stale hit: frame 5 still reachable
    assert_ne!(l.map(&[6], true), b);                         // IOVA not reused before the flush
    for _ in 0..3 { let x = l.map(&[7], true); l.unmap(x, 1); }
    assert_eq!(l.flushes, 1);                                 // the 4th queued unmap flushed
    assert_eq!(l.dma(b, &mut data, true, &mut mem), Err(Fault::Unmapped(b)));
    // 5. flushes for 1M map-DMA-unmap cycles; timing is of this model only
    for (name, strict) in [("strict", true), ("lazy x64", false)] {
        let (mut m, t) = (Iommu::new(strict, 64), std::time::Instant::now());
        for i in 0..1_000_000u64 {
            let x = m.map(&[i % 32], true);
            m.dma(x, &mut data[..64], true, &mut mem).unwrap();
            m.unmap(x, 1);
        }
        println!("{name}: flushes {} hits {} misses {} model time {:?}", m.flushes, m.hits, m.misses, t.elapsed());
    }
}

Complexity and constants

Model: O(1) per page. Real hardware walks several table levels on a miss, one memory read each (not measured here). Table size grows with mapped pages, not with IOVA span. Measured on this Mac (Apple M4 Pro), five runs, model only: 1,000,000 map + DMA + unmap cycles take 52-63 ms strict (1,000,000 invalidations) and 51-54 ms lazy with batch 64 (15,625 invalidations). The model's invalidation is free, so the times say nothing about hardware; the invalidation counts are the point.

Efficiency

  • Cost: a hit adds little; a miss costs table-walk memory reads and an invalidation a command to the IOMMU (neither measured here). In one study on Intel Xeon Scalable and AMD EPYC at 200 Gbps, IOTLB misses caused up to 20 % throughput loss against no IOMMU (Farshin et al.). Bound: translation latency and IOTLB reach.
  • Levers:
    • Map once, reuse buffers (pools registered at start) — model: 1,000,000 invalidations to none — user-space drivers, long-lived rings.
    • Lazy invalidation (iommu.strict=0) — batching, model 64x fewer — stale window opens (kernel parameters).
    • Hugepage-backed buffers — fewer IOTLB entries; the same study recovered the whole loss with them (Farshin et al.) — high-rate NICs.
    • Passthrough mode iommu.passthrough=1 — no translation (kernel parameters) — trusted host-owned devices only.
    • Pinned host memory for accelerator copies — NVIDIA's guide gives roughly 12 GB/s on PCIe x16 Gen3 (CUDA best practices) — repeated host-device transfers.
  • Naive → standard → state of the art: map and unmap per I/O, strict flush · one invalidation per I/O (model: 1,000,000 for 1,000,000); lazy batches or persistent mappings · invalidations per batch, or none after setup; hugepage-aligned persistent mappings · loss recovered in the study above.

Implementation notes

  • Streaming mappings serve one transfer, coherent ones long-lived descriptor rings (HOWTO).
  • Set the DMA mask (dma_set_mask_and_coherent) to what the device can address; beyond it the kernel uses swiotlb bounce buffers, copies the CPU makes, slower than direct DMA (swiotlb).
  • Interrupt remapping is the other half of isolation (VFIO).
  • Device permissions apply to a whole group (SPDK).
  • iommufd has RLIMIT_MEMLOCK accounting for pinned pages (iommufd).

Pitfalls

  • Lazy mode: after unmap the device can still reach the freed page until the flush (model asserts it); free the IOVA only after.
  • Groups: a bridge without ACS, or a multifunction device with internal paths, merges devices into one group; all must be bound to VFIO (VFIO, DPDK guide).
  • Missing syncs on non-coherent CPUs corrupt data (HOWTO).
  • Granule-sized mappings expose neighbours: a mapping covering part of a granule lets an untrusted device see the rest, so the kernel bounces such I/O (swiotlb).
  • DPDK's vfio-pci no-IOMMU mode has no IOMMU to confine the device (DPDK guide).

Variants and alternatives

SR-IOVthe device presents Virtual Functions; each gets its own IOMMU identity and can go to a different guest. The VFIO doc names VFs the best sign of a "well-behaved" device (PCI IOV). See GPU virtualization.
Nested translationiommufd supports a guest-managed stage-1 table over a host stage-2 (iommufd); related to nested paging.
Paravirtual devicesvirtio shares memory without device ownership.

In production

  • Cloud and VM hosts: device assignment to guests (VFIO; KVM).
  • Storage and network user-space drivers: DPDK and SPDK recommend vfio-pci where an IOMMU exists (DPDK guide, SPDK); see kernel bypass and NVMe queues.
  • Laptops: Windows Kernel DMA Protection uses the IOMMU against drive-by attacks via Thunderbolt-class ports (Microsoft); Linux relies on the IOMMU too (kernel Thunderbolt doc).
  • SoCs: Arm SMMU gives bus masters outside the CPU 1 or 2 translation stages (binding).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
Linux IOMMU, VFIO, iommufdCtorvalds/linux—————reference behaviour for all of the above
DPDKCDPDK/dpdkapt install dpdk————vfio-pci poll-mode drivers
SPDKCspdk/spdkgit clone --recurse-submodules https://github.com/spdk/spdk————user-space NVMe, uses VFIO
QEMUCqemu/qemuapt install qemu-system-x86————VFIO device assignment (not verified here)
vfio-ioctlsRustrust-vmm/vfiocargo add vfio-ioctls————Rust wrappers for VFIO ioctls for VMMs

Sources

  • Linux kernel docs cited inline: VFIO, iommufd, DMA API, DMA API HOWTO, swiotlb, kernel parameters.
  • Farshin, Rizzo, Elmeleegy, Kostic, Overcoming the IOTLB wall for multi-100-Gbps Linux-based networking, PeerJ Computer Science 2023. paper
  • Amit, Ben-Yehuda, Yassour, IOMMU: Strategies for Mitigating the IOTLB Bottleneck, Lecture Notes in Computer Science 2011. paper
  • Malka, Amit, Ben-Yehuda, Tsafrir, rIOMMU, ASPLOS 2015 (full title and content not read here). paper

Used by

gpu asset decompression · plannedDecompress assets on the GPU (GDeflate, DirectStorage) straight from NVMe to VRAM
gpu virtualization · plannedShare GPUs across VMs or containers: SR-IOV, mediated passthrough, time slicing, MIG partitions

Systems (8):

Alternatives and related

Interrupt handlingA CPU busy with its own work must notice events from devices and timers promptly. Notifying it per event costs more than the work when events arrive in thousands per second, but waiting too long adds delay.Virtual memory and the TLBPrograms that jump around large in-memory data (hash tables, graphs, model weights) can run far slower than expected because every address must be translated. This guide shows when translation becomes the bottleneck and when huge pages fix it.
hardware virtualization kvm · plannedVT-x/AMD-V via KVM: vCPU run loop, VM exits, MMIO emulation
nested paging ept · plannedEPT/NPT two-level address translation for guests
Kernel-bypass networkingA router, load balancer or firewall must handle tens of millions of small packets per second per core. The operating system's per-packet overhead is too high, so packets must reach the program more directly.
nvme queues · plannedNVMe submission and completion queue pairs, doorbells and polling from a driver
virtio devices · plannedVirtqueue rings for paravirtual block, net and vsock devices
double buffering dma · plannedOverlap compute with DMA into ping-pong scratchpad buffers on accelerators and DSPs
huge pages · planned2 MB/1 GB pages to cut TLB misses; THP vs explicit hugetlbfs