Using Performance Counters

High CPU load heats up systems, costs power and may be an indication of rogue software. Learn how to automate CPU load monitoring with performance counters!

Recently, one of my notebooks really heated up, and it turned out there was some background process running at high CPU load. While this can be normal, i.e. during AV scans, it can also be a warning sign that something is wrong, and maybe some rogue software is going wild.

While you can manually open up Task Manager and identify the process(es) causing the issue, that’s not always working: some processes decrease their CPU usage the moment they detect user activity, and you’re not always noticing high CPU loads when they occur.

Measuring Performance

That’s what Performance Counters are for: they measure various technical aspects of your computer system, for example CPU load of running processes, and you can then evaluate the results, create monitoring logs, and draw your conclusions.

How Performance Is Measured

Before we can look at actual performance data, it is important to understand how it is created. At the start of each performance measurement, there is raw technical snapshot sensor data. If you’d like to monitor disk drive throughput, for example, you’d look at the current bytes/s sensor data. And if you’d like to identify processes with high CPU usage, you’d look at the current CPU usage.

Performance data is then calculated by comparing two snapshots. Cooked (or formatted) performance data returns the average performance in a human-readable format, i.e. Percent CPU Time.

PowerShell Approaches

You have two fundamentally different ways to measure performance data via PowerShell, each with its own merits:

  • Get-Counter: the cmdlet Get-Counter does it all for you: you specify the counter and the sample interval, and the cmdlet returns the cooked information to you.
  • WMI Raw Sensor Data: via Get-CimInstance, you can directly read the raw performance counters, obtain two samples in your desired interval, then calculate the cooked values yourself. While this approach involves a bit more work, it also provides a great deal of additional flexibility as you’ll see below.

Working With Raw Sensor Data

WMI comes with a group of classes that all start with Win32_PerfRawData_, and when you query their instances, you get the raw current sensor readings. For example, to see the current CPU load for all processes, run this:

Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process

You get a data set per running process with various sensor data. To monitor just a specific process, you can use WMI filters and specify the process id of the process you are after. The line below grabs the instance that represents your currently running PowerShell host:

# process id of current powershell host is always in $pid
# replace $pid by any other process id to monitor a different process:
$id = $pid

Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process -Filter "IDProcess=$id"

The result looks like this:

Caption                 : 
Description             : 
Name                    : powershell_ise
Frequency_Object        : 10000000
Frequency_PerfTime      : 10000000
Frequency_Sys100NS      : 10000000
Timestamp_Object        : 132351310509258468
Timestamp_PerfTime      : 109828216855
Timestamp_Sys100NS      : 132351310509258468
CreatingProcessID       : 8480
ElapsedTime             : 132351307022536667
HandleCount             : 1026
IDProcess               : 5376
IODataBytesPersec       : 18028003
IODataOperationsPersec  : 6834
IOOtherBytesPersec      : 2033290
IOOtherOperationsPersec : 47965
IOReadBytesPersec       : 17821372
IOReadOperationsPersec  : 6733
IOWriteBytesPersec      : 206631
IOWriteOperationsPersec : 101
PageFaultsPersec        : 376745
PageFileBytes           : 476393472
PageFileBytesPeak       : 694177792
PercentPrivilegedTime   : 47343750
PercentProcessorTime    : 162343750
PercentUserTime         : 115000000
PoolNonpagedBytes       : 81480
PoolPagedBytes          : 1459880
PriorityBase            : 8
PrivateBytes            : 476393472
ThreadCount             : 29
VirtualBytes            : 5965791232
VirtualBytesPeak        : 6295343104
WorkingSet              : 539709440
WorkingSetPeak          : 755175424
WorkingSetPrivate       : 378011648
PSComputerName          : 

The CPU percentage can be found in PercentProcessorTime which is the sum of PercentUserTime and PercentPrivilegedTime. It is a huge number, not a percentage, and this number grows over time.

Comparing Snapshots

To find out the actual CPU percentage, you need to take another snapshot and then compare them. Let’s turn this into two very useful new monitoring commands: Start-MeasureCPU and Stop-MeasureCPU! With these two new functions, you’ll be able to calculate the average CPU load of PowerShell scripts or any other process you are interested in:

function Start-MeasureCpu
    # default process id to powershells own process id:
    $id = $pid
  # get snapshot and return it:
  return Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process -Filter "IDProcess=$id"
