Last active
December 4, 2023 22:15
-
-
Save sean-m/181995052f7329831130e95b49d89a43 to your computer and use it in GitHub Desktop.
A time estimator for use with PowerShell's Write-Progress. Provided a total number of cycles, it tracks how long the last 100 or less itterations of your main loop took and calculates the remaining time based on velocity. Note: when ticks exceed total, it returns a seconds remaining of 0, but continues to track the rate that work is getting done.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
A time estimator for use with PowerShell's Write-Progress. Provided a total | |
number of cycles, it tracks how long the last 100 or less itterations of your | |
main loop took and calculates the remaining time based on velocity. Note: when | |
ticks exceed total, it returns a seconds remaining of 0, but continues to track | |
the rate that work is getting done. | |
The intended use case is with Write-Progress, calls to that cmdlet are really | |
slow on PowerShell 2-5, efforts have been made to maintain low overhead. If | |
you're after performance this is still useful, just log the progress yourself. | |
For instance, using [System.Diagnostics.Trace]::Write() and watching with | |
Sysinternals DbgView is a faster alternative than logging to the console. How | |
much sense that makes when worrying about wallclock time is up to you. | |
#> | |
class TimeEstimator { | |
$Total | |
hidden [long] $Ticks = 0 | |
static hidden $bufferSize = 128 # keep this as power of 2 | |
hidden $Tickline = (New-Object long[] ([TimeEstimator]::bufferSize)) | |
hidden $Timeline = (New-Object long[] ([TimeEstimator]::bufferSize)) | |
hidden [double] $Rate = 0.0 | |
hidden $ComputeInterval | |
hidden $LastComputeState = @([DateTime]::Now.Ticks,0) | |
hidden $DynamicInterval = $false | |
hidden [int] ParseTotal ($Total) { | |
if ($Total -is [array] -or $Total -is [System.Collections.IEnumerable]) { | |
return $Total.Count | |
} elseif ($Total -match '^[\d\.]+') { | |
return [int]$Total | |
} | |
#TODO can't initialize with a numeric value for total so can't compute | |
# percentage complete, but could still measure the processing rate. | |
# Will probably just crash things but using this wrong should never crash | |
# a script, should just show a warning. | |
return $Total | |
} | |
# Initialize with a total amount of itterations you're expecting but let the | |
# script figure out how frequently to compute the rate of work. Currently | |
# it tries to balance out by recomputing every 11 seconds or so. If your | |
# work happens faster than that, you probably don't need a progress bar. | |
TimeEstimator($Total) { | |
$this.Total = $this.ParseTotal($Total) | |
$this.ComputeInterval = [int]([Math]::Max(1,[Math]::Min(5, $this.Total / 10))) | |
$this.DynamicInterval = $true | |
$this.LastComputeState = @([datetime]::Now.Ticks, $this.Ticks) | |
} | |
# Initialize with a total amount of itterations you're expecting and how frequently you | |
# want to compute the rate of work. Compute interval is in iterations not wall clock time. | |
TimeEstimator([int]$Total, [int]$ComputeInterval) { | |
$this.Total = $this.ParseTotal($Total) | |
$this.ComputeInterval = [Math]::Max($ComputeInterval, 16) # Don't compute more frequently than every 15. For slow stuff, should probably let the script figure it out by not supplying a ComputeInterval to the constructor. | |
$this.LastComputeState = @([datetime]::Now.Ticks, $this.Ticks) | |
} | |
[void] Tick() { | |
$this.Ticks++ | |
$i = $this.Ticks -band ([TimeEstimator]::bufferSize - 1) | |
$this.Tickline[$i] = $this.Ticks | |
$this.Timeline[$i] = [datetime]::Now.Ticks | |
if ($this.Ticks % $this.ComputeInterval -eq 0 ` | |
-or ([DateTime]::Now.Ticks - $this.LastComputeState[0]) / [timespan]::TicksPerSecond -gt 11) { | |
# Generate a time estimate based on work done recently | |
# TODO improve by doing a rolling weighted average based on how many have been performed minute over minute since the beginning, | |
# ignoring minutes which fall outside the stddev for the last 2 minutes. Ideally it should compute a linear regression for | |
# estimating but that would require some kind of sampling scheme to keep data use bounded. Perhaps a second ring buffer that | |
# collects samples based on wallclock time, no more than every 20 seconds or so to perform linear regression on. Needs benchmarking | |
# to determine performance impact but could easily use System.Numerics.Vector<T> to make use of SIMD acceleration for multiplication. | |
$measurement = ($this.Timeline.Where({ $_ -ne 0 }) | Measure-Object -Minimum -Maximum) | |
$span = [double]([decimal]($measurement.Maximum - $measurement.Minimum) / [timespan]::TicksPerSecond) | |
$this.Rate = ([double]($measurement.Count) / $span) | |
#Write-Host ("Measurement count: {0} timespan: {1} tick min {2} tick max {3}" -f $measurement.Count, $span, $measurement.Minimum, $measurement.Maximum) | |
<# | |
$span = ([decimal]([datetime]::Now.Ticks - $this.LastComputeState[0]) / [timespan]::TicksPerSecond) | |
if ($span -eq 0) { return } | |
$this.Rate = ([double]($this.Ticks - $this.LastComputeState[1]) / $span) | |
#> | |
# When computing the interval dynamically, target resampling every 10 seconds or | |
# doing at least 10 samples for the given input set if processing things quicly. | |
# Yes this adds overhead to very quick operations but if you're doing something | |
# super fast, you shouldn't be using Write-Progress. | |
if ($this.DynamicInterval) { | |
$this.ComputeInterval = [int]([Math]::Max(2.0, [Math]::Min(10.0 * $this.Rate, $this.Total / 10.0))) | |
} | |
$this.LastComputeState = @([datetime]::Now.Ticks, $this.Ticks) # capture time and number of itterations | |
} | |
} | |
[string] GetStatusQuote() { | |
if ($this.Rate -eq 0.0) { return 'Too early to tell' } | |
return "More than {0}/{1} completed. Rate: {2:N2} / sec" -f $this.Ticks, $this.Total, $this.Rate | |
} | |
[int] GetSecondsRemaining() { | |
if ($this.Rate -eq 0.0) { return [int]::MaxValue } | |
return [Math]::Max(0, [Math]::Min([int]::MaxValue, (($this.Total - $this.Ticks) / $this.Rate))) | |
} | |
[int] GetPercentage () { | |
filter Percentage { | |
param ([ValidateRange(0,[int32]::MaxValue)]$count=0, | |
[ValidateRange(1,[int32]::MaxValue)]$total=1) | |
[Math]::Min(100, [Math]::Round(($count/$total) * 100)) | |
} | |
return (Percentage -count ($this.Ticks) -total ($this.Total)) | |
} | |
[void] ShowDefaultProgress () { | |
$this.ShowDefaultProgress("Processing pipeline") | |
} | |
[void] ShowDefaultProgress ($Msg) { | |
Write-Progress -Activity $Msg ` | |
-Status ($this.GetStatusQuote()) ` | |
-PercentComplete ($this.GetPercentage()) ` | |
-SecondsRemaining ($this.GetSecondsRemaining()) | |
} | |
} | |
filter Write-PipelineProgress { | |
param ([TimeEstimator]$Estimator) | |
if ($Estimator) { | |
$Estimator.Tick() | |
$Estimator.ShowDefaultProgress() | |
} | |
$_ | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment