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 infofunction 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 jobfunction 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 loadfunction 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 workfunction 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 diskfunction 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 functionsWrite-Host "`nVDI Image Benchmark" -ForegroundColor CyanWrite-Host "Tag: $Tag" -ForegroundColor Cyan$env_ = Get-EnvironmentInfo$env_ | Format-List | Out-String | Write-HostWrite-Host "[1/3] CPU phase ($DurationSeconds sec)..." -ForegroundColor Yellow$cpu = Test-CpuLoad -Seconds $DurationSecondsWrite-Host "[2/3] Memory phase ($MemoryTargetMB MB)..." -ForegroundColor Yellow$mem = Test-MemoryLoad -TargetMB $MemoryTargetMBif (-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:$fileExistsWrite-Host "Appended to $OutputCsv" -ForegroundColor Green
Leave a Reply