function Stop-MeasureCpu
    # submit the previously taken snapshot
  # get the process id the initial snapshot was taken on:
  $id = $StartSnapshot.IDProcess
  # get a second snapshot
  $EndSnapshot = Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process -Filter "IDProcess=$id"

  # determine the time interval between the two snapshots in 100ns units:
  $time = $EndSnapshot.Timestamp_Sys100NS - $StartSnapshot.Timestamp_Sys100NS
  # get the number of logical cpus
  $cores = [Environment]::ProcessorCount
  # calculate cpu time
  # NOTE: CPU time is per CORE, so divide by available CORES to get total average CPU time
    TotalPercent = [Math]::Round(($EndSnapshot.PercentProcessorTime - $StartSnapshot.PercentProcessorTime)/$time*100/$cores,1)
    UserPercent = [Math]::Round(($EndSnapshot.PercentUserTime - $StartSnapshot.PercentUserTime)/$time*100/$cores,1)
    PrivilegedPercent = [Math]::Round(($EndSnapshot.PercentPrivilegedTime - $StartSnapshot.PercentPrivilegedTime)/$time*100/$cores,1)

Now it’s trivial to find out how much average CPU load some PowerShell code burned. Try this:

# get a first snapshot
$snap = Start-MeasureCpu

# do something that is cost-intense
$updates = Get-Hotfix

# once done, take a second snapshot and compare to the first
Stop-MeasureCpu -StartSnapshot $snap

The result looks similar to this:

Total User Privileged
----- ---- ----------
  1,9  1,4        0,5

IMPORTANT: Performance counters generally return the process CPU percentage as it is reported by Resource Monitor. For some unknown reason, Task Manager reports much higher CPU percentages that don’t really make sense.

For example, when you run an endless loop in a PowerShell console, it causes a 100% load on one core, and performance counters as well as Resource Monitor correctly return 25% on a four-core system. Task Manager in contrast reports a load in excess of 30%.

If anyone has a good explanation, please leave your comment below!


Admittedly, you need to do some coding yourself, but requesting the raw sensor data via WMI and calculating the cooked values yourself provides you with a lot of flexibility while keeping the monitoring overhead low:

All you need to do is take two snapshots, regardless of how long your monitoring interval may be. Inbetween the two snapshots, your PowerShell stays responsive so you can continue to do whatever you want while measuring the performance.

And since WMI supports remoting, you could easily monitor processes remotely on other machines.


As soon as you want to monitor more than one process, or monitor unknown processes with high CPU load, there is more code required. Still, it is no monster task. The next section shows an example.

Continuous Monitoring

Originally, I wanted to continuously monitor my machine for potentially rogue processes that exceed a given threshold CPU percentage.

With the knowhow you got so far, you now have all that is required for it. Here is a solution that monitors all running processes and reports the ones that have more than 25% CPU load per core:

# monitor endlessly for processes exceeding 10% CPU load within 4 seconds

# monitoring interval (in seconds)
[ValidateRange(1,120)][int]$seconds = 4

# minimum percentage per core to report:
[ValidateRange(0,100)][int]$minpercentage = 10

# find out number of logical processors:
$cores = [Environment]::ProcessorCount

# get first snapshot and turn into hashtable with process id as key
# IMPORTANT: make sure the key is of type [int]:
$snap1 = Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process | Group-Object -Property { [int]$_.IDProcess } -AsHashTable

