Posted Interrupts

A posted interrupt is an interrupt that is recorded into a vCPU’s virtual APIC by hardware and delivered to the still-running guest with no VM exit at all. Without it, an interrupt destined for a guest — from a passed-through device, or an inter-processor interrupt (IPI) from another vCPU — must trap into the hypervisor so the hypervisor can inject it; that exit is the dominant cost of I/O-heavy virtualization. Posted interrupts eliminate it. The mechanism hinges on one structure, the Posted-Interrupt Descriptor (PID): a small, cache-line-aligned chunk of memory holding a 256-bit bitmap of pending vectors plus a handful of control bits. A device’s Message-Signaled Interrupt (MSI), routed through the IOMMU in posted mode, or a CPU sending a posted IPI, sets the target vector’s bit in the PID and fires a special notification vector; the receiving CPU, finding the target vCPU executing in non-root mode, merges the posted vector straight into the guest’s virtual APIC and delivers it — hypervisor never involved. Posted interrupts are the delivery half of interrupt virtualization and depend strictly on the register half, APIC Virtualization (APICv and AVIC).

This note is pinned to Linux 6.12 (one of five longterm branches per kernel.org/releases.json read 2026-09-12, currently 6.12.109), with KVM’s posted-interrupt logic verified directly against arch/x86/kvm/vmx/posted_intr.c and the descriptor layout against arch/x86/include/asm/posted_intr.h. The architectural claims are checked against Intel SDM Volume 3C §30.6 (order number 326019-078US) and the Intel VT-d Architecture Specification Rev. 5.20 (April 2026), both fetched and read for this revision. Intel calls the CPU feature posted interrupts and the I/O-side feature VT-d posted interrupts (VT-d being Intel’s IOMMU, “Virtualization Technology for Directed I/O”); AMD’s equivalent is folded into AVIC via its IOMMU Guest APIC (GA) log path. We lead with the Intel mechanism because KVM models it most explicitly and because Intel’s specifications are the ones that could actually be retrieved, then treat the AMD analogue — which is what the machine these measurements come from actually runs.

What this note owns, and what it does not. This note owns the delivery mechanism: the posted-interrupt descriptor, the notification vector and its two values, the SN/ON/NDST protocol between KVM and the IOMMU, and the exit-free path from a device MSI to a guest interrupt handler. Three boundaries matter. APIC Virtualization (APICv and AVIC) owns the register half — the virtual-APIC page, virtual-interrupt delivery, RVI/VPPR and the vAPIC backing page — and posting is strictly dependent on it, not a peer of it. The IOMMU and DMA Remapping owns interrupt remapping as an IOMMU subsystem: the Interrupt Remapping Table, IRTE allocation, irq_remapping_cap(), and the DMA-side translation that shares the same hardware; this note covers only what changes when an IRTE is flipped from remapped format into posted format, and treats the rest of the IRTE lifecycle as that note’s territory. Interrupt Injection and the Virtual APIC owns the fallback path taken whenever posting is unavailable. Nothing here duplicates Two-Dimensional Paging (EPT and NPT); memory virtualization and interrupt virtualization are independent axes.


Mental Model — A Mailbox the Hardware Stamps

Think of each vCPU as owning a mailbox — the Posted-Interrupt Descriptor — pinned at a fixed host-physical address. Anyone who wants to interrupt that vCPU does not knock on the hypervisor’s door; they walk up to the mailbox, set a flag for the specific interrupt vector, and ring a bell aimed at whatever physical CPU the vCPU is currently running on. If the vCPU is home (running in the guest), it hears the bell and reads its mail immediately — the interrupt is delivered inside the guest with no exit. If the vCPU is out (descheduled or blocked), the bell is configured to instead summon the hypervisor, which wakes the vCPU so it can read its mail on the way back in.

flowchart LR
  subgraph SRC["Interrupt sources"]
    DEV["Passthrough device MSI"]
    IPI["Posted IPI from another vCPU"]
  end
  DEV -->|"IOMMU IRTE in posted mode"| PID["Posted-Interrupt Descriptor (PID)<br/>PIR bitmap | ON | SN | NV | NDST"]
  IPI -->|"CPU posts vector"| PID
  PID -->|"if NV = POSTED_INTR_VECTOR<br/>+ vCPU running (NDST)"| RUN["Notification IPI to pCPU<br/>CPU merges PIR into vIRR, delivers<br/>NO VM EXIT"]
  PID -->|"if NV = WAKEUP_VECTOR<br/>(vCPU blocked)"| WAKE["pi_wakeup_handler<br/>wakes vCPU; KVM syncs PIR to vIRR on entry"]

The two destinies of a posted interrupt. What it shows: both a passthrough device (via the IOMMU’s Interrupt Remapping Table Entry in posted mode) and a CPU sending an IPI deposit a vector into the same descriptor. The descriptor’s Notification Vector (NV) field decides the outcome: if it holds POSTED_INTR_VECTOR and the vCPU is running, the interrupt lands in the guest with no exit; if it holds the wakeup vector (vCPU blocked), the wakeup handler runs in the host and wakes the vCPU. The insight to take: a single bit-field (NV) is what makes the same descriptor either an exit-free fast path or a wake-the-blocked-vCPU slow path — KVM flips NV on every schedule out/in.


The Posted-Interrupt Descriptor

Everything starts with the descriptor’s layout, which the kernel defines exactly in arch/x86/include/asm/posted_intr.h:

/* Posted-Interrupt Descriptor */
struct pi_desc {
	union {
		u32 pir[8];     /* Posted interrupt requested */
		u64 pir64[4];
	};
	union {
		struct {
			u16	notifications; /* Suppress and outstanding bits */
			u8	nv;
			u8	rsvd_2;
			u32	ndst;
		};
		u64 control;
	};
	u32 rsvd[6];
} __aligned(64);

