Microsoft wypuścił niedawno nowego Powershella, wersja 7. Podobno ma on zjednoczyć i ujednolicić wszystkie poprzednie wersje i biorąc pod uwagę wysiłki i inwestycje giganta z Redmond może się okazać, że się uda.
Optymistycznie dopisałem na końcu dzisiejszego tytułu numerek jeden w nadziei, że rozwinie mi się z tego seria wpisów - wersja 7 ma mnóstwo smakowitych przypraw, których brakowało w wersji 5.1 (oraz Core, czyli 6.x), naprawdę jest o czym pisać. Póki co serii nie ma, ale od czegoś trzeba zacząć. A najlepiej od najsmaczniejszego kawałka: nowy Powershell obsługuje równoległe pętle ForEach!
Coś, czego dotychczas strasznie brakowało, w końcu jest w zasięgu ręki i to nawet całkiem amatorskiej. Nie trzeba doktoratów ani studiowania diagramów Harela, żeby załapać ideę.
O sssso chozzzzi?
Chozzzi o to, że najnowszy Powershell dopuszcza w poleceniu foreach opcję -Parallel, której użycie sprawi, że kolejne iteracje pętli będą się wykonywać bez oczekiwania na zakończenie poprzednich. Innymi słowy - w tym samym czasie!
Efekt może być całkiem piorunujący: jeżeli pętla "biegnie" po różnych maszynach, wywołując kod zdalnie na każdej z nich, wówczas możemy liczyć na skrócenie czasu proporcjonalne do ilości zdalnych maszyn.
Ale nawet bez takich hopsztosów, na maszynie lokalnej (wielowątkowej) też można się całkiem nieźle zabawić.
Spójrzmy na najprostszy możliwy przykład: wykonamy stukrotne losowanie liczby całkowitej między 20 a 30, następnie poczekamy sobie za każdym razem tyle milisekund, ile wylosowaliśmy. Najpierw po staremu:
1..100 | foreach {Start-Sleep -Milliseconds (Get-Random -Minimum 20 -Maximum 30)}
Pukamy Enter, czekamy chwilę... i nic, wykonuje się bez błędów. Ale też bez żadnych interesujących nas informacji. Spróbujmy więc zmierzyć czas trwania naszego eksperymentu:
Measure-Command {1..100 | foreach {Start-Sleep -Milliseconds (Get-Random -Minimum 20 -Maximum 30)}}
Teraz jest dużo ciekawiej. Oto wynik:
Days : 0 Hours : 0 Minutes : 0 Seconds : 3 Milliseconds : 130 Ticks : 31308312 TotalDays : 3.62364722222222E-05 TotalHours : 0.000869675333333333 TotalMinutes : 0.05218052 TotalSeconds : 3.1308312 TotalMilliseconds : 3130.8312
Potrzebujemy aż tylu informacji? Moim zdaniem wystarczy w zupełności jeden wynik, w milisekundach:
(Measure-Command {1..100 | foreach {Start-Sleep -Milliseconds (Get-Random -Minimum 20 -Maximum 30)}}).TotalMilliseconds
Wynik:
3124.9469
OK, czyli widzimy wyraźnie, że stukrotne oczekiwanie między 20 a 30 milisekund wykonuje się około trzech sekund. Oczekiwałbym raczej okolic dwóch i pół sekundy, ale pewnie trzeba jeszcze uwzględnić narzut samej pętli (Powershell jest językiem wysokopoziomowym, nie jest więc demonem prędkości).
A teraz skosztujmy magii najnowszej wersji i spróbujmy naszą pętelkę uwspółbieżnić:
(Measure-Command {1..100 | foreach -Parallel {Start-Sleep -Milliseconds (Get-Random -Minimum 20 -Maximum 30)}}).TotalMilliseconds
Magia polega na dodaniu opcji -Parallel do pętli foreach. Teraz kod wykonuje się w czasie około 1315 milisekund! A więc na oko jakieś dwa i pół razy szybciej.
Spróbujemy teraz trochę rozciągnąć nasz eksperyment w czasie: zamiast losować dziesiątki milisekund, będziemy losować setki. Dzięki temu zmniejszymy nieco efekt narzutu pętli i innych etceterów i dostaniemy bardziej miarodajne wyniki (chociaż będziemy musieli nieco dłużej poczekać).
Po staremu:
(Measure-Command {1..100 | foreach {Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 120)}}).TotalMilliseconds
Wynik: 11690 milisekund.
A teraz po nowemu:
(Measure-Command {1..100 | foreach -Parallel {Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 120)}}).TotalMilliseconds
Wynik: 3527 milisekund, czyli prawie trzyipółkrotnie szybciej.
Przy jeszcze większym wydłużeniu czasu wykonywania pojedynczego przebiegu pętli okazałoby się, że czas wykonania z opcją -Parallel zbliża się asymptotycznie do 20% czasu wykonania bez tej opcji. Dzieje się tak, ponieważ domyślnie Powershell rozkłada pętlę z opcją -Parallel na pięć rdzeni (a raczej wątków). Nawet jeżeli mamy procesor ośmio- czy szesnastowątkowy, to i tak domyślnie wykorzystamy tylko pięć.
Chyba że...
Chyba że skorzystamy z innej opcji zatytułowanej -ThrottleLimit, która nakazuje korzystać z tylu wątków, ile chcemy (ale oczywiście nie więcej, niż jest akurat pod ręką).
Poniższy kod pokazuje zależność między czasem wykonania a ilością wykorzystanych wątków. Mam procesor ośmiowątkowy i da się to zauważyć na poniższym przykładzie:
foreach($n in 1..16){$n.ToString()+":"+(measure-command {1..100 | foreach -ThrottleLimit $n -Parallel {Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 70)}}).TotalMilliseconds.ToString()}
Wynik:
1:12022.7903 2:5984.0288 3:4086.215 4:2888.1541 5:2182.0602 6:1805.0725 7:1602.9308 8:1385.5676 9:1377.71 10:1325.2778 11:1338.5241 12:1350.8028 13:1388.9493 14:1301.6482 15:1385.6124 16:1383.6232
Powyższe słupki należy interpretować następująco: po lewej stronie dwukropka mamy wartość parametru ThrottleLimit (czyli wymaganą liczbę współbieżnych wątków), po prawej zaś - czas wykonania stu losowych oczekiwań między 50 a 70 milisekund każde.
Jak widać czas ten sukcesywnie spada wraz ze wzrostem liczby użytych wątków, ale tylko do ośmiu - potem się stabilizuje. Dzieje się tak, ponieważ nie da się na ośmiowątkowym procesorze wykonywać więcej niż ośmiu rzeczy na raz. Jeżeli pracownik potrzebuje dwóch godzin na wykopanie łopatą dziury metr na metr na metr, to dwunastu pracowników nie wykopie tej dziury w dziesięć minut, tak samo jak dziewięć kobiet nie donosi pojedynczej ciąży w miesiąc. No nie da się i już. Pozabijają się prędzej tymi łopatami niż cokolwiek wykopią.
A teraz przykład nieco bardziej praktyczny. Porównamy sobie czas wykonania polecenia PING w wersji po kolei oraz współbieżnie:
(Measure-Command {"www.google.com","www.bing.com","www.apple.com" | ForEach-Object {ping $_ | Out-Null}}).TotalMilliseconds
Wynik: 9168 milisekund
Mniej więcej się zgadza: ping wysyła domyślnie cztery pingi, czeka między każdym z nich jedną sekundę (czyli między czterema pingami czeka trzy sekundy). Trzy sekundy na każdą z trzech domen, razem dziewięć sekund z niedużym hakiem.
A teraz po nowemu:
(Measure-Command {"www.google.com","www.bing.com","www.apple.com" | ForEach-Object -Parallel {ping $_ | Out-Null}}).TotalMilliseconds
Wynik: 3104 milisekundy. Trzy razy szybciej, bo wszystkie trzy pingi wykonywały się w tym samym czasie.
Proste?
No ba!
Przestrzegę jeszcze, żeby nie nadużywać opcji -Parallel, ponieważ nie jest ona panaceum na wszelkie problemy z wydajnością naszego kodu. Jeżeli na przykład chcemy policzyć sumy kontrolne plików w folderze, wówczas okaże się, że wąskim gardłem jest mechanizm dostępu do plików i próbując przyspieszyć tylko sobie pogorszymy sytuację:
(Measure-Command {Get-ChildItem -File -Recurse | Select-Object -First 100 | foreach {(Get-FileHash $_.FullName).Hash}}).TotalMilliseconds
Wynik: 185 ms
To samo po nowemu:
(Measure-Command {Get-ChildItem -File -Recurse | Select-Object -First 100 | foreach -Parallel {(Get-FileHash $_.FullName).Hash}}).TotalMilliseconds
Wynik: 2381 ms (tak, prawie 13 razy wolniej!) Próbujemy odczytać pięć różnych plików na raz, z tego samego dysku. Całkiem jak z tymi łopatami i kopaniem dołów...
Jeszcze gorzej będzie, jeżeli zechcemy w takiej równoległej pętli modyfikować wartość zmiennej. Zobaczmy na przykładzie prostego sumowania pierwszych stu liczb naturalnych:
$suma = 0; 1..100|foreach {$suma+=$_}; Write-Output $suma
Wynik: 5050, wszystko ok. A teraz z opcją -Parallel:
$suma = 0; 1..100|foreach -Parallel {$suma+=$_}; Write-Output $suma
Wynik: 0.
WTF?
Nie chciało mi się już kopać w czeluściach Jęternetów, ale obstawiam, że lokalne zmienne nie działają w pętli -Parallel tak samo jak w "normalnym" skrypcie. Musiałbym doczytać o zasięgach widoczności zmiennych, a już mi się nie chce. Może któryś z Czytelników jest bardziej ode mnie w temacie i zechce się podzielić?
Tak czy siak, końcowy wniosek na dziś jest taki, że warto używać opcji -Parallel, ale trzeba to robić z głową! Najlepiej wtedy, gdy w pętli wołamy coś zewnętrznego, co długo trwa i gania na różnych maszynach.
Proszę sprawdzić, czy w grupie docelowej M$ są już brane pod uwagę człekokształtne:
foreach($n in 1..(Get-Random -Minimum 1 -Maximum Get-TotalThrottleNumber) )
…
(reszta kodu bez zmian)
Powyższa komenda zwalniałaby od myślenia i dawała szanse również rezusom i makakom. Wprawdzie Naruto przegrał pierwszy proces o prawa autorskie, ale kiedy stanie na ramionach Giganta, może się to zmienić.
Dobry pomysł, tylko zamiast nieistniejącego Get-TotalThrottleNumber można użyć (Get-CIMInstance -Class ‘CIM_Processor’).NumberOfLogicalProcessors