n1philipp
Goto Top

Performance Problem Powershell

Hallo Zusammen,

erstmal vielen Dank an alle, ich habe schon viel lernen können beim passiven mitlesen face-smile aber jetzt stehe ich vor einem Problem:

ein Kunde hat mir 138 .TSV (also Dateien mit Tab als Trenner) Dateien gegeben mit jeweils ca. 400 MB und ca. 1.000.000 Zeilen je Datei, also insgesamt 51GB und 135.894.981 Zeilen.
Diese Dateien haben alle die gleichen 11 Spalten und sind nicht sortiert.

Mein Kunde würde gerne diese Dateien in Jahresordner und darunter dann Monatsdateien haben.
Das Datum sieht nicht immer gleich aus (leider...), dass mein Script jetzt mal eine Datei mit "Jan" oder "January" und eine mit "Januar" erstellt ist mir (erstmal) egal:

hier nur ein paar Beispiele
Sat, 1 Jan 2000 12:40:07 +0000
Sat, 10 Mar 2001 08:16:36 +0800
Monday, 14 January 2013 17:00:31 GMT
Thu, 19 Mar 2009 12:27:23 +0000 (UTC)
Thu, 21 Jun 2012 18:00:37 +0100 (BST)
Sun, 13 May 2012 12:06:41 -0400 (Eastern Daylight Time)
Mon, 14 May 2012 09:29:02 -0400 (EDT)

Das folgende Script läuft auch ohne Probleme, nur halt sehr sehr langsam. Bei der Masse dürfte es ca. 2 Jahre dauern aber solange will mein Kunde verständlicherweise nicht warten :D

könnt ihr mir sagen warum das Script so langsam ist und mir eventuell Tipps geben (bitte habt Nachsicht mit mir, bin erst zwei Tage mit Powershell zugange face-smile )? liegt es am Import-CSV?
ich hatte das ganze schon über Batch am laufen aber aufgrund der unterschiedlichen Datums Formatierungen und Sonderzeichen die in den Zellen vorhanden sind bin ich auf immer mehr Fehler gestoßen und habe mich dann zur Powershell verleiten lassen was auch eigentlich ganz gut geklappt hat, zumindest solange ich mit Testdaten gespielt habe :D


Foreach ($File in Get-ChildItem -Path "\\hier steht der Pfad wo die Daten abgelegt sind\") {  
$File.FullName
$i = 0
$csv = Import-CSV $File.FullName -Delimiter '	'   
$csv | %{
            $eins = $_.'MESSAGE-ID'  
            $zwei = $_.'DATE'  
            $drei = $_.'FROM'  
            $vier = $_.'APPARENTLY-TO'  
            $fünf = $_.'BCC'  
            $sechs = $_.'CC'  
            $sieben = $_.'TO'  
            $acht = $_.'X-ZANTAZ-RECIP'  
            $neun = $_.'SUBJECT'  
            $zehn = $_.'X-ZANTAZDOCCLASS'  
            $elf = $_.'PATH'  
	$Meinmonat =  $csv.DATE[$i].split(" ")[2]  
	$Meinjahr =  $csv.DATE[$i].split(" ")[3]  
	if ( $Meinjahr -like '20??' )   
		{
		$PATH = "\\hier steht der Pfad wo ich die Daten hinlege\$Meinjahr"  
		$PathFile = "$Path\$Meinmonat $Meinjahr.tsv"  
		} 
			else 
			{
			$PATH = "\\hier steht der Pfad wo ich die Daten im Fehlerfall hinlege also wenn das Jahr nicht richtig ist\Fehler"  
			$PathFile = "$Path\Fehler.tsv"  
			}
	if (!(Test-Path $PATH)) {New-Item -Path $PATH -ItemType Directory}

    if (!(Test-Path $PathFile)) {New-Item $PathFile -ItemType file
                                "MESSAGE-ID	DATE	FROM	APPARENTLY-TO	BCC	CC	TO	X-ZANTAZ-RECIP	SUBJECT	X-ZANTAZDOCCLASS	PATH" >> $PathFile  
                                }
    "$eins	$zwei	$drei	$vier	$fünf	$sechs	$sieben	$acht	$neun	$zehn	$elf" >> $PathFile  
    
	$i++
	}
Pause
}

Content-ID: 1141377437

Url: https://administrator.de/forum/performance-problem-powershell-1141377437.html

Ausgedruckt am: 15.04.2025 um 09:04 Uhr

godlie
godlie 10.08.2021 um 13:29:17 Uhr
Goto Top
Hallo,

ja das könnte am import-csv liegen, du kannst es ja mal recht einfach testen indem du statt dem import einfach mal so etwas verwendest, Headers u. Delimter sind natürlich anzupassen.

Tu dir und uns einen gefallen, verwende Englische Variablennamen! Wenn es schon Deutsch sein muss dann ohne Umlaute das f?hrt nur zu Problemen face-smile

$Fields = 'Date', 'Time', 'Action', 'Source', 'Destination', 'User'  
$Delimiter = ' '  

ForEach ($file in $files)
{
    $CSVFirstRow = Get-Content -TotalCount 2 $file.FullName |
      ConvertFrom-Csv -Delimiter $Delimiter
    $CSVFields = $CSVFirstRow.psobject.Properties.Name
    $FieldIndices = $Fields | Foreach-Object { $CSVFields.IndexOf($_) }

    Get-Content $file.FullName -ReadCount 1000 |
      Foreach-Object {
        foreach($Line in $_){
          [string]::Join(',', ($s.Split($Delimiter)[$FieldIndices]))  
        }
      } |
        Set-Content "$dst\NEW - $($file.BaseName).csv"  
}

grüsse
erikro
erikro 10.08.2021 um 14:36:07 Uhr
Goto Top
Moin,