Field by field, this is the entire contract between hardware and KVM:

  • pir[8] — Posted Interrupt Request. A 256-bit bitmap, “one bit per vector, for up to 256 vectors” (Xen VT-d PI doc). Setting bit N means “vector N is pending for this vCPU.” A device or a CPU sets a bit here; the CPU later drains these bits into the guest’s virtual Interrupt Request Register (vIRR).
  • notifications packs the two critical control bits, named POSTED_INTR_ON (bit 0) and POSTED_INTR_SN (bit 1):
    • ON — Outstanding Notification “indicates if there is a notification event outstanding (not processed by processor or software) for this Posted Interrupt Descriptor.” Hardware sets ON to 1 when it posts a vector and fires the notification; the receiver clears it after draining. It is the “you have new mail” flag that prevents redundant notifications.
    • SN — Suppress Notification. When set, the poster records the vector in the PIR but does not fire a notification event. KVM sets SN when a vCPU is preempted (still runnable but not on a CPU) so that posts accumulate silently rather than spamming notifications at a CPU that isn’t running the guest.
  • nv — Notification Vector. The host-physical interrupt vector that hardware fires when it posts. KVM swaps this between two values: POSTED_INTR_VECTOR (the running-vCPU fast path) and POSTED_INTR_WAKEUP_VECTOR (the blocked-vCPU path).
  • ndst — Notification Destination. The physical APIC ID of the logical processor to notify — i.e. which pCPU the target vCPU is running on. KVM updates this on vCPU migration.
  • The whole descriptor is __aligned(64) — one cache line — because hardware reads and writes it atomically and false sharing would be catastrophic.

The descriptor has two owners, and only one of them is the CPU

The bullet list above is how Linux sees the descriptor. It is not how the CPU sees it, and the difference is the single most clarifying fact about posted interrupts. Intel’s SDM defines the posted-interrupt descriptor in Table 30-1 (SDM Vol. 3C §30.6, order number 326019-078US) with exactly three rows:

Bit position(s)NameDescription (verbatim from SDM Table 30-1)
255:0Posted-interrupt requests“One bit for each interrupt vector. There is a posted-interrupt request for a vector if the corresponding bit is 1.”
256Outstanding notification“If this bit is set, there is a notification outstanding for one or more posted interrupts in bits 255:0.”
511:257Reserved for software and other agents“These bits may be used by software and by other agents in the system (e.g., chipset). The processor does not modify these bits.”

So from the processor’s point of view there is no SN field, no NV field and no NDST field in the descriptor at all — everything above bit 256 is a region the CPU will not touch. The CPU gets its notification vector and its descriptor address from the VMCS, not from the descriptor: “The posted-interrupt notification vector and the address of the posted-interrupt descriptor are fields in the VMCS.”

SN, NV and NDST are defined by a different specification — the VT-d architecture, which carves them out of the region the CPU ignores (Intel VT-d Architecture Specification, Rev. 5.20, April 2026, §9.11):

BitsFieldDefined byWritten byRead byMeaning
255:0PIR — Posted Interrupt RequestsSDM §30.6 and VT-d §9.11the IOMMU (device path), a CPU via pi_test_and_set_pir() (IPI path)the CPU, during posted-interrupt processingOne bit per vector. Set bit N = “vector N is pending for this vCPU.”
256ON — Outstanding NotificationSDM §30.6 and VT-d §9.11the IOMMU, or KVM via pi_test_and_set_on()the CPU (cleared atomically in step 3 of delivery) and pi_wakeup_handler()“A notification is already in flight.” Suppresses redundant notification IPIs.
257SN — Suppress NotificationVT-d onlyKVM only (pi_set_sn() on preemption)the IOMMU“Record the vector but do not ring the bell.” Used when the vCPU is preempted-but-runnable.
271:258ReservedVT-dnobody—Must be zero; the IOMMU blocks the interrupt if reserved bits are non-zero.
279:272NV — Notification VectorVT-d onlyKVM onlythe IOMMUThe host physical vector the IOMMU raises. KVM swaps this between POSTED_INTR_VECTOR and POSTED_INTR_WAKEUP_VECTOR.
287:280ReservedVT-dnobody—Must be zero.
319:288NDST — Notification DestinationVT-d onlyKVM only (on vCPU migration)the IOMMUPhysical APIC ID of the target logical processor. In x2APIC mode the whole 32 bits are the APIC ID; in xAPIC mode only bits 303:296 carry ID[7:0].
511:320ReservedVT-dnobody—“Software must program these bits to 0.”

The posted-interrupt descriptor, field by field, with the specification that defines each and the agent that writes it. What it shows: two fields are shared CPU/IOMMU contract; three are a KVM-to-IOMMU contract that the CPU never reads. The insight to take: this is why NV and NDST exist at all. The CPU does not need them — it learns its notification vector from the VMCS and it is already the destination. The IOMMU is a separate device sitting on the far side of the fabric with no idea which physical CPU a given vCPU is on, so KVM has to leave it a note in memory saying “ring this vector, at this APIC ID.” NDST is KVM’s scheduler telling the IOMMU where the vCPU went.

packet-beta
0-255: "PIR — Posted Interrupt Requests: one bit per vector, 256 vectors (SDM + VT-d)"
256: "ON"
257: "SN"
258-271: "Reserved (RsvdZ)"
272-279: "NV"
280-287: "Rsvd"
288-319: "NDST — Notification Destination (physical APIC ID) — VT-d only"
320-511: "Reserved — software must program these bits to 0"

The 64-byte posted-interrupt descriptor at bit accuracy, drawn from VT-d Rev. 5.20 Figure 9-11 and Table 9-11. What it shows: the exact bit layout, and that half the structure (bits 320–511) is unused padding. The insight to take: the padding is not waste — it is what makes the descriptor exactly 64 bytes, one cache line. The IOMMU performs a “coherent atomic read-modify-write” that claims exclusive ownership of the hosting cache line, so any other data sharing that line would be bounced between the IOMMU and the CPU on every single device interrupt. The __aligned(64) in Linux’s struct pi_desc is enforcing an architectural requirement, not an optimization.

Line the Linux structure up against the spec and every field matches exactly: pir[8] is bits 0–255; the u16 notifications word covers bits 256–271 with POSTED_INTR_ON as its bit 0 (descriptor bit 256) and POSTED_INTR_SN as its bit 1 (descriptor bit 257); u8 nv is bits 272–279; u8 rsvd_2 is bits 280–287; u32 ndst is bits 288–319; and u32 rsvd[6] is bits 320–511. Linux’s struct pi_desc is a literal transcription of VT-d Figure 9-11.

The seven steps the CPU actually performs

This is the mechanism the whole note is about, and the SDM states it as an explicit algorithm. When “external-interrupt exiting” is 1 — which it is, because KVM wants control of host interrupts — an unmasked external interrupt would normally force a VM exit. Setting the “process posted interrupts” VM-execution control changes that behaviour to the following, performed by the processor “in an uninterruptible manner” (SDM Vol. 3C §30.6):

  1. The local APIC is acknowledged, yielding an interrupt vector — the physical vector.
  2. If the physical vector equals the posted-interrupt notification vector, continue. Otherwise a VM exit occurs as it normally would for an external interrupt. This is the branch point of the entire feature.
  3. The processor clears the outstanding-notification bit (ON) in the descriptor, atomically, leaving the rest of the descriptor unmodified — “e.g., with a locked AND operation.”
  4. The processor writes zero to the local APIC’s EOI register, dismissing the notification interrupt.
  5. The processor performs a logical-OR of PIR into VIRR and clears PIR, with the guarantee that “no other agent can read or write a PIR bit (or group of bits) between the time it is read and when it is cleared.”
  6. RVI is set to the maximum of its old value and the highest index of all bits that were set in PIR.
  7. The processor evaluates pending virtual interrupts, and may deliver one immediately.

