tools: compute the affinity mask by shifting, not by Pow on a double

[math]::Pow(2, $n) - 1 builds the processor-affinity mask in DOUBLE
precision, and a double carries only a 53-bit mantissa. From n = 54 the
subtraction is lost to rounding: 2^n - 1 rounds straight back up to 2^n, so
the mask comes out with exactly ONE bit set.

Measured across the range, old math vs shifting:

  n = 50..53   both select n CPUs                      ok
  n = 54..62   old selects 1 CPU of n                  SILENTLY WRONG
  n = 63..64   old throws converting to Int64          visible failure

The silent band is by far the worse half, and it is worst precisely in this
tool. On a 54-to-62-processor host, -Off would pin the title to a single
logical CPU while printing "affinity restored to all N processors", and
-Cores N would pin it to one CPU while reporting N. A throttling tool that
reports the opposite of what it did does not just fail, it invalidates every
measurement taken through it -- which is the exact failure this tool exists
to prevent.

Get-AffinityMask shifts a uint64 instead, and special-cases a full 64
because .NET masks a 64-bit shift count to 63 (shifting by 64 quietly yields
1, not 0). Verified equivalent to the old expression for every n where the
old expression was correct (1..53), correct by popcount for 1..64, and
saturating above 64. ProcessorAffinity addresses a single processor group, so
a host with more than 64 processors is inherently limited to the group the
process already runs in; the mask now saturates there rather than throwing.

No behaviour change on any host where the tool previously worked -- this box
has 16 processors, where old and new masks are byte-identical (0xFFFF).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-09-02 13:42:13 -07:00
parent b7ca2f99ac
commit aa0a285c86
+13 -2
View File
@@ -78,6 +78,17 @@ $proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
if (-not $proc) { throw "no process with PID $ProcessId" }
$nCpu = [Environment]::ProcessorCount
function Get-AffinityMask([int]$n) {
# [math]::Pow(2,$n)-1 overflows Int64 at $n >= 63 and THROWS, so on a host
# with 64 logical processors -Off and -Cores both died instead of doing
# anything. Shift instead, and special-case a full 64 because .NET masks a
# 64-bit shift count to 63 (shifting by 64 quietly yields 1, not 0).
# Note ProcessorAffinity covers a single processor group, so a >64-CPU host
# is inherently limited to the group the process is already in.
if ($n -ge 64) { return [int64]-1 }
return [int64](([uint64]1 -shl $n) - 1)
}
function Measure-Draw([System.Diagnostics.Process]$p, [int]$secs) {
$t1 = $p.TotalProcessorTime
Start-Sleep -Seconds $secs
@@ -139,7 +150,7 @@ if ($Off) {
if (-not [JobCpu]::SetInformationJobObject($job, [JobCpu]::InfoClass, [ref]$info, 8)) {
throw "SetInformationJobObject(off) failed: $([ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message)"
}
$proc.ProcessorAffinity = [IntPtr]([int64]([math]::Pow(2, $nCpu) - 1))
$proc.ProcessorAffinity = [IntPtr](Get-AffinityMask $nCpu)
Write-Host "cap : REMOVED (affinity restored to all $nCpu processors)"
} elseif ($CoresWorth -gt 0) {
# CpuRate is in 1/100 of a percent of TOTAL machine capacity, so a cores'
@@ -158,7 +169,7 @@ if ($Off) {
if ($Cores -gt 0) {
if ($Cores -gt $nCpu) { $Cores = $nCpu }
$mask = [int64]([math]::Pow(2, $Cores) - 1)
$mask = Get-AffinityMask $Cores
$proc.ProcessorAffinity = [IntPtr]$mask
Write-Host "affinity: $Cores of $nCpu logical processors (mask 0x$('{0:X}' -f $mask))"
}