mache keine Pipe auf foreach-object, sondern eine foreach-Schleife (https://devblogs.microsoft.com/scripting/getting-to-know-foreach-and-for ..).

hth

Erik
emeriks
emeriks 10.08.2021 aktualisiert um 16:24:04 Uhr
Goto Top
Hi,
wenn ich das richtig sehe, dann testest Du jetzt 135 Mio Mal, ob der Zielpfad und die Zieldatei vorhanden sind. Das kostest natürlich auch Zeit.
Ich würde entweder auf Verdacht alle potentiellen Ordner und Dateien im Voraus erstellen und diese dann auf Deine Weise füllen.
Oder es auf eine Exception ankommen lassen und dann in diesem Falle die entsprechend fehlende Datei erst erstellen. Bei angenommen 10 Jahren hättest Du max. 120 Exceptions. Das wäre aber immer noch besser, als 135 Mio Mal die Existenz der Dateien zu prüfen.

E.

Edit:
Angenommen, das Prüfen der Datei-Existenz dauert jeweils 100 ms. Dann sind das bei 135 Mio Mal
- 13,5 Mio s, oder
- 225.000 min, oder
- 3.750 h, oder
- 156 d
erikro
Lösung erikro 10.08.2021 aktualisiert um 18:01:06 Uhr
Goto Top
Moin,

jetzt habe ich mir mal näher angeschaut, was Du da eigentlich treibst. Das geht viel einfacher und dann wahrscheinlich auch deutlich schneller.

Zitat von @n1philipp:
ein Kunde hat mir 138 .TSV (also Dateien mit Tab als Trenner) Dateien gegeben mit jeweils ca. 400 MB und ca. 1.000.000 Zeilen

Nur so nebenbei: Die Dinger heißen auch dann csv, wenn sie nicht mit Kommata separiert sind. Oder besser: Es gibt so gut wie keine CSV, die mit Kommata separiert ist. face-wink

Diese Dateien haben alle die gleichen 11 Spalten und sind nicht sortiert.

Ok, macht aber nichts. Grundregel: Die Reihenfolge der Spalten und der Datensätze darf keine Rolle spielen. Wichtig: Alle Spalten sind in allen Dateien mit den gleichen Namen vorhanden.

Mein Kunde würde gerne diese Dateien in Jahresordner und darunter dann Monatsdateien haben.

Na wenn der Kunde das so will. face-wink

Das Datum sieht nicht immer gleich aus (leider...), dass mein Script jetzt mal eine Datei mit "Jan" oder "January" und eine mit "Januar" erstellt ist mir (erstmal) egal:

Das liegt daran, dass Du es nicht richtig machst.


hier nur ein paar Beispiele
Sat, 1 Jan 2000 12:40:07 +0000
Sat, 10 Mar 2001 08:16:36 +0800
Monday, 14 January 2013 17:00:31 GMT
Thu, 19 Mar 2009 12:27:23 +0000 (UTC)
Thu, 21 Jun 2012 18:00:37 +0100 (BST)
Sun, 13 May 2012 12:06:41 -0400 (Eastern Daylight Time)
Mon, 14 May 2012 09:29:02 -0400 (EDT)

Das, was ich gleich tue, sollte bei allen halbwegs gängigen Datumsformaten klappen. Da ich aus den von Dir gelieferten Daten schließe, dass es sich um Email-Header oder so handelt, sollten das gängige Formate sein. Mit den Beispielen hier habe ich es getestet.

Das folgende Script läuft auch ohne Probleme, nur halt sehr sehr langsam. Bei der Masse dürfte es ca. 2 Jahre dauern aber solange will mein Kunde verständlicherweise nicht warten :D

Warum nicht? face-wink

Ich schreibe das mal um und kommentiere gleichzeitig Deinen Code:

# Ein paar Deklarationen für später:

# Der Pfad auf die Datei muss exisitieren. Die Datei selbst nicht.
$errorpath = "x:\da\wo\die\fehler\hinsollen\fehler.csv"  
$path = "x:\da\wo\die\Unterordner\hinsollen"  

$months = @("Jan","Feb","Mar","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez")  

Foreach ($File in Get-ChildItem -Path "\\hier steht der Pfad wo die Daten abgelegt sind\") {  

# $File.FullName unnötig
# $i = 0

$csv = Import-CSV $File.FullName -Delimiter "`t"  

# Hier kannst Du zwar auch einfach einen TAB machen.
# Aber das ist schwerer zu lesen und führt später nur zur Verwirrung. Deshalb besser `t.
# Den Backtick nimmt man in der Powershell da, wo man in vielen anderen Sprachen 
# den Backslash nimmt.
# Das nennt man das Escape-Zeichen.

# $csv | %{ keine Pipe!
#             $eins = $_.'MESSAGE-ID' 
#             $zwei = $_.'DATE' 
#            $drei = $_.'FROM' 
#            $vier = $_.'APPARENTLY-TO' 
#            $fünf = $_.'BCC' 
#             $sechs = $_.'CC' 
#             $sieben = $_.'TO' 
#             $acht = $_.'X-ZANTAZ-RECIP' 
#             $neun = $_.'SUBJECT' 
#             $zehn = $_.'X-ZANTAZDOCCLASS' 
#             $elf = $_.'PATH' 
# 	$Meinmonat =  $csv.DATE[$i].split(" ")[2] 
# 	$Meinjahr =  $csv.DATE[$i].split(" ")[3] 

# Nein, das ist eher objektdesorientiert. Das brauchst Du alles nicht.
# Warum auch sollte man Variablen, die man schon hat, in neue Variablen schreiben?

foreach($line in $csv) {

    # Speichern in einer neuen Variablen, um die Originaldaten zu erhalten
    $date = $line.DATE
    # Entfernen der evtl. störenden Zeitzonenangabe am Ende
    $date = $date -replace "\S*$|\(.*\)",""  
    $month = $months[$(get-date("$date").month-1]  
    $year = $(get-date("$date")).year  

#    if ( $Meinjahr -like '20??' )  
# Das ist nicht schön. Besser so:
    if($year -lt 2000 -or $year -isnot [int32]) {
    #Hier teste ich, ob die Zahl kleiner als 2000 ist
    # oder evtl. auch gar keine Zahl. Dann Fehler.

        $line | export-csv $errorpath -append -notypeinformation -force

    else {
# Danke an @emeriks für den Hinweis. Wir machen hier mal lieber kein if, sondern try-catch

        try {

            $line | export-csv $path\$year\$month.csv -append -notypeinformation -erroraction continue

        }
        catch {

            if (!(Test-Path $path\$year)) {New-Item -Path $PATH -ItemType Directory}
            try {

                 $line | export-csv $path\$year\$month.csv -append -notypeinformation -erroraction continue

            }
            catch {

                 write-error "Ein unerwarteter Fehler ist aufgetreten!"  

            }

        }
     }
}

Ich hoffe, ich habe alle Klammern an der richtigen Stelle zu gemacht und auch sonst keine Typos. Ausprobieren und falls es Fehler wirft, dann gerne nachfragen. Ich habe nicht getestet.

hth

Erik
149062
149062 10.08.2021 aktualisiert um 19:21:21 Uhr
Goto Top
Zitat von @erikro:

Moin,

mache keine Pipe auf foreach-object, sondern eine foreach-Schleife (https://devblogs.microsoft.com/scripting/getting-to-know-foreach-and-for ..).

hth

Erik

Oder man kombiniert die Vorteile von der Pipeline mit der Geschwindigkeit von foreach indem man beides in ein schnelles "Foreach-ObjectFast" packt face-wink
function Foreach-ObjectFast
{
  param
  (
    [ScriptBlock]
    $Process,
    
    [ScriptBlock]
    $Begin,
    
    [ScriptBlock]
    $End
  )
  
  begin
  {
    $code = @"  
& {
  begin
  {
    $Begin
  }
  process
  {
    $Process
  }
  end
  {
    $End
  }
}
"@  
    $pip = [ScriptBlock]::Create($code).GetSteppablePipeline()
    $pip.Begin($true)
  }
  process 
  {
    $pip.Process($_)
  }
  end
  {
    $pip.End()
  }
}
So kann man auch Where-Object & Co beschleunigen.

Und warum nutzt ihr nicht die Multithreading-Vorteile? z.B. via Foreach-Object -parallel in der PS 7, wäre ja zu schade die restlichen Kerne brach liegen zu lassen und nur mit einem Kern zu arbeiten.
n1philipp
n1philipp 12.08.2021 um 21:42:07 Uhr
Goto Top
Vielen Lieben Dank an alle und ein ganz besonderes Dankeschön an @eriko.

Die Lösung funktioniert echt super (nachdem ich noch ganze zwei Klammern schließen musste 😉) so läuft das Script ca. 2 Woche statt 2 Jahren :D auf jeden Fall ein kleiner Performance Boost.

Aus einem mir noch unerklärlichen Fehler ist das Script abgebrochen, jetzt stellt sich mir die Frage, ob man es noch so erweitern könnte, dass man es an der Stelle, wo es abgebrochen ist erneut starten kann. Da das Script aber Zeile für Zeile durch die Dateien läuft müsste es die Zeile in der Monatsdatei suchen, in der diese gespeichert werden würde oder man merkt sich, wo genau man dran war. Aber das kostet wieder Zeit, oder?
erikro
erikro 13.08.2021 um 11:01:47 Uhr
Goto Top
Moin,

gern geschehen. Poste mal das fertige Skript mit allen korrekten Klammern und ohne die vielen auskommentierten Zeilen. Dann baue ich Dir das noch ein. Eine Korrektur gleich:

In Zeile 62 und 70 die erroraction von "continue" auf "stop" ändern. Sonst läuft die Fehlerbehandlung nicht richtig. Da hatte ich an dem Tag einen Denkfehler. face-wink

Dann vielleicht noch

$line | export-csv $errorpath -append -notypeinformation -force

nach der Zeile 75.

Liebe Grüße

Erik

Zeit kostet das natürlich, wenn man mitloggt, in welcher Datei, in welcher Zeile man gerade ist.

Liebe Grüße

Erik
n1philipp
n1philipp 16.08.2021 um 01:23:11 Uhr
Goto Top
Hi Erik,

ich hab deine Korrekturen eingebaut und mich auch schon am Zähler versucht, scheint auch zu klappen face-smile bekomme nur leider nach ein paar (vielen) Stunden nun die Fehlermeldung "set-content : Stream was not readable" bei Zeile 14 ""$File $i" | set-content Status.txt" und scheinbar braucht dieser Fehler viel Laufzeit (für die ersten 14 Dateien je ca. 3h und bei der letzten hat das Script schon 16h gebraucht)

ich hab eine Status Datei die nur Protokolliert, wann mit einer Datei begonnen wird und wann die Bearbeitung fertig ist und wie viele Zeilen (komplett) diese hat. In der zweiten Status Datei steht dann der Dateiname und die aktuelle Zeile.

Was ich mir nur jetzt überlege, was mache ich, wenn es abbricht... wie starte ich dann wieder an der Stelle neu face-big-smile

ich habe auch versucht die von @149062 genannte Foreach-ObjectFast oder Foreach-Object -parallel einzuabeuen aber es will dann einfach gar nicht mehr laufen face-big-smile

$errorpath = "\\Pfad\fehler.csv"  
$path = "\\Pfad\"  
"Neubeginn der Protokolldatei " | set-content Status_Dateien.txt   
"Neubeginn der Protokolldatei " | set-content Status.txt  
$months = @("Jan","Feb","Mar","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez")  

Foreach ($File in Get-ChildItem -Path "\\Pfad\*") {  
$d = Get-Date
"Start:   $File $d" | add-content  Status_Dateien.txt  
$i = 0
$csv = Import-CSV $File.FullName -Delimiter "`t"  
foreach($line in $csv) {
    $i = $i + 1
    "$File $i" | set-content Status.txt  
    $date = $line.DATE
    $date = $date -replace "\S*$|\(.*\)",""  
    $month = $months[$(get-date("$date")).month-1]  
    $year = $(get-date("$date")).year  
    if($year -lt 2000 -or $year -isnot [int32]) {
        $line | export-csv $errorpath -append -notypeinformation -force }
    else {
        try {
            $line | export-csv $path\$year\$month.csv -append -notypeinformation -erroraction stop
        }
        catch {
            if (!(Test-Path $path\$year)) {New-Item -Path $PATH\$year -ItemType Directory}
            try {
                 $line | export-csv $path\$year\$month.csv -append -notypeinformation -erroraction stop
            }
            catch {
                write-error "Ein unerwarteter Fehler ist aufgetreten!"  
                Write-Host $line
                $line | export-csv $errorpath -append -notypeinformation -force
            }
        }
     }
     }
     $d = Get-Date
     "Fertig:  $File $d" | add-content  Status_Dateien.txt  
     "Zeilen:  $i" | add-content  Status_Dateien.txt  
     }
     "Wir sind fertig" | add-content  Status_Dateien.txt  
     Pause
n1philipp
n1philipp 16.08.2021 um 08:33:56 Uhr
Goto Top
Ich habe grade gesehen, dass die powershell.exe mehr als 10 GB Memory für sich nutzt, ist das normal? auf dem Server waren nur noch ca. 30 bis 100 MB (schwankend) frei. Kommt daher eventuell der Fehler?
149062
149062 16.08.2021, aktualisiert am 17.08.2021 um 12:03:20 Uhr
Goto Top
Zitat von @n1philipp:

Ich habe grade gesehen, dass die powershell.exe mehr als 10 GB Memory für sich nutzt, ist das normal? auf dem Server waren nur noch ca. 30 bis 100 MB (schwankend) frei. Kommt daher eventuell der Fehler?

Wenn du für jede Zeile der Files so viele Variablen deklarierst ist klar das irgendwann der Speicher ausgeht und zwischendurch keine Garbage Collection machst face-wink.

Wenn es wirklich um Geschwindigkeit geht mach ich sowas persönlich immer mit nem Bash-Script. sed/(g)awk sind was sowas angeht im Vergleich mit der PS rasend schnell und auf Zeilenverarbeitung spezialisiert.
Hab das gerade mal mit nem gawk Script gecheckt. Das erstellt aus einem Demo File mit 1.000.000 Zeilen auf nem 2,8GHz 8 Kern i7 in ca 20 Sekunden die entsprechenden csv files. Da kommt die Powershell auch mit Multithreading niemals hin. Für deine Anzahl wäre die Arbeit dann in ca. 45 Minuten erledigt, wenn man das ganze dann noch auf die Prozessorkerne auftteilt indem man es mehrfach startet lässt sich das nochmal um den Faktor x(Kernanzahl) reduzieren.
Bei 8 Kernen wäre die Aufgabe dann optimalerweise also in 5-6 Minuten erledigt, wenn das Ausgabe-Medium mitmacht.

Bei Interesse am gawk Script einfach bei mir per PN melden.
n1philipp
n1philipp 16.08.2021 um 19:10:04 Uhr
Goto Top
Wenn du für jede Zeile der Files so viele Variablen deklarierst ist klar das irgendwann der Speicher ausgeht und zwischendurch keine Garbage Collection machst face-wink face-wink.
Ich renne halt von einem Problem ins nächste und suche nach Lösungen face-big-smile

awk sind was sowas angeht im Vergleich mit der PS rasend schnell und auf Zeilenverarbeitung spezialisiert.
ich musste erstmal googlen was das ist aber es hört sich so an, als wenn das sehr gut zu meinem Problem oder eher zu meiner Aufgabenstellung passt face-smile

Für deine Anzahl wäre die Arbeit dann in ca. 45 Minuten erledigt
das wäre ja der Hammer face-smile

Bei Interesse am gawk Script einfach bei mir melden.
Hiermit melde ich mein Intresse face-smile
erikro
erikro 24.08.2021 um 19:35:40 Uhr
Goto Top
Moin,

etwas später. Aber ich hatte zu tun. face-wink

Zitat von @149062:
Wenn es wirklich um Geschwindigkeit geht mach ich sowas persönlich immer mit nem Bash-Script. sed/(g)awk sind was sowas angeht im Vergleich mit der PS rasend schnell und auf Zeilenverarbeitung spezialisiert.

Das ist eine gute Idee. Warum hatte ich die nicht? face-wink Und gawk gibt es ja sogar auch für Windows. Ist dann wahrscheinlich nur halb so schnell aber immer noch schneller als die PS. face-wink

Bei Interesse am gawk Script einfach bei mir per PN melden.

Och nööööööö. Bitte hier weitermachen. face-wink Das ist mit Sicherheit von allgemeinem Interesse.

Liebe Grüße

Erik
erikro
erikro 24.08.2021 um 19:57:51 Uhr
Goto Top
Moin,

Zitat von @n1philipp:
$errorpath = "\\Pfad\fehler.csv"  
> $path = "\\Pfad\"  
> "Neubeginn der Protokolldatei " | set-content Status_Dateien.txt   
> "Neubeginn der Protokolldatei " | set-content Status.txt  
> [...]
>      "Wir sind fertig" | add-content  Status_Dateien.txt  
>      Pause

Zu Deiner Frage:

Nicht
$i = $i +1
sondern
$i++

Das, was Du geschrieben hast, ist nicht falsch. Das zweite ist aber einfach üblich seit C++. Jetzt weißt Du auch, warum C++ C++ heißt. face-wink

Dann nicht:
"$file $i" | set-content status.txt  
sondern
# Ganz oben
$status = status.txt
[...]
set-content -value "$file $i" -path $status  

Genauso auch an den Stellen, an denen Du add-content benutzt. Merke: Die Pipe ist immer langsamer als die direkte Übergabe des Objekts an den Befehl. (Warum das hier -value und nicht -InputObject heißt, muss mir mal einer erklären.)

Dann solltest Du am Ende der foreach-Schleife mit remove-variable mindestens $i besser alle Varibalen, die in der Schleife erzeugt werden, vernichten, um den Arbeitsspeicher sofort freizugeben. Dazu wäre es hilfreich, die Variablennamen mit einem Prä- oder Suffix zu versehen, da der Befehl Wildcards im Variablennamen erlaubt.

Die Idee mit der bash und gawk ist aber deutlich zielführender.

hth

Erik