Steps 3 through 7 are the entire job a hypervisor would otherwise have had to do — drain the pending-interrupt bitmap into the virtual APIC, recompute priority, and inject — and the processor does all of it while still in VMX non-root operation. There is no VM exit anywhere in steps 3–7. Step 2 is what makes it possible: a single vector comparison decides whether this interrupt is “one of the guest’s” (handle it here) or “one of the host’s” (exit as usual). Everything else in the design — the descriptor, the notification vector, NDST, the wakeup vector — exists to arrange for step 2 to say yes as often as possible.

Two consequences fall directly out of the spec text and are easy to get wrong. First, step 5’s atomicity guarantee is architectural, which is why KVM’s pi_test_and_set_pir() can be a plain test_and_set_bit with no lock: the CPU-side drain is already serialized against it by hardware. Second, the SDM notes that posted-interrupt processing can occur “when the logical processor is in the active, HLT, or MWAIT states,” and that a processor in HLT “returns to the HLT state after step #7 (if a pending virtual interrupt was recognized, the logical processor may immediately wake)”. A hlt-ing guest can therefore be woken by a posted interrupt with no exit at all — but only if KVM left the vCPU in the guest rather than blocking it in the host, which is exactly the trade-off the halt-polling code makes.

The control bits are manipulated through atomic helpers in the same header: pi_test_and_set_pir(vector, pi_desc) to post a vector, pi_test_and_set_on() / pi_test_and_clear_on() for ON, pi_set_sn() / pi_test_and_clear_sn() for SN. These map to test_and_set_bit on pi_desc->control, so they are lock-free and SMP-safe — which matters because, as a kernel comment warns, “PID.ON can be set at any time by a different vCPU or by hardware, e.g. a device.”


Mechanical Walk-through — The CPU-to-CPU (IPI) Path

The cleanest path to understand is one vCPU posting an interrupt to another (a virtual IPI, or KVM injecting a device interrupt it received). KVM’s vmx_deliver_posted_interrupt() in vmx.c is the entry point:

static int vmx_deliver_posted_interrupt(struct kvm_vcpu *vcpu, int vector)
{
	struct vcpu_vmx *vmx = to_vmx(vcpu);
	int r;
 
	r = vmx_deliver_nested_posted_interrupt(vcpu, vector);
	if (!r)
		return 0;
 
	/* Note, this is called iff the local APIC is in-kernel. */
	if (!vcpu->arch.apic->apicv_active)
		return -1;
 
	if (pi_test_and_set_pir(vector, &vmx->pi_desc))
		return 0;
 
	/* If a previous notification has sent the IPI, nothing to do.  */
	if (pi_test_and_set_on(&vmx->pi_desc))
		return 0;
 
	kvm_vcpu_trigger_posted_interrupt(vcpu, POSTED_INTR_VECTOR);
	return 0;
}

Line by line, this is the whole posting algorithm:

  1. Try the nested path first (for L2 guests); ignore for the base case.
  2. if (!vcpu->arch.apic->apicv_active) return -1; — this is the hard dependency: posted interrupts only work when APICv is active. If APICv is off (inhibited or unsupported), vmx_deliver_posted_interrupt() returns -1 and the caller vmx_deliver_interrupt() falls back to the classic exit-and-inject path (kvm_lapic_set_irr + KVM_REQ_EVENT + kvm_vcpu_kick). Posted interrupts cannot stand alone; they are an extension of APIC Virtualization (APICv and AVIC).
  3. pi_test_and_set_pir(vector, ...) — set the vector’s bit in the PIR. If it was already set, a notification is already in flight, so return — no need to ring the bell twice.
  4. pi_test_and_set_on(...) — set the ON bit. If it was already set, again a notification is pending; return.
  5. kvm_vcpu_trigger_posted_interrupt(vcpu, POSTED_INTR_VECTOR) — ring the bell.

The bell-ringing in kvm_vcpu_trigger_posted_interrupt() is where the magic concentrates:

if (vcpu->mode == IN_GUEST_MODE) {
	if (vcpu != kvm_get_running_vcpu())
		__apic_send_IPI_mask(get_cpu_mask(vcpu->cpu), pi_vec);
	return;
}
...
kvm_vcpu_wake_up(vcpu);

If the target vCPU is IN_GUEST_MODE (running on a physical CPU), KVM sends a real hardware IPI carrying POSTED_INTR_VECTOR to the pCPU at vcpu->cpu. Here is the crucial part: POSTED_INTR_VECTOR is a special vector the CPU recognizes while in non-root mode. When that IPI arrives at a CPU running the guest, the hardware (not KVM) reads the PID, ORs the PIR bits into the guest’s vIRR, clears ON, re-evaluates priority, and delivers the highest-priority interrupt into the guest — all without a VM exit. The guest’s interrupt handler runs. KVM’s code never executes on the receiving side. If instead the vCPU is not in guest mode, the else branch calls kvm_vcpu_wake_up() — the vCPU is blocked or in root mode, and KVM will drain the PIR into the vIRR via sync_pir_to_irr on the next entry.

Blocking, the wakeup vector, and SN

The subtle engineering is what happens when a vCPU blocks (e.g. executes HLT and goes to sleep). A posted interrupt aimed at POSTED_INTR_VECTOR does nothing useful if the vCPU isn’t running — the bell rings into an empty house. So on schedule-out, vmx_vcpu_pi_put() → pi_enable_wakeup_handler() rewrites the descriptor’s NV to POSTED_INTR_WAKEUP_VECTOR and adds the vCPU to a per-CPU wakeup list:

static void pi_enable_wakeup_handler(struct kvm_vcpu *vcpu)
{
	...
	raw_spin_lock(&per_cpu(wakeup_vcpus_on_cpu_lock, vcpu->cpu));
	list_add_tail(&vmx->pi_wakeup_list,
		      &per_cpu(wakeup_vcpus_on_cpu, vcpu->cpu));
	raw_spin_unlock(&per_cpu(wakeup_vcpus_on_cpu_lock, vcpu->cpu));
	...
	do {
		new.control = old.control;
		new.nv = POSTED_INTR_WAKEUP_VECTOR;   /* set 'NV' to 'wakeup vector' */
	} while (pi_try_set_control(pi_desc, &old.control, new.control));
 
	if (pi_test_on(&new))
		__apic_send_IPI_self(POSTED_INTR_WAKEUP_VECTOR);
}

