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 = Resolve-DiskTestPath -Path (Get-Parameter $Parameters 'Path' $env:TEMP) $required = [long]$sizeMB * 1MB * $script:DiskTestFreeSpaceFactor $free = $null $qualifier = '' if ("$path" -match '^([A-Za-z]:)') { $qualifier = $Matches[1] } 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 = [IO.Path]::Combine($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() } # Through .NET like everything else done to this file, so that whatever let the # file be written also lets it be removed. A gigabyte left in a Customer's # profile because a cmdlet read the path differently is not an acceptable failure. try { [IO.File]::Delete($file) } catch { } } [pscustomobject]$data } function Resolve-DiskTestPath { <# .SYNOPSIS The directory the test file goes in: the one asked for, or the profile's own temporary directory when that one is not there. .DESCRIPTION %TEMP% is handed to a process in whatever form the logon gave it, and for a profile whose folder is not a valid 8.3 name - max.mustermann - that is a short path such as C:\Users\MAXMUS~1.MUS\AppData\Local\Temp. PowerShell's file system provider expands short names itself and gives up with "an object at the specified path does not exist" where Windows would have opened the file. So no cmdlet touches this path: it is checked and used through .NET, and when it cannot be found the long form of the same directory is used instead. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Path) if ($Path -and [IO.Directory]::Exists($Path)) { return $Path } $fallback = [IO.Path]::Combine([Environment]::GetFolderPath('LocalApplicationData'), 'Temp') if ([IO.Directory]::Exists($fallback)) { return $fallback } $Path } function Get-DiskTestJudgement { [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') } $severity = Get-SeverityBelow $rate $warnBelow $failBelow $signal = @() if ($severity -in 'WARN', 'FAIL') { $signal = @('disk-write-slow') } New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') ` -Severity $severity -Value ('{0:N0} MB/s' -f $rate) ` -Hint (Get-Text 'Hint.DiskTest.SATASSD300500NVMe') -Signal $signal } 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 $severity = Get-Severity $average $warn $fail $signal = @() if ($severity -in 'WARN', 'FAIL') { $signal = @('disk-write-slow') } New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') ` -Severity $severity ` -Value ((Get-Text 'Value.DiskTest.Latency') -f $average, $p95, $worst) ` -Hint (Get-Text 'Hint.DiskTest.HighLatencySluggishOutlookAnd') -Signal $signal } 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') } function ConvertTo-DiskTestFinding { <# .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-DiskTestJudgement -Data $Data -Parameters $Parameters | Add-SituationPowerNote -Situation $Situation } |