PS Performance

PowerShell ist das dafür berühmt, mit einem "Einzeiler" große Aufgaben zu lösen. Aber mit PowerShell können auch ganz umfangreiche Programme entwickelt werden. Wenn Umgebungen aber größer werden, dann ist Performance mehr und mehr ein Thema. Es gibt je nach eingesetztem Befehl durchaus mächtige Unterschiede.

Die Messungen sind eine Momentaufnahme auf einem Notebook von 2009 ohne SSD mit einem Core i5 der ersten Generation und daher nicht mit aktuellen Systemen vergleichbar.

Measure-Command

Erster Schritt ist natürlich das "Messen" der Laufzeit. Dazu kann das Commandlet "Measure-Command" dienen, welches die Laufzeit eines Anweisungsblocks ermittelt. Allerdings führt das Commandlet den Block so erst mal nur "einmal" aus und zudem werden die Ausgaben des Codes an die Pipeline unterdrückt, was wieder Verfälschungen mit sich bringen kann. für einfache Aufgaben ist es aber ausreichbar.

PS C:\> Measure-Command {Get-ChildItem}

Days  : 0
Hours : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 4
Ticks : 41882
TotalDays         : 4,8474537037037E-08
TotalHours        : 1,16338888888889E-06
TotalMinutes      : 6,98033333333333E-05
TotalSeconds      : 0,0041882
TotalMilliseconds : 4,1882

Der Befehlsblock in geschweiften Klammern kann auch durchaus mehrere Codezeilen enthalten. Allerdings ist der Code gekapselt, d.h. Ausgaben, die hier bei "Get-Childitem" natürlich vorhanden sind, gehen unter. Variable-Zuweisungen in dem Block sind aber auch außerhalb des Blocks verwendbar. Das folgende Beispiel geht also schon:

$laufzeit = measure-command { $recipientlist = get-recipient}
write-host "Laufzeit $laufzeit"
forach ($recipient in $recipientlist) {
    write-host "Recipient:"$recipient.identity
}

Wer damit misst, sollte die Befehle immer mehrfach aufrufen, denn die Laufzeit schwankt. Oft dauert gerade der erste Start länger und Folgeaufrufe sind zügiger. Sobald Abhängigkeiten mit anderen Diensten, z.B. LDAP-Abfragen, Webservices, im Spiel sind, müssen die Ergebnisse relativiert betrachtet werden.

Gerade auch kurze Befehle sollten über eine Schleife einfach mehrfach gestartet werden, z.B.

measure-command {
    1..100000 | %{
        get-date "01.01.1900"
    }
}

Wer mehr oder mehrere Punkte im Code müssen möchte, kann dies mit GET-DATE tun, um die aktuelle Zeit sehr genau zu erhalten und von vorher ermittelten Werten abzuziehen.

Measure-Command hat aus meiner Sicht einen großen Nachteil, dass es die Ausgaben des im Block ausgeführten Befehls nicht weiterreichen kann, sondern nur die Laufzeit zurück meldet. Die im Block ermittelten Daten o.ä. sind damit nicht erreichbar. Hier muss man dann doch wieder vorher und nachher mit Get-Datei die Differenz messen.

Beispiele

Um die Auswirkungen anschaulich zu machen, habe ich drei Aufgaben heran gezogen, die das Potential aufzeigen sollen:

  • Exchange Empfänger ermitteln
    Das ist sicher eine häufige Aufgabenstellung eine Liste von Empfängern nach Kriterien zu erstellen
  • PSCustomObject als Ausgabe vorbereiten
    Oft erforderlich, wenn Sie als Rückgabe eine Liste mit Objekten erzeugen wollen, die wiederum Properties nutzt.
  • Variable Zuweisen
    Am Ende noch ein ganz einfaches Beispiel für die Initialisierung von Variablen.
  • Das "Count" Property
    Warum die einfache Anzeige der gefundenen Objekte Zeit kostet
  • Debug-Ausgaben mit Write-Host
    Wichtige Informationen bremsen extrem

Schauen wir uns die Ergebnisse im einzelnen an:

Beispiel 1: Exchange Empfängerabfrage / AD Abfrage

Ich denke für Exchange Administratoren ist die Abfrage der Exchange Empfänger ein häufiger Prozess, der auf mehrere Wege durchgeführt werden kann.

  • Get-Recipient
    Dieses Exchange Commandlet nutzt die Exchange Remote PowerShell um Exchange Empfänger zu ermitteln. Es ist sehr leistungsfähig und liefert nett bereitgestellte Objekte als Rückgabe.
  • Get-ADObject
    Etwas einfacher ist Get-ADObject aus dem Windows 2008 PowerShell Modul "Active Directory". Es macht aber auch keine direkten LDAP-Abfragen, sondern nutzt die ADWS-Dienste eines Windows 2008 DCs oder höher.
  • [ADSI]
    Der gute alte ADSI-Zugriff von PowerShell geht direkt per LDAP an die Server. Das Ergebnis sind "Rohdaten" ohne weitere Unterstützung, durch den LDAP-Zugriff ist dieser Weg nicht nutzbar, wenn Office 365 im Spiel ist und eine Verarbeitung über die Pipeline ist uneffektiv, da ein "Findall()" immer erst alle Daten sucht ehe es weiter geht. Dafür funktioniert dies auch ohne Exchange Module auf dem PC
  • LDIFDE/CSVDE
    Außer Konkurrenz laufen diese beide Programme, die ich in DirSync-Projekten oft verwende. Anscheinend haben die Entwickler dieser Programme ziemlich viel optimiert, denn ein Export per CSVDE ist fast immer noch um einiges schneller als alle der drei vorherigen Optionen. Ich denke das sind dann die Einschränkungen eines interpretierten Skripts

