Consideraciones de rendimiento de scripting de PowerShell

Los scripts de PowerShell que aprovechan .NET directamente y evitan la canalización tienden a ser más rápidos que PowerShell idiomático. PowerShell idiomático usa cmdlets y funciones de PowerShell, a menudo aprovechando la canalización y recurriendo a .NET solo cuando sea necesario.

Nota

Muchas de las técnicas que se describen aquí no son de PowerShell idiomático y pueden reducir la legibilidad de un script de PowerShell. Se recomienda a los creadores de scripts que usen PowerShell idiomático a menos que el rendimiento indique lo contrario.

Supresión de la salida

Hay muchas maneras de evitar escribir objetos en la canalización.

  • Asignación a $null
  • Conversión a [void]
  • Redireccionamiento de archivos a $null
  • Canalización a Out-Null

Las velocidades de asignación a $null, la conversión a [void]y el redireccionamiento de archivos a $null son casi idénticas. Sin embargo, llamar a Out-Null en un bucle grande puede ser un proceso considerablemente más lento, especialmente en PowerShell 5.1.x.

$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'
        }
    }
}

Estas pruebas se ejecutaron en una máquina Windows 11 en PowerShell 7.3.4. A continuación se muestran resultados:

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

Los tiempos y velocidades relativas pueden variar en función del hardware, la versión de PowerShell y la carga de trabajo actual del sistema.

Adición de matrices

La generación de una lista de elementos a menudo se realiza mediante una matriz con el operador de suma:

$results = @()
$results += Do-Something
$results += Do-SomethingElse
$results

La adición de matrices es ineficaz porque las matrices tienen un tamaño fijo. Cada adición a la matriz crea una matriz lo suficientemente grande como para contener todos los elementos de los operandos izquierdo y derecho. Los elementos de ambos operandos se copian en la nueva matriz. En el caso de colecciones pequeñas, es posible que esta sobrecarga no sea importante. En el caso de las colecciones de gran tamaño, el rendimiento puede verse afectado.

Hay un par de alternativas. Si no necesita realmente una matriz, podría usar mejor una lista genérica con tipo (List<T>):

$results = [System.Collections.Generic.List[object]]::new()
$results.AddRange((Do-Something))
$results.AddRange((Do-SomethingElse))
$results

El impacto que el uso de la adición de matrices tiene en el rendimiento crece exponencialmente con el tamaño de la colección y el número de adiciones. Este código compara explícitamente la asignación de valores a una matriz con la adición de matrices y el uso del método Add() en List<T>. La asignación explícita se define como la línea de base para el rendimiento.

$tests = @{
    'PowerShell Explicit Assignment' = {
        param($count)

        $result = foreach($i in 1..$count) {
            $i
        }
    }
    '.Add(..) 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'
        }
    }
}

Estas pruebas se ejecutaron en una máquina Windows 11 en PowerShell 7.3.4.

CollectionSize Test                           TotalMilliseconds RelativeSpeed
-------------- ----                           ----------------- -------------
          5120 PowerShell Explicit Assignment             26.65 1x
          5120 .Add(..) to List<T>                       110.98 4.16x
          5120 += Operator to Array                      402.91 15.12x
         10240 PowerShell Explicit Assignment              0.49 1x
         10240 .Add(..) to List<T>                       137.67 280.96x
         10240 += Operator to Array                     1678.13 3424.76x
        102400 PowerShell Explicit Assignment             11.18 1x
        102400 .Add(..) to List<T>                      1384.03 123.8x
        102400 += Operator to Array                   201991.06 18067.18x

Cuando se trabaja con colecciones grandes, la adición de matrices es considerablemente más lenta que realizar adiciones a List<T>.

Al usar List<T>, debe crear la lista con un tipo específico, como String o Int. Al agregar objetos de un tipo diferente a la lista, se convierten al tipo especificado. Si no se pueden convertir al tipo especificado, el método genera una excepción.

$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

Cuando necesite que la lista sea una colección de distintos tipos de objetos, créela con Object como tipo de lista. Puede enumerar la colección para inspeccionar los tipos de los objetos que contiene.

$objectList = [System.Collections.Generic.List[object]]::new()
$objectList.Add(1)
$objectList.Add('2')
$objectList.Add(3.0)
$objectList.GetEnumerator().ForEach({ "$_ is $($_.GetType())" })
1 is int
2 is string
3 is double

Si necesita una matriz, puede llamar al método ToArray() en la lista o permitir que PowerShell cree la matriz automáticamente:

