mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
aa0a285c86
[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>
180 lines
8.7 KiB
PowerShell
180 lines
8.7 KiB
PowerShell
# cpu_throttle.ps1 — simulate a weaker host CPU for any psxrecomp title.
|
|
#
|
|
# WHY THIS EXISTS: "runs fine here, players report slowdown there" is not
|
|
# reproducible on a fast dev box. Rather than guess, throttle the running
|
|
# process until the reported symptom appears, then read the budget off the
|
|
# dial. Uses the Windows Job Object CPU rate control (the same hard cap the
|
|
# scheduler applies to containers), so it needs no launcher/runtime support
|
|
# and works on a LIVE process — never restart a session to arm it.
|
|
#
|
|
# The job is NAMED, so the cap can be re-dialed at any time from a fresh shell
|
|
# without re-assigning the process. A job object outlives the handle that made
|
|
# it for as long as a process is still inside it, so this script exits and the
|
|
# cap stays on.
|
|
#
|
|
# Two independent knobs, both reversible while the game runs:
|
|
# -CoresWorth <n> hard CPU-rate cap, expressed as cores' worth of compute.
|
|
# This is the "slower clock" dial. 0.5 on a 60fps title
|
|
# that wants ~1 core is roughly a half-speed CPU.
|
|
# -Cores <n> processor affinity: how many logical CPUs the process may
|
|
# run on at all. This is the "fewer cores" dial.
|
|
#
|
|
# Usage:
|
|
# tools\cpu_throttle.ps1 -ProcessId 51368 -CoresWorth 0.5
|
|
# tools\cpu_throttle.ps1 -ProcessId 51368 -CoresWorth 0.35 # tighten, live
|
|
# tools\cpu_throttle.ps1 -ProcessId 51368 -Cores 2 # 2 cores only
|
|
# tools\cpu_throttle.ps1 -ProcessId 51368 -Measure # sample, no change
|
|
# tools\cpu_throttle.ps1 -ProcessId 51368 -Off # remove cap + affinity
|
|
#
|
|
# NOTE: the hard cap is enforced by withholding scheduling quanta, so a heavily
|
|
# throttled process stutters rather than degrading smoothly. That is fine for
|
|
# "does the reported hitch appear", and misleading for "what is the real frame
|
|
# time on that CPU". Read it as a reproduction tool, not a profiler.
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][int]$ProcessId,
|
|
[double]$CoresWorth = 0,
|
|
[int]$Cores = 0,
|
|
[switch]$Measure,
|
|
[switch]$Off,
|
|
[int]$SampleSeconds = 4
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
Add-Type @'
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
|
|
public static class JobCpu {
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
public static extern IntPtr CreateJobObjectW(IntPtr sa, string name);
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
public static extern IntPtr OpenJobObjectW(uint access, bool inherit, string name);
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
public static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref CPU_RATE info, int len);
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
public static extern bool CloseHandle(IntPtr h);
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public struct CPU_RATE { public uint ControlFlags; public uint RateOrWeight; }
|
|
|
|
// JobObjectCpuRateControlInformation
|
|
public const int InfoClass = 15;
|
|
public const uint ENABLE = 0x1;
|
|
public const uint HARD_CAP = 0x4;
|
|
// PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION
|
|
public const uint PROC_ACCESS = 0x0100 | 0x0001 | 0x0400;
|
|
public const uint JOB_ALL_ACCESS = 0x1F001F;
|
|
}
|
|
'@
|
|
|
|
$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
|
|
$p.Refresh()
|
|
$ms = ($p.TotalProcessorTime - $t1).TotalMilliseconds
|
|
[pscustomobject]@{
|
|
CoresWorth = $ms / ($secs * 1000.0)
|
|
SystemPct = $ms / ($secs * 1000.0 * $nCpu) * 100
|
|
}
|
|
}
|
|
|
|
Write-Host "process : $($proc.ProcessName) PID $ProcessId ($($proc.Threads.Count) threads)"
|
|
Write-Host "machine : $nCpu logical processors"
|
|
|
|
if ($Measure) {
|
|
$d = Measure-Draw $proc $SampleSeconds
|
|
Write-Host ("draw : {0:N2} cores' worth ({1:N2}% of machine)" -f $d.CoresWorth, $d.SystemPct)
|
|
return
|
|
}
|
|
|
|
$jobName = "psxrecomp_throttle_$ProcessId"
|
|
|
|
# Reuse the existing job if this PID was already throttled, so re-dialing does
|
|
# not nest a second job (nested caps only ever ratchet DOWN — you could never
|
|
# loosen one). Otherwise create it and assign the live process once.
|
|
# CreateJobObject on an existing NAME returns a handle to that same job and
|
|
# sets ERROR_ALREADY_EXISTS -- it does not make a second one. That is a more
|
|
# reliable reuse probe than OpenJobObject, which needs the name to still be
|
|
# resolvable in this session's namespace and quietly fails when it is not.
|
|
# Getting this wrong would nest a second job, and nested CPU caps only ever
|
|
# take the MINIMUM, so a loosened dial would silently do nothing.
|
|
$ERROR_ALREADY_EXISTS = 183
|
|
$job = [JobCpu]::CreateJobObjectW([IntPtr]::Zero, $jobName)
|
|
$lastErr = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
|
if ($job -eq [IntPtr]::Zero) { throw "CreateJobObject failed: $([ComponentModel.Win32Exception]::new($lastErr).Message)" }
|
|
$fresh = ($lastErr -ne $ERROR_ALREADY_EXISTS)
|
|
|
|
if ($fresh) {
|
|
$hProc = [JobCpu]::OpenProcess([JobCpu]::PROC_ACCESS, $false, $ProcessId)
|
|
if ($hProc -eq [IntPtr]::Zero) { throw "OpenProcess failed: $([ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message)" }
|
|
if (-not [JobCpu]::AssignProcessToJobObject($job, $hProc)) {
|
|
$e = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
|
[JobCpu]::CloseHandle($hProc) | Out-Null
|
|
throw "AssignProcessToJobObject failed ($e): $([ComponentModel.Win32Exception]::new($e).Message)"
|
|
}
|
|
[JobCpu]::CloseHandle($hProc) | Out-Null
|
|
Write-Host "job : created '$jobName' and assigned the live process"
|
|
} else {
|
|
Write-Host "job : reusing '$jobName' (re-dialing, process untouched)"
|
|
}
|
|
|
|
$info = New-Object JobCpu+CPU_RATE
|
|
if ($Off) {
|
|
# An all-zero control block is rejected (ERROR_INVALID_PARAMETER): the API
|
|
# validates the union against the flags. Releasing a cap therefore means
|
|
# setting it to the full machine (100% == CpuRate 10000), not clearing it.
|
|
$info.ControlFlags = [JobCpu]::ENABLE -bor [JobCpu]::HARD_CAP
|
|
$info.RateOrWeight = [uint32]10000
|
|
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](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'
|
|
# worth figure has to be divided by the processor count. Clamp to the API's
|
|
# valid 1..10000 range; anything below 1 would silently mean "no limit".
|
|
$rate = [int][math]::Round($CoresWorth / $nCpu * 10000)
|
|
if ($rate -lt 1) { $rate = 1 }
|
|
if ($rate -gt 10000) { $rate = 10000 }
|
|
$info.ControlFlags = [JobCpu]::ENABLE -bor [JobCpu]::HARD_CAP
|
|
$info.RateOrWeight = [uint32]$rate
|
|
if (-not [JobCpu]::SetInformationJobObject($job, [JobCpu]::InfoClass, [ref]$info, 8)) {
|
|
throw "SetInformationJobObject failed: $([ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message)"
|
|
}
|
|
Write-Host ("cap : {0:N2} cores' worth = {1:N2}% of machine (CpuRate {2})" -f $CoresWorth, ($rate/100.0), $rate)
|
|
}
|
|
|
|
if ($Cores -gt 0) {
|
|
if ($Cores -gt $nCpu) { $Cores = $nCpu }
|
|
$mask = Get-AffinityMask $Cores
|
|
$proc.ProcessorAffinity = [IntPtr]$mask
|
|
Write-Host "affinity: $Cores of $nCpu logical processors (mask 0x$('{0:X}' -f $mask))"
|
|
}
|
|
|
|
[JobCpu]::CloseHandle($job) | Out-Null
|
|
$d = Measure-Draw $proc $SampleSeconds
|
|
Write-Host ("result : {0:N2} cores' worth ({1:N2}% of machine)" -f $d.CoresWorth, $d.SystemPct)
|