Ich habe alle drei Optionen einmal gegeneinander gestellt, um die Performance in einer etwas größeren Umgebung zu demonstrieren. Das Beispiel fragt ca. 30.000 Einträge ab. Um die Ausgabe der Objekte zu ersparen, wird einfach nur die Anzahl der Objekte zurück gegeben. Dennoch sind die Zeiten natürlich nur eine Momentaufnahme meiner Umgebung. Aber es ist offensichtlich, das "höherwertigen" Commandlets und speziell die Exchange remote PowerShell deutlich langsamer. Insofern muss man speziell für aufwändige Auswertungen mit vielen Empfängern überlegen, ob bei der Suche schon gefiltert oder ein schnellerer Weg genutzt wird.

Die Dauer gibt eine Momentaufnahme meiner VMs wieder und ist nicht repräsentativ. Es gibt Varianten bei denen die Initialisierung länger dauert aber beim Zugriff dann schneller sind während andere sofort aber dann nicht so schnell Daten liefern. Auch kann über die PageSize ein Tuning erfolgen.

Option und Code Laufzeit

Exchange PowerShell-Befehl

measure-command { `
    write-host "total:"(get-recipient -resultsize unlimited).count
}

349 Sek

Abfrage per Windows 2008 Get-ADObject, Erfordert aber einen Windows 2008 DC mit ADWS

measure-command { `
    write-host "total:"(get-adobject `
   -ldapfilter "(mailnickname=*)" `
   -ResultSetSize $null `
   -server "server:3268").count 
}

733 Sek

Klassische Abfrage per ADSI.

measure-command {
   $root = [system.directoryservices.activedirectory.forest]::getcurrentforest().rootdomain.name
   $adsearch= New-Object System.DirectoryServices.DirectorySearcher([ADSI]"GC://$root")
   $adsearch.Propertiestoload.add("distinguishedname") | out-null
   $adsearch.Propertiestoload.add("mail") | out-null
   $adsearch.Propertiestoload.add("ProxyAddresses") | out-null
   $adsearch.pagesize = 100
   $adsearch.filter = "(mailnickname=*)"
   $adsearch.filter = "(objectclass=*)"
   $adresult =$adsearch.findall()
   write-host $adresult.count
}

# Ausgabe dann per FOR-Schleife
ForEach ($strResult In $adresult) {
    $strDN = $strResult.Properties.Item("distinguishedName")
    Write-Host "DN:"$strDN
}

30 Sek

Klassische Abfrage per ADO (COM-Objekt)

measure-command {
    $adoConnection = New-Object -comObject "ADODB.Connection"
    $adoConnection.Open("Provider=ADsDSOObject;")
    $adoCommand = New-Object -comObject "ADODB.Command"
    $adoCommand.ActiveConnection = $adoConnection
    $adoCommand.Properties.Item("Page Size") = 100
    $adoCommand.Properties.Item("Timeout") = 30
    $adoCommand.Properties.Item("Cache Results") = $False

    $root = [system.directoryservices.activedirectory.forest]::getcurrentforest().rootdomain.name
    $strbase="GC://$root"
    $strFilter = "(mail=*)"
    $strAttributes = "distinguishedName,mail,proxyaddresses"
    $strScope = "subtree"
    $strQuery = "$strBase;$strFilter;$strAttributes;$strScope"

    $adoCommand.CommandText = $strQuery
    $adoRecordset = $adoCommand.Execute()
    write-host "Items Found:"$adoRecordset.recordcount
}

# Ausgabe hier dann mit FindFirst ud FindNext
Do {
    $adoRecordset.Fields.Item("distinguishedName") | Select-Object Value
    $adoRecordset.MoveNext()
} until ($adoRecordset.EOF)
$adoRecordset.Close()
$adoConnection.Close()

26 Sek

Außer Konkurrenz habe ich die Liste einfach mal mit CSVDE in eine Datei exportiert.

measure-command {
    CSVDE -f Benutzer.csv -r "(mailnickname=*)" -l "mail,proxyaddresses" -s msxfaq.net -t 3268
}

Obwohl die Daten also noch in eine Datei geschrieben werden, ist CSVDE sehr schnell unterwegs.

7 Sek

Allerdings muss man hier natürlich zugeben, dass die Exchange PowerShell für den Get-Recipient einige Fehler mehr aus dem AD auslesen muss, um das Ausgabeobjekte zu befüllen. Beim ADSI-Query kann ich genau angeben, welche Felder ich benötige und damit natürlich Zeit sparen.

Beispiel 2: Variablen zuweisen

Diese Aufgabe ist eine kleine Vorarbeit für Beispiel 3. Stellen Sie sic vor ich initialisiere eine Datenstruktur und habe mehrere Felder um ein Datum zu speichern. Um später zu erkennen, welche Felder gar nicht beschrieben wurden, wird ein Startdatum weit in der Vergangenheit gesetzt. So funktionieren "Vergleiche" weiterhin. Stellen sie sich nun vor, Sie müssen eine umfangreiche Liste mit vielen Objekten aufbauen und alle DatUmfelder so setzen. Dann ist das schon ein Zeitfaktor. Also starten wir mit der Generierung eines Datums, welches drei Variablen füllt:

Option und Code Laufzeit

Klassischer Weg über Get-Date Commandlet

measure-command {
   1..10000 | %{
      $a=get-date "01.01.1900";
      $b=get-date "01.01.1900";
      $c=get-date "01.01.1900"
   }
}

5,7 Sek

Alternativer Weg mit Konvertierung

measure-command {
   1..10000 | %{
      $a=[datetime]"01.01.1900";
      $b=[datetime]"01.01.1900";
      $c=[datetime]"01.01.1900"
   }
}

1,8 Sek

Es geht sogar noch schneller. Da ein DATETIME kein komplexes Objekt, sondern ein Basistyp ist, ist eine Zuweisung keine Referenz, sondern eine Kopie (Clone)

measure-command {
   $t=get-date "01.01.1900";
   1..10000 | %{
      $a=$t;
      $b=$t;
      $c=$t
   }
}

1,2 Sek

Es kann also auch in PowerShell sinnvoll sein, eine Start-Wert, der häufiger verwendet wird, schon vorzubereiten.

Beispiel 3: Objekte für Listenaufbau belegen

Viele meiner Skripte erstellen Berichte und Ausgaben, die im Code schon als CSV-Datei exportiert werden sollen. Diese Ergebnisse eines Datensatzes speichert man natürlich nicht in vielen einzelnen Variablen, sondern fasst diese als "PSCustomObjekt" zusammen. Bei der Initialisierung muss man natürlich darauf achten, dass jedes Ergebnis wieder mit einem neu initialisierten Objekt anfängt. Ein "$obj1=$obj2" ist bei PSCustomObjekts schlecht, da dabei nicht das Objekt kopiert wird, sondern nur eine Referenz erstellt wird. ändert man dann $obj1, dann ändert sich auch $obj2. Folgendes Beispiel verdeutlicht das.

Das wirkt sogar noch nach, wenn das Objekt genutzt wird, um es in ein Dictionary oder eine Arrayliste zu addieren. Also muss ich das Objekt neu aufbauen, oder einen "Clone" erstellen. Leider kann man PSCustomObjects nicht einfach Clonen, so dass ich es einfach neu aufbaue. Und dazu gibt es drei Optionen.

Option und Code Laufzeit

Option 1: Klassischer Weg ein PSCustomObjekt zu instanziieren und mit Werten zu beladen

measure-command {
   1..10000 | %{	
    $newpso =new-object psObject
    $newpso | Add-Member -MemberType noteproperty -Name "mailaddress" -Value "notset"
    $newpso | Add-Member -MemberType noteproperty -Name "PrimarySmtpAddress" -Value "notset"
    $newpso | Add-Member -MemberType noteproperty -Name "TotalItem" -Value 0
    $newpso | Add-Member -MemberType noteproperty -Name "date1" -Value (get-date 01.01.1900)
    $newpso | Add-Member -MemberType noteproperty -Name "date2" -Value (get-date 01.01.1900)
    $newpso | Add-Member -MemberType noteproperty -Name "date3" -Value (get-date 01.01.1900)
   }
}

24 Sek

Option 2: Man kann aber auch bei der Instanzierung des Objekts schon Properties mitgeben und füllen, das schon deutlich schneller geht.

Measure-Command {
   1..10000 | %{
      $newpso = New-Object PSObject -Property @{
         mailaddress = "NotSet"
         PrimarySmtpAddress = "NotSet"
         totalItem = 0
         date1 = (Get-Date 01.01.1900)
         date2 = (Get-Date 01.01.1900)
         date3 = (Get-Date 01.01.1900)
      }
   }
}

7 Sek

Option 3: Alternativer Weg ein PSCustomObjekt zu instanziieren und mit Werten zu beladen

measure-command {
   1..10000 | %{
      $newpso = ("" | select mailaddress,PrimarySmtpAddress,TotalItem,Date1,date2,date3)
      $newpso.mailaddress="NotSet"
      $newpso.PrimarySmtpAddress="NotSet"
      $newpso.totalItem=0
      $newpso.date1=(get-date 01.01.1900)
      $newpso.date2=(get-date 01.01.1900)
      $newpso.date3=(get-date 01.01.1900)
   }
}

8 Sek

Option 4: Zuletzt noch ein Beispiel eines Lesers mit C# als Basisklasse. (Tipp kam von Claudio Spizzi)

Add-Type @"
    namespace Test {
        public class MyObject {
            public string mailaddress;
            public string primärySmtpAddress;
            public int totalItem;
            public System.DateTime date1;
            public System.DateTime date2;
            public System.DateTime date3;
        }
    }
"@

(Measure-Command {
   1..10000 | %{
    $newpso5 = New-Object Test.MyObject
    $newpso5.mailaddress="NotSet"
    $newpso5.PrimarySmtpAddress="NotSet"
    $newpso5.totalItem=0
    $newpso5.date1=(Get-Date 01.01.1900)
    $newpso5.date2=(Get-Date 01.01.1900)
    $newpso5.date3=(Get-Date 01.01.1900)
   }
}).TotalSeconds

Diese Version ist noch mal ein klein wenig schneller als die Option 3.

7 Sek

Option 5: Alternativ könnte man natürlich ein Masterobjekt ablegen, welche einfach kopiert wird.

$newpso = ("" | select mailaddress,PrimarySmtpAddress,TotalItem,Date1,date2,date3)
$newpso.mailaddress="NotSet"
$newpso.PrimarySmtpAddress="NotSet"
$newpso.totalItem=0
$newpso.date1=(get-date 01.01.1900)
$newpso.date2=(get-date 01.01.1900)
$newpso.date3=(get-date 01.01.1900)
measure-command {
1..10000 | %{
  $newpso2 = ($newpso |select *)
} }

Dieser Wert ist natürlich optimistisch. Wenn Sie die Default-Werte ändern wollten, dann muss nach dem Copy natürlich noch Zeit für die Zuweisung eingeplant werden.

3 Sek

Noch schneller ist natürlich "Native Copy"

$newpso = ("" | select mailaddress,PrimarySmtpAddress,TotalItem,Date1,date2,date3)
$newpso.mailaddress="NotSet"
$newpso.PrimarySmtpAddress="NotSet"
$newpso.totalItem=0
$newpso.date1=(get-date 01.01.1900)
$newpso.date2=(get-date 01.01.1900)
$newpso.date3=(get-date 01.01.1900)
measure-command {
   1..10000 | %{
      $newpso2 = $newpso.psobject.copy() }
}

1,5 Sek

Es ist gut zu sehen, dass der Trick mit dem "select" deutlich schneller ist als der klassische Ansatz oder der Versuch ein Objekt zu kopieren. Beim klassischen Ansatz kostet es einfach zu viel Zeit immer wieder "Add-Member" aufzurufen. Der direkte Weg über die Copy-Methode von PSOBJECT ist aber am schnellsten.

Beispiel 4: Das "Count-Property"

Es ist so schön einfach, wenn ein Skript per ADO eine Datenbankabfrage oder per ADSI eine Suche im Active Directory durchgeführt hat und voller Stoltz ist das erste, was ein Programmierer als nächste Zeile einbaut ein

write-host "Gefundene Elemente:" $resultset.count

Unschön dabei ist, dass sowohl ADO als auch ADSI erst dann einen "Count" zurück geben können, wenn Sie die ganzen Daten einmal ausgelesen haben. Sie verlieren also kostbare Zeit. Wenn die Anzahl der Ergebnisse im Skript nicht für etwas anderes erforderlich ist, z.B. eine Fortschrittsanzeige auszugeben oder die Gültigkeit der Rückgabe zu prüfen, dann kann man das einfach sein lassen und direkt zur Verarbeitung der Ergebnisse übergehen.

Es spricht ja nichts dagegen bei der Verarbeitung einige nützlichere Counter mit hochzuzählen und auszugeben.

Beispiel 5: "Write-host" und Start-Transcript

Zugegeben ich schreibe viele Skripte und natürlich möchte ich wissen, was die Skripte genau tun. Ich kann ja nicht jedes Skript permanent im Debugger laufen lassen. Also machte ich, was auch viele andere Administratoren, Consultants und Entwickler machen: Sie schreiben "Debugausgaben" in das Script. Und nichts ist einfacher als ein "Write-Host" und wenn ich das auch mit protokollieren will, dann addiere ich noch ein Start-Transcript am Anfang.

Aber genau die Ausgaben auf dem Bildschirm machen die Skripte langsam, da die Ausgabe auf dem Bildschirm gescrollt werden muss etc. Nicht umsonst habe ich mir eine eigene Routine gebaut, die "Write-Host" ersetzt und die Ausgaben direkt in eine Datei umlenkt und einige Mehrwertfunktionen bietet. (Siehe MSXFAQLogger).

Kleiner Tipp: Wenn Sie viele Daten verarbeiten und einen Statusupdate erhalten wollen ohne dass der Bildschirm allzu viel scrollt, dann zeigen Sie doch den Fortschritt mit einer nummer auf, die nur all 100 oder 1000 Änderungen aktualisiert wird.

if (!($count%1000)) {write-host "`rUsers:"$count -nonewline}

Write-Host ist also nicht das schnellste Modul, aber geht es schneller ?. Ja, z.B. über [console]

Option und Code Laufzeit

Option 1: Ausgabe von 1000 "Textzeilen" mit write-host.

measure-command {
   1..100000 | %{
      write-host "hallo"
   }
}

Ich habe nicht ermittelt, wie viel Zeit das "Scrollen" der Console benötigt.

1,1 Sec/1000
101 Sek/100000

Option 2: Ausgabe von 1000 "Textzeilen" mit [console]

measure-command {
   1..100000 | %{
      [console]::Write("hallo`n")
   }
}

Ich habe nicht ermittelt, wie viel Zeit das "Scrollen" der Console benötigt.

0,4 Sec/1000
36 Sek/100000

Option 3: Ausgabe von 1000 "Textzeilen" mit "out-host". Natürlich nur nutzbar, wenn Sie STDOUT als Pipelineausgabe nicht anderweitig benötigen. Zudem schummelt das Script bei Measure-command, da keine Ausgabe erzeugt wird

$start=get-date
   1..100000 | %{
      "hallo"
   }
write-host "dauer" ((get-date)-$start).totalseconds

2 Sek/1000
66 Sek/100000

Spart man sich aber die Ausgabe auf den Bildschirm und lässt die Daten in eine Datei schreiben, dann wird es noch langsamer, da das Anhängen an die Datei scheinbar viel Zeit braucht.

measure-command {
   1..1000 | %{
      if (!($_ % 100)) {write-host $_}
      "hallo" | out-file .\debug.txt -append
   }
}

Out-File ist also eher ein langsamer Vertreter.

11 Sec/1000

Gerne übersehen wird aber, dass es noch Add-Content gibt. (Dank an Timo Kehler für den Tipp)

measure-command {
   1..1000 | %{
      if (!($_ % 100)) {write-host $_}
      "hallo" | add-content .\debug.txt
   }
}

Es ist fast doppelt so schnell wie Out-File.

6 Sek/1000

Dann doch besser die Ausgaben erst mal im Speicher aufaddieren und am Ende schreiben, was aber das Risiko hat, dass beim Abbruch des Skript gar nicht geschrieben wurde. Es sei denn sie fangen das mit einem TRAP ab um dann noch mal zu schreiben. Und es ist gut zu sehe, dass die Verarbeitung von Zeile zu Zeile langsamer wird, egal ob $result nun ein String oder Array.

measure-command {
   Trap {"Error: $_"; $result | out-file .\debug.txt ;Break;}
   $result=@()  # oder [string]$result=""
   1..100000 | %{
      if (!($_ % 100)) {write-host $_}
      $result+="hallo"
   }
   $result | out-file .\debug.txt
}

0,1Sek/1000
137Sek/100000

Set-Content und Add-Content können Daten über die Pipeline erhalten. Aber damit ist die nächste Bremse schon eingebaut. wie die folgenden Beispiele Zeiten. (Auch hier Dank an Timo Kehler für den Hinweis)

$content = get-content 50mbfile.txt

(Measure-Command {
   $content | out-file .\50mb-outfile.txt
}).TotalSeconds
774

(Measure-Command {
   $content | set-content .\50mb-setcontent.txt
}).TotalSeconds
81

(Measure-Command {
   set-content .\50mb-setcontent.txt -value $content
}).TotalSeconds
10

Aber auch 10 Sekunden sind recht lange zum Schreiben einer 50 MB Textdatei.

10 Sek

Dass speziell das Schreiben in Dateien noch schneller gehen kann beweist wieder mal, dass eine direkte Nutzung der .NET-Klassen einen großen Vorteil haben kann. Der Streamwriter/TextWriter nutzt intern einen Buffer um nicht jede Ausgabe sofort zu schreiben, sondern in Lücken.

measure-command {
   $debugfile = New-Object System.IO.StreamWriter "c:\temp\debug.txt"
   1..100000 | %{
      if (!($_ % 100)) {write-host $_}
      $debugfile.WriteLine("hallo")
   }
   $debugfile.Close();
}

Analog könnte man noch einen "System.IO.StringWriter" nutzen, der aber gleich schnell ist. Allerdings sollten Sie ein Skript dann nicht mit "CTRL-C" abbrechen, da dann die Handle nicht geschlossen werden. Erst ein Beenden der PowerShell gibt die Handles wieder frei.

0,1Sek/1000
10Sek/100000

Der StreamWriter ist also bezüglich "schreiben" durch nichts zu übertreffen und damit die erste Wahl, um Debug-Dateien wegzuschreiben. Sie sehen schon die Laufzeitunterschiede einer einfachen Ausgabe.

Beispiel 6: Textdateien lesen

Textdateien sind eigentlich was schönes, da man sie einfach lesen und schreiben kann und in fast jedem Editor auch anzeigen und bearbeiten kann. Zugegeben kommt Notepad auch unter Windows 8 mit großen Dateien immer noch an seine Grenzen, so dass man gut beraten ist, alternative Editoren (z.B. Notepad++, Textpad) zu verwenden oder gleich die Informationen per Skript zu extrahieren. So bin also auch ich immer mal genötigt, Textdateien schnell zu parsen und mittlerweile ist natürlich PowerShell mein Mittel der Wahl. Als ich aber einmal ein 50 Megabyte großes Lync Client Tracefile parsen wollte, ist mir die Langsamkeit aufgefallen und das wollte ich nicht ganz wahr haben. Daraufhin habe ich eine kleine Mess-Serie aufgestellt, wie man eine große Textdatei am besten zeilenweise schnell einliest. PowerShell eröffnet dabei doch einige Optionen. Alle Befehle verwendeten die gleiche PowerShell und ich habe die Befehle immer zweimal gestartet, um etwaige Verfälschungen durch Caching zu eliminieren. Es kann also sein, dass die Zeiten "zu gut" sind, weil Daten noch in einem Cache gelegen haben. Virenscanner war aktiv aber es war mein Notebook. Hyperschnelle Raid-Controller mit eigenen Caches gibt es also nicht. Die Zeiten wurden mit Measure-Command gemessen.

Methode Zeit Sek Beschreibung

COPY <datei> <datei2>

0,1

ich habe einfach mal einen COPY gemacht, der die 50 MB gelesen aber auch wieder geschrieben hat. Was Windows da mi Caches tut, ist mir suspekt aber 50 MB von einer 2,5" IDE-Disk in 0,1 Sek zu lesen und zu schreiben ist mir suspekt.

type <date> -> <datei2>

256

Ein klassische DOS-Type per Pipeline in eine Datei ist sehr langsam. Das liegt aber wohl eher am "Append"-Schreiben denn am Lesen.

type <datei> -> nul

0,1

Die Gegenprobe mit einem Ausgeben nach NUL war dann auch deutlich schneller

get-content <datei> `
   | out-null

52

Get-Content liest die Datei zeilenweise ein und damit kann man sicher kein Performanceblumentopf gewinnen. Aber scheinbar liest das Commandlet wirklich sehr langsam die Dateien ein.

get-content <datei> `
   -readcout 0 `
   | out-null

2,5

Erweitert man Get-Content aber mit dem Parameter "ReadCount 0", dann ist der gleiche Befehl 25mal schneller. Wer also eh die ganze Datei lesen will, sollte direkt den Parameter angeben.

select-string datei `
   -pattern * `
   | out-null

37

Auch das Commandlet "Select-String" kann direkt Informationen aus Dateien auslesen. Damit es aber alle Zeilen liefert, muss man den Filter auf ".*" setzen. Trotz erforderlicher Auswertung jeder einzelner Zeile mit dem regulären Ausdruck ist Select-String deutlich schneller als Get-Content.

import-csv datei `
   -delimiter `
   | out-null

36

Auch das "Import-CSV"-Commandlet kann man für den Import einer Textdatei missbrauchen. Allerdings sollte man den Feldtrenner so vorgeben, dass eben nichts getrennt wird. Dann steht im ersten Feld der Pipeline einfach die komplette Zeile

System.File.IO
ReadLines

1,5

Das Einlesen mit folgendem Code ist sehr schnell.

foreach ($line in [System.IO.File]::ReadLines($pwd.path+"\Lync-UccApi-0.UccApilog"))  {
#  $line
}

System.File.IO
ReadAllLines

1,3

Mit ReadAllLines geht es sogar noch ein bisschen schneller.

foreach ($line in [System.IO.File]::ReadAllLines($pwd.path+"\Lync-UccApi-0.UccApilog"))  {
#  $line
}

Wenn ich mir das so anschaue, dann ist es schon erschreckend, wie langsam der ein oder andere Code ist. Ich kann aber nicht genau verstehen, warum z.B. "Type" so viel schneller sein soll, so es doch auch nur ein "alias" für "Get-Content" ist.

Beispiel 7: Textdateien schreiben

Auch hier gibt es wieder mehrere Möglichkeiten, zum eine Zeile an eine Textdatei anzuhängen.

Methode Zeit Sek Beschreibung

measure-command {
1..1000 |`
   %{`
      $_ |`
      out-file `
         -append append1.txt
   }
}

5,5

Ich schreibe die Zahlen von 1 bis 1000 an das Ende einer Datei, indem ich sie über die Pipeline an "Out-File" -append anhänge

measure-command {
1..1000 | `
   %{`
      $_ | `
      add-content `
         append2.txt
   }
}

5,8sec

Variante 2 nutzt dazu Add-Content

measure-command {
   $outfile = New-Object System.IO.StreamWriter "c:\temp\append3.txt"
   1..1000 | %{
      $outfile.WriteLine("hallo")
   }
   $outfile.Close();
}

0,2 sec

Zuletzt die "Native" Version mit Dateien. Deutlich schneller als alle anderen Ausgaben.

measure-command {
   1..1000 | %{
      $outfile = New-Object System.IO.StreamWriter "c:\temp\append3.txt"
      $outfile.WriteLine("hallo")
      $outfile.Close();
   }
}

3 Sek

Jedes mal die Datei zu öffnen und anzuhängen dauert deutlich länger.

Offensichtlich ist die Ausgabe über .NET Funktionen deutlich schneller als Add-Content oder Out-File. Allerdings ist der Einsatz natürlich in Skripten einfacher als in "Einzeilern"

Beispiel 8: CSV-Dateien lesen und schreiben

Die Performance-Überlegungen zu CSV-Dateien habe ich auf PS CSV-Datei beschrieben.

Beispiel 9: Listen erweitern

Natürlich lebt PowerShell auch von der Pipeline. Aber manchmal möchte ich die Ergebnisse doch erst in einer Liste sammeln und danach weiter verarbeiten oder z.B. mit Export-CSV innerhalb des Programms in eine Datei exportieren. Also nehme ich die Werte und "addiere" diese. Am Beispiel mit String könnte das so aussehen:

[String]$ergebnis = ""

foreach ($repeat in 0..9) {
    $start = get-date
    foreach ($zeile in 0..999) {
        $ergebnis += "Zeile $repeat $zeile"
    }
write-host "Repeat $repeat  Dauer: "((get-date) - $start).totalseconds
}

Ausgabe:
Repeat:0  Dauer:  0,0210012
Repeat:1  Dauer:  0,0150009
Repeat:2  Dauer:  0,0220013
Repeat:3  Dauer:  0,053003
Repeat:4  Dauer:  0,297017
Repeat:5  Dauer:  0,3610207
Repeat:6  Dauer:  0,5040289
Repeat:7  Dauer:  0,5900338
Repeat:8  Dauer:  0,6060346
Repeat:9  Dauer:  0,6610378

Ich habe noch nicht hinter die Kulissen geschaut aber anscheinend kopiert PowerShell (oder .NET) intern die Variable beim Erweitern um statt dynamische mit Pointern zu arbeiten. Es ist also vielleicht nicht die beste Idee, so eine Liste aufzubauen. Das gilt insbesondere, wenn sie ganz große Strukturen über Strings aufbauen wollen.

Deutlich besser schlagen sich Hashtabellen:

[hashtable]$ergebnis = @{}
foreach ($repeat in 0..9) {
	$start = get-date
	foreach ($zeile in 0..999) {
		$ergebnis.add( ([string]$repeat+[string]$zeile),"Zeile $repeat $zeile")
	}
	write-host "Repeat:$repeat  Dauer: "((get-date) - $start).totalseconds
}

Repeat:0  Dauer:  0,0370021
Repeat:1  Dauer:  0,0130008
Repeat:2  Dauer:  0,0100006
Repeat:3  Dauer:  0,0100005
Repeat:4  Dauer:  0,0150008
Repeat:5  Dauer:  0,0090005
Repeat:6  Dauer:  0,0090005
Repeat:7  Dauer:  0,0130007
Repeat:8  Dauer:  0,0100006
Repeat:9  Dauer:  0,0120007

Im Gegensatz dazu schlagen sich Arrays sogar noch schlechter:

[Array]$ergebnis = @()
foreach ($repeat in 0..9) {
	$start = get-date
	foreach ($zeile in 0..999) {
		$ergebnis += "Zeile $repeat $zeile"
	}
	write-host "Repeat:$repeat  Dauer: "((get-date) - $start).totalseconds
}

Repeat:0  Dauer:  0,0740042
Repeat:1  Dauer:  0,1830105
Repeat:2  Dauer:  0,2580147
Repeat:3  Dauer:  0,3360192
Repeat:4  Dauer:  0,4260243
Repeat:5  Dauer:  0,52503
Repeat:6  Dauer:  0,6060346
Repeat:7  Dauer:  0,70004
Repeat:8  Dauer:  0,8160467
Repeat:9  Dauer:  1,1250643

Weitere Variablen habe ich aber nicht untersucht

Haben Sie eine bessere Idee für ein Aufbau einfacher Listen ?

Beispiel 10: Informationen speichern, lesen und filtern

Beim Projekt Reportweb nutze ich den Trick, Daten einmalig zu ermitteln und zu speichern, um Sie dann in anderen Skripten wieder zu verwerten. Der beste Ablageplatz wäre natürlich eine SQL-Datenbank aber auch CSV und XML-Dateien eignen sich durchaus für die Ablage kleinerer Daten.

Wer gerne mit PowerShell-Objekten arbeitet, wird schon sehr früh das "Export-CliXML"-Commandlet gefunden haben, welches das Objekt "serialisiert" und speichert. Durch die Serialisierung gehen einige Feldinformationen natürlich verloren aber im großen ganzen können die Daten später gut wieder eingelesen werden.

CSV oder XML?

Allerdings ist dieser Weg für größere Datenmengen nicht wirklich geeignet. Ich habe für eine Quota-Auswertung einmal von allen Mailboxen den DN, die Mailadresse und die Quota-Einstellungen in einem [object[]] -Array gespeichert. für ca. 15.000 Postfächer wurde daraus eine 200 MB XML-Datei. Das Speichern und insbesondere das Laden hat sehr lange gedauert. Das Format bei Export-CliXML ist doch sehr überladen und eher für kleine Strukturen geeignet.

Wenn ich die Information als CSV-Datei geschrieben habe, was in dem Beispiel keinerlei Informationsverlust bedeutet hat, dann wurde die CSV-Datei gerade mal 2MB (also 1%) groß und war deutlich schneller geschrieben und geladen.

Where oder Filter

Sehr viele Commandlets (z.B. Exchange) erlauben einen Filter schon bei dem Aufruf des Commandlets. Wenn Sie also z.B. alle Mailboxen erhalten wollen, die ActiveSync nutzen, dann ist es besser schon bei der Suche zu filtern und damit nur eine Teilmenge zu bekommen anstatt per WHERE das Ergebnis zu filtern.

# schnell
get-mailbox -filter "ActiveSyncEnabled -eq $true"

#langsam
get-mailbox | where {$_.ActiveSyncEnabled -eq $true}

Allerdings kann man nicht auf alle Felder filtern

Where oder Hashtable ?

In dem gleichen Zusammenhang habe ich die Daten aus der CSV-Datei später mehrmals eingelesen und musste zu einer Liste von Postfächern die betreffende Zeile aus der Liste ermitteln. Das geht recht einfach mit einem "Where".

$Quotatable = (import-csv -Path ($reportwebwwwpath+"quotatable.csv"))
 
Foreach ($mb in get-mailbox) {
    quotaentry = ($quotatable | where {$_.PrimarySmtpAddress -eq $mb.PrimarySmtpAddress})
    write-host $quotaentry.Warn
}

Interessant war hier, dass dieser Filter ca. 1,5Sek gebraucht hat. Das hört sich kurz an, aber stört mächtig, wenn man 10.000 Elemente so verarbeitet. 15000 Sekunden sind über 4 Stunden! Da in dem Beispiel aber immer die Suche nach der Mailadresse erforderlich war, habe ich beim Import einfach die CSV-Datensätze in eine Hashtable übernommen.

[hashtable]$quotatable=@{}
foreach ($entry in (import-csv -Path ($reportwebwwwpath+"quotatable.csv"))) {
    $quotatable.add(($entry.PrimarySmtpAddress),$entry)
}

Foreach ($mb in get-mailbox) {
    write-host $quotatable.item($mb.PrimarySmtpAddress).Warn
}

Das "Laden" der Tabelle dauerte mit 5 Sek etwas länger als der einfache Import-CSV, aber das Suchen und auswerten der passenden Objekte war dann deutlich schneller. Die gesamte Verarbeitung war in weniger als 5 Minuten dann durch.

Der "Umweg" über eine Hashtabelle ist als immer lohnen, wenn Sie in einem Datenbestand nur nach einem Schlüssel suchen.

Beispiel 11: Funktionen einbinden

Aus Gründen der Modularisierung und Wiederverwendung von Code bietet es sich an, geeignete Codeabschnitte als Funktion in eigene PS1-Dateien oder PS1M-Module auszulagern und im Hauptskript dann einzubinden. Auch dazu gibt es unterschiedliche Ansätze, die sich auf die Performance niederschlagen können.

Achtung:
Die Beispiele hier zeigen rein den Overhead der jeweiligen Einbindung. Da der Code aber nicht "aktiv tut", sind die Zahlen viel drastischer aber dennoch nicht zu untersch�tzen.

Das Muster besteht aus einem MAIN-Skript, welches in zwei füllen auf eine ausgelagerte Funktion in einem SUB.PS1 zurück greift.

Version Main.ps1 Sub.ps1 Laufzeit Bemerkung
Einfache Schleife

$sum=0
foreach ($item in 1..1000) {
    $sum += $item 
}
write-host "Main End sum: $sum"

Entfällt

9 msec

Nicht modular

Unterroutine in der Schleife

$sum=0
foreach ($item in 1..1000) {
    $sum += . .\sub.ps1  #
}
write-host "Main End sum: $sum"

$item

1680 msec

Die Unterroutine wird immer wieder aufgerufen, was letztlich sehr langsam ist.

Einbindung als Funktion

function add-item {
    $item
}
 
$sum=0
foreach ($item in 1..1000) {
$sum += add-item
}
write-host "Main End sum: $sum"

Entfällt

120 msec

Die Auslagerung in eine Funktion kostet schon Performance im Vergleich zu den 9 msec der direkten Nutzung. Modular ist das aber nicht.

Einbindung als externe Funktion über DOT Sourcing

. .\sub.ps1   # einbinden
$sum=0
foreach ($item in 1..1000) {
    $sum += add-item
}
write-host "Main End sum: $sum"

#sub.ps1
function add-item {
    $item
}

120 msec

Lagert man die Funktion aus, aber bindet sie einmal ein, dann ist es vergleichbar schnell. So kann Modularität funktionieren.

Interessant ist schon, dass allein die Auslagerung in eine Funktion innerhalb der gleichen PS1-Datei schon die Ausführung derart verlangsamt. Wird aber die Funktion in jeder Schleife neu aus einer anderen PS1-Datei bezogen, dann leidet die Performance stark. Bindet man die Funktion aber vorher über DOT-Sourcing mit ein, dann ist man nicht schlechter dran, als wenn man die Funktion im gleichen Skript definiert.

Für meine Write-Trace-Lösung wollte ich wissen, ob der Aufruf einer PS1-Datei schneller oder langsamer ist als das Einbinden als Modul mit Funktion

Version

Code

Dauer

 

Einbindung über Aufruf einer PS1-Datei

measure-command { `
   1..1000 `
   | %{ `
   .\write-trace.ps1 `
      -Message "test" `
      -level 5
   }
}

1,93 Sek

Für meine Write-Trace-Lösung wollte ich wissen, ob der Aufruf einer PS1-Datei schneller oder langsamer

Einbindung als Funktion einer PSM1

measure-command { `
   1..1000 `
   | %{ `
      .\write-trace2 `
         -Message "test" `
         -level 5 `
      } `
}

1,90 Sek

 

Hier war kein signifikanter Unterschied zu sehen.

PowerShell und XML

Es ist mit PowerShell sehr einfach XML-Dateien zu lesen. Das geht schon mit einem kleinen "OneLiner" relativ schnell:

[xml]$xmldatei = get-content .\test.xml

Durch die Angabe von [xml] muss PowerShell die Datei  konvertieren. Das geht aber dennoch sehr schnell. Auch die Befehle "Import-clixml" und "Export-clixml" können genutzt werden, um Objekte einfach in XML-Dateien zu exportieren. Bei größeren Datenmengen kann das aber schon etwas dauern.

Methode Zeit Sek Beschreibung

measure-command {
  get-process | export-clixml .\process.xml
}

26

Zuerst erzeuge ich mal eine größere XML-Datei. Eine Liste der Prozesse als CliXML-exportiert ergibt bei mir ca. 11 Megabyte

measure-command {$a=get-content c:\temp\temp\process.xml}

3

Einlesen als Textdatei ohne weitere Optimierung. Wenn Sie weiter oben den Abschnitt über das Einlesen von Textdateien können, dann kann da schon noch schneller werden

measure-command { [xml]$a=get-content c:\temp\temp\process.xml}

3

Mit gleichzeitiger Konvertierung in ein XML-Objekt ist es nicht langsamer

measure-command { [xml]$a=get-content c:\temp\temp\process.xml}

<0,5Sek

Importiert man diese XML aber mit Import-CLIXML, dann ist es deutlich schneller. Get-Content ist nicht so berauschend.

Bleibt aber die Frage, wie das mit schreiben von XML-Dateien ist. Im MSDN gibt es dazu eine eigene Seite

Das hat mich dann mal neugierig gemacht, wie die unterschiede da sein können. Sie müssen dabei natürlich unterscheiden, ob sie eine XML-Datei komplett frei verändern wollen, oder eine XML neu aufbauen und damit sequentiell schreiben.

Um die Zeiten zu müssen, habe ich die Schleife mit 10.000 und 100.000 Iterationen gemacht. Sie sehen gut, wie sich das auswirkt. XML-Writer scheint sehr linear zu sein, während die beiden anderen Methoden viel länger brauchen.

Methode Zeit Sek Beschreibung

Einfache XML mit XMLDocument

measure-command {
  [xml]$doc=""
  $root=$doc.CreateElement("root")
  $doc.appendchild($root)
  1..100000 | %{
    $sub=$doc.CreateElement("sub")
    $sub.InnerText = $_
    $root.appendchild($sub)
  }
  $root.outerxml | out-file $"C:\temp\test.xml"
}

3Sek
330 Sek

Zuerst erzeuge ich ein fast leeres XML-Dokument vom Typ "System.Xml.XmlDocument" und leite davon entsprechende Child-Elemente ab, die ich dann addiere.

Es bringt übrigens nichts, wenn Sie die Instanzierung von $sub nur einmal machen oder per "Clone" neu erstellen.

Nachteilig ist eine hohe CPU Last und vor allem ein hoher Speicherverbrauch und es wird immer langsamer

Die Version mit XMLWriter

measure-command {
  $xmlWriter = New-Object System.Xml.XmlTextWriter("C:\temp\test.xml", $null)

  $xmlWriter.WriteStartElement("root")

  1..100000 | %{
  $xmlWriter.WriteStartElement("count")
  $xmlWriter.WriteValue(1)
  $xmlWriter.WriteEndElement()
  }
  $xmlWriter.WriteEndElement()
  $xmlWriter.Close()
}

0,4 Sek
4  Sek

Der XML-Writer ist hier aber deutlich schneller.

Direkte Textdatei

measure-command {
  $crlf = "`r`n"

  [string]$xmltext="<?xml version=`"1.0`" encoding=`"ISO-8859-15`"?>" + $crlf

  1..100000 | %{
     $xmltext+=("      <count>"+ $_+"</count>") + $crlf
  }
  $xmltext+="" + $crlf
#  $xmltext | out-file $"C:\temp\test.xml"
}

17 Sek
1200 Sek

Zuletzt können Sie natürlich auf alle Objekte verzichten und direkt XML-Code schreiben.

Schön ist es nicht und Fehler können einfach gemacht werden. Durch die langsame String-Verarbeitung wird das Skript auch langsam

Sie sollten also beim Anlegen von XML-Dateien überlegen, ob sie den wahlfreien Zugriffe auf alle Knoten benötigen, oder ob eine sequentielle Ausgabe ausreichend ist.

Weitere Themen

Und das waren nun nur ein paar Beispiele. Es gibt noch weitere Themen, die knifflig sein können, z.B.:

  • Eventlog
    Es macht sehr wohl einen unterschied, ob sie mit "Get-WinEvent" oder "Get-Eventlog" arbeiten
  • Dateizugriffe
    Wer mit "out-File" oder "out-csv" arbeitet, nutzt den einfachen aber sicher nicht den schnellsten Weg. StreamWriter sind für große Dateien eine ebenso einfache aber viel schnellere Alternative
  • Suchen und Vergleichen
    Gerade beim Abgleich von Daten muss man überlegen, ob man jede Information in der Quelle im Ziel "sucht" oder ob es nicht vielleicht schneller geht, die Daten des Ziels flugs in eine Hashtable einzulesen und dort dann "im Memory" zu suchen. Das ist insbesondere bei AD-Abfragen oft deutlich schneller anstatt den DC mit massenhaft Anfragen zu belästigen.

Insofern kann es sich schon mal lohnen zu schauen, wo ein Code seine Zeit verbringt.

Zusammenfassung

Auch wenn PowerShell relative einfach ist und passende Commandlets gerade im Bereich Exchange einen Zugriff auf Objekte deutlich unterstützen, so muss zumindest in größeren Umgebungen geprüft werden, ob die einfachen aber ressourcenintensiven Commandlets die Anforderungen an die Laufzeit erfüllen. Ich merke dies immer in Umgebungen, wo ich mit PowerShell Skripte zur Auswertung erstelle. Wenn Sie z.B. per PowerShell die Adressbücher von zwei Mailsystemen auslesen, um diese dann zu "vergleichen", dann ist es nicht lustig, wenn eine Auswertung 10 Minuten und länger läuft.

Weitere Links