$results = @(
    Do-Something
    Do-SomethingElse
)

En este ejemplo, PowerShell crea un objeto ArrayList para que contenga los resultados escritos en la canalización dentro de la expresión de matriz. Justo antes de la asignación a $results, PowerShell convierte ArrayList en un elemento object[].

Adición de cadenas

Las cadenas son inmutables. Cada adición a la cadena crea realmente una cadena lo suficientemente grande como para contener todos los elementos de los operandos izquierdo y derecho y, después, copia los elementos de ambos operandos en la nueva cadena. En el caso de cadenas pequeñas, es posible que esta sobrecarga no sea importante. En el caso de las cadenas de gran tamaño, esto puede afectar al rendimiento y al consumo de memoria.

Hay al menos dos alternativas:

  • El operador -join concatena cadenas
  • La clase StringBuilder de .NET proporciona una cadena mutable.

En el ejemplo siguiente se compara el rendimiento de estos tres métodos de creación de una cadena.

$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'
        }
    }
}

Estas pruebas se ejecutaron en una máquina Windows 10 en PowerShell 7.3.4. La salida muestra que el operador -join es el más rápido, seguido de la clase StringBuilder.

Iterations Test                   TotalMilliseconds RelativeSpeed
---------- ----                   ----------------- -------------
     10240 Join operator                       7.08 1x
     10240 StringBuilder                      54.10 7.64x
     10240 Addition Assignment +=            724.16 102.28x
     51200 Join operator                      41.76 1x
     51200 StringBuilder                     318.06 7.62x
     51200 Addition Assignment +=          17693.06 423.68x
    102400 Join operator                     106.98 1x
    102400 StringBuilder                     543.84 5.08x
    102400 Addition Assignment +=          90693.13 847.76x

Los tiempos y velocidades relativas pueden variar en función del hardware, la versión de PowerShell y la carga de trabajo actual del sistema.

Procesamiento de archivos grandes

La manera idiomática de procesar un archivo en PowerShell podría similar a lo siguiente:

Get-Content $path | Where-Object { $_.Length -gt 10 }

Esto puede ser un orden de magnitud más lento que usar de forma directa las API de .NET:

try
{
    $stream = [System.IO.StreamReader]::new($path)
    while ($line = $stream.ReadLine())
    {
        if ($line.Length -gt 10)
        {
            $line
        }
    }
}
finally
{
    $stream.Dispose()
}

Búsqueda de entradas por propiedad en colecciones grandes

Es habitual usar una propiedad compartida para identificar el mismo registro en colecciones diferentes, como usar un nombre para recuperar un identificador de una lista y un correo electrónico de otro. El proceso de iterar por la primera lista para buscar el registro coincidente en la segunda colección es lento. En concreto, el filtrado repetido de la segunda colección tiene una gran sobrecarga.

Dadas dos colecciones, una con un elemento ID y Name, y la otra con un elemento Name y Email:

$Employees = 1..10000 | ForEach-Object {
    [PSCustomObject]@{
        Id   = $_
        Name = "Name$_"
    }
}

$Accounts = 2500..7500 | ForEach-Object {
    [PSCustomObject]@{
        Name = "Name$_"
        Email = "Name$_@fabrikam.com"
    }
}

La manera habitual de conciliar estas colecciones para devolver una lista de objetos con las propiedades ID, Name y Email podría tener este aspecto:

$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
    }
}

Pero esa implementación tiene que filtrar los 5000 elementos de la colección $Accounts una vez por cada elemento de la colección $Employee. Esto puede tardar minutos, incluso para esta búsqueda de un solo valor.

En su lugar, puede crear una tabla hash que use la propiedad Name compartida como clave y la cuenta coincidente como valor.

$LookupHash = @{}
foreach ($Account in $Accounts) {
    $LookupHash[$Account.Name] = $Account
}

Buscar claves en una tabla hash es mucho más rápido que filtrar una colección por valores de propiedad. En lugar de comprobar cada elemento de la colección, PowerShell puede comprobar si la clave está definida y usar su valor.

$Results = $Employees | ForEach-Object -Process {
    $Email = $LookupHash[$_.Name].Email
    [pscustomobject]@{
        Id    = $_.Id
        Name  = $_.Name
        Email = $Email
    }
}

Esto es mucho más rápido. Mientras el filtro de bucle tardó minutos en completarse, la búsqueda de hash tardó menos de un segundo.

Evitar Write-Host