Now, when a device posts an interrupt to this sleeping vCPU, the notification fires POSTED_INTR_WAKEUP_VECTOR instead — an ordinary host interrupt whose handler, pi_wakeup_handler(), walks the per-CPU list and wakes any vCPU whose ON bit is set:

void pi_wakeup_handler(void)
{
	...
	list_for_each_entry(vmx, wakeup_list, pi_wakeup_list) {
		if (pi_test_on(&vmx->pi_desc))
			kvm_vcpu_wake_up(&vmx->vcpu);
	}
	...
}

The final self-IPI in pi_enable_wakeup_handler() closes a race: if an interrupt was posted between the kvm_vcpu_is_blocking check and the NV update, it would have fired on the old (non-wakeup) vector and been lost; the self-IPI re-triggers the wakeup path. On schedule-in, vmx_vcpu_pi_load() reverses everything — removes the vCPU from the wakeup list, restores new.nv = POSTED_INTR_VECTOR, refreshes ndst to the new pCPU (handling migration), and clears SN.

The SN (Suppress Notification) bit handles a third state: preempted but runnable. vmx_vcpu_pi_put() sets SN “when the vCPU is preempted” — posts still accumulate in the PIR, but no notification fires, avoiding a storm of IPIs at a CPU that has scheduled the vCPU out but will run it again soon. vmx_vcpu_pi_load() clears SN on the way back in, then re-reads the PIR and sets ON if anything accumulated while suppressed.


VT-d Posted Interrupts — Wiring a Device MSI Straight to the Guest

The CPU path above handles IPIs. The far more valuable case is a passthrough device (VFIO Framework): a NIC or NVMe drive assigned directly to the guest, raising thousands of interrupts per second. Normally each device MSI is captured by the host, causes an exit, and is injected — death by a thousand exits. VT-d posted interrupts make the IOMMU post the device’s MSI directly into the vCPU’s descriptor.

The IOMMU’s Interrupt Remapping Table has one entry (IRTE) per device interrupt. In normal remapped mode an IRTE specifies a physical CPU destination and a host vector. In posted mode the IRTE format changes (Xen VT-d PI doc): instead of a destination/vector it holds

  • the Posted-Interrupt Descriptor Address (the host-physical address of the target vCPU’s PID),
  • the Virtual Vector (the guest’s vector for this interrupt), and
  • the URG (Urgent) bit.

When the device raises its MSI, the IOMMU — not the CPU, not KVM — does the posting: it sets the Virtual Vector’s bit in the PIR of the descriptor named by the IRTE, then evaluates the posting condition. Per the Xen doc, the IOMMU posts and notifies when ('ON' == 0) && ('URG' == 1 || 'SN' == 0); that is, fire a notification only if none is already outstanding and either the interrupt is urgent or notifications aren’t suppressed. If it notifies, it sends the descriptor’s NV to the descriptor’s NDST. The result: a device interrupt is delivered to a running guest vCPU without the host CPU ever leaving the guest.

KVM programs the IOMMU into posted mode in vmx_pi_update_irte():

int vmx_pi_update_irte(struct kvm *kvm, unsigned int host_irq,
		       uint32_t guest_irq, bool set)
{
	...
	if (!vmx_can_use_vtd_pi(kvm))
		return 0;
	...
	hlist_for_each_entry(e, &irq_rt->map[guest_irq], link) {
		if (e->type != KVM_IRQ_ROUTING_MSI)
			continue;
		kvm_set_msi_irq(kvm, e, &irq);
		if (!kvm_intr_is_single_vcpu(kvm, &irq, &vcpu) ||
		    !kvm_irq_is_postable(&irq)) {
			ret = irq_set_vcpu_affinity(host_irq, NULL);  /* remapped mode */
			...
			continue;
		}
		vcpu_info.pi_desc_addr = __pa(vcpu_to_pi_desc(vcpu));
		vcpu_info.vector = irq.vector;
		if (set)
			ret = irq_set_vcpu_affinity(host_irq, &vcpu_info);  /* posted mode */
		else
			ret = irq_set_vcpu_affinity(host_irq, NULL);
	}
	...
}

The decisive lines: KVM computes vcpu_info.pi_desc_addr = __pa(vcpu_to_pi_desc(vcpu)) — the physical address of the target vCPU’s descriptor — and the guest’s vcpu_info.vector, then calls irq_set_vcpu_affinity(host_irq, &vcpu_info). That hands the descriptor address and virtual vector down to the IOMMU driver, which rewrites the IRTE into posted mode. The guard vmx_can_use_vtd_pi() checks enable_apicv, that the VM has an assigned device, and irq_remapping_cap(IRQ_POSTING_CAP) — VT-d posting is only attempted when all the pieces are present.

Two important restrictions are visible in the code. First, only MSI-type routes are eligible (e->type != KVM_IRQ_ROUTING_MSI → skip), and only single-destination interrupts: “VT-d PI cannot support posting multicast/broadcast interrupts to a vCPU.” Multi-destination and lowest-priority interrupts stay in remapped mode (irq_set_vcpu_affinity(host_irq, NULL)). Second, the whole thing depends on APICv (enable_apicv in vmx_can_use_vtd_pi).


The Three Delivery Paths, Side by Side

The point of the whole feature is one number: VM exits per device interrupt. Here are the three paths a guest-bound interrupt can take, and what each costs.

sequenceDiagram
    autonumber
    participant NIC as Passthrough NIC
    participant IOMMU
    participant PID as Posted-Int Descriptor
    participant CPU as Physical CPU
    participant KVM as KVM (host, root mode)
    participant G as Guest vCPU (non-root)

    Note over NIC,G: PATH A — trap and emulate: 2 VM exits per interrupt
    NIC->>IOMMU: MSI write
    IOMMU->>CPU: remapped host vector
    CPU-->>KVM: VM EXIT 1 — external interrupt
    KVM->>KVM: match irqfd, kvm_lapic_set_irr(), KVM_REQ_EVENT, kick
    KVM->>G: VM entry, injecting via VM-entry interruption-information
    G-->>KVM: VM EXIT 2 — guest writes APIC EOI
    KVM->>G: VM entry

Path A: the classic trap-and-emulate path, still the fallback whenever APICv is inhibited. What it shows: two full round trips through root mode for one device interrupt — one to receive it, one to acknowledge it. The insight to take: the second exit is the one people forget. Without APIC virtualization the guest’s EOI write is an APIC-register access that must also be trapped, so the cost is not one exit per interrupt but two.

