Private/Kinds/DiskTest.ps1
|
# The DiskTest Kind: what the system disk actually does, rather than what it claims. # # The Storage Kind reads what Windows says about the disk. This one writes to it. The two # disagree often enough to be worth both: a drive that reports Healthy and idle can still # take 40 ms to commit a 4 KB write once real-time scanning and encryption are in the way, # and that number is what a Technician needs when Outlook feels slow on a healthy machine. # The scratch file is written in 4 MB blocks, and the latency probe writes 4 KB at a time # because that is the shape of the writes Outlook and a database client actually make. $script:DiskTestBlockBytes = 4MB $script:DiskTestSmallWriteBytes = 4096 $script:DiskTestSmallWriteCount = 500 # A test that fills the disk it is measuring is worse than no test, so it wants room for # the file three times over before it starts. $script:DiskTestFreeSpaceFactor = 3 function Get-DiskTestData { <# .SYNOPSIS Measures the system disk by writing to it. Decides nothing about what it measures. .DESCRIPTION The scratch file is removed in a finally block, so an interrupted Run does not leave a gigabyte behind on a Customer's machine. Whether there was room to run at all is reported as data - the Judge decides what an unrun measurement means. #> [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $sizeMB = [int](Get-Parameter $Parameters 'SizeMB' 1024) $path = Get-Parameter $Parameters 'Path' $env:TEMP $required = [long]$sizeMB * 1MB * $script:DiskTestFreeSpaceFactor $free = $null $qualifier = Split-Path $path -Qualifier -ErrorAction SilentlyContinue if ($qualifier) { $volume = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$qualifier'" -ErrorAction SilentlyContinue $free = $volume.FreeSpace } $data = [ordered]@{ PSTypeName = 'Gutcheck.Data.DiskTest' Path = $path SizeMB = $sizeMB FreeBytes = $free RequiredBytes = $required Measured = $false SequentialWriteMBps = $null SequentialReadMBps = $null SyncWriteLatencyMs = @() } if ($null -eq $free -or $free -lt $required) { return [pscustomobject]$data } $file = Join-Path $path ('gutcheck_disktest_{0}.tmp' -f [guid]::NewGuid()) $blocks = [int]($sizeMB * 1MB / $script:DiskTestBlockBytes) if ($blocks -lt 1) { $blocks = 1 } $buffer = New-Object byte[] $script:DiskTestBlockBytes (New-Object Random).NextBytes($buffer) $stream = $null try { # WriteThrough, so the measurement is of the disk rather than of the write cache. $stream = New-Object IO.FileStream($file, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) $watch = [Diagnostics.Stopwatch]::StartNew() $writing = Get-Text 'Console.Progress.DiskWrite' $shownPercent = -1 for ($i = 0; $i -lt $blocks; $i++) { # Drawn with the stopwatch paused, and only when the percentage moves, so the # throughput measured is the disk's and not the console's. $percent = [int][math]::Floor($i * 100 / $blocks) if ($percent -ne $shownPercent) { $watch.Stop() Write-CheckProgress -Status $writing -Step $i -Of $blocks $watch.Start() $shownPercent = $percent } $stream.Write($buffer, 0, $script:DiskTestBlockBytes) } $stream.Flush($true) $watch.Stop() if ($watch.Elapsed.TotalSeconds -gt 0) { $data.SequentialWriteMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds } # Small synchronous writes at random offsets: what an OST or a database file does. $small = New-Object byte[] $script:DiskTestSmallWriteBytes (New-Object Random).NextBytes($small) $random = New-Object Random $maxOffset = [int]([long]$blocks * $script:DiskTestBlockBytes / $script:DiskTestSmallWriteBytes) - 1 if ($maxOffset -lt 1) { $maxOffset = 1 } $latencies = New-Object System.Collections.Generic.List[double] $latencyStatus = Get-Text 'Console.Progress.DiskLatency' for ($i = 0; $i -lt $script:DiskTestSmallWriteCount; $i++) { # Outside the stopwatch, which starts below, so the latency is the disk's alone. Write-CheckProgress -Status $latencyStatus -Step $i -Of $script:DiskTestSmallWriteCount $stream.Position = [long]$random.Next(0, $maxOffset) * $script:DiskTestSmallWriteBytes $one = [Diagnostics.Stopwatch]::StartNew() $stream.Write($small, 0, $script:DiskTestSmallWriteBytes) $stream.Flush($true) $latencies.Add($one.Elapsed.TotalMilliseconds) } $data.SyncWriteLatencyMs = @($latencies) $stream.Dispose(); $stream = $null $watch.Restart() $read = [IO.File]::OpenRead($file) try { while ($read.Read($buffer, 0, $script:DiskTestBlockBytes) -gt 0) { } } finally { $read.Dispose() } $watch.Stop() if ($watch.Elapsed.TotalSeconds -gt 0) { $data.SequentialReadMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds } $data.Measured = $true } finally { if ($stream) { $stream.Dispose() } Remove-Item $file -Force -ErrorAction SilentlyContinue } [pscustomobject]$data } function ConvertTo-DiskTestFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) if (-not (Get-DataProperty $Data 'Measured')) { return New-DiskTestSkippedFinding -Data $Data } New-SequentialWriteFinding -Data $Data -Parameters $Parameters New-SyncWriteLatencyFinding -Data $Data -Parameters $Parameters New-SequentialReadFinding -Data $Data -Parameters $Parameters } function New-DiskTestSkippedFinding { <# .SYNOPSIS Why nothing was measured. A Check that did not run belongs in the Report. #> [CmdletBinding()] param([AllowNull()]$Data) $free = ConvertTo-Number (Get-DataProperty $Data 'FreeBytes') $required = ConvertTo-Number (Get-DataProperty $Data 'RequiredBytes') if ($null -eq $free) { return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') ` -Hint (Get-Text 'Hint.DiskTest.TheScratchVolumeDidNot') } New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') -Severity INFO ` -Value ((Get-Text 'Value.DiskTest.NotEnoughSpace') -f ($free / 1GB), ($required / 1GB)) ` -Hint (Get-Text 'Hint.DiskTest.FreeUpSpaceAndRerun') } function New-SequentialWriteFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # Graded downwards: it is the small number that is the problem. $warnBelow = Get-Parameter $Parameters 'SequentialWriteWarnBelowMBps' 250 $failBelow = Get-Parameter $Parameters 'SequentialWriteFailBelowMBps' 80 $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialWriteMBps') if ($null -eq $rate) { return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') ` -Hint (Get-Text 'Hint.DiskTest.TheWriteWasNotTimed') } New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') ` -Severity (Get-SeverityBelow $rate $warnBelow $failBelow) -Value ('{0:N0} MB/s' -f $rate) ` -Hint (Get-Text 'Hint.DiskTest.SATASSD300500NVMe') } function New-SyncWriteLatencyFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warn = Get-Parameter $Parameters 'SyncWriteLatencyWarnMs' 8 $fail = Get-Parameter $Parameters 'SyncWriteLatencyFailMs' 20 $samples = (Get-DataCollection $Data 'SyncWriteLatencyMs') $average = Get-SampleAverage $samples if ($null -eq $average) { return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') ` -Hint (Get-Text 'Hint.DiskTest.NoSmallWriteLatencyWas') } # Judged on the average, reported with the tail: one stall in five hundred writes is # not a sluggish disk, but a Technician still wants to see that it happened. $p95 = Get-SamplePercentile $samples 95 $worst = Get-SamplePercentile $samples 100 New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') ` -Severity (Get-Severity $average $warn $fail) ` -Value ((Get-Text 'Value.DiskTest.Latency') -f $average, $p95, $worst) ` -Hint (Get-Text 'Hint.DiskTest.HighLatencySluggishOutlookAnd') } function New-SequentialReadFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialReadMBps') if ($null -eq $rate) { return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') ` -Hint (Get-Text 'Hint.DiskTest.TheReadWasNotTimed') } # Never graded: the file it reads back was just written, so the cache serves much of # it and the number says more about RAM than about the disk. New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') -Severity INFO ` -Value ('{0:N0} MB/s' -f $rate) -Hint (Get-Text 'Hint.DiskTest.MayBeInflatedByThe') } |