Por lo general, se considera un procedimiento incorrecto escribir la salida directamente en la consola, pero cuando tiene sentido, muchos scripts usan Write-Host.

Si tiene que escribir muchos mensajes en la consola, Write-Host puede ser un orden de magnitud más lento que [Console]::WriteLine() para hosts específicos como pwsh.exe, powershell.exe o powershell_ise.exe. Sin embargo, no se garantiza que [Console]::WriteLine() funcione en todos los hosts. Además, la salida escrita mediante [Console]::WriteLine() no se escribe en transcripciones iniciadas por Start-Transcript.

En lugar de usar Write-Host, considere la posibilidad de usar Write-Output.

compilación Just-In-Time

PowerShell compila el código de script en el código de bytes que se interpreta. A partir de PowerShell 3, para el código que se ejecuta repetidamente en un bucle, PowerShell puede mejorar el rendimiento mediante la compilación Just-In-Time (JIT) del código en código nativo.

Los bucles que tienen menos de 300 instrucciones son aptos para la compilación JIT. Los bucles más grandes son demasiado costosos de compilar. Cuando el bucle se ha ejecutado 16 veces, el script se compila con JIT en segundo plano. Cuando se completa la compilación con JIT, la ejecución se transfiere al código compilado.

Evitar llamadas repetidas a una función

Llamar a una función puede ser una operación costosa. Si llama a una función en un bucle estrecho de larga duración, considere la posibilidad de mover el bucle dentro de la función.

Considere los siguientes ejemplos:

$ranGen = New-Object System.Random
$RepeatCount = 10000

'Basic for-loop = {0}ms' -f (Measure-Command -Expression {
    for ($i = 0; $i -lt $RepeatCount; $i++) {
        $Null = $ranGen.Next()
    }
}).TotalMilliseconds

'Wrapped in a function = {0}ms' -f (Measure-Command -Expression {
    function Get-RandNum_Core {
        param ($ranGen)
        $ranGen.Next()
    }

    for ($i = 0; $i -lt $RepeatCount; $i++) {
        $Null = Get-RandNum_Core $ranGen
    }
}).TotalMilliseconds

'For-loop in a function = {0}ms' -f (Measure-Command -Expression {
    function Get-RandNum_All {
        param ($ranGen)
        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $Null = $ranGen.Next()
        }
    }

    Get-RandNum_All $ranGen
}).TotalMilliseconds

El ejemplo Basic for-loop es la base de referencia para el rendimiento. El segundo ejemplo encapsula el generador de números aleatorios en una función a la que se llama en un bucle ajustado. El tercer ejemplo mueve el bucle dentro de la función. Solo se llama a la función una vez, pero el código sigue generando 10 000 números aleatorios. Observe la diferencia en los tiempos de ejecución de cada ejemplo.

Basic for-loop = 47.8668ms
Wrapped in a function = 820.1396ms
For-loop in a function = 23.3193ms

Evitar el encapsulamiento de canalizaciones de cmdlet

La mayoría de los cmdlets se implementan para la canalización, que es un proceso y una sintaxis de carácter secuencial. Por ejemplo:

cmdlet1 | cmdlet2 | cmdlet3

La inicialización de una nueva canalización puede ser costosa, por lo que debe evitar encapsular una canalización de cmdlet en otra canalización existente.

Considere el ejemplo siguiente. El archivo Input.csv contiene 2100 líneas. El comando Export-Csv se encapsula dentro de la canalización ForEach-Object. El cmdlet Export-Csv se invoca para cada iteración del bucle ForEach-Object.

'Wrapped = {0:N2} ms' -f (Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 1 } -Process {
        [PSCustomObject]@{
            Id = $Id
            Name = $_.opened_by
        } | Export-Csv .\Output1.csv -Append
    }
}).TotalMilliseconds

Wrapped = 15,968.78 ms

En el ejemplo siguiente, el comando Export-Csv se movió fuera de la canalización ForEach-Object. En este caso, Export-Csv se invoca solo una vez, pero sigue procesando todos los objetos pasados fuera de ForEach-Object.

'Unwrapped = {0:N2} ms' -f (Measure-Command -Expression {
      Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 2 } -Process {
          [PSCustomObject]@{
              Id = $Id
              Name = $_.opened_by
          }
      } | Export-Csv .\Output2.csv
  }).TotalMilliseconds

Unwrapped = 42.92 ms

El ejemplo desencapsulado es 372 veces más rápido. Además, tenga en cuenta que la primera implementación requiere el parámetro Append , que no es necesario para la implementación posterior.