#
VM Fleet
Copyright(c) Microsoft Corporation
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#>
#
# Template Profiles
#
# Template profiles state througput in relative units per target per timespan.
# If a timespan contains two targets, one with 1 relative IOPS and one with 9
# relative IOPS, applying an aggregate IOPS throughput of 100 would result in
# 10 & 90 respectively.
#
# Throughput MUST be in template in order to apply throughput limits.
#
# Note: random/stridesize elements MUST be removed if -Random will be allowed for
# a profile.
#
# Peak/General: a single thread high-QD unbuffered/writethrough single target template
# with latency/iops measured. 10MiB random write data source. All substitutions
# allowed.
#
# Peak and General differ in distribution. General is across a non-uniform distribution
# of the entire available workingset, while Peak has no built-in distribution. This allows
# Peak to be instantiated with a reduced workingset (-BaseOffset/-MaxOffset) for a uniformly
# loaded small workingset.
#
#
# Derived from: -t1 -b4k -o32 -w0 -r -Suw -D -L -Z10m *1
# Random element removed, also BaseFileOffset/MaxFileSize
# Profile specific required parameters override WriteRatio, BlockSize
#
$PeakProfile = @"
xml
false
false
true
true
false
1000
0
*1
4096
true
true
random
10485760
0
32
0
0
1
3
"@
$GeneralProfile = @"
xml
false
false
true
true
false
1000
0
*1
4096
true
true
random
10485760
5
10
0
32
0
0
1
3
"@
# VDI Profile: DiskSpd parameters that mimic a VDI workload
#
# Derived from:
# (write component) -t1 -o8 -b32k -w100 -g6i -rs20 -rdpct95/5:4/10 -Z10m -f10g *1
# (read component) -t1 -o8 -b8k -w0 -g6i -rs80 -rdpct95/5:4/10 -f8g *1
$VDIProfile = @"
xml
false
false
true
true
false
0
1000
0
*1
32768
0
10737418240
true
true
random
10485760
32768
20
5
10
0
8
100
6
1
3
*1
8192
0
8589934592
true
true
sequential
8192
80
5
10
0
8
0
6
1
3
"@
# SQL Profile: DiskSpd parameters that mimic an SQL workload with log transaction
#
# Derived from:
# (OLTP) -t4 -o32 -r -b8k -g1500i -w30 -rdpct95/5:4/10 -B5g -Z10m *1
# (Log) -t1 -o1 -s -b32k -g300i -w100 -f5g -Z10m *1
$SQLProfile = @"
xml
false
false
true
true
false
1000
0
*1
8192
5368709120
0
true
true
random
10485760
8192
5
10
0
32
30
1500
4
3
*1
32768
0
5368709120
true
true
random
10485760
32768
0
1
100
300
1
3
"@
#
# Requires - parameters which must be provided
# AllowsRandSeq - compatibility with -Random/-Sequential rewrite rule
# Compatibility - list of parameters for compatibility check
# Compatible - provides sense of the Compatibility list for simpler bulk inclusion/exclusion
# true - an inclusion list; parameters not mentioned are not allowed (empty = none allowed)
# false - an exclusion list; parameters mentioned are not allowed (empty = all allowed)
#
$FleetProfiles = @{
Peak = @{
Profile = $PeakProfile
AllowRandSeq = $true
Requires = @('WriteRatio', 'BlockSize')
Compatibility = $null
Compatible = $false
}
General = @{
Profile = $GeneralProfile
AllowRandSeq = $true
Requires = @('WriteRatio', 'BlockSize')
Compatibility = $null
Compatible = $false
}
VDI = @{
Profile = $VDIProfile
AllowRandSeq = $false
Requires = $null
Compatibility = $null
Compatible = $true
}
SQL = @{
Profile = $SQLProfile
AllowRandSeq = $false
Requires = $null
Compatibility = $null
Compatible = $true
}
}
function Get-FleetProfileXml
{
[CmdletBinding()]
param(
[Parameter()]
[string]
$Name,
[Parameter()]
[uint32]
$Warmup = 300,
[Parameter()]
[uint32]
$Duration = 60,
[Parameter()]
[uint32]
$Cooldown = 30,
[ValidateRange(0, 100)]
[uint32]
$WriteRatio,
[Parameter()]
[uint32]
$ThreadsPerTarget,
[Parameter()]
[uint32]
$BlockSize,
[Parameter()]
[uint32]
$Alignment,
[Parameter()]
[switch]
$Random,
[Parameter()]
[switch]
$Sequential,
[ValidateRange(0, 100)]
[uint32]
$RandomRatio,
[Parameter()]
[uint32]
$RequestCount,
[Parameter()]
[uint64]
$ThreadStride,
[Parameter()]
[uint64]
$BaseOffset,
[Parameter()]
[uint64]
$MaxOffset,
[Parameter()]
[ValidateRange(1,60)]
[uint32]
$IoBucketDurationSeconds
)
$x = $null
#
# Get base profile and validate profile-specific paramters
#
if (-not $PSBoundParameters.ContainsKey('Name'))
{
Write-Error "Available profiles: $(@($FleetProfiles.Keys | Sort-Object) -join ', ')"
return
}
elseif (-not $FleetProfiles.ContainsKey($Name))
{
Write-Error "Unknown profile $Name; available: $(@($FleetProfiles.Keys | Sort-Object) -join ', ')"
return
}
#
# Switch validation
#
if ($PsBoundParameters.ContainsKey('Sequential') -and $PsBoundParameters.ContainsKey('Random'))
{
Write-Error "Random and Sequential cannot be specified together"
return
}
#
# Requires check
#
$err = @()
foreach ($arg in $FleetProfiles[$Name].Requires)
{
if (-not $PSBoundParameters.ContainsKey($arg))
{
$err += ,$arg
}
}
if ($err.Count)
{
Write-Error "$Name profile requires specification of $($err -join ', ')"
return
}
#
# Incompatible check
#
if (-not ($FleetProfiles[$Name].Compatible))
{
foreach ($arg in $FleetProfiles[$Name].Compatibility)
{
if ($PSBoundParameters.ContainsKey($arg))
{
$err += ,$arg
}
}
}
#
# Compatible check
#
else
{
# Always-on core parameters + required list + compatible list
$baseParameters = @('Name', 'Warmup', 'Duration', 'Cooldown')
foreach ($arg in $PSBoundParameters.Keys)
{
if ($baseParameters -notcontains $arg -and
$FleetProfiles[$Name].Requires -notcontains $arg -and
$FleetProfiles[$Name].Compatibility -notcontains $arg)
{
$err += ,$arg
}
}
}
if ($err.Count)
{
Write-Error "$Name profile does not allow specification of $($err -join ', ')"
return
}
#
# Now load/modify profile.
#
$x = [xml] $FleetProfiles[$Name].Profile
#
# Perform replacements @ TimeSpan
#
foreach ($timeSpan in $x.SelectNodes("Profile/TimeSpans/TimeSpan"))
{
$timeSpan.Warmup = [string] $Warmup
$timeSpan.Duration = [string] $Duration
$timeSpan.Cooldown = [string] $Cooldown
if ($PSBoundParameters.ContainsKey('IoBucketDurationSeconds'))
{
$timeSpan.IoBucketDuration = [string] ($IoBucketDurationSeconds * 1000)
}
# Autoscale to best clock fit >1000 n-second IOPS bucket datapoints.
else
{
$timeSpan.IoBucketDuration = [string] ((FitClockRate -TotalSeconds $Duration -Samples 1000) * 1000)
}
}
#
# Perform replacements @ Target
#
foreach ($targetSet in $x.SelectNodes("Profile/TimeSpans/TimeSpan/Targets"))
{
$targets = $targetSet.SelectNodes("Target")
foreach ($target in $targets)
{
#
# 1:1 Common Substitutions
#
$TargetSubstitutions = @{
BlockSize = 'BlockSize'
RequestCount = 'RequestCount'
ThreadsPerTarget = 'ThreadsPerFile'
ThreadStride = 'ThreadStride'
WriteRatio = 'WriteRatio'
MaxOffset = 'MaxFileSize'
BaseOffset = 'BaseFileOffset'
}
foreach ($s in $TargetSubstitutions.Keys)
{
if ($PSBoundParameters.ContainsKey($s))
{
SetSingleNode -Xml $x -ParentNode $target -Node $TargetSubstitutions[$s] -Value ([string] $PSBoundParameters[$s])
}
}
#
# Target Random/Sequential/Alignment
#
if ($FleetProfiles[$Name].AllowRandSeq)
{
# Inherit default buffer alignment from buffer size
if (-not $PSBoundParameters.ContainsKey('Alignment'))
{
$Alignment = $BlockSize
}
# Random or RandomRatio
if ($Random -or $PsBoundParameters.ContainsKey('RandomRatio'))
{
$e = $x.CreateNode([xml.xmlnodetype]::Element, 'Random', '')
$e.InnerText = [string] $Alignment
$null = $target.AppendChild($e)
if ($PsBoundParameters.ContainsKey('RandomRatio'))
{
$e = $x.CreateNode([xml.xmlnodetype]::Element, 'RandomRatio', '')
$e.InnerText = [string] $RandomRatio
$null = $target.AppendChild($e)
}
}
# Sequential (default if neither specified)
elseif ($Sequential -or -not $Random)
{
$e = $x.CreateNode([xml.xmlnodetype]::Element, 'StrideSize', '')
$e.InnerText = [string] $Alignment
$null = $target.AppendChild($e)
#
# Remove any distribution attached to the target - not compatibile (or relevant);
# sequential is sequential.
#
$dists = $target.SelectNodes("Distribution")
foreach ($dist in $dists)
{
$null = $target.RemoveChild($dist)
}
}
}
}
}
$x
}
function Set-FleetProfile
{
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline = $true, Mandatory = $true)]
[xml]
$ProfileXml,
[Parameter()]
[uint32]
$Throughput,
[Parameter()]
[ValidateSet("IOPS","BPMS")]
[string]
$ThroughputUnit = "IOPS",
[Parameter()]
[uint32]
$Warmup,
[Parameter()]
[uint32]
$Duration,
[Parameter()]
[uint32]
$Cooldown
)
process
{
$x = $ProfileXml.Clone()
foreach ($timeSpan in $x.SelectNodes("Profile/TimeSpans/TimeSpan"))
{
if ($PSBoundParameters.ContainsKey('Warmup'))
{
$timeSpan.Warmup = [string] $Warmup
}
if ($PSBoundParameters.ContainsKey('Cooldown'))
{
$timeSpan.Cooldown = [string] $Cooldown
}
if ($PSBoundParameters.ContainsKey('Duration'))
{
$timeSpan.Duration = [string] $Duration
}
if ($PSBoundParameters.ContainsKey('Throughput'))
{
# Fail if timespan is in threadpool form - DISKSPD does not support
# throughput in this case.
$t = $timeSpan.SelectSingleNode("ThreadCount")
if ($null -ne $t -and $t.InnerText -ne 0)
{
throw "Cannot set bounded throughput on profile using thread pool (-F/TimeSpan ThreadCount)"
}
foreach ($targetSet in $timeSpan.SelectNodes("Targets"))
{
#
# Pass 1 - Total
#
$total = [uint32] 0
$nTargets = 0
foreach ($target in $targetSet.Target)
{
$thisTput = 0
$e = $target.SelectSingleNode("Throughput")
if ($null -ne $e)
{
$thisTput = [uint32] $e.InnerText
$e = $target.SelectSingleNode("ThreadsPerFile")
if ($null -eq $e -or ([uint32] $e.InnerText) -eq 0)
{
throw "ThreadsPerFile is not present - zero or absent - to scale Throughput (target $nTargets)"
}
$thisTput *= [uint32] $e.InnerText
}
if (($total -ne 0 -and $thisTput -eq 0) -or
($total -eq 0 -and $thisTput -ne 0 -and $nTargets -ne 0))
{
throw "Cannot set throughput on profile with a combination of bounded/unbounded targets (target $nTargets)"
}
$total += $thisTput
++$nTargets
}
# If there is no throughout specified (unbounded) and no ratio specified
# in the profile, we are done - already unbounded.
if ($Throughput -eq 0 -and $total -eq 0) { continue }
#
# Pass 2a - Distribute nonzero by ratio, as well as zero to single target
#
if ($Throughput -ne 0 -or $targets.Count -eq 1)
{
foreach ($target in $targetSet.Target)
{
# Distribute equally
if ($total -eq 0)
{
SetSingleNode -Xml $x -ParentNode $target -Node 'Throughput' -Value ([int] ($Throughput / $nTargets))
$e = $target.SelectSingleNode("Throughput")
}
# Distribute proportionally
# Note: in absolute terms the numerator $thisTput should be scaled by #threads to arrive at
# a fraction the new throughput, but then we would immediatly divide #threads out of the result
# to arrive at per thread throughput; this can be avoided.
else
{
$e = $target.SelectSingleNode("Throughput")
$thisTput = [uint32] $e.InnerText
$e.InnerText = [string] [int] ($Throughput * ($thisTput / $total))
}
$e.SetAttribute('unit', $ThroughputUnit)
}
}
# Pass 2b - Distribute "zero" across multiple by converting target threads to pool
# with weighted ratio of throughputs on targets. Set interlocked sequential
# on sequential targets unless a nonzero threadstride is already present.
# This can result in a close approximation of unbounded result on the tput
# limited specification without needing dynamic scale-up, but should be used
# with caution.
else
{
# Loop targets to count threads, move tputs to weights and set interlocked sequential.
$nThreads = 0
foreach ($target in $targetSet.Target)
{
# Count threads
$e = $target.SelectSingleNode("ThreadsPerFile")
if ($null -eq $e -or $e.InnerText -eq 0)
{
throw "Invalid -t/ThreadsPerFile specification (absent or zero) converting to unbounded form"
}
$thisThreads = [uint32] $e.InnerText
$nThreads += $thisThreads
# Remove ThreadsPerFile/RequestCount @ Target
$null = $target.RemoveChild($e)
$e = $target.SelectSingleNode("RequestCount")
if ($null -ne $e) { $null = $target.RemoveChild($e) }
# Move Throuhput to Weight. Note - Throughput guaranteed to exist or we would have
# returned at the 0/0 check. Weight scales by number of threads on the target.
$e = $target.SelectSingleNode("Throughput")
$thisTput = [uint32] $e.InnerText
$null = $target.RemoveChild($e)
SetSingleNode -Xml $x -ParentNode $target -Node 'Weight' -Value ($thisTput * $thisThreads)
# Sequential target without ThreadStride? Move to interlocked.
$e = $target.SelectSingleNode("StrideSize")
if ($null -ne $e)
{
$e = $target.SelectSingleNode("ThreadStride")
if ($null -eq $e -or $e.InnerText -eq 0)
{
SetSingleNode -Xml $x -ParentNode $target -Node 'InterlockedSequential' -Value 'true'
}
}
}
# Now set TimeSpan level thread pool with high request count
SetSingleNode -Xml $x -ParentNode $timeSpan -Node 'ThreadCount' -Value $nThreads
SetSingleNode -Xml $x -ParentNode $timeSpan -Node 'RequestCount' -Value 32
}
}
}
}
$x
}
}
function GetFleetProfileFootprint
{
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline = $true, Mandatory = $true)]
[xml]
$ProfileXml,
[Parameter()]
[switch]
$Read
)
$r = @{}
# Capture min base offset and max max offset across timespans for each target.
# Note that 0 max offset indicates it is not bounded (bounded by target size).
foreach ($target in $ProfileXml.SelectNodes("Profile/TimeSpans/TimeSpan/Targets/Target"))
{
# Skip write-only target specs if only interested in read workingset
if ($Read -and $target.WriteRatio -eq '100')
{
continue
}
$bo = [uint64](GetSingleNode $target 'BaseFileOffset')
$mo = [uint64](GetSingleNode $target 'MaxFileSize')
$node = $r[$target.Path]
if ($null -ne $node)
{
if ($node.BaseOffset -gt $bo)
{
$r[$target.Path].BaseOffset = $bo
}
if ($mo -eq 0)
{
$r[$target.Path].MaxOffset = 0
}
elseif ($node.MaxOffset -ne 0 -and $node.MaxOffset -lt $mo)
{
$r[$target.Path].MaxOffset = $mo
}
}
else
{
$r[$target.Path] = [pscustomobject] @{
BaseOffset = $bo
MaxOffset = $mo
}
}
}
$r
}
function Convert-FleetXmlToString
{
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline = $true, Mandatory = $true)]
[object]
$InputObject
)
process {
switch ($InputObject.GetType().FullName)
{
"System.Xml.XmlDocument" { break }
"System.Xml.XmlElement" { break }
default {
throw "Unknown object type $_ - must be XmlDocument or XmlElement"
}
}
$sw = [System.IO.StringWriter]::new()
$xo = [System.Xml.XmlTextWriter]::new($sw)
$xo.Formatting = [System.Xml.Formatting]::Indented
$InputObject.WriteTo($xo)
$sw.ToString()
}
}
function FitClockRate
{
param(
[ValidateRange(1,(1000*60*60))]
[uint32]
$TotalSeconds,
[uint32]
$Samples
)
# Perform a best-fit for the longest interval which produces at least $Samples over
# the given $TotalSeconds time period and evenly divide minutes/hours. E.g.:
# 1..60 |? { 60 % $_ -eq 0 })
#
# Range is sanity capped at 1000h.
$div = 1,2,3,4,5,6,10,12,15,20,30,60
$last = 1
# First multiple is seconds, second produces minutes.
foreach ($mul in (1,60))
{
foreach ($d in $div)
{
# Skip 60s and roll over to 1 minute.
# 60 is only used for minutes. (e.g., 1hr)
if ($mul -eq 1 -and $d -eq 60 ) { break }
if (($TotalSeconds / ($d * $mul)) -lt $Samples)
{
return $last
}
$last = $d * $mul
}
}
return $last
}
function SetSingleNode
{
param(
[xml]
$Xml,
[xml.xmlelement]
$ParentNode,
[string]
$Node,
[string]
$Value
)
# Set/Create child node to given value
$e = $ParentNode.SelectSingleNode($Node)
if ($null -ne $e)
{
$e.InnerText = $Value
}
else
{
$e = $Xml.CreateNode([xml.xmlnodetype]::Element, $Node, '')
$e.InnerText = $Value
$null = $ParentNode.AppendChild($e)
}
}
function GetSingleNode
{
param(
[xml.xmlelement]
$ParentNode,
[string]
$Node
)
# Gete child node, if present
$e = $ParentNode.SelectSingleNode($Node)
if ($null -ne $e)
{
return $e.InnerText
}
return $null
}
function IsProfileSingleTimespan
{
param(
[xml]
$ProfileXml
)
$ts = @($ProfileXml.SelectNodes("Profile/TimeSpans/TimeSpan"))
return ($ts.Count -eq 1)
}
function IsProfileThroughputLimited
{
param(
[xml]
$ProfileXml
)
$tputs = @($ProfileXml.SelectNodes("Profile/TimeSpans/TimeSpan/Targets/Target/Throughput"))
foreach ($t in $tputs)
{
if (([uint32]$t.InnerText) -ne 0)
{
return $true
}
}
return $false
}
function IsProfileSingleTarget
{
param(
[xml]
$ProfileXml
)
$tgts = @($ProfileXml.SelectNodes("Profile/TimeSpans/TimeSpan/Targets/Target"))
return ($tgts.Count -eq 1)
}