sequenceDiagram
    autonumber
    participant NIC as Passthrough NIC
    participant IOMMU
    participant PID as Posted-Int Descriptor
    participant CPU as Physical CPU
    participant KVM as KVM (host, root mode)
    participant G as Guest vCPU (non-root)

    Note over NIC,G: PATH B — VT-d posted interrupts: 0 VM exits per interrupt
    NIC->>IOMMU: MSI write
    IOMMU->>PID: atomic RMW — set PIR bit for the GUEST vector<br/>compute X = (ON==0) AND (URG OR SN==0)
    IOMMU->>CPU: if X, notification IPI — vector NV, destination NDST
    Note over CPU,G: KVM never runs. CPU stays in non-root mode.
    CPU->>PID: clear ON atomically, write 0 to host APIC EOI
    CPU->>G: OR PIR into VIRR, clear PIR, set RVI, evaluate and deliver
    G->>G: guest ISR runs, guest EOI is virtualized by APICv

Path B: the posted path. What it shows: the same interrupt, with KVM present as a participant and never once activated. The IOMMU writes the guest’s own vector number into memory, and the CPU merges it into the virtual APIC itself. The insight to take: compare the vector that travels on each path. In Path A the IOMMU emits a host vector that KVM must translate back into a guest vector. In Path B the IOMMU emits the guest’s vector directly, because the IRTE in posted format stores the virtual vector. Removing the translation is what removes the hypervisor.

A — trap and emulateB — APICv + VT-d posted (Intel)C — AVIC + IOMMU GA (AMD)
VM exits per device interrupt, vCPU running2 (receive + EOI)00
VM exits per virtual IPI, target vCPU running2 (ICR write + EOI on target)0 (IPI virtualization + posting)0 (AVIC accelerates the ICR write)
VM exits when target vCPU is blocked21 — the wakeup vector fires in the host, pi_wakeup_handler() wakes the vCPU1 — the IOMMU writes a GA-log entry, avic_ga_log_notifier() wakes the vCPU
Who writes the pending vectorKVM, in software (kvm_lapic_set_irr)the IOMMU, into the PID’s PIR; then the CPU into VIRRthe IOMMU, directly into the vAPIC backing page
Where the “which pCPU” answer livesKVM’s scheduler, in softwareNDST field of the PID, updated by vmx_vcpu_pi_load()Physical APIC ID table entry, updated by avic_update_iommu_vcpu_affinity()
Suppression while preemptedn/aSN bit in the PIDIsRunning bit in the Physical APIC ID table
CPU requirementVMX or SVM“process posted interrupts” VM-execution control + virtual-interrupt deliveryX86_FEATURE_AVIC (CPUID Fn8000_000A_EDX[13])
IOMMU requirementinterrupt remappingVT-d with IRQ_POSTING_CAPAMD-Vi with the GA and GAM_vAPIC extended features
Kernel knob—kvm_intel.enable_apicv, kvm.enable_device_posted_irqs (6.16+)kvm_amd.avic, kvm_amd.enable_device_posted_irqs (6.16+)
Restrictionnonesingle-destination MSI only — multicast/broadcast and lowest-priority stay remappedsame class of restriction; only single-vCPU-targeted interrupts are accelerated

The three interrupt-delivery paths compared. What it shows: Intel and AMD reach an identical exit count by structurally analogous but differently-named means. The insight to take: read the “who writes the pending vector” row. Both vendors solve the same problem — let a device deposit a guest interrupt into guest-visible memory without the hypervisor — and both need the same two side-conditions: a place to record where the vCPU is running (NDST / the Physical APIC ID table) and a way to say “record but do not ring” while the vCPU is preempted (SN / IsRunning). The vocabulary differs completely; the architecture does not. The one genuine asymmetry is naming: Intel splits the feature into “posted interrupts” (CPU) plus “VT-d posted interrupts” (IOMMU), whereas AMD folds the whole thing into AVIC, so there is no AMD feature called “posted interrupts” — asking whether an AMD box supports them is a category error, and the right question is whether it supports AVIC with an IOMMU in GA-vAPIC mode.


The AMD Analogue

AMD reaches the same end through AVIC’s IOMMU integration rather than a separate “posted interrupt” feature. When an assigned device’s interrupt targets a vCPU that is not running (IsRunning = 0 in the Physical APIC ID table), the AMD IOMMU has already set the pending bit in the vAPIC backing page and then logs a Guest APIC (GA) log entry, which fires avic_ga_log_notifier() in arch/x86/kvm/svm/avic.c:

int avic_ga_log_notifier(u32 ga_tag)
{
	u32 vm_id = AVIC_GATAG_TO_VMID(ga_tag);
	u32 vcpu_id = AVIC_GATAG_TO_VCPUID(ga_tag);
	...
	/* the IOMMU should have already set the pending bit in the vAPIC
	 * backing page. So, we just need to schedule in the vcpu. */
	if (vcpu)
		kvm_vcpu_wake_up(vcpu);
	return 0;
}

The ga_tag encodes the VM ID and vCPU ID; KVM decodes it, finds the vCPU, and wakes it — the AMD equivalent of the Intel pi_wakeup_handler() path. For a running target, the AMD IOMMU writes the backing page and rings the AVIC doorbell directly, no exit — exactly the role NV/NDST play on Intel. The affinity bookkeeping happens in avic_vcpu_load()/avic_vcpu_put() via avic_update_iommu_vcpu_affinity(), the analogue of Intel’s ndst update.


Measured on This Box — an AMD Machine, So AVIC, Not VT-d

This note was written on an AMD Ryzen AI MAX+ 395 w/ Radeon 8060S (Zen 5, 32 logical CPUs) running Linux 7.1.13 on Fedora 44, measured 2026-09-12. That makes it the wrong architecture for Intel VT-d posted interrupts and the right architecture for the AMD path — so the checks below are the AMD ones, and they come out positive.

The hardware and driver state, read unprivileged

$ grep -o 'vmx\|svm' /proc/cpuinfo | head -1
svm
$ grep -m1 ^flags /proc/cpuinfo | tr ' ' '\n' | grep -E '^(svm|npt|avic|x2avic|v_vmsave_vmload|vgif|vnmi|v_spec_ctrl)$'
svm  npt  avic  v_vmsave_vmload  vgif  x2avic  v_spec_ctrl  vnmi

avic is X86_FEATURE_AVIC, defined as word 15 bit 13 — that is CPUID Fn8000_000A_EDX[13], the SVM feature-identification leaf (arch/x86/include/asm/cpufeatures.h L379, v6.12). x2avic is bit 18, AVIC extended to x2APIC mode. The CPU half of the feature is present.

