Вопросы производительности сценариев PowerShell
Сценарии PowerShell, которые используют .NET напрямую в обход конвейера, часто работают быстрее, чем идиоматический код PowerShell. Idiomatic PowerShell использует командлеты и функции PowerShell, часто используя конвейер и прибегая к .NET только при необходимости.
Примечание.
Многие описанные здесь методы не идиоматичны PowerShell и могут снизить удобочитаемость скрипта PowerShell. Если жесткие требования к производительности отсутствуют, разработчикам сценариев рекомендуется следовать идиоматике PowerShell.
Подавление вывода
Существует множество способов избежать записи объектов в конвейер.
- Назначение или перенаправление файлов в
$null
- Приведение к
[void]
- Канал в
Out-Null
Скорость назначения $null
, приведение к [void]
и перенаправлению $null
файлов практически идентичны. Однако вызов Out-Null
в большом цикле может быть значительно медленнее, особенно в PowerShell 5.1.
$tests = @{
'Assign to $null' = {
$arrayList = [System.Collections.ArrayList]::new()
foreach ($i in 0..$args[0]) {
$null = $arraylist.Add($i)
}
}
'Cast to [void]' = {
$arrayList = [System.Collections.ArrayList]::new()
foreach ($i in 0..$args[0]) {
[void] $arraylist.Add($i)
}
}
'Redirect to $null' = {
$arrayList = [System.Collections.ArrayList]::new()
foreach ($i in 0..$args[0]) {
$arraylist.Add($i) > $null
}
}
'Pipe to Out-Null' = {
$arrayList = [System.Collections.ArrayList]::new()
foreach ($i in 0..$args[0]) {
$arraylist.Add($i) | Out-Null
}
}
}
10kb, 50kb, 100kb | ForEach-Object {
$groupResult = foreach ($test in $tests.GetEnumerator()) {
$ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds
[pscustomobject]@{
Iterations = $_
Test = $test.Key
TotalMilliseconds = [math]::Round($ms, 2)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
$groupResult = $groupResult | Sort-Object TotalMilliseconds
$groupResult | Select-Object *, @{
Name = 'RelativeSpeed'
Expression = {
$relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
[math]::Round($relativeSpeed, 2).ToString() + 'x'
}
}
}
Эти тесты были запущены на компьютере с Windows 11 в PowerShell 7.3.4. Результаты показаны ниже.
Iterations Test TotalMilliseconds RelativeSpeed
---------- ---- ----------------- -------------
10240 Assign to $null 36.74 1x
10240 Redirect to $null 55.84 1.52x
10240 Cast to [void] 62.96 1.71x
10240 Pipe to Out-Null 81.65 2.22x
51200 Assign to $null 193.92 1x
51200 Cast to [void] 200.77 1.04x
51200 Redirect to $null 219.69 1.13x
51200 Pipe to Out-Null 329.62 1.7x
102400 Redirect to $null 386.08 1x
102400 Assign to $null 392.13 1.02x
102400 Cast to [void] 405.24 1.05x
102400 Pipe to Out-Null 572.94 1.48x
Время и относительные скорости могут отличаться в зависимости от оборудования, версии PowerShell и текущей рабочей нагрузки в системе.
Сложение массивов
Создание списка элементов часто выполняется с помощью массива с оператором сложения:
$results = @()
$results += Get-Something
$results += Get-SomethingElse
$results
Добавление массива неэффективно, так как массивы имеют фиксированный размер. Каждое дополнение к массиву создает достаточно большой массив для хранения всех элементов как левых, так и правых операндов. Элементы обоих операндов копируются в новый массив. Для небольших коллекций такая дополнительная нагрузка может не иметь значения. Производительность может страдать от больших коллекций.
Существует пара альтернативных вариантов. Если на самом деле не требуется массив, рекомендуется использовать типизированный универсальный список ([List<T>]
):
$results = [System.Collections.Generic.List[object]]::new()
$results.AddRange((Get-Something))
$results.AddRange((Get-SomethingElse))
$results
Влияние на производительность добавления массива увеличивается экспоненциально с размером коллекции и числами. Этот код сравнивает явное назначение значений массиву с использованием добавления массива и использования Add(T)
метода в объекте [List<T>]
. Он определяет явное назначение в качестве базового показателя производительности.
$tests = @{
'PowerShell Explicit Assignment' = {
param($count)
$result = foreach($i in 1..$count) {
$i
}
}
'.Add(T) to List<T>' = {
param($count)
$result = [Collections.Generic.List[int]]::new()
foreach($i in 1..$count) {
$result.Add($i)
}
}
'+= Operator to Array' = {
param($count)
$result = @()
foreach($i in 1..$count) {
$result += $i
}
}
}
5kb, 10kb, 100kb | ForEach-Object {
$groupResult = foreach($test in $tests.GetEnumerator()) {
$ms = (Measure-Command { & $test.Value -Count $_ }).TotalMilliseconds
[pscustomobject]@{
CollectionSize = $_
Test = $test.Key
TotalMilliseconds = [math]::Round($ms, 2)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
$groupResult = $groupResult | Sort-Object TotalMilliseconds
$groupResult | Select-Object *, @{
Name = 'RelativeSpeed'
Expression = {
$relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
[math]::Round($relativeSpeed, 2).ToString() + 'x'
}
}
}
Эти тесты были запущены на компьютере с Windows 11 в PowerShell 7.3.4.
CollectionSize Test TotalMilliseconds RelativeSpeed
-------------- ---- ----------------- -------------
5120 PowerShell Explicit Assignment 26.65 1x
5120 .Add(T) to List<T> 110.98 4.16x
5120 += Operator to Array 402.91 15.12x
10240 PowerShell Explicit Assignment 0.49 1x
10240 .Add(T) to List<T> 137.67 280.96x
10240 += Operator to Array 1678.13 3424.76x
102400 PowerShell Explicit Assignment 11.18 1x
102400 .Add(T) to List<T> 1384.03 123.8x
102400 += Operator to Array 201991.06 18067.18x
При работе с большими коллекциями добавление массива значительно медленнее, чем добавление в .List<T>
При использовании [List<T>]
объекта необходимо создать список с определенным типом, например [String]
или [Int]
. При добавлении объектов другого типа в список они приведение к указанному типу. Если они не могут быть приведение к указанному типу, метод вызывает исключение.
$intList = [System.Collections.Generic.List[int]]::new()
$intList.Add(1)
$intList.Add('2')
$intList.Add(3.0)
$intList.Add('Four')
$intList
MethodException:
Line |
5 | $intList.Add('Four')
| ~~~~~~~~~~~~~~~~~~~~
| Cannot convert argument "item", with value: "Four", for "Add" to type
"System.Int32": "Cannot convert value "Four" to type "System.Int32".
Error: "The input string 'Four' was not in a correct format.""
1
2
3
Если вам нужно, чтобы список был коллекцией различных типов объектов, создайте его с [Object]
типом списка. Вы можете перечислить коллекцию, чтобы проверить типы объектов в нем.
$objectList = [System.Collections.Generic.List[object]]::new()
$objectList.Add(1)
$objectList.Add('2')
$objectList.Add(3.0)
$objectList | ForEach-Object { "$_ is $($_.GetType())" }
1 is int
2 is string
3 is double
Если требуется массив, можно вызвать ToArray()
метод в списке или позволить PowerShell создать массив для вас:
$results = @(
Get-Something
Get-SomethingElse
)
В этом примере PowerShell создает [ArrayList]
для хранения результатов, которые записываются в конвейер в выражении массива. Непосредственно перед тем, как назначать $results
, PowerShell преобразует [ArrayList]
в [Object[]]
.
Добавление строк
Строки являются неизменяемыми. Каждое добавление к строке фактически создает новую строку, достаточно большую для хранения содержимого левого и правого операндов, а затем копирует элементы обоих операндов в эту новую строку. Для небольших строк такая дополнительная нагрузка может не иметь значения. Для больших строк это может повлиять на производительность и потребление памяти.
Существует по крайней мере два варианта:
- Оператор
-join
объединяет строки - Класс .NET
[StringBuilder]
предоставляет изменяемую строку
В следующем примере сравнивается производительность этих трех методов построения строки.
$tests = @{
'StringBuilder' = {
$sb = [System.Text.StringBuilder]::new()
foreach ($i in 0..$args[0]) {
$sb = $sb.AppendLine("Iteration $i")
}
$sb.ToString()
}
'Join operator' = {
$string = @(
foreach ($i in 0..$args[0]) {
"Iteration $i"
}
) -join "`n"
$string
}
'Addition Assignment +=' = {
$string = ''
foreach ($i in 0..$args[0]) {
$string += "Iteration $i`n"
}
$string
}
}
10kb, 50kb, 100kb | ForEach-Object {
$groupResult = foreach ($test in $tests.GetEnumerator()) {
$ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds
[pscustomobject]@{
Iterations = $_
Test = $test.Key
TotalMilliseconds = [math]::Round($ms, 2)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
$groupResult = $groupResult | Sort-Object TotalMilliseconds
$groupResult | Select-Object *, @{
Name = 'RelativeSpeed'
Expression = {
$relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
[math]::Round($relativeSpeed, 2).ToString() + 'x'
}
}
}
Эти тесты были запущены на компьютере с Windows 11 в PowerShell 7.4.2. Выходные данные показывают, что -join
оператор является самым быстрым, а затем классом [StringBuilder]
.
Iterations Test TotalMilliseconds RelativeSpeed
---------- ---- ----------------- -------------
10240 Join operator 14.75 1x
10240 StringBuilder 62.44 4.23x
10240 Addition Assignment += 619.64 42.01x
51200 Join operator 43.15 1x
51200 StringBuilder 304.32 7.05x
51200 Addition Assignment += 14225.13 329.67x
102400 Join operator 85.62 1x
102400 StringBuilder 499.12 5.83x
102400 Addition Assignment += 67640.79 790.01x
Время и относительные скорости могут отличаться в зависимости от оборудования, версии PowerShell и текущей рабочей нагрузки в системе.
Обработка больших файлов
Идиоматическая обработка файла в PowerShell выглядит примерно так:
Get-Content $path | Where-Object Length -GT 10
Это может быть более медленным порядком, чем использование API .NET напрямую. Например, можно использовать класс .NET [StreamReader]
:
try {
$reader = [System.IO.StreamReader]::new($path)
while (-not $reader.EndOfStream) {
$line = $reader.ReadLine()
if ($line.Length -gt 10) {
$line
}
}
}
finally {
if ($reader) {
$reader.Dispose()
}
}
Кроме того, можно использовать ReadLines
метод оболочки[System.IO.File]
StreamReader
, упрощающий процесс чтения:
foreach ($line in [System.IO.File]::ReadLines($path)) {
if ($line.Length -gt 10) {
$line
}
}
Поиск записей по свойству в больших коллекциях
Обычно необходимо использовать общее свойство для идентификации одной записи в разных коллекциях, например с помощью имени для получения идентификатора из одного списка и электронной почты из другого. Итерации по первому списку, чтобы найти соответствующую запись во второй коллекции, медленно. В частности, повторная фильтрация второй коллекции имеет большие затраты.
Учитывая две коллекции, одна с идентификатором и именем, другая с именем и адресом электронной почты:
$Employees = 1..10000 | ForEach-Object {
[PSCustomObject]@{
Id = $_
Name = "Name$_"
}
}
$Accounts = 2500..7500 | ForEach-Object {
[PSCustomObject]@{
Name = "Name$_"
Email = "Name$_@fabrikam.com"
}
}
Обычный способ примирить эти коллекции для возврата списка объектов с идентификатором, именем и свойством электронной почты может выглядеть следующим образом:
$Results = $Employees | ForEach-Object -Process {
$Employee = $_
$Account = $Accounts | Where-Object -FilterScript {
$_.Name -eq $Employee.Name
}
[pscustomobject]@{
Id = $Employee.Id
Name = $Employee.Name
Email = $Account.Email
}
}
Однако эта реализация должна фильтровать все 5000 элементов в $Accounts
коллекции один раз для каждого элемента в $Employee
коллекции. Это может занять несколько минут, даже для поиска с одним значением.
Вместо этого можно сделать хэш-таблицу , которая использует общее свойство Name в качестве ключа и соответствующую учетную запись в качестве значения.
$LookupHash = @{}
foreach ($Account in $Accounts) {
$LookupHash[$Account.Name] = $Account
}
Поиск ключей в хэш-таблице гораздо быстрее, чем фильтрация коллекции по значениям свойств. Вместо проверка каждого элемента в коллекции PowerShell может проверка, если ключ определен и использует его значение.
$Results = $Employees | ForEach-Object -Process {
$Email = $LookupHash[$_.Name].Email
[pscustomobject]@{
Id = $_.Id
Name = $_.Name
Email = $Email
}
}
Это гораздо быстрее. Пока фильтр циклов занимает несколько минут, хэш-поиск занимает менее секунды.
Тщательно используйте узел записи
Команда Write-Host
должна использоваться только в том случае, если необходимо написать форматированный текст в консоль узла, а не писать объекты в конвейер Success .
Write-Host
может быть порядком медленнее, чем [Console]::WriteLine()
для определенных узлов, таких как pwsh.exe
, powershell.exe
или powershell_ise.exe
. Однако не гарантируется, [Console]::WriteLine()
что он работает во всех узлах. Кроме того, выходные данные, записанные с помощью [Console]::WriteLine()
, не записываются в расшифровки, запущенные Start-Transcript
.
Компиляция JIT
PowerShell компилирует код скрипта в байт-код, который интерпретируется. Начиная с PowerShell 3, для кода, который многократно выполняется в цикле, PowerShell может повысить производительность, скомпилируя код в машинный код.
Циклы, у которых менее 300 инструкций, подходят для JIT-компиляции. Компиляция циклов большего размера обходится слишком дорого. Если цикл был выполнен 16 раз, скрипт компилируется JIT-компилятором в фоновом режиме. После завершения JIT-компиляции выполнение передается в компилированный код.
Избегайте повторных вызовов функции
Вызов функции может потребовать значительных ресурсов. Если вы вызываете функцию в длительном жестком цикле, рассмотрите возможность перемещения цикла внутри функции.
Рассмотрим следующие примеры:
$tests = @{
'Simple for-loop' = {
param([int] $RepeatCount, [random] $RanGen)
for ($i = 0; $i -lt $RepeatCount; $i++) {
$null = $RanGen.Next()
}
}
'Wrapped in a function' = {
param([int] $RepeatCount, [random] $RanGen)
function Get-RandomNumberCore {
param ($rng)
$rng.Next()
}
for ($i = 0; $i -lt $RepeatCount; $i++) {
$null = Get-RandomNumberCore -rng $RanGen
}
}
'for-loop in a function' = {
param([int] $RepeatCount, [random] $RanGen)
function Get-RandomNumberAll {
param ($rng, $count)
for ($i = 0; $i -lt $count; $i++) {
$null = $rng.Next()
}
}
Get-RandomNumberAll -rng $RanGen -count $RepeatCount
}
}
5kb, 10kb, 100kb | ForEach-Object {
$rng = [random]::new()
$groupResult = foreach ($test in $tests.GetEnumerator()) {
$ms = Measure-Command { & $test.Value -RepeatCount $_ -RanGen $rng }
[pscustomobject]@{
CollectionSize = $_
Test = $test.Key
TotalMilliseconds = [math]::Round($ms.TotalMilliseconds,2)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
$groupResult = $groupResult | Sort-Object TotalMilliseconds
$groupResult | Select-Object *, @{
Name = 'RelativeSpeed'
Expression = {
$relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
[math]::Round($relativeSpeed, 2).ToString() + 'x'
}
}
}
Базовый пример цикла — это база для повышения производительности. Второй пример упаковывает генератор случайных чисел в функцию, которая вызывается в жестком цикле. В третьем примере цикл перемещается внутри функции. Функция вызывается только один раз, но код по-прежнему создает то же количество случайных чисел. Обратите внимание на разницу во времени выполнения для каждого примера.
CollectionSize Test TotalMilliseconds RelativeSpeed
-------------- ---- ----------------- -------------
5120 for-loop in a function 9.62 1x
5120 Simple for-loop 10.55 1.1x
5120 Wrapped in a function 62.39 6.49x
10240 Simple for-loop 17.79 1x
10240 for-loop in a function 18.48 1.04x
10240 Wrapped in a function 127.39 7.16x
102400 for-loop in a function 179.19 1x
102400 Simple for-loop 181.58 1.01x
102400 Wrapped in a function 1155.57 6.45x
Избегайте упаковки конвейеров командлетов
Большинство командлетов реализованы для конвейера, который является последовательным синтаксисом и процессом. Например:
cmdlet1 | cmdlet2 | cmdlet3
Инициализация нового конвейера может быть дорогой, поэтому следует избегать упаковки конвейера командлета в другой существующий конвейер.
Рассмотрим следующий пример. Файл Input.csv
содержит 2100 строк. Команда Export-Csv
упаковывается в ForEach-Object
конвейер. Export-Csv
Командлет вызывается для каждой ForEach-Object
итерации цикла.
$measure = Measure-Command -Expression {
Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 1 } -Process {
[PSCustomObject]@{
Id = $Id
Name = $_.opened_by
} | Export-Csv .\Output1.csv -Append
}
}
'Wrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Wrapped = 15,968.78 ms
В следующем примере Export-Csv
команда была перемещена за пределы конвейера ForEach-Object
.
В этом случае Export-Csv
вызывается только один раз, но все равно обрабатывает все объекты, передаваемые ForEach-Object
из.
$measure = Measure-Command -Expression {
Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 2 } -Process {
[PSCustomObject]@{
Id = $Id
Name = $_.opened_by
}
} | Export-Csv .\Output2.csv
}
'Unwrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Unwrapped = 42.92 ms
Пример распакованного фрагмента составляет 372 раза быстрее. Кроме того, обратите внимание, что для первой реализации требуется параметр Append, который не требуется для последующей реализации.
Использование OrderedDictionary для динамического создания новых объектов
Существуют ситуации, когда может потребоваться динамически создавать объекты на основе некоторых входных данных, возможно, наиболее часто используемый способ создания нового PSObject и добавления новых свойств с помощью командлетаAdd-Member
. Затраты на производительность небольших коллекций, использующих этот метод, могут быть незначительными, однако это может стать очень заметным для больших коллекций. В этом случае рекомендуется использовать [OrderedDictionary]
и преобразовать его в PSObject с помощью [pscustomobject]
акселератора типов. Дополнительные сведения см. в разделе "Создание упорядоченных словарей" about_Hash_Tables.
Предположим, что у вас есть следующий ответ API, хранящийся в переменной $json
.
{
"tables": [
{
"name": "PrimaryResult",
"columns": [
{ "name": "Type", "type": "string" },
{ "name": "TenantId", "type": "string" },
{ "name": "count_", "type": "long" }
],
"rows": [
[ "Usage", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
[ "Usage", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
[ "BillingFact", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
[ "BillingFact", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
[ "Operation", "63613592-b6f7-4c3d-a390-22ba13102111", "7" ],
[ "Operation", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "5" ]
]
}
]
}
Предположим, что вы хотите экспортировать эти данные в CSV-файл. Сначала необходимо создать новые объекты и добавить свойства и значения с помощью командлета Add-Member
.
$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
$obj = [psobject]::new()
$index = 0
foreach ($column in $columns) {
$obj | Add-Member -MemberType NoteProperty -Name $column.name -Value $row[$index++]
}
$obj
}
OrderedDictionary
С помощью кода можно преобразовать в:
$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
$obj = [ordered]@{}
$index = 0
foreach ($column in $columns) {
$obj[$column.name] = $row[$index++]
}
[pscustomobject] $obj
}
В обоих случаях выходные $result
данные будут одинаковыми:
Type TenantId count_
---- -------- ------
Usage 63613592-b6f7-4c3d-a390-22ba13102111 1
Usage d436f322-a9f4-4aad-9a7d-271fbf66001c 1
BillingFact 63613592-b6f7-4c3d-a390-22ba13102111 1
BillingFact d436f322-a9f4-4aad-9a7d-271fbf66001c 1
Operation 63613592-b6f7-4c3d-a390-22ba13102111 7
Operation d436f322-a9f4-4aad-9a7d-271fbf66001c 5
Последний подход становится экспоненциально более эффективным по мере увеличения числа объектов и свойств элементов.
Ниже приведено сравнение производительности трех методов создания объектов с 5 свойствами:
$tests = @{
'[ordered] into [pscustomobject] cast' = {
param([int] $iterations, [string[]] $props)
foreach ($i in 1..$iterations) {
$obj = [ordered]@{}
foreach ($prop in $props) {
$obj[$prop] = $i
}
[pscustomobject] $obj
}
}
'Add-Member' = {
param([int] $iterations, [string[]] $props)
foreach ($i in 1..$iterations) {
$obj = [psobject]::new()
foreach ($prop in $props) {
$obj | Add-Member -MemberType NoteProperty -Name $prop -Value $i
}
$obj
}
}
'PSObject.Properties.Add' = {
param([int] $iterations, [string[]] $props)
# this is how, behind the scenes, `Add-Member` attaches
# new properties to our PSObject.
# Worth having it here for performance comparison
foreach ($i in 1..$iterations) {
$obj = [psobject]::new()
foreach ($prop in $props) {
$obj.PSObject.Properties.Add(
[psnoteproperty]::new($prop, $i))
}
$obj
}
}
}
$properties = 'Prop1', 'Prop2', 'Prop3', 'Prop4', 'Prop5'
1kb, 10kb, 100kb | ForEach-Object {
$groupResult = foreach ($test in $tests.GetEnumerator()) {
$ms = Measure-Command { & $test.Value -iterations $_ -props $properties }
[pscustomobject]@{
Iterations = $_
Test = $test.Key
TotalMilliseconds = [math]::Round($ms.TotalMilliseconds, 2)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
$groupResult = $groupResult | Sort-Object TotalMilliseconds
$groupResult | Select-Object *, @{
Name = 'RelativeSpeed'
Expression = {
$relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
[math]::Round($relativeSpeed, 2).ToString() + 'x'
}
}
}
И это результаты:
Iterations Test TotalMilliseconds RelativeSpeed
---------- ---- ----------------- -------------
1024 [ordered] into [pscustomobject] cast 22.00 1x
1024 PSObject.Properties.Add 153.17 6.96x
1024 Add-Member 261.96 11.91x
10240 [ordered] into [pscustomobject] cast 65.24 1x
10240 PSObject.Properties.Add 1293.07 19.82x
10240 Add-Member 2203.03 33.77x
102400 [ordered] into [pscustomobject] cast 639.83 1x
102400 PSObject.Properties.Add 13914.67 21.75x
102400 Add-Member 23496.08 36.72x
Дополнительные ссылки
PowerShell