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.
- 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 inVFIO_IOMMU_MAP_DMA. - 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).
- Program: put the IOVA into the device's descriptors or registers.
- Translate: every device access passes the IOMMU, which checks the IOTLB and on a miss walks the device's table.
- Fault: no entry or wrong permission blocks the access; the IOMMU may raise an interrupt (Arm SMMU binding).
- Hand back: for streaming mappings on non-coherent systems call
dma_sync_single_for_cpu/_devicebefore the CPU reads or the device reuses the buffer (DMA API HOWTO). Coherent buffers (dma_alloc_coherent) need no syncs. - Unmap: remove entries and invalidate the IOTLB: synchronously (strict) or queued (lazy).
- 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_MEMLOCKaccounting 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-pcino-IOMMU mode has no IOMMU to confine the device (DPDK guide).
Variants and alternatives
In production
- Cloud and VM hosts: device assignment to guests (VFIO; KVM).
- Storage and network user-space drivers: DPDK and SPDK recommend
vfio-pciwhere 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Linux IOMMU, VFIO, iommufd | C | torvalds/linux | — | — | — | — | — | reference behaviour for all of the above |
| DPDK | C | DPDK/dpdk | apt install dpdk | — | — | — | — | vfio-pci poll-mode drivers |
| SPDK | C | spdk/spdk | git clone --recurse-submodules https://github.com/spdk/spdk | — | — | — | — | user-space NVMe, uses VFIO |
| QEMU | C | qemu/qemu | apt install qemu-system-x86 | — | — | — | — | VFIO device assignment (not verified here) |
| vfio-ioctls | Rust | rust-vmm/vfio | cargo 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