$ cat /sys/module/kvm_amd/parameters/avic
Y
$ cat /sys/module/kvm_amd/parameters/force_avic
N
$ cat /sys/module/kvm_amd/parameters/enable_device_posted_irqs
Y
$ cat /sys/module/kvm_amd/parameters/enable_ipiv
Y
$ ls /sys/module/kvm_intel/parameters/
ls: cannot access '/sys/module/kvm_intel/parameters/': No such file or directory
$ ls /sys/class/iommu/
ivhd0 -> ../../devices/pci0000:00/0000:00:00.2/iommu/ivhd0/

avic = Y with force_avic = N means AVIC was enabled because the hardware genuinely advertises it, not forced on over a firmware bug — avic_hardware_setup() only leaves it on without force_avic when boot_cpu_has(X86_FEATURE_AVIC) (arch/x86/kvm/svm/avic.c L1194–L1204, v6.12). ivhd0 is an AMD-Vi IOMMU (an IVHD, I/O Virtualization Hardware Definition, block from the ACPI IVRS table); there is no dmar entry because there is no Intel IOMMU here.

The decisive line is in the boot log

$ journalctl -k | grep AMD-Vi
AMD-Vi: Using global IVHD EFR:0x246577efa2254afa, EFR2:0x10
AMD-Vi: Extended features (0x246577efa2254afa, 0x10): PPR NX GT [5] IA GA PC GA_vAPIC
AMD-Vi: Interrupt remapping enabled
AMD-Vi: Virtual APIC enabled

Those last two lines are worth far more than they look, because both strings are emitted from code that has already made the decision:

  • GA_vAPIC is printed only when check_feature(FEATURE_GAM_VAPIC) succeeds — FEATURE_GAM_VAPIC is bit 21 of the IOMMU Extended Feature Register (drivers/iommu/amd/amd_iommu_types.h L101, print at drivers/iommu/amd/init.c L2149–L2150). The IOMMU on this board can write guest APIC state directly. GA (bit 7) alongside it is the Guest APIC log.

  • Virtual APIC enabled is the last statement of the block that sets up GA logging, and the line immediately before it is the one that matters:

    for_each_iommu(iommu) {
    	if (iommu_init_ga_log(iommu) || iommu_ga_log_enable(iommu))
    		return;
    	iommu_feature_enable(iommu, CONTROL_GAM_EN);
    	...
    }
    amd_iommu_irq_ops.capability |= (1 << IRQ_POSTING_CAP);
    pr_info("Virtual APIC enabled

”);


**`IRQ_POSTING_CAP` is exactly the capability KVM tests.** On Intel it is checked by `vmx_can_use_vtd_pi()` via `irq_remapping_cap(IRQ_POSTING_CAP)`; from 6.16 onward it is checked once for both vendors in `kvm_x86_vendor_init()` as `enable_device_posted_irqs &= enable_apicv && irq_remapping_cap(IRQ_POSTING_CAP)`. So the printed line "AMD-Vi: Virtual APIC enabled" is a **direct, unprivileged, primary observation that interrupt posting from devices is available and armed on this machine.** Combined with `kvm_amd.enable_device_posted_irqs = Y`, every precondition on both sides is satisfied.

One thing is *not* enabled: there is no `AMD-Vi: X2APIC enabled` line, which `init.c` prints when `amd_iommu_xt_mode == IRQ_REMAP_X2APIC_MODE`. The IOMMU is doing interrupt remapping in xAPIC mode despite the CPU supporting x2AVIC — consistent with the default boot (`/proc/cmdline` carries no `amd_iommu=` or `iommu=` options at all).

### What a VM exit actually costs here — the quantity posting removes

The argument for posted interrupts is entirely "you avoid a VM exit," so the number that matters is what one exit costs on this hardware. `/dev/kvm` on this box is mode `crw-rw-rw-`, so a VM can be built without privilege. The measurement creates a VM, maps 2 MiB, creates an in-kernel irqchip and one vCPU, and runs a 16-bit real-mode guest that executes an intercepted instruction *N* times:

66 BE nn nn nn nn mov esi, N L:66 31 C0 xor eax, eax 0F A2 cpuid ← SVM-intercepted: VM exit, handled in-kernel 66 4E dec esi ← cpuid clobbers eax/ebx/ecx/edx but NOT esi 75 F7 jnz L E6 E0 out 0xE0, al ← KVM_EXIT_IO, returns to userspace


Running it at *N* = 1,000 and *N* = 201,000 and differencing removes all fixed setup cost; running it again with `cpuid` patched to two `NOP`s gives a control that isolates the guest's own loop cost. Best of seven runs each:

| Variant | *N* = 1,000 | *N* = 201,000 | Δ time | Δ `exits` stat | Per iteration |
| --- | --- | --- | --- | --- | --- |
| `cpuid` (intercepted) | 1.626 ms | 313.711 ms | 312.085 ms | 200,222 | **1,560.4 ns** |
| `NOP` control (no exit) | 0.052 ms | 0.137 ms | 0.085 ms | 0 | **0.42 ns** |
| **difference** | | | | | **≈ 1,560 ns per VM exit + re-entry** |

*One VM exit round trip measured on this box. **What it shows:** the guest loop runs at 0.42 ns per iteration when nothing is intercepted — full native speed, as expected, since the guest is executing directly on the CPU. Add one intercepted instruction and each iteration costs 1,560 ns, a factor of roughly 3,700. **The insight to take:** this is the per-interrupt tax that posting removes. A passthrough NIC at 200,000 interrupts per second would spend 0.31 seconds of every second — 31 % of a core — purely entering and leaving the hypervisor, before doing any useful work at all.*

The `exits` figure is read from the vCPU's own `KVM_GET_STATS_FD` binary-stats stream, and it lands on 200,222 against a predicted 200,000, confirming the loop is producing exactly one exit per iteration.