# wrapping code into scriptblock so you can optionally
# pipe the results to Export-Csv or Export-Excel...
& {
  # monitor endlessly:
  While ($true)
    # wait for the monitoring interval:
    Start-Sleep -Seconds $seconds
    # get a second snapshot:
    $snap2 = Get-CimInstance -ClassName Win32_PerfRawData_PerfProc_Process | Group-Object -Property { [int]$_.IDProcess } -AsHashTable
    # get a timestamp for the reporting:
    $date = Get-Date -Format 'HH:mm:ss'

    # process each process id in the snapshot:
    foreach($_ in $snap2.Keys)
      $id = $_
      # ignore idle and system:
      if ($id -eq 0) { continue }
        # calculate exact time interval in 100ns units:
        $time = $snap2[$id].Timestamp_Sys100NS - $snap1[$id].Timestamp_Sys100NS
        # get process name:
        $name = $snap2[$id].Name
        # calculate total cpu percentage per process:
        $percent = ($snap2[$id].PercentProcessorTime - $snap1[$id].PercentProcessorTime)/$time*100
        # if below threshold, ignore:
        if ($percent -lt $minPercentage) { continue }
        # if there was an error, i.e. newly launched process without previous snapshot, ignore:
      # return cooked data:
        Date = $date
        ID = $id
        Name = $name
        Percent = [Math]::Round( ($percent/$cores),0)
        PercentPerCore = [Math]::Round( $percent,0)

      # use second snapshot as first snapshot in next iteration:
      $snap1 = $snap2

To turn the results into a CSV report, simply pipe the data to Export-Csv. Press Ctrl+C to abort monitoring.

Note that this script will return data only when it detects a process with more than 10% CPU load in at least one core.

Comparing Instances

The challenge here is to compare unknown processes: each snapshot is not a single instance but rather an array of objects representing the processes that ran at the time the snapshot was taken.

That’s why I am using Group-Object to group the data as a hashtable, and I am using the process id (found in IDProcess) as a key. However, since this property yields an unsigned integer and PowerShell uses signed integers for process ids, I am using a scriptblock to convert IDProcess to [int]. This way, the hashtable is using an integer (signed integer) key.

Now it’s easy to enumerate the keys of the hashtable (representing the process ids of all currently running processes) and comparing them to their equivalent in the previous snapshot.


I am using While($true) to run an endless loop for continuous monitoring. Classic loops do not support pipelines, though, so you wouldn’t be able to process the emitted results in real-time and for example pipe the data to Export-Csv.

That’s why I wrapped the loop into a scriptblock, effectively turning the loop into a pipeline-aware construct. You can test this by adding | Out-GridView to the code: the monitoring results will now pop up in real-time in a gridview.

Using Formatted Performance Counters

You don’t need to deal with raw sensor data yourself. If you want to, you can obtain formatted data as well.

WMI Is Deprecated

While WMI does support formatted performance data, it doesn’t really work via PowerShell and yields random results:

# get FORMATTED performance data for current PowerShell host:
$id = $pid

# note the WMI classes *PerfFormattedData* as opposed to the previous *PerfRawData*:
Get-CimInstance -ClassName Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess=$id" | Select-Object Percent*

You do get back cooked values which may look like this:

PercentPrivilegedTime PercentProcessorTime PercentUserTime
--------------------- -------------------- ---------------
                    0                    5               5

Originally, these WMI classes were designed to work with a COM Object called WBemScripting.SWBemRefresher, and you’d use this object to ask the instance to refresh and recalculate their cooked values in the intervals you needed.

This object is neither available in PowerShell nor in C#, so you can’t use WMI to get formatted performance data out of the box.

Get-Counter To The Rescue

That’s why PowerShell added the cmdlet Get-Counter in PowerShell 3. It provides cooked performance data but comes with its own tail of problems.

The idea is beautiful: you simply specify the performance counter you want to use, plus the desired sample interval, and the cmdlet does all the tricky stuff for you:

(Get-Counter -Counter '\Process(*)\% Processor Time' -SampleInterval 4).CounterSamples

If you’re lucky, the line works for you. But you’ll just as well might encounter a number of issues:

  • Localized Counter Names: believe it or not, but the counter names are localized, so above example works only on English systems.
  • Missing Comparison Values: when a process isn’t running both at the beginning and the end of the sample interval, the cmdlet can’t calculate the cooked value and emits an exception. So you’ll always need to also add -ErrorAction Ignore to get rid of nasty exceptions.
  • No Process Id: even if the command works, the output seems to be incomplete: the results contain only process names, not unique process ids. If there is more than one instance of a given process running, the counter just adds incrementing numbers, i.e. powershell, powershell#1, powershell#2, …

Dealing With Localized Counter Names

The most severe limitation of Get-Counter are the localized counter names. When you use Get-Counter with counter names, your script will no longer run on machines that use a different language. That’s bad.

Fortunately, there are two API functions you can use to convert localized counter names to id numbers (and vice versa). This way, you can use culture-invariant id numbers and translate them to the localized counter names when needed.

Get-PerformanceCounterId takes a localized performance counter name and translates it to a language-agnostic id number. Get-PerformanceCounterLocalName does the opposite and translates the id number to the appropriate local name:

function Get-PerformanceCounterId 
        $ComputerName = $env:COMPUTERNAME
    $code = '[DllImport("pdh.dll", SetLastError=true, CharSet=CharSet.Unicode)]public static extern UInt32 PdhLookupPerfIndexByName(string szMachineName, string szNameBuffer, ref uint dwNameIndex);'
    $type = Add-Type -MemberDefinition $code -PassThru -Name PerfCounter2 -Namespace Utility
    [UInt32]$Index = 0
    if ($type::PdhLookupPerfIndexByName($ComputerName, $Name, [Ref]$Index) -eq 0)
      throw "Cannot find '$Name' on '$ComputerName'."

Function Get-PerformanceCounterLocalName
    $ComputerName = $env:COMPUTERNAME
  $code = '[DllImport("pdh.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern UInt32 PdhLookupPerfNameByIndex(string szMachineName, uint dwNameIndex, System.Text.StringBuilder szNameBuffer, ref uint pcchNameBufferSize);'
  $type = Add-Type -MemberDefinition $code -PassThru -Name PerfCounter1 -Namespace Utility
  $Buffer = [System.Text.StringBuilder]::new(1024)
  [UInt32]$BufferSize = $Buffer.Capacity
  $rv = $type::PdhLookupPerfNameByIndex($ComputerName, $id, $Buffer, [Ref]$BufferSize)
  if ($rv -eq 0)
    $Buffer.ToString().Substring(0, $BufferSize-1)
    Throw 'Get-PerformanceCounterLocalName : Unable to retrieve localized name. Check computer name and performance counter ID.'

Using Id Numbers Instead Of Localized Names

This way, you can now write code that runs on any locale. This code always returns the current CPU load, regardless of the language the machine uses:

# construct performance counter by id:
$processor = Get-PerformanceCounterLocalName 230
$percentProcessorTime = Get-PerformanceCounterLocalName 6
$counter = "\$processor(*)\$percentProcessorTime"
Write-Warning $counter

# translate local names to id:
(Get-Counter -Counter $counter).CounterSamples | Out-GridView

Investigating Id Numbers

Let’s also quickly look at the other way around and assume you want to access a different performance counter. Maybe you want to get the file system disk activity. So you start by searching for your keyword (use the language of your system):

Get-Counter -ListSet *disk*

The result returns all performance counters with your keyword:

CounterSetName     : FileSystem Disk Activity
MachineName        : .
CounterSetType     : MultiInstance
Description        : The FileSystem Disk Activity performance counter set consists of counters that measure the aspect of filesystem's IO Activity.  This counter set measures the number 
                     of bytes filesystem read from and wrote to the disk drive.
Paths              : {\FileSystem Disk Activity(*)\FileSystem Bytes Written, \FileSystem Disk Activity(*)\FileSystem Bytes Read}
PathsWithInstances : {\FileSystem Disk Activity(default)\FileSystem Bytes Written, \FileSystem Disk Activity(_Total)\FileSystem Bytes Written, \FileSystem Disk 
                     Activity(default)\FileSystem Bytes Read, \FileSystem Disk Activity(_Total)\FileSystem Bytes Read}
Counter            : {\FileSystem Disk Activity(*)\FileSystem Bytes Written, \FileSystem Disk Activity(*)\FileSystem Bytes Read}

CounterSetName     : Storage Spaces Virtual Disk
MachineName        : .
CounterSetType     : SingleInstance
Description        : The Storage Spaces Virtual Disk performance object consists of counters that show information about Storage Spaces virtual disks.
Paths              : {\Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Count, \Storage Spaces Virtual 
                     Disk(*)\Virtual Disk Repair Phase 5 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 5 Count...}
PathsWithInstances : {\Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Count, \Storage Spaces Virtual 
                     Disk(*)\Virtual Disk Repair Phase 5 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 5 Count...}
Counter            : {\Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 6 Count, \Storage Spaces Virtual 
                     Disk(*)\Virtual Disk Repair Phase 5 Status, \Storage Spaces Virtual Disk(*)\Virtual Disk Repair Phase 5 Count...}

CounterSetName     : LogicalDisk
MachineName        : .
CounterSetType     : MultiInstance
Description        : The Logical Disk performance object consists of counters that monitor logical partitions of a hard or fixed disk drives.  Performance Monitor identifies logical disks 
                     by their a drive letter, such as C.
Paths              : {\LogicalDisk(*)\% Free Space, \LogicalDisk(*)\Free Megabytes, \LogicalDisk(*)\Current Disk Queue Length, \LogicalDisk(*)\% Disk Time...}
PathsWithInstances : {\LogicalDisk(C:)\% Free Space, \LogicalDisk(HarddiskVolume4)\% Free Space, \LogicalDisk(HarddiskVolume5)\% Free Space, \LogicalDisk(HarddiskVolume6)\% Free Space...}
Counter            : {\LogicalDisk(*)\% Free Space, \LogicalDisk(*)\Free Megabytes, \LogicalDisk(*)\Current Disk Queue Length, \LogicalDisk(*)\% Disk Time...}

CounterSetName     : PhysicalDisk
MachineName        : .
CounterSetType     : MultiInstance
Description        : The Physical Disk performance object consists of counters that monitor hard or fixed disk drive on a computer.  Disks are used to store file, program, and paging data 
                     and are read to retrieve these items, and written to record changes to them.  The values of physical disk counters are sums of the values of the logical disks (or 
                     partitions) into which they are divided.
Paths              : {\PhysicalDisk(*)\Current Disk Queue Length, \PhysicalDisk(*)\% Disk Time, \PhysicalDisk(*)\Avg. Disk Queue Length, \PhysicalDisk(*)\% Disk Read Time...}
PathsWithInstances : {\PhysicalDisk(0 C:)\Current Disk Queue Length, \PhysicalDisk(1 F:)\Current Disk Queue Length, \PhysicalDisk(2 E:)\Current Disk Queue Length, \PhysicalDisk(3 
                     D:)\Current Disk Queue Length...}
Counter            : {\PhysicalDisk(*)\Current Disk Queue Length, \PhysicalDisk(*)\% Disk Time, \PhysicalDisk(*)\Avg. Disk Queue Length, \PhysicalDisk(*)\% Disk Read Time...}

Let’s assume you want to use the counter \FileSystem Disk Activity(*)\FileSystem Bytes Read. Now translate both text parts to Id numbers:

Get-PerformanceCounterID -Name 'FileSystem Disk Activity'
Get-PerformanceCounterID -Name 'FileSystem Bytes Read'

As it turns out, the numeric constants are 12484 and 12486. So this would be the language-invariant code you could use:

# construct performance counter by id:
$activity = Get-PerformanceCounterLocalName 12484 
$bytesRead = Get-PerformanceCounterLocalName 12486
$counter = "\$activity(*)\$bytesRead"
Write-Warning $counter

# translate local names to id:
(Get-Counter -Counter $counter -SampleInterval 4).CounterSamples | Out-GridView

Dealing With Missing Process Ids

A specific problem with Get-Counter and monitoring processes is that the counters do not return the true process ids.

To resolve this, you’d have to run a separate counter query and let Get-Counter return the association between its internal awkward process names and the process ids:

# get process name to id associations...
$data = (Get-Counter "\Process(*)\ID Process" -ErrorAction Ignore).CounterSamples | 
  # use regex to extract process name...
  Where-Object { $_.Path -match '\\process\((.*?)\)\\'} | ForEach-Object { 
  # return information
    Id = $_.CookedValue
    Name = $matches[1]

# turn data into lookup hashtables:
$Id2Name = $data | Group-Object -Property { [int]$_.Id } -AsHashTable
$Name2Id = $data | Group-Object -Property Name -AsHashTable -AsString

# translate process id to name:
# translate name to process id:

However, this takes at least one second because of the way how Get-Counterworks: it always retrieves two snapshots within a sample interval, and the smallest sample interval is one second. Plus, as always with Get-Counter, the solution is localized and will only work on English systems (unless you start using the neutral id numbers as described above).

A much faster and completely language-neutral way uses the raw performance data as it is returned by WMI:

# get raw process information which includes process id and internal name:
$data = Get-CimInstance Win32_PerfRawData_PerfProc_Process | Select-Object -Property IDProcess,Name 

# turn data into lookup hashtables:
$Id2Name = $data | Group-Object -Property { [int]$_.IDProcess } -AsHashTable
$Name2Id = $data | Group-Object -Property Name -AsHashTable -AsString

# translate process id to name:
# translate name to process id:

Join the Discussion!

Please leave a comment below so others can join the discussion. Of course you can also twitter but it would really be nice to have all thoughts and comments right next to the article so we all can enjoy them: