interrupt-affinity-utility.ps1

<#PSScriptInfo

.VERSION 1.0.1

.GUID 3b047844-89f8-4d9e-a386-64d9af75161c

.AUTHOR vadyaravadim

.COMPANYNAME

.COPYRIGHT

.TAGS Windows Windows10 Windows11 Gaming InterruptAffinity IRQ DPC Latency MSI PCI Performance Tweak

.LICENSEURI https://github.com/vadyaravadim/interrupt-affinity-utility/blob/main/LICENSE

.PROJECTURI https://github.com/vadyaravadim/interrupt-affinity-utility

.ICONURI

.EXTERNALMODULEDEPENDENCIES

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES

.PRIVATEDATA

#>


<#
.SYNOPSIS
    Interrupt affinity policy manager for PCI devices.
.DESCRIPTION
    Lists PCI devices with their current interrupt affinity policy in
    Out-GridView, then pins the interrupts of the devices you select to the
    CPU cores you pick in a second grid (P/E-cores labeled on hybrid CPUs).
    Writes the documented DevicePolicy / AssignmentSetOverride registry
    values. Zero external dependencies. Windows PowerShell 5.1+.
.PARAMETER ShowAll
    Show every PCI device with an Interrupt Management key, including
    bridges and abstract controllers (hidden by default).
.PARAMETER Reset
    Remove the affinity policy override from the selected devices
    (restore the machine default) instead of setting one.
.NOTES
    Restart the device (disable/enable in Device Manager) or reboot for
    changes to take effect.
    Revert: apply the affinity_undo_<stamp>.reg file written before each
    change to revert that run, or affinity_undo_original.reg to restore the
    state each device had before this tool first touched it.
    (-Reset deletes both values; if another tool wrote a policy you want
    back, only the undo files restore it.)
#>

[CmdletBinding()]
param(
    [switch]$ShowAll,
    [switch]$Reset,
    [switch]$Elevated   # internal: set by the self-elevation relaunch
)

$ErrorActionPreference = 'Stop'

# Keep the self-elevated window open so the user can read the output.
function Wait-IfElevatedWindow {
    if ($Elevated) { Read-Host "Press Enter to close" | Out-Null }
}

# Without this, an unhandled error closes the self-elevated window before
# the user can read the message.
trap {
    Write-Host "ERROR: $_" -ForegroundColor Red
    Wait-IfElevatedWindow
    # Under `irm | iex` this runs inside the user's own session, where `exit`
    # would close their console - rethrow so only the piped script stops.
    if ($PSCommandPath) { exit 1 }
    break
}

# Mode switches forwarded on every relaunch (the irm|iex bootstrap rerun below
# and the self-elevation later) - one list so neither path can silently drop one.
function Get-ForwardedSwitchList {
    $a = @()
    if ($ShowAll) { $a += '-ShowAll' }
    if ($Reset)   { $a += '-Reset' }
    $a
}

# Launched via `irm <url> | iex` - no file on disk. The undo .reg files are
# written next to the script, so a stable path is required: save the script to
# the user profile (not TEMP - the undo files must survive automatic temp
# cleanup) and rerun it from there (the rerun handles elevation).
if (-not $PSCommandPath) {
    # The piped text is not recoverable from inside iex ($MyInvocation there
    # holds the caller's command line, not the script body) - download the
    # script.
    try {
        $body = Invoke-RestMethod 'https://raw.githubusercontent.com/vadyaravadim/interrupt-affinity-utility/main/interrupt-affinity-utility.ps1' -TimeoutSec 30
    } catch {
        Write-Host "ERROR: could not download the script ($($_.Exception.Message)). Check your internet connection, or save the script to a file and run it from there." -ForegroundColor Red
        return
    }
    $saved = Join-Path $env:USERPROFILE 'interrupt-affinity-utility.ps1'
    if ((Test-Path $saved) -and ([IO.File]::ReadAllText($saved) -cne $body)) {
        Copy-Item $saved "$saved.bak" -Force
        Write-Host "Existing $saved differs - previous copy kept as $saved.bak" -ForegroundColor Yellow
    }
    [IO.File]::WriteAllText($saved, $body, [Text.Encoding]::UTF8)
    Write-Host "Script saved to: $saved (the undo files will be written next to it)" -ForegroundColor Cyan
    # @(): a single forwarded switch unrolls to a scalar, and splatting a
    # scalar string breaks powershell.exe -File switch binding on PS 5.1.
    $fwd = @(Get-ForwardedSwitchList)
    powershell -NoProfile -ExecutionPolicy Bypass -File $saved @fwd
    # The rerun's exit code stays in $LASTEXITCODE for scripted callers.
    return
}

$principal = New-Object Security.Principal.WindowsPrincipal(
    [Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    Write-Host "Not running as Administrator. Requesting elevation..." -ForegroundColor Yellow
    try {
        $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
                     '-File', "`"$PSCommandPath`"", '-Elevated') + (Get-ForwardedSwitchList)
        Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs
    } catch {
        # Not always a refusal (UAC service disabled, ...) - show the real cause.
        Write-Host "ERROR: elevation failed ($($_.Exception.Message)). Run this script as Administrator." -ForegroundColor Red
        Read-Host "Press Enter to close" | Out-Null
    }
    return
}

# PowerShell 7 ships without Out-GridView (Server Core has none at all);
# fail up front with instructions instead of a raw CommandNotFound mid-run.
if (-not (Get-Command Out-GridView -ErrorAction SilentlyContinue)) {
    Write-Host "Out-GridView is not available in this PowerShell. Run the script with Windows PowerShell (powershell.exe), or install the Microsoft.PowerShell.GraphicalTools module." -ForegroundColor Red
    Wait-IfElevatedWindow
    return
}

# Latency-critical classes kept when -ShowAll is NOT set. Matched by ClassGUID,
# not display name: names are localized and OEM-specific (a real xHCI controller
# can be named "(Intel(R),3.20,1.20)"), so keyword matching silently misses devices.
$IncludeClassGuids = @(
    '{4d36e968-e325-11ce-bfc1-08002be10318}',  # Display (GPU)
    '{4d36e972-e325-11ce-bfc1-08002be10318}',  # Net (NIC)
    '{4d36e96c-e325-11ce-bfc1-08002be10318}',  # Media (sound cards)
    '{36fc9e60-c465-11cf-8056-444553540000}'   # USB host controllers
)

# Audio bus controllers register under the System class, not Media; matched
# by their locale-invariant bus service names (HD Audio, Intel SST).
$AudioBusServices = @('HDAudBus', 'IntcAudioBus')

$PolicyNames = @{
    0 = 'MachineDefault'
    1 = 'AllCloseProcessors'
    2 = 'OneCloseProcessor'
    3 = 'AllProcessors'
    4 = 'SpecifiedProcessors'
    5 = 'SpreadMessages'
    6 = 'Steered (system)'
}

function Get-DeviceName {
    param([Microsoft.Win32.RegistryKey]$Key)
    $fn = $Key.GetValue('FriendlyName')
    if ([string]::IsNullOrWhiteSpace($fn)) { $fn = $Key.GetValue('DeviceDesc') }
    if ($fn -and $fn -match '^@') {
        # Indirect string "@file,resid;Text": the text is everything after the
        # FIRST ';' and may itself contain ';'. Keep the raw string if nothing
        # follows ';' (a malformed indirect string) so the device stays visible.
        $text = ($fn -split ';', 2)[1]
        if (-not [string]::IsNullOrWhiteSpace($text)) { $fn = $text }
    }
    return $fn
}

# Logical processors with their efficiency class (P/E on hybrid CPUs) and
# physical core, via GetSystemCpuSetInformation - the documented source of
# EfficiencyClass; WMI has no per-core equivalent.
function Get-CpuTopology {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class CpuSets {
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool GetSystemCpuSetInformation(
        IntPtr information, uint bufferLength, out uint returnedLength, IntPtr process, uint flags);
}
'@

    # Probe size, then fetch; re-probe if the topology grew in between
    # (CPU hot-add on a VM) instead of dying on ERROR_INSUFFICIENT_BUFFER.
    $bytes = $null
    while ($null -eq $bytes) {
        $len = [uint32]0
        [void][CpuSets]::GetSystemCpuSetInformation([IntPtr]::Zero, 0, [ref]$len, [IntPtr]::Zero, 0)
        if ($len -eq 0) { throw "GetSystemCpuSetInformation returned no data" }
        $buf = [Runtime.InteropServices.Marshal]::AllocHGlobal([int]$len)
        try {
            if ([CpuSets]::GetSystemCpuSetInformation($buf, $len, [ref]$len, [IntPtr]::Zero, 0)) {
                $bytes = New-Object byte[] $len
                [Runtime.InteropServices.Marshal]::Copy($buf, $bytes, 0, [int]$len)
            } else {
                $err = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
                if ($err -ne 122) { throw "GetSystemCpuSetInformation failed: $err" }   # 122 = ERROR_INSUFFICIENT_BUFFER
            }
        } finally {
            [Runtime.InteropServices.Marshal]::FreeHGlobal($buf)
        }
    }
    # SYSTEM_CPU_SET_INFORMATION (x64): Size u32 @0, Type u32 @4, Group u16 @12,
    # LogicalProcessorIndex u8 @14, CoreIndex u8 @15, EfficiencyClass u8 @18
    $cpus = New-Object System.Collections.Generic.List[object]
    $pos = 0
    while ($pos -lt $bytes.Length) {
        $size = [BitConverter]::ToUInt32($bytes, $pos)
        if ($size -eq 0) { break }
        if ([BitConverter]::ToUInt32($bytes, $pos + 4) -eq 0) {   # CpuSetInformation
            $cpus.Add([PSCustomObject]@{
                CPU      = [int]$bytes[$pos + 14]
                Core     = [int]$bytes[$pos + 15]
                Group    = [BitConverter]::ToUInt16($bytes, $pos + 12)
                EffClass = [int]$bytes[$pos + 18]
            })
        }
        $pos += $size
    }
    return $cpus
}

# "0-3,16,18" from a KAFFINITY mask, for the Cores column.
function ConvertTo-CoreList {
    param([uint64]$Mask)
    $set = 0..63 | Where-Object { ($Mask -shr $_) -band 1 }
    if (-not $set) { return '-' }
    $ranges = New-Object System.Collections.Generic.List[string]
    $start = $prev = $set[0]
    foreach ($i in ($set | Select-Object -Skip 1)) {
        if ($i -ne $prev + 1) {
            $ranges.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" }))
            $start = $i
        }
        $prev = $i
    }
    $ranges.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" }))
    return $ranges -join ','
}

# AssignmentSetOverride may be REG_BINARY (little endian), REG_DWORD or
# REG_QWORD - all documented; normalize to a uint64 mask. $null = a foreign
# value in a type this tool cannot read, so the caller can surface it
# instead of showing it as "no override".
function ConvertTo-Mask {
    param($Value)
    if ($Value -is [byte[]]) {
        $padded = New-Object byte[] 8
        [Array]::Copy($Value, $padded, [Math]::Min($Value.Length, 8))
        return [BitConverter]::ToUInt64($padded, 0)
    }
    if ($Value -is [int])  { return [BitConverter]::ToUInt32([BitConverter]::GetBytes($Value), 0) }
    if ($Value -is [long]) { return [BitConverter]::ToUInt64([BitConverter]::GetBytes($Value), 0) }
    return $null
}

function ConvertTo-HexPairs {
    param([byte[]]$Bytes)
    ($Bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
}

# One .reg line restoring a value in its ORIGINAL registry type, or deleting
# it if it was absent - so undo round-trips values written by other tools too.
function Get-UndoLine {
    param([Microsoft.Win32.RegistryKey]$Key, [string]$Name)
    if ($null -eq $Key -or $Name -notin $Key.GetValueNames()) { return "`"$Name`"=-" }
    $raw = $Key.GetValue($Name, $null, 'DoNotExpandEnvironmentNames')
    switch ($Key.GetValueKind($Name)) {
        'DWord'        { return ('"{0}"=dword:{1:x8}' -f $Name,
                         [BitConverter]::ToUInt32([BitConverter]::GetBytes([int]$raw), 0)) }
        'QWord'        { return ('"{0}"=hex(b):{1}' -f $Name,
                         (ConvertTo-HexPairs ([BitConverter]::GetBytes([long]$raw)))) }
        'Binary'       { return ('"{0}"=hex:{1}' -f $Name, (ConvertTo-HexPairs $raw)) }
        'String'       { return ('"{0}"="{1}"' -f $Name,
                         $raw.Replace('\', '\\').Replace('"', '\"')) }
        'ExpandString' { return ('"{0}"=hex(2):{1}' -f $Name,
                         (ConvertTo-HexPairs ([Text.Encoding]::Unicode.GetBytes("$raw`0")))) }
        'MultiString'  { return ('"{0}"=hex(7):{1}' -f $Name,
                         (ConvertTo-HexPairs ([Text.Encoding]::Unicode.GetBytes((($raw -join "`0") + "`0`0"))))) }
        # REG_NONE / unknown kinds have no .reg literal; deleting restores the default.
        default        { return "`"$Name`"=-" }
    }
}

Write-Host "Scanning PCI devices..." -ForegroundColor Cyan
$pciRoot = 'HKLM:\SYSTEM\CurrentControlSet\Enum\PCI'
$rows = New-Object System.Collections.Generic.List[object]

$hidden = 0
foreach ($devClass in Get-ChildItem $pciRoot -ErrorAction SilentlyContinue) {
    foreach ($inst in Get-ChildItem $devClass.PSPath -ErrorAction SilentlyContinue) {
        $name = Get-DeviceName -Key $inst
        if ([string]::IsNullOrWhiteSpace($name)) { continue }

        # Interrupt-capable devices expose "Device Parameters\Interrupt Management".
        $imPath = Join-Path $inst.PSPath 'Device Parameters\Interrupt Management'
        if (-not (Test-Path $imPath)) { continue }

        if (-not $ShowAll) {
            $classGuid = $inst.GetValue('ClassGUID')
            if ($classGuid -notin $IncludeClassGuids -and
                $inst.GetValue('Service') -notin $AudioBusServices) { $hidden++; continue }
        }

        # Absent key/values = no override: the OS picks the processors.
        $apPath = Join-Path $imPath 'Affinity Policy'
        $policy = 'Default'; $cores = '-'
        if (Test-Path $apPath) {
            $apKey = Get-Item $apPath
            $dp = $apKey.GetValue('DevicePolicy')
            if ($null -ne $dp) {
                # -as: a foreign value (REG_SZ, out-of-range QWORD) must show as
                # Unknown, not abort the whole scan via the trap.
                $dpInt = $dp -as [int]
                $policy = if ($null -ne $dpInt -and $PolicyNames[$dpInt]) { $PolicyNames[$dpInt] }
                          else { "Unknown ($dp)" }
            }
            $aso = $apKey.GetValue('AssignmentSetOverride')
            if ($null -ne $aso) {
                $m = ConvertTo-Mask $aso
                $cores = if ($null -ne $m) { ConvertTo-CoreList -Mask $m } else { "Unreadable ($aso)" }
            }
        }

        $rows.Add([PSCustomObject]@{
            Name     = $name
            Policy   = $policy
            Cores    = $cores
            DeviceID = $inst.PSChildName
            RegPath  = $apPath   # target key we will write
        })
    }
}

if ($rows.Count -eq 0) {
    Write-Host "No matching devices found. Try -ShowAll." -ForegroundColor Yellow
    Wait-IfElevatedWindow
    return
}
if ($hidden) {
    Write-Host "$hidden more device(s) (storage controllers, bridges, ...) are hidden by the default filter. Use -ShowAll to include them." -ForegroundColor DarkGray
}

if ($Reset) { $title = 'Select devices to RESET interrupt affinity to machine default' }
else        { $title = 'Select devices whose interrupts to pin' }
$selected = $rows |
    Sort-Object Policy, Name |
    Out-GridView -Title "$title (Ctrl-click for multiple)" -PassThru

if (-not $selected) {
    Write-Host "No devices selected. No changes made." -ForegroundColor Yellow
    Wait-IfElevatedWindow
    return
}

$mask = [uint64]0
if (-not $Reset) {
    $cpus = Get-CpuTopology
    $extra = @($cpus | Where-Object { $_.Group -ne 0 -or $_.CPU -gt 63 })
    if ($extra) {
        # KAFFINITY covers 64 processors of group 0; beyond that needs group-aware
        # tools (this machine class is server hardware, not the target here).
        Write-Host "$($extra.Count) processor(s) beyond CPU 63 / group 0 cannot be targeted and are not listed." -ForegroundColor Yellow
        $cpus = @($cpus | Where-Object { $_.Group -eq 0 -and $_.CPU -le 63 })
    }
    # Higher efficiency class = performance core (Intel hybrid: P=1, E=0).
    $maxClass = ($cpus | Measure-Object EffClass -Maximum).Maximum
    $hasSmt = [bool]($cpus | Group-Object Core | Where-Object Count -gt 1)
    $coreRows = foreach ($c in $cpus) {
        $row = [ordered]@{
            CPU  = $c.CPU
            Type = if ($maxClass -eq 0) { 'Core' }
                   elseif ($c.EffClass -eq $maxClass) { 'P-Core' } else { 'E-Core' }
        }
        # Two CPUs sharing one PhysicalCore = SMT/HT pair; column only shown then.
        if ($hasSmt) { $row['PhysicalCore'] = $c.Core }
        [PSCustomObject]$row
    }

    $picked = $coreRows | Sort-Object CPU |
        Out-GridView -Title "Select CPU core(s) to handle interrupts of $(@($selected).Count) device(s)" -PassThru
    if (-not $picked) {
        Write-Host "No cores selected. No changes made." -ForegroundColor Yellow
        Wait-IfElevatedWindow
        return
    }
    foreach ($c in $picked) { $mask = $mask -bor ([uint64]1 -shl $c.CPU) }
    Write-Host ("Target cores: {0} (mask 0x{1:X})" -f (ConvertTo-CoreList -Mask $mask), $mask) -ForegroundColor Cyan
}

# Lightweight rollback: record the CURRENT state of every selected key into a
# per-run .reg file BEFORE changing anything (double-clicking it reverts this
# run), and append the same stanza to a cumulative first-touch file that keeps
# the state each device had before this tool EVER touched it - applying that
# one file restores the original state no matter how many runs happened since.
# Undo is value-level on purpose: a "[-key]" stanza would also wipe values the
# tool never wrote (e.g. DevicePriority set by a driver or another tweak);
# deleting the values may leave an empty key behind, which is harmless.
# The suffix loop keeps two runs within the same second from clobbering
# each other's undo file.
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$undoFile = Join-Path $PSScriptRoot "affinity_undo_$stamp.reg"
$n = 1
while (Test-Path $undoFile) { $undoFile = Join-Path $PSScriptRoot ("affinity_undo_{0}_{1}.reg" -f $stamp, $n++) }
$origFile = Join-Path $PSScriptRoot 'affinity_undo_original.reg'
if (-not (Test-Path $origFile)) {
    Set-Content -Path $origFile -Value "Windows Registry Editor Version 5.00`r`n" -Encoding Unicode
}
$origText = Get-Content $origFile -Raw
$undo = New-Object System.Text.StringBuilder
[void]$undo.AppendLine('Windows Registry Editor Version 5.00')
[void]$undo.AppendLine('')
$origAdd = New-Object System.Text.StringBuilder
foreach ($d in $selected) {
    # Provider path -> raw path for the .reg format
    $raw = $d.RegPath -replace '^.*Registry::', ''
    $apKey = if (Test-Path $d.RegPath) { Get-Item $d.RegPath } else { $null }
    $stanza = "[$raw]`r`n" +
              (Get-UndoLine -Key $apKey -Name 'DevicePolicy') + "`r`n" +
              (Get-UndoLine -Key $apKey -Name 'AssignmentSetOverride') + "`r`n"
    [void]$undo.AppendLine($stanza)
    if (-not $origText.Contains("[$raw]")) { [void]$origAdd.AppendLine($stanza) }
}
Set-Content -Path $undoFile -Value $undo.ToString() -Encoding Unicode
if ($origAdd.Length) { Add-Content -Path $origFile -Value $origAdd.ToString() -Encoding Unicode }
Write-Host "Undo files: $undoFile (reverts this run); $origFile (restores the pre-tool state)" -ForegroundColor Cyan

$updated = 0
$failed  = 0
foreach ($d in $selected) {
    try {
        if ($Reset) {
            if (Test-Path $d.RegPath) {
                Remove-ItemProperty -Path $d.RegPath -Name 'DevicePolicy' -ErrorAction SilentlyContinue
                Remove-ItemProperty -Path $d.RegPath -Name 'AssignmentSetOverride' -ErrorAction SilentlyContinue
            }
            Write-Host (" [RESET] {0}" -f $d.Name) -ForegroundColor Green
        } else {
            if (-not (Test-Path $d.RegPath)) {
                New-Item -Path $d.RegPath -Force | Out-Null
            }
            # DevicePolicy 4 = IrqPolicySpecifiedProcessors; the mask is only
            # honored with this policy. REG_BINARY little endian, as documented.
            New-ItemProperty -Path $d.RegPath -Name 'DevicePolicy' `
                -Value 4 -PropertyType DWord -Force | Out-Null
            New-ItemProperty -Path $d.RegPath -Name 'AssignmentSetOverride' `
                -Value ([BitConverter]::GetBytes($mask)) -PropertyType Binary -Force | Out-Null
            Write-Host (" [PIN {0}] {1}" -f (ConvertTo-CoreList -Mask $mask), $d.Name) -ForegroundColor Green
        }
        $updated++
    } catch {
        Write-Host (" [ERR] {0}: {1}" -f $d.Name, $_) -ForegroundColor Red
        $failed++
    }
}

Write-Host ""
Write-Host "Done. $updated of $(@($selected).Count) device(s) updated." -ForegroundColor Green
if ($failed) {
    # The undo file lists the failed devices too; reverting an unchanged value is a no-op.
    Write-Host "$failed device(s) failed - see errors above." -ForegroundColor Yellow
}
Write-Host "Restart the device (disable/enable in Device Manager) or REBOOT for changes to take effect." -ForegroundColor Green
Wait-IfElevatedWindow