> [!warning] Uncertain
> 
> Verify: that **1,560 ns is not the architectural VM-exit latency of Zen 5.** It is an upper bound that bundles three things: the bare `VMEXIT`/`VMRUN` pair, KVM's `CPUID` emulation (a `kvm_find_cpuid_entry()` lookup and register writeback, not a trivial handler), and this kernel's speculative-execution mitigations. The last is substantial and specific to this machine: `/sys/devices/system/cpu/vulnerabilities/` reports `spec_rstack_overflow: Mitigation: IBPB on VMEXIT only` **and** `vmscape: Mitigation: IBPB on VMEXIT`. An **IBPB (Indirect Branch Prediction Barrier) is issued on every single VM exit** on this box, and IBPB costs hundreds to low-thousands of cycles by itself. A bare exit on unmitigated Zen 5 hardware would be substantially cheaper. Reason: no microarchitectural VM-exit latency figure for Zen 5 was obtainable, and the AMD APM could not be retrieved (see the callout below). To resolve: re-run with `spec_rstack_overflow=off vmscape=off` on the kernel command line and difference the two, and/or use a cheaper intercepted instruction than `CPUID`. #uncertain
>
> The direction of the error only strengthens the note's argument rather than weakening it: **contemporary speculative-execution mitigations have made VM exits more expensive, not less**, which raises the value of every mechanism that avoids one. `IBPB on VMEXIT` is a mitigation that did not exist when posted interrupts were designed.

### What could not be demonstrated, and the exact command that would settle it

Honest accounting of the walls, each with the specific thing that would knock it down:

| Claim | Demonstrated here? | The wall | What would resolve it |
| --- | --- | --- | --- |
| CPU supports AVIC / x2AVIC | **yes** | — | `grep -m1 ^flags /proc/cpuinfo` (done) |
| KVM has AVIC enabled | **yes** | — | `cat /sys/module/kvm_amd/parameters/avic` (done) |
| IOMMU supports guest vAPIC and posting is armed | **yes** | — | `journalctl -k \| grep AMD-Vi` (done) |
| Cost of one VM exit | **yes** (with caveats above) | — | the `/dev/kvm` micro-benchmark (done) |
| A device interrupt actually delivered with zero exits | **no** | Needs a real VM with a **passed-through PCI device**. VFIO binding requires root (`/dev/vfio/*` is root-only) and the device must be in its own IOMMU group and detached from its host driver. | As root: `modprobe vfio-pci`, bind a NIC, boot a guest with `-device vfio-pci,host=...`, then `perf stat -e kvm:kvm_avic_incomplete_ipi,kvm:kvm_avic_unaccelerated_access -a` and compare exit counts against `kvm_amd.avic=0`. |
| AVIC-specific exit counters | **no** | KVM's per-vCPU binary stats expose 45 counters (`exits`, `irq_injections`, `irq_window_exits`, …) but **no** `avic_*` entries — the AVIC interception counters are not plumbed into the binary-stats interface. | `/sys/kernel/tracing/events/kvm/` — **Permission denied** for a non-root user on this box. As root, enable `kvm:kvm_avic_incomplete_ipi` and `kvm:kvm_avic_unaccelerated_access`. |
| Intel VT-d posted interrupts | **no, and never on this box** | This is an AMD machine. `/sys/module/kvm_intel/` does not exist; `/sys/class/iommu/` contains `ivhd0`, not a `dmar` entry. | Different hardware. Not a permissions problem. |

> [!warning] Uncertain
> 
> Verify: every AMD architectural claim in this note against the **AMD64 Architecture Programmer's Manual Volume 2** (document 24593) and the **AMD I/O Virtualization Technology (IOMMU) Specification** (document 48882). Reason: **AMD's documentation was unreachable, measured 2026-09-12.** `https://www.amd.com/` and every path under it — including `/system/files/TechDocs/24593.pdf` and `/content/dam/amd/en/documents/processor-tech-docs/programmer-references/24593.pdf` — return **HTTP 000** to `curl`, failing as `HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR`; forcing `--http1.1` times out after 30 s with zero bytes. `docs.amd.com` **is** reachable (HTTP 200) and is a FluidTopics portal whose catalogue API at `/api/khub/maps` returns 16 MB listing **3,065 documents** — but a regex sweep of every title for `programmer|iommu|amd64|24593|48882|virtualiz` returns **zero hits**: that portal carries the Xilinx/Vivado/ROCm documentation, not the CPU architecture manuals. Mirrors tried and failed: `mirror.math.princeton.edu` (404), `cdn.preterhuman.net` (403), `mimuw.edu.pl` (403). **Consequently every AMD statement in this note is sourced from the Linux kernel implementation and from this machine's own hardware registers, not from AMD's specification.** That is a genuine primary source for *what Linux does* and *what this silicon reports*, but it is not the architectural contract, and where the two could differ this note cannot tell you. By contrast the Intel claims **are** spec-backed: SDM Vol. 3C (326019-078US) and VT-d Rev. 5.20 were both fetched and read. To resolve: obtain 24593 and 48882 from a verifiable mirror, confirming each against its title page and revision number before citing. #uncertain

---

## Failure Modes and Common Misunderstandings

- **"Posted interrupts work without APICv."** They do not. The `if (!vcpu->arch.apic->apicv_active) return -1;` in `vmx_deliver_posted_interrupt()` and the `enable_apicv` check in `vmx_can_use_vtd_pi()` make posted interrupts strictly dependent on [[APIC Virtualization (APICv and AVIC)]]. Any inhibit that disables APICv (nesting, APIC-ID aliasing) also disables posting and silently reverts to exit-and-inject.
- **Lost wakeups under migration.** The per-CPU wakeup list plus the `wakeup_vcpus_on_cpu_lock` exist precisely because a blocked vCPU can be woken *and migrated* to a different pCPU; `vmx_vcpu_pi_load()` must remove it from the *previous* pCPU's list. A kernel comment flags the deadlock hazard: "IRQs must be disabled when taking this lock, otherwise deadlock will occur if a wakeup IRQ arrives and attempts to acquire the lock." Bugs here manifest as a vCPU that sleeps through a device interrupt.
- **The "spurious notification is benign" design.** Much of the code tolerates extra notifications (the doorbell to a migrated vCPU, the IPI to a vCPU that just exited). The comment in `kvm_vcpu_trigger_posted_interrupt` enumerates the three cases (stays in non-root; exits but runnable; exits and blocked) and shows each ends benignly because `sync_pir_to_irr` runs on the next entry. The invariant being protected is *no lost interrupt*, not *no redundant notification*.
- **Memory-ordering between IOMMU and CPU.** Because the IOMMU writes ON/PIR concurrently with the CPU, the code carries explicit barriers — e.g. `smp_mb__after_atomic()` after clearing ON in `vmx_sync_pir_to_irr` ("IOMMU can write to PID.ON, so the barrier matters even on UP") and after clearing SN in `vmx_vcpu_pi_load`. Omitting these would race a freshly-posted vector against the drain.
- **Only single-destination MSIs post.** Multicast/broadcast and lowest-priority interrupts cannot use VT-d PI and stay in remapped mode, still costing an exit. A passthrough device whose interrupts are deliberately spread across vCPUs (some `irqbalance` configurations) may therefore *not* get the posted-interrupt benefit it expected.

