Performance metrics for testing gold images

While building and updating gold images, you need to make sure that your changes aren’t negatively impacting VM performance. This should be a critical step since minor performance hits on a gold image will compound when deployed across the environment.

Each run appends one row to a CSV with a tag you choose, so results build into a history you can compare across image builds — higher numbers are better across all metrics.

What it measures:

  • CPU — saturates every logical core with math work for a set duration, reports total operations/sec and average CPU utilization
  • Memory — allocates and writes ~2GB (configurable) in chunks, then reads it back, reporting write/read throughput in MB/s
  • Disk — writes then reads a large test file (bypassing OS cache) at a path you choose, reporting write/read MB/s

Common options:

-OutputCsv C:\Bench\Results.csv — where the log lives
-Tag "after-change-name" — label each run so the CSV stays readable
-DurationSeconds 60 — longer CPU test (default 30)
-DiskTestPath C:\Temp — point disk test to a specific drive
-MemoryTargetMB 4096 — bigger memory testing
-SkipDisk — skip the disk phase (e.g., testing a read-only golden image)

Example command:
.\Test-VDIImagePerformance.ps1 -Tag “after-removing-onedrive” -DurationSeconds 60 -DiskTestPath D:\Temp

<#
.SYNOPSIS
Synthetic CPU / memory / disk benchmark for gauging VDI gold image performance changes.
.DESCRIPTION
Runs a repeatable, app-free workload against the VM to compare before and after numbers when testing image optimizations.
Every run adds a row to a CSV file so you build up a history across image builds.
Three phases:
1. CPU - saturates every logical processor with math work for N seconds, reports total operations/sec across all cores.
2. Memory - allocates and fills large byte arrays repeatedly, reports MB/sec write throughput and MB/sec read throughput.
3. Disk - writes then reads a large test file to the target path, reports MB/sec for each one.
While each phase runs, a background sampler records overall % prrocessor time and
available MBytes to help gauge performance.
.PARAMETER OutputCsv
Path to the results file. Defaults to .\VDIBenchResults.csv in the current folder.
.PARAMETER Tag
A short label for this run. e.g. "baseline", "after patching", etc. Makes the CSV readable when you're comparing several runs.
.PARAMETER DurationSeconds
How long the CPU phase runs. Default is 30. Memory and disk phases are sized independently (see -MemoryTargetMB and -DiskTestFileMB).
.PARAMETER MemoryTargetMB
Total MB to allocate/write/read during the memory phase. Default 2048 (2 GB).
Keep this comfortably under available RAM on the image to prevent paging.
.PARAMETER DiskTestPath
Folder to write the disk test file into. Defaults to the system temp folder.
.PARAMETER DiskTestFileMB
Size of the disk test file in MB. Default 1024 (1 GB). Bigger sizes reduce the influence of cache effects but takes longer.
.PARAMETER SkipDisk
Skip the disk phase entirely
.EXAMPLE
.\Test-VDIImagePerformance.ps1 -Tag "baseline"
.EXAMPLE
.\Test-VDIImagePerformance.ps1 -Tag "after-removing-onedrive" -DurationSeconds 60 -DiskTestPath D:\Temp
.NOTES
This is a simple benchmark - it does not simulate real user workload (Office, browser, etc.).
Use it to compare the same image against itself over time and not to compare against a different builds.
For accurate comparisons:
- Run on the same VM size / same host / same time of day
- Close other applications first
- Run the script 2-3 times and compare the average instead of a single run
#>
[CmdletBinding()]
param(
[string]$OutputCsv = ".\VDIBenchResults.csv",
[string]$Tag = "unlabeled",
[int]$DurationSeconds = 30,
[int]$MemoryTargetMB = 2048,
[string]$DiskTestPath = $env:TEMP,
[int]$DiskTestFileMB = 1024,
[switch]$SkipDisk
)
$ErrorActionPreference = "Stop"
# Lets grab the VM info
function Get-EnvironmentInfo {
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$cs = Get-CimInstance Win32_ComputerSystem
[PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
OSCaption = $os.Caption
OSBuild = $os.BuildNumber
CPUName = $cpu.Name.Trim()
LogicalCores = $env:NUMBER_OF_PROCESSORS
TotalMemoryGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 2)
}
}
# Run the background sampling job to gather CPU % and available RAM while a phase runs.
function Start-CounterSampler {
param([int]$IntervalSeconds = 1)
Start-Job -ScriptBlock {
param($Interval)
$samples = @()
while ($true) {
try {
$cpu = (Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction Stop).CounterSamples.CookedValue
$mem = (Get-Counter '\Memory\Available MBytes' -ErrorAction Stop).CounterSamples.CookedValue
$samples += [PSCustomObject]@{ CPU = $cpu; AvailMB = $mem }
} catch { }
Start-Sleep -Seconds $Interval
}
} -ArgumentList $IntervalSeconds
}
# Clean up the sampling job
function Stop-CounterSampler {
param($Job)
$samples = Receive-Job -Job $Job -Keep -ErrorAction SilentlyContinue
Stop-Job -Job $Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $Job -Force -ErrorAction SilentlyContinue | Out-Null
if ($samples -and $samples.Count -gt 0) {
[PSCustomObject]@{
AvgCPUPercent = [math]::Round(($samples | Measure-Object -Property CPU -Average).Average, 1)
MinAvailMB = [math]::Round(($samples | Measure-Object -Property AvailMB -Minimum).Minimum, 0)
}
} else {
[PSCustomObject]@{ AvgCPUPercent = $null; MinAvailMB = $null }
}
}
# Build some math processes to test the CPU load
function Test-CpuLoad {
param([int]$Seconds)
$coreCount = [Environment]::ProcessorCount
Write-Host " Spinning up $coreCount worker thread(s) for $Seconds sec..." -ForegroundColor DarkGray
$sampler = Start-CounterSampler
$pool = [runspacefactory]::CreateRunspacePool(1, $coreCount)
$pool.Open()
$jobs = @()
$script = {
param($Seconds)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
[double]$x = 1.0000001
[long]$ops = 0
while ($sw.Elapsed.TotalSeconds -lt $Seconds) {
for ($i = 0; $i -lt 100000; $i++) {
$x = [math]::Sqrt($x * $x + 1.0) * [math]::Sin($x)
if ([double]::IsInfinity($x) -or [double]::IsNaN($x)) { $x = 1.0000001 }
}
$ops += 100000
}
return $ops
}
for ($i = 0; $i -lt $coreCount; $i++) {
$ps = [powershell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript($script).AddArgument($Seconds)
$jobs += [PSCustomObject]@{ Pipe = $ps; Handle = $ps.BeginInvoke() }
}
$totalOps = 0
foreach ($j in $jobs) {
# EndInvoke() returns a PSDataCollection, not a scalar - unwrap/sum it rather
# than adding it directly, or PowerShell can't find an addition operator for it
$output = $j.Pipe.EndInvoke($j.Handle)
$sum = ($output | Measure-Object -Sum).Sum
if ($sum) { $totalOps += $sum }
if ($j.Pipe.HadErrors) {
foreach ($e in $j.Pipe.Streams.Error) { Write-Warning "CPU worker error: $e" }
}
$j.Pipe.Dispose()
}
$pool.Close()
$pool.Dispose()
$counters = Stop-CounterSampler -Job $sampler
[PSCustomObject]@{
Cores = $coreCount
TotalOperations = $totalOps
OpsPerSecond = [math]::Round($totalOps / $Seconds, 0)
AvgCPUPercent = $counters.AvgCPUPercent
}
}
# Build a high RW process to make the RAM work
function Test-MemoryLoad {
param([int]$TargetMB)
Write-Host " Allocating/writing/reading ~$TargetMB MB in 64MB chunks..." -ForegroundColor DarkGray
$sampler = Start-CounterSampler
$chunkMB = 64
$chunks = [math]::Ceiling($TargetMB / $chunkMB)
$chunkSize = $chunkMB * 1MB
$rng = [System.Random]::new()
$arrays = New-Object 'byte[][]' $chunks
$swWrite = [System.Diagnostics.Stopwatch]::StartNew()
for ($i = 0; $i -lt $chunks; $i++) {
$buf = New-Object byte[] $chunkSize
$rng.NextBytes($buf)
$arrays[$i] = $buf
}
$swWrite.Stop()
$swRead = [System.Diagnostics.Stopwatch]::StartNew()
[long]$checksum = 0
foreach ($buf in $arrays) {
# Touch every byte so this can't be optimized away and reflects real read bandwidth
for ($j = 0; $j -lt $buf.Length; $j += 4096) { $checksum += $buf[$j] }
}
$swRead.Stop()
$arrays = $null
[System.GC]::Collect()
$counters = Stop-CounterSampler -Job $sampler
$actualMB = $chunks * $chunkMB
[PSCustomObject]@{
TargetMB = $actualMB
WriteThroughputMBs = [math]::Round($actualMB / $swWrite.Elapsed.TotalSeconds, 1)
ReadThroughputMBs = [math]::Round($actualMB / $swRead.Elapsed.TotalSeconds, 1)
MinAvailMBDuring = $counters.MinAvailMB
}
}
# Build the last test process to write and read files to disk
function Test-DiskLoad {
param([string]$Path, [int]$FileMB)
$testFile = Join-Path $Path ("vdibench_{0}.tmp" -f ([guid]::NewGuid().ToString("N").Substring(0,8)))
Write-Host " Writing/reading a $FileMB MB file at $testFile..." -ForegroundColor DarkGray
$blockSize = 1MB
$blocks = $FileMB
$buf = New-Object byte[] $blockSize
[System.Random]::new().NextBytes($buf)
try {
# WriteThrough pushes data past the OS cache so the number reflects real disk speed
$fsWrite = [System.IO.FileStream]::new(
$testFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write,
[System.IO.FileShare]::None, $blockSize, [System.IO.FileOptions]::WriteThrough)
$swWrite = [System.Diagnostics.Stopwatch]::StartNew()
for ($i = 0; $i -lt $blocks; $i++) { $fsWrite.Write($buf, 0, $buf.Length) }
$fsWrite.Flush($true)
$swWrite.Stop()
$fsWrite.Close()
$fsRead = [System.IO.FileStream]::new(
$testFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read,
[System.IO.FileShare]::None, $blockSize, [System.IO.FileOptions]::SequentialScan)
$readBuf = New-Object byte[] $blockSize
$swRead = [System.Diagnostics.Stopwatch]::StartNew()
while ($fsRead.Read($readBuf, 0, $readBuf.Length) -gt 0) { }
$swRead.Stop()
$fsRead.Close()
[PSCustomObject]@{
FileMB = $FileMB
WriteThroughputMBs = [math]::Round($FileMB / $swWrite.Elapsed.TotalSeconds, 1)
ReadThroughputMBs = [math]::Round($FileMB / $swRead.Elapsed.TotalSeconds, 1)
}
} finally {
if (Test-Path $testFile) { Remove-Item $testFile -Force -ErrorAction SilentlyContinue }
}
}
# ---- Main ----
# Now we put together the interface and call the test functions
Write-Host "`nVDI Image Benchmark" -ForegroundColor Cyan
Write-Host "Tag: $Tag" -ForegroundColor Cyan
$env_ = Get-EnvironmentInfo
$env_ | Format-List | Out-String | Write-Host
Write-Host "[1/3] CPU phase ($DurationSeconds sec)..." -ForegroundColor Yellow
$cpu = Test-CpuLoad -Seconds $DurationSeconds
Write-Host "[2/3] Memory phase ($MemoryTargetMB MB)..." -ForegroundColor Yellow
$mem = Test-MemoryLoad -TargetMB $MemoryTargetMB
if (-not $SkipDisk) {
Write-Host "[3/3] Disk phase ($DiskTestFileMB MB at $DiskTestPath)..." -ForegroundColor Yellow
$disk = Test-DiskLoad -Path $DiskTestPath -FileMB $DiskTestFileMB
} else {
Write-Host "[3/3] Disk phase skipped." -ForegroundColor DarkGray
$disk = [PSCustomObject]@{ FileMB = $null; WriteThroughputMBs = $null; ReadThroughputMBs = $null }
}
$result = [PSCustomObject]@{
Timestamp = (Get-Date).ToString("s")
Tag = $Tag
ComputerName = $env_.ComputerName
OSBuild = $env_.OSBuild
CPUName = $env_.CPUName
LogicalCores = $env_.LogicalCores
TotalMemoryGB = $env_.TotalMemoryGB
CPU_OpsPerSecond = $cpu.OpsPerSecond
CPU_AvgPercent = $cpu.AvgCPUPercent
Mem_WriteMBs = $mem.WriteThroughputMBs
Mem_ReadMBs = $mem.ReadThroughputMBs
Mem_MinAvailMB = $mem.MinAvailMBDuring
Disk_WriteMBs = $disk.WriteThroughputMBs
Disk_ReadMBs = $disk.ReadThroughputMBs
}
Write-Host "`n---- Results ----" -ForegroundColor Cyan
$result | Format-List | Out-String | Write-Host
$fileExists = Test-Path $OutputCsv
$result | Export-Csv -Path $OutputCsv -NoTypeInformation -Append:$fileExists
Write-Host "Appended to $OutputCsv" -ForegroundColor Green

Leave a Reply

Discover more from vNinja

Subscribe now to keep reading and get access to the full archive.

Continue reading