Private/Kinds/Cpu.ps1
|
# The Cpu Kind: what the processor is doing when nobody is asking it to do anything. # # A machine that is busy at rest is busy for a reason - a scan, an update, an indexer, a # runaway process - and the Section names the culprit so second level does not have to # revisit the machine. $script:CpuSampleCount = 5 # The process snapshot is a rate over an interval rather than an instant, so it needs its # own settle regardless of the shared sampling wait. $script:CpuProcessSampleSeconds = 2 # What Get-CpuData hands its Judge. See Private/DataShape.ps1. $script:CpuDataShape = @{ ProcessorTimeSamples = 'List' PerformanceLimitSamples = 'List' TopProcessesByCpu = 'List' ProcessSampleSeconds = 'Value' LimitCause = 'Value' } function Get-CpuData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $filter = "Name='_Total'" # The first read of a formatted-performance class is primed with zeroes; discard it. [void](Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue) # One step per reading plus the process snapshot's settle, which is the longest wait. $steps = $script:CpuSampleCount + 1 $samples = @(1..$script:CpuSampleCount | ForEach-Object { Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:CpuSampleCount) -Step ($_ - 1) -Of $steps Wait-Sample Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue }) # The script divided this counter by a core count the System Check had stashed in # script scope, so reordering the two Checks silently changed the column. Asking the # runtime directly removes that coupling. On Windows PowerShell 5.1 a machine with # more than 64 logical processors reports only the current processor group; no # Target Machine in the estate is close to that. $logical = [Environment]::ProcessorCount [void](Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue) $settled = Get-SampleSeconds -Seconds $script:CpuProcessSampleSeconds Write-CheckProgress -Status (Get-Text 'Console.Progress.Settle') -Step $script:CpuSampleCount -Of $steps Wait-Sample -Seconds $script:CpuProcessSampleSeconds $top = @(Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne '_Total' -and $_.Name -ne 'Idle' } | Sort-Object PercentProcessorTime -Descending | Select-Object -First 12 | ForEach-Object { [pscustomobject]@{ Process = $_.Name 'CPU %' = [math]::Round($_.PercentProcessorTime / [math]::Max($logical, 1), 1) 'Private MB' = [math]::Round($_.WorkingSetPrivate / 1MB) 'IO KB/s' = [math]::Round($_.IODataBytesPersec / 1KB) } }) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Cpu' ProcessorTimeSamples = @($samples | ForEach-Object { $_.PercentProcessorTime }) PerformanceLimitSamples = @($samples | ForEach-Object { $_.PercentPerformanceLimit }) TopProcessesByCpu = $top # How long the process snapshot actually settled for, so the Section can say so # rather than claim a window it did not use. ProcessSampleSeconds = $settled # What Windows itself says of a limit on the processor: the power plan's # maximum, and its thermal throttle. The firmware's own limit it does not say. LimitCause = Get-CpuLimitCause } } function ConvertFrom-CpuPowerPlanMaximum { <# .SYNOPSIS The power plan's maximum processor state, for mains and for battery, out of what powercfg prints. Nothing where it printed no such setting. Pure. .DESCRIPTION powercfg prints in the language of Windows, so the lines are read by position: the setting's minimum, maximum and increment, then what is set for mains and for battery, each a number in hex. Read off this machine (German Windows 11 26300, 2026-10-11): 0x0, 0x64, 0x1, 0x64, 0x64. The last two are taken. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$Line) $numbers = @(@($Line) | ForEach-Object { $found = [regex]::Match("$_", '0x([0-9a-fA-F]{8})\s*$') if ($found.Success) { [Convert]::ToInt32($found.Groups[1].Value, 16) } }) if ($numbers.Count -lt 5) { return } [pscustomobject]@{ Mains = $numbers[-2]; Battery = $numbers[-1] } } function Get-CpuLimitCause { <# .SYNOPSIS What Windows says of a limit on the processor: the active power plan's maximum processor state, and the thermal zones with their throttle. Never throws. .DESCRIPTION A Gatherer: it reads and decides nothing. Read is false for what could not be read, which is not "no limit". #> [CmdletBinding()] [OutputType([psobject])] param() $plan = [pscustomobject]@{ Read = $false; Mains = $null; Battery = $null } try { $printed = @(& powercfg.exe /query SCHEME_CURRENT SUB_PROCESSOR PROCTHROTTLEMAX 2>$null) $max = ConvertFrom-CpuPowerPlanMaximum -Line $printed if ($max) { $plan = [pscustomobject]@{ Read = $true; Mains = $max.Mains; Battery = $max.Battery } } } catch { } $zones = @() $zonesRead = $false try { $zones = @(Get-CimInstance Win32_PerfFormattedData_Counters_ThermalZoneInformation -ErrorAction Stop | ForEach-Object { # Temperature is in Kelvin. $kelvin = ConvertTo-Number $_.Temperature [pscustomobject]@{ Name = "$($_.Name)" PassiveLimit = ConvertTo-Number $_.PercentPassiveLimit TemperatureC = $(if ($null -ne $kelvin -and $kelvin -gt 0) { [int][math]::Round($kelvin - 273.15) }) } }) $zonesRead = $true } catch { } [pscustomobject]@{ PowerPlan = $plan; ThermalZones = $zones; ZonesRead = $zonesRead } } function Get-CpuJudgement { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Situation ) New-CpuLoadFinding -Data $Data -Parameters $Parameters New-CpuPerformanceLimitFinding -Data $Data -Parameters $Parameters -Situation $Situation } function ConvertTo-CpuSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $seconds = ConvertTo-Number (Get-DataProperty $Data 'ProcessSampleSeconds') if ($null -eq $seconds) { $seconds = $script:CpuProcessSampleSeconds } New-Section -Title ((Get-Text 'Title.Cpu.TopProcessesByCpu') -f $seconds) -Help (Get-Text 'Help.Cpu.TopProcessesByCpu') -Row @( (Get-DataCollection $Data 'TopProcessesByCpu') | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.Cpu.Process')] = Get-DataProperty $_ 'Process' $row[(Get-Text 'Column.Cpu.Percent')] = Get-DataProperty $_ 'CPU %' $row[(Get-Text 'Column.Cpu.PrivateMB')] = Get-DataProperty $_ 'Private MB' $row[(Get-Text 'Column.Cpu.IoKBps')] = Get-DataProperty $_ 'IO KB/s' [pscustomobject]$row } ) # What Windows says of a limit on the processor, where it was read. $cause = Get-DataProperty $Data 'LimitCause' $plan = Get-DataProperty $cause 'PowerPlan' $zones = Get-DataCollection $cause 'ThermalZones' if ((Get-DataProperty $plan 'Read') -or $zones.Count) { $what = Get-Text 'Column.Cpu.Limit.What' $value = Get-Text 'Column.Cpu.Limit.Value' New-Section -Title (Get-Text 'Title.Cpu.Limit') -Help (Get-Text 'Help.Cpu.Limit') -Row @( if (Get-DataProperty $plan 'Read') { $row = [ordered]@{}; $row[$what] = Get-Text 'Value.Cpu.Limit.Row.PlanMains' $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.Percent') -f (Get-DataProperty $plan 'Mains'); [pscustomobject]$row $row = [ordered]@{}; $row[$what] = Get-Text 'Value.Cpu.Limit.Row.PlanBattery' $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.Percent') -f (Get-DataProperty $plan 'Battery'); [pscustomobject]$row } $zones | ForEach-Object { $row = [ordered]@{}; $row[$what] = (Get-Text 'Value.Cpu.Limit.Row.Zone') -f (Get-DataProperty $_ 'Name') $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.ZoneValue') -f (Get-DataProperty $_ 'PassiveLimit'), (Get-DataProperty $_ 'TemperatureC'); [pscustomobject]$row } ) } } function New-CpuLoadFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warn = Get-Parameter $Parameters 'CpuLoadAtRestWarnPercent' 30 $fail = Get-Parameter $Parameters 'CpuLoadAtRestFailPercent' 60 $average = Get-SampleAverage (Get-DataProperty $Data 'ProcessorTimeSamples') if ($null -eq $average) { return New-UnavailableFinding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') ` -Hint (Get-Text 'Hint.Cpu.TheProcessorPerformanceCountersDid') } New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') -Severity (Get-Severity $average $warn $fail) ` -Value ('{0:N0} %' -f $average) ` -Hint (Get-Text 'Hint.Cpu.HighLoadWhileIdleSee') } function Get-CpuLimitExplanation { <# .SYNOPSIS Why the processor is limited, as far as Windows says: what to put beside the percentage, and what to do about it. Nothing where the cause was not gathered. Pure. .DESCRIPTION The power plan's maximum for the power the machine is on, and a thermal zone that throttles, are named. Where both were read and neither limits, it is the firmware. Where one of them was not read, nothing is blamed. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [AllowNull()]$Situation) $cause = Get-DataProperty $Data 'LimitCause' if ($null -eq $cause) { return } $plan = Get-DataProperty $cause 'PowerPlan' $causes = @() $hints = @() if (Get-DataProperty $plan 'Read') { # On mains unless the machine is known to be on battery. $source = Select-SituationText -Situation $Situation -Fact Power -Text @{ Battery = 'Battery'; Unknown = 'Mains' } $max = ConvertTo-Number (Get-DataProperty $plan $source) if ($null -ne $max -and $max -lt 100) { $where = Get-Text 'Value.Cpu.Limit.OnMains' if ($source -eq 'Battery') { $where = Get-Text 'Value.Cpu.Limit.OnBattery' } $causes += (Get-Text 'Value.Cpu.Limit.Plan') -f $max, $where $hints += Get-Text 'Hint.Cpu.Limit.Plan' } } $hot = @((Get-DataCollection $cause 'ThermalZones') | Where-Object { $limit = ConvertTo-Number (Get-DataProperty $_ 'PassiveLimit'); $null -ne $limit -and $limit -lt 100 } | Sort-Object { ConvertTo-Number (Get-DataProperty $_ 'PassiveLimit') }) if ($hot.Count) { $causes += (Get-Text 'Value.Cpu.Limit.Thermal') -f (Get-DataProperty $hot[0] 'PassiveLimit'), (Get-DataProperty $hot[0] 'TemperatureC') $hints += Get-Text 'Hint.Cpu.Limit.Thermal' } if (-not $causes.Count) { if ((Get-DataProperty $plan 'Read') -and (Get-DataProperty $cause 'ZonesRead')) { $causes += Get-Text 'Value.Cpu.Limit.Firmware' $hints += Get-Text 'Hint.Cpu.Limit.Firmware' } else { $causes += Get-Text 'Value.Cpu.Limit.Unknown' $hints += Get-Text 'Hint.Cpu.FirmwareOSIsCappingThe' } } [pscustomobject]@{ Cause = ($causes -join '; '); Hint = ($hints -join ' | ') } } function New-CpuPerformanceLimitFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation) # Graded downwards, and the script only ever warned here, so the FAIL threshold # defaults to a percentage no reading can fall below. $warnBelow = Get-Parameter $Parameters 'CpuPerformanceLimitWarnBelowPercent' 90 $failBelow = Get-Parameter $Parameters 'CpuPerformanceLimitFailBelowPercent' ([double]::MinValue) # Absent on processors that do not expose a performance limit. The script reported # nothing at all in that case rather than a gap, and so does this. $average = Get-SampleAverage (Get-DataProperty $Data 'PerformanceLimitSamples') if ($null -eq $average) { return } $severity = Get-SeverityBelow $average $warnBelow $failBelow $value = '{0:N0} %' -f $average $hint = Get-Text 'Hint.Cpu.FirmwareOSIsCappingThe' # What limits it, where it is limited and Windows says. if ($severity -in 'WARN', 'FAIL') { $why = Get-CpuLimitExplanation -Data $Data -Situation $Situation if ($why) { $value = (Get-Text 'Value.Cpu.Limit.Cause') -f ('{0:N0}' -f $average), $why.Cause $hint = $why.Hint } } New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPUPerformanceLimit') -Severity $severity ` -Value $value -Meaning (Get-Text 'Meaning.Cpu.Limit') -Hint $hint } function ConvertTo-CpuFinding { <# .SYNOPSIS The Judge. What it measured, and - where that is not in order - that the machine was on battery when it was measured, which Windows makes slower. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Situation ) Get-CpuJudgement -Data $Data -Parameters $Parameters -Situation $Situation | Add-SituationPowerNote -Situation $Situation } |