---

## Production Notes and History

Posted interrupts did not arrive in one piece; they arrived as three features, years apart. The merge points below were established by **existence-checking** — fetching the same file at successive release tags and recording the first tag in which the defining symbol appears — rather than from changelog memory:

| Feature | Defining symbol | Absent at | **Present at** | What it added |
| --- | --- | --- | --- | --- |
| CPU-side posted interrupts | `struct pi_desc`, `POSTED_INTR_VECTOR` in `arch/x86/kvm/vmx.c` and `entry_arch.h` | **v3.9** | **v3.10** | The descriptor and the notification vector. Guest-to-guest IPIs could be posted; devices could not. |
| VT-d posted interrupts, IOMMU side | `IRQ_POSTING_CAP` in `arch/x86/include/asm/irq_remapping.h`; `set_vcpu_affinity` in `drivers/iommu/intel_irq_remapping.c` | **v4.1** | **v4.2** | The IOMMU learned to write a PID instead of raising a host vector. Nothing used it yet. |
| VT-d posted interrupts, KVM side | `vmx_update_pi_irte()` in `arch/x86/kvm/vmx.c` | **v4.3** | **v4.4** | KVM started programming IRTEs into posted mode, joining the two halves. This is when the feature became usable. |
| Vendor-neutral device-posting knob | `enable_device_posted_irqs` in `arch/x86/kvm/x86.c` | **v6.15** | **v6.16** | A single `bool` in shared x86 code, exported to both `kvm_intel` and `kvm_amd`, narrowed at init by `enable_device_posted_irqs &= enable_apicv && irq_remapping_cap(IRQ_POSTING_CAP)`. |

*The staged arrival of posted interrupts in Linux. **What it shows:** roughly three years between the CPU feature (3.10) and a usable device path (4.4), and another decade before the two vendors' device posting was expressed as one flag (6.16). **The insight to take:** the last row is why this note's pinned kernel matters. On **v6.12 there is no `enable_device_posted_irqs`** — `grep` finds zero occurrences in `arch/x86/kvm/svm/svm.c` at that tag — yet the machine this note was measured on exposes `/sys/module/kvm_amd/parameters/enable_device_posted_irqs`, because it runs 7.1.13. A reader who checks that file on an older kernel and finds it missing has not found a misconfiguration; the knob simply did not exist before 6.16.*

The Feng Wu / Jun Nakajima series that produced the 4.2/4.4 work was posted in v2 on 2014-12-03 ([LWN 2014](https://lwn.net/Articles/624245/)), and its reason for existing is stated bluntly there: "external interrupts from direct-assigned devices can be delivered to guests without VMM intervention when guest is running in non-root mode."

> [!important] "Posted interrupts" and "posted MSI" are different features with confusingly similar names
> 
> Since **v6.10** (absent at v6.9) the same header defines `enable_posted_msi`, guarded by `return enable_posted_msi && irq_remapping_cap(IRQ_POSTING_CAP);`. Despite sharing the `IRQ_POSTING_CAP` test, **posted MSI is a host-side feature that has nothing to do with virtualization**: it coalesces *host* device MSIs through a per-CPU posted-interrupt descriptor to reduce host interrupt overhead, with no guest and no vCPU involved. This note is about guest-bound posting. If you are reading a `CONFIG_X86_POSTED_MSI` discussion, you are reading about the other one.

In practice posted interrupts are the difference between usable and unusable high-rate passthrough I/O. A 100 Gbit NIC assigned via [[VFIO Framework|VFIO]]/SR-IOV can generate hundreds of thousands of interrupts per second; without posting, each is an exit (~1 microsecond of overhead), capping effective interrupt throughput and burning host CPU on entry/exit. With posting, those interrupts land in the guest at near-native cost, which is why posted interrupts are effectively mandatory for network-function-virtualization and high-performance-storage VMs. The `kvm:kvm_pi_irte_update` tracepoint (emitted from `vmx_pi_update_irte`) confirms an IRTE was switched into posted mode; its presence is the operational signal that a passthrough device's interrupts are taking the exit-free path.

> [!note] Resolved
> 
> An earlier revision of this note carried an uncertainty flag saying the VT-d merge version was "the 4.4 era" on the strength of an LWN submission date, and that the SDM's architectural definition had not been read. **Both are now resolved.** The version question is settled by the existence-check table above: IOMMU support v4.2, KVM wiring v4.4, CPU-side posting v3.10 — so "4.4 era" was right for the part that made the feature usable and wrong for the feature as a whole. And Intel SDM Vol. 3C §30.6 "Posted-Interrupt Processing" (order number 326019-078US) was fetched at HTTP 200 from `cdrdv2-public.intel.com` and is quoted directly in *The seven steps the CPU actually performs* above, together with VT-d Architecture Specification Rev. 5.20 (April 2026) §5.2.3 and §9.11. The remaining documentation gap is AMD's, flagged separately above.

---

## See Also

- [[APIC Virtualization (APICv and AVIC)]] — the hard dependency: posted interrupts require the virtual-APIC page / backing page and virtual-interrupt delivery this note builds on
- [[Interrupt Injection and the Virtual APIC]] — the fallback path when posting is unavailable (exit, set vIRR, `KVM_REQ_EVENT`, kick)
- [[irqfd and ioeventfd]] — the eventfd plumbing by which VFIO and virtio interrupt sources reach KVM's injection/posting path
- [[VFIO Framework]] — passthrough devices whose MSIs are the prime beneficiary of VT-d posted interrupts
- [[The IOMMU and DMA Remapping]] — **the boundary to state carefully.** That note owns interrupt remapping as an IOMMU subsystem: the Interrupt Remapping Table, IRTE allocation and lifecycle, `irq_remapping_cap()`, and DMA translation on the same hardware. This note owns only the delta — what changes when an IRTE is switched from *remapped* format (IM=0: a host destination and a host vector) into *posted* format (IM=1: a descriptor address, a **guest** vector, and an URG bit). Read that note for the table, this one for the format flip.
- [[VFIO Framework]] — the passthrough path that makes device posting worth having, and the `/dev/vfio/*` root requirement that blocked the end-to-end demonstration here
- [[Intel VMX and AMD SVM]] — the non-root mode in which `POSTED_INTR_VECTOR` is recognized exit-free
- [[KVM vCPU Run Loop]] — where `sync_pir_to_irr` drains the PIR into the vIRR on entry
- [[Linux Virtualization MOC]] — the parent map; this note sits in §8, the interrupt-virtualization "eliminate the VM exit" section