diff --git a/tools/cpu_throttle.ps1 b/tools/cpu_throttle.ps1 new file mode 100644 index 00000000..fec24ab0 --- /dev/null +++ b/tools/cpu_throttle.ps1 @@ -0,0 +1,168 @@ +# 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 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 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 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]([int64]([math]::Pow(2, $nCpu) - 1)) + 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 = [int64]([math]::Pow(2, $Cores) - 1) + $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)