From aa0a285c86f7081787573b2a6f1f1f9a5dcf4b92 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 2 Sep 2026 13:42:13 -0700 Subject: [PATCH] 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) --- tools/cpu_throttle.ps1 | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tools/cpu_throttle.ps1 b/tools/cpu_throttle.ps1 index fec24ab0..870bf4d1 100644 --- a/tools/cpu_throttle.ps1 +++ b/tools/cpu_throttle.ps1 @@ -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))" }