Niebezpieczeństwa: pułapki i zagrożenia środowiskowe

Podróżując przez świat, zamki czy jaskinie prędzej czy później gracze natkną się na nawiedzony pokój, zapadnię czy kuszę która sama strzela po nadepnięciu na płytkę.

Wykrywanie Niebezpieczeństw

Każde Niebezpieczeństwo w stat blocku ma wypisane swoje stealth. Przy nim może się znajdować wymagane proficiency

  1. Jeśli nie ma wypisanego minimalnego proficiency by go zauważyć to GM automatycznie robi perception check przeciwko Stealth DC Niebezpieczeństwa dla wszystkich graczy
  2. Jeśli jest wypisane minimalne proficiency (choćby trained), to tylko osoby które aktywnie szukają Niebezpieczeństw mają szansę na ten check, i tylko pod warunkiem, że mają minimalny wymagany poziom w percepcji

Oznacza to, że jeśli ktoś szuka Niebezpieczeństw i w pomieszczeniu znajduje się pułapka która ma stealth z dopiskiem ekspert a szukający ma percepcję na poziomie trained to osoba ta nie dostaje w ogóle możliwości znalezienia jej bo nie może w ogóle rzucić na percepcję by ją znaleźć.

Warto tutaj też zauważyć, że akcja Seek pozwala tylko na znalezienie kreatur lub obiektów, niekoniecznie na znalezienie Niebezpieczeństw, jest to szczególnie ważne w przypadku Niebezpieczeństw które są bardzo nieoczywiste (np. pojawiający się haunt którego źródłem jest obraz – przy pomocy Seek można znaleźć obraz, ale niekoniecznie że jest on źródłem tego Niebezpieczeństwa)

Obecność magicznych niebezpieczeństw można również znaleźć przy pomocy detect magic ale czar ten nie da informacji jak wyłączyć czy ominąć dane niebezpieczeństwo, i niekoniecznie musi dać informacje co konkretnie jest źródłem

Triggerowanie Niebezpieczeństw

Każde Niebezpieczeństwo ma trigger definiujący co musi sie stać aby zostało ono aktywowane, np. dla pułapek może to być mechanizm który po naciśnięciu aktywuje ją, gdy dla zagrożeń środowiskowych samo znalezienie się odpowiednio blisko może być triggerem.

Jeśli drużyna nie wykryje Niebezpieczeńswa w trakcie podróżowania, zostaje ono aktywowane. W większości przypadków mają one opisaną reakcję którą wykonują natychmiast po aktywowaniu. Dodatkowo, skomplikowane niebezpieczeństwa najczęściej rzucają też automatycznie na inicjatywę, zaczynając nową walkę lub dołączając do trwających – a Niebezpieczeństwo stwarza zagrożenie dłuższy czas.

Darmowe Akcje

W niektórych przypadkach niebezpieczeńśtwa zamiast reakcji mają darmowe akcje pozwalające zadziałać więcej niż raz na rundę, dobrym przykładem jest Quicksand który wciągnie każdą istotę która w nim stoi, a nie tylko pierwszą w rundzie jak byłoby gdyby zamiast free action miało reakcję

Rutyny

Skomplikowane Niebezpieczeństwa mają zaprogramowane akcje których się trzymają zwane rutynami. Po strigerowaniu, jeśli gracze nie są w trakcie walki to rzucają na inicjatywę, Niebezpieczeństwo najczęściej rzuca z Stealthu.

W każdej rundzie swojej inicjatywy Niebezpieczeństwo wykonuje wypisane akcje. To co nie jest oczywiste patrząc na stat blok to ilość akcji które Niebezpieczeństwo ma na turę. Z zasady niebezpieczeństwo najczęściej nie ma 3 akcji na turę tylko jedną. Czasami przy wpisie Routine Niebezpieczeństwo ma dopisek z ilością akcji – oznacza to ile razy na turę dana akcja jest wykonywana. Jeśli nie ma dopisku to wykonuje raz i tura się kończy. Jeśli ma napisane 2 akcje, to wykonuje 2 razy i tura się kończy. Może się zdarzyć że wykonuje np. 4 razy na turę – jest to prawidłowe. Najczęściej w takich sytuacjach można w jakiś sposób ograniczyć tę ilość akcji, np. 4-głowa strzelająca statua może mieć 4 ataki dopóki jest w pełni sprawna, ale po zniszczeniu jednej z głów będzie mieć już ich tylko 3

Wyłączanie Niebezpieczeństw

Najbardziej wszechstronnym sposobem na wyłączenie Niebezpieczeństwa to Disable a Device aczkolwiek bardzo często wystarczy odpowiednio mocno uderzać by je zniszczyć. Zagrożenia środowiskowe często można wyłączyć przy pomocy Natury czy Survivalu a Haunty przy pomocy Occultism czy Religion. W każdym przypadku próba wyłączenia jest 2-akcyjną aktywnością i krytyczna porażka może mieć dalsze konsekwencje – najczęściej trigerując dane Niebezpieczeństwo.

Podobnie jak przy wykrywaniu Niebezpieczeństwo może wymagać minimalnego proficiency w danym skillu by w ogóle móc spróbować.

Może się też zdarzyć, że dane Niebezpieczeństwo może wymagać kilku udanych checków – np. w przypadku wspomnianej wcześniej 4-głowej strzelającej statuy może się okazać że przy każdej z głów trzeba osobno pogmerać.

Warto tu wspomnieć że niektóre Niebezpieczeństwa mogą zostać zresetowane. Czasami dzieje się to automatycznie po pewnym okresie czasu, czasem może wymagać interakcji przez jakąś istotę

Innym sposobem na poradzenie sobie z Niebezpieczeńśtwem jest zaatakowanie i uszkodzenie go. Najczęściej wiąże się to z strigerowaniem go, aczkolwiek może się zdarzyć że zniszczenie go jednym ciosem zapobiega temu. Nie jest to jednak reguła – szczególnie jeśli jest zbudowane z kilku części.

Dwa rodzaje Niebezpieczeństw

Niebezpieczeństwa dzielą się na dwa rodzaje

Różnią się one tym, że proste używają swojej reakcji raz w trakcie gdy skomplikowane działają podobnie do potworków, rzucają na inicjatywę i mają swoje akcje – nawet jeśli są zautomatyzowane w swoim statblocku do najczęściej jednej możliwej akcji.

W zależności od skomplikowania jest też przydzielany inny exp za pokonanie danego zagrozenia. W przypadku Skomplikowanego zagrożenia jest to tyle samo doświadczenia co za potworka o danem poziomie, w przypadku Prostego jest to 1/5 tej wartości. Np. 3-poziomowa drużyna napotykająca 5-poziomowe skomplikowane zagrożenie dostanie za niego 80 punktów doświadczenia ponieważ musiała praktycznie walczyć z 5-poziomowym potworkiem. Ta sama drużyna napotykająca 5-poziomowe proste zagrożenie dostanie tylko 16 punktów doświadczenia ponieważ było ono dużo mniejszym wyzwaniem.

Doświadczenie za dane zagrożenie otrzymuje się niezależnie od tego czy zostało ono striggerowane, ominięte czy wyłączone. Natomiast – jeśli drużyna drugi raz napotka dokładnie to samo zagrożenie po tym jak go wcześniej ominęła – nie dostaje drugi raz doświadczenia za niego.

Heksploracja

Przyszła pora na Hexplorację. Nazwa bierze się – nie zgadniecie – z połączenia słów „hex” oraz „eksploracja” i oznacza… eksplorowanie heksów. Dziękuję za przyjście na mój Ted Talk. A tak na poważnie.

Mapa do heksploracji podzielona jest na hexy/szcześciany, każdy z nich ma mniej więcej 12 mil na przekątną od wierzchołka do wierzchołka. Hexy mogą zawierać różne rzeczy:

  • Puste – hexy w których poza piękną naturą i szansą na losowe spotkanie nic specjalnego nie ma
  • Standardowe – hexy w których są spotkania, które występują one automatycznie jak będziecie podróżować przez dany hex. Może to być walka ale mogą to też być ruiny, nietypowe miejsce i sporo innych rzeczy
  • Landmarki – hexy w których są bardzo charakterystyczne/nietypowe fragmenty krajobrazu, najczęściej są odkrywane jak się jest na hexie obok, ale niektóre można odkryć z nawet większych odległości
  • Ukryte – hexy w których są pewne rzeczy ale trzeba zbadać hex by mieć szansę na odkrycie ich. Często NPC rozsiani po świecie również mogą do nich zaprowadzić – jeśli sami wiedzą o danej rzeczy (i często chcą czegoś w zamian)
  • Sekretne – hexy w których pewne rzeczy pojawiają się tylko w pewnych okolicznościach
  • Zasoby – dotyczy kingmakera, do tego wrócimy przy zasadach dotyczących budowaniu królestwa, generalnie są to hexy posiadające coś co można wykorzystać (np. złoża złota)

Są dwa rodzaje rzeczy które może robić cała grupa w trakcie hexploracji

  1. Podróż – przemieszczanie się pomiędzy hexami
  2. Rekonesans – dokładne badanie hexu, wcześniejszy rekonesans terenu będzie też potrzebny później przy budowaniu królestwa

Drużyna wybiera co robi danego dnia. Każda z tych rzeczy liczy się jako 1 aktywność i robi to cała grupa. Teoretycznie możecie się podzielić na mniejsze grupki – przy czym każdego dnia jest flat szansa na 0-2 losowych spotkań, no i po rozdzieleniu się tylko część drużyny może brać udział w walce, więc szczególnie na niskich poziomach oraz gdy nie macie NPC mogących uzupełnić braki w drużynie – nie polecam rozdzielania się. Losowe spotkania w trakcie eksploracji nie wykluczają losowych spotkań w trakcie obozowania.

Ilość aktywności które można zrobić w trakcie hexploracji zależy od podstawowej prędkości najwolniejszej osoby w drużynie. Warto tutaj zaznaczyć, że to nie zależy tylko od prędkości postaci, ale też od terenu, do tego wrócę zaraz ale jeśli poruszacie się przez trudny teren to prędkość dzielona jest na pół.

  • 5-10 stóp: 1/2 – zrobienie jednej aktywności, jak choćby podróż przez dany hex zajmuje 2 dni zamiast 1
  • 15-25 stóp: 1 – domyślna wartość dla większości sytuacji, gdy nie przemieszczacie się przez trudny teren
  • 30-40 stóp: 2 – można zrobić podróż o 2 hexy, albo podróż i rekonesans jednego dnia
  • 45-55 stóp: 3
  • 60 stóp i więcej: 4

Konie posiadają prędkość 40 więc jeśli cała drużyna zaopatrzy się w nie to znacznie przyspieszy to podróżowanie. Zwykły koń to koszt 8 gp, koń bojowy – taki który nie panikuje gdy zacznie się walka 30 gp. Więcej informacji o Walce z Mounta

Wszyscy macie domyślnie 25 stóp. Prędkość przemieszczania zależy od trudności terenu. Podróż przez

  • pola to jak podróż przez normalny teren
  • lasy to jak podróż przez trudny teren – czyli prędkość postaci dzieli się na 2. Jeśli wszyscy macie 25 ft to w trudnym terenie macie 12.5 stóp, możecie mieć całą aktywność ale utnie to godzinę z obozowania
  • góry i bagna to podróż jak przez większy trudny teren – czyli prędkość postaci dzieli się na 3. Jeśli wszyscy macie 25 ft czyli w większym trudnym terenie macie 8.3, czyli pół aktywności/dzień. Pamiętajcie, że jeśli ktoś jest encumbered to dostaje 10 stóp kary do ruchu. Dla zwykłych koni te liczby to odpowiednio zwykły teren: 40, trudny: 20, większy trudny: 13.3.

Walka z mounta

Podsumowanie zasad walki z mounta

  • mount musi być przynajmniej 1 rozmiar większy od was oraz być willing
  • mount współdzieli inicjatywę jeźdźca: rzuca sobie osobno na inicjatywę, ale tak długo jak ktoś na nim jeździ to wykonuje akcje natychmiastowo w trakcie tury jeźdźca
  • aby mount coś robił trzeba użyć akcji na command an animal: jest to nature check przeciwko Will DC zwierzęcia. Jeśli ma się Ride general feat to ma się automatycznie sukces na tym checku do przemieszczania zwierzęcia. Check na Command an animal trzeba robić za każdym razem jak chce się by koń coś robił. Zwykłe mounty wymagają command an animal na każdą swoją akcję. Czyli używacie command an animal za każdym razem jak chcecie żeby zwierze coś robiło i dostaje ono 1 akcję a nie 2. Wyjątkiem jest animal companion który jest minionem i on dostaje 2 akcje po command an animal, ale może mieć maksymalnie 2 akcje na ture. Oznacza to że w walce jak chcecie by koń przemieścił się to jest 1 akcja, ale jak chcecie by przemieścił się 2 razy, albo przemieścił i zaatakował to są 2 akcje i za każdym razem trzeba zrobić command an animal
  • jeśli zwierze ma wieloakcyjne aktywności, to używa się command an animal raz, ale zużywa ono tyle samo akcji co aktywność zwierzęcia. Np. koń ma 2-akcyjny Gallop który pozwala przemieścić się w 2 akcjach o 100 stóp zamiast robić 2x stride i mieć łącznie 80 stóp ruchu ale command an animal by on to zrobił wymaga również zużycia 2 akcji przez gracza przy czym wystarczy check zrobić raz
  • jeśli nie użyje się command an animal to zwierze domyślnie nic nie robi
  • możecie też wydawać komendy zwierzętom na których aktualnie nikt nie jeździ, na sukcesie wykonują one tę akcję w swojej najbliższej turze
  • wy oraz wasz mount walczycie jako jedna jednostka, w związku z tym współdzielicie MAP: jeśli gracz zaatakuje a następnie zrobi command an animal by koń zaatakował, to ma on -5 MAP
  • zajmujecie każdą kratkę waszego mounta na potrzeby ataku, obrony oraz efektów obszarówych
  • atakując was przeciwnik wybiera czy targetuje jeźdźca czy mounta, ale ataki obszarowe najczęściej obejmują obu
  • w związku z tym, że wasz mount jest rozmiar większy, na ataki do których stałby na drodze (np. ktoś stoi na ziemi i atakuje was mieczem) macie lesser cover (+1 do AC)
  • bronie z reach działają odrobinkę inaczej: jeśli mount jest medium size to działa on normalnie ale jeśli mount jest large albo huge to możecie atakować kratki przylegające do mounta jak macie 5 albo 10 stóp zasięgu i 2 kratki dalej jak macie 15 stóp zasięgu
  • ponieważ będąc na mouncie nie możecie tak łatwo sobą ruszać macie -2 circ. pen. do Reflex save
  • jedyną akcją ruchu jaką możecie zrobić będąc na mouncie to Mount action by się dismountować. Nie będąc na koniu możecie użyć Mount akcji aby na niego wejść. W przypadku willing zwierzęcia nic więcej się nie dzieje.
  • jeśli mount jest unwilling, przestraszony itd to może użyć reakcji Buck na zrzucenie jeźdżca z siebie: jeździec który triggeruje to musi zrobić Reflex save albo spaść na ziemię, na krytycznej porażce dodatkowo dostaje d6 obrażeń. Zwierzęta które ufają jeźdźcowi nie zrobią tego, chyba że jest mocno wystraszony albo zrobi się coś przeciwko koniu
  • bronie które mają trait jousting (np. lanca) mają 2 dodatkowe rzeczy:
    1. zadają dodatkowy dmg jeśli jeździec poruszy się przynajmniej 10 stóp przed zaatakowaniem, ten bonusowy dmg jest równy liczbie kostek broni, dla zwykłej lancy jest to 1, czyli obrażenia z niej będą bazowo 1d8+1
    2. jeżdżąc na mouncie można trzymać je w 1 ręce, wtedy kość obrażeń zmniejsza się do wylistowanej przy jousting, czyli dla zwykłej lancy z 1d8 maleje do 1d6
  • można kupić zbroję na mounta/barding, nie może on być magicznie ulepszony runami, ale istnieją magiczne bardingi
  • mounty nie nauczone do walki (takie jak riding horse) dostają na początek walki status Fleeing oraz Frightened 4. U siebie robię małe houserule: fleeing rozstrzygam dopiero po pierwszym command an animal, a nie na początku tury. Możecie zrobić command an animal aby skasować fleeing z mounta. Jak w swojej turze nie zrobicie command an animal to koń będzie zużywał swoje pozostałe akcje na uciekanie jak tylko skończycie swoją 3 akcję. Jak command an animal na skasowanie fleeing się nie powiedzie to koń automatycznie użyje stride by uciekać od walki, ale dam opcję na spróbowanie drugi raz zanim on poruszy się drugi raz. Wydaje mi się to bardziej fair niż koń który zanim jego jeździec zdąży cokolwiek zrobić ucieka 120 stóp od walki
  • walki nie boją się specjalnie wytrenowane do tego mounty, takie jak war horse

Wykrywanie i ukrywanie się

Na blogu od dawna nic się nie pojawiało więc postanowiłem zacząć wrzucać tu artykuły które do tej pory pisałem na discordzie MoRPGs, dotyczące Pathfindera 2. Zacznijmy od wykrywania przeciwników i ukrywania się.

Jest w zasadzie 5 poziomów wykrycia istoty (technicznie 4, ale doliczę tu concealed)

  1. Observed – widzisz kogoś normalnie
  2. Concealed – widzisz kogoś ale nie całkowicie, np. znajduje się w gęstej mgle, dymie albo jesteś dazzled. Atakując taką istotę GM dodatkowo rzuca DC 5 flat check, w przypadku porażki nie trafiasz niezależnie od wyniku kości ataku
  3. Hidden – nie widzisz danej osoby, ale wiesz na której kratce się znajduje. Może być schowany za zasłoną i wiesz, że jest za tą konkretną zasłoną, może być niewidzialny ale słyszysz jak się przemieszcza itd. Atakując taką osobę GM robi DC 11 flat check, jak w przypadku concealed – na porażce nie trafiasz niezależnie od wyniku kości
  4. Undetected – nie widzisz danej osoby, nie wiesz na której kratce się znajduje, ale wiesz, że jest gdzieś w okolicy. Możesz strzelać na ślepo targetując dowolne kratki i tutaj również jest DC 11 flat check, natomiast w tym przypadku oba rzuty, również test ataku, wykonuje GM w sekrecie. Innymi słowy – jeśli nie trafisz to nie wiesz czy dlatego że w danej kratce niczego nie było, było ale rzuciłeś mało i nie trafiłeś bo nie przebiłeś AC czy było ale nie zdałeś flat checku. W zasadzie tylko jak trafisz to wiesz, że Ci się poszczęściło
  5. Unnoticed – nie wiesz, że coś się znajduje w Twojej okolicy. Możesz atakować na oślep tak samo jak na Undetected ale nie masz większego powodu by uważać że coś się w okolicy znajduje

Przy wykrywaniu istot warto też wspomnieć o różnych zmysłach. Każda istota ma 3 rodzaje zmysłów. To na jakim poziomie wykrycia jest istota dla danej osoby zależy od najlepszego zmysłu który jest w stanie wykryć tę istotę. Brzmi skomplikowanie ale wcale nie jest!

  1. Precise – dla graczy to wzrok, pozwala on na observed. Jego utrata (chociażby status blinded) sprawia że nie da się obserwować przeciwnika. Nie mając żadnego zmysłu precise traktujesz każdy teren jako (przynajmniej) trudny teren
  2. Imprecise – dla graczy to najczęściej słuch aczkolwiek są opcje na zapach, pozwala na maksymalnie hidden aczkolwiek w niektórych sytuacjach jak np. opieranie się na słuchu w głośnym pomieszczeniu trzeba zrobić dodatkowo seek action
  3. Vague – dla graczy to najczęściej zapach, pozwala na maksymalnie undetected

Jak widzicie dla was o ile wzrok jak domyślnie jedyny precise zmysł jest też jedynym który pozwala normalnie atakować – najłatwiej też się przed nim ukryć. Wchodząc do pomieszczenia w którym podejrzewacie, że mogą być przeciwnicy można bez problemu robić Seek action korzystając z innych zmysłów niż wzrok, opisując czy się rozgląda, nasłuchuje czy stara wywąchać. Z tego powodu opcje dające dodatkowy imprecise zmysł, (jak np. Serpent’s Tongue u nagaji) może być bardzo przydatne do wykrywania normalnie bardzo dobrze ukrywających się przeciwników.

Skoro mamy ogarnięte podstawy, przejdźmy do ukrywania się. W walce są dwie akcje pozwalające na to, że ma się inny status niż observed u danego przeciwnika.

  1. Hide – chowanie się za coverem albo greater coverem Check wykonywany jest w sekrecie i porównywany z perception DC istot dookoła – w przypadku sukcesu jest się hidden. Do checku dodaje się bonus z coveru (+2 circ.) albo greater coveru (+4 circ). Domyślnie jedyne akcje które można wykonywać bez tracenia hidden to hide, sneak, oraz step. Jeśli utraci się cover od istoty przed którą się ukrywało (może być zniszczony, ale mogła też okrążyć od drugiej strony) to automatycznie jest się observed
  2. Sneak – przesuwanie się od jednego coveru do drugiego coveru, check również wykonywany jest w sekrecie, na koniec ruchu, porusza się z połową prędkości i na koniec ruchu trzeba mieć znowu cover. Jeśli było się hidden na początku ruchu: to w przypadku sukcesu na koniec jest się undetected (i przez cały ruch się było undetected), w przypadku porażki na koniec jest się dalej hidden (i przez cały ruch było się hidden), w przypadku krytycznej porażki jest się observed (i przez cały ruch było się observed).

Zarówno w sytuacji jak się jest hidden oraz gdy jest się unobserved to atakowanie przeciwnika sprawia, że jest on flat-footed, po czym automatycznie jest się observed. Ponieważ zarówno hide jak i sneak porównuje się z perception DC przeciwników to może się okazać że dla niektórych jest się hidden a dla innych observed w tym samym momencie. Np. załóżmy że przeciwnik A ma +7 do percepcji (czyli DC to 17), a przeciewnik B ma +11 do percepcji (czyli DC to 21). Gracz robi hide i GM wyrzucił w sumie 19. Gracz dla przeciwnika A będzie hidden ale dla przeciwnika B będzie wciąż observed. Następnie gracz robi sneak i GM wyrzucił w sumie 25: gracz dla przeciwnika A będzie undetected, ale dla przeciwnika B wciąż będzie observed ponieważ na początku sneaku nie był hidden tylko observed. Co lepsze – z racji że oba testy są sekretne to gracz na meta poziomie nie ma pojęcia czy jest ukryty czy nie. W zasadzie jedynie w momencie gdy atakuje i ma coś co aktywuje się w momencie jak przeciwnik jest flat-footed (np. sneak attack) – to dopiero wtedy wie, że był faktycznie ukryty.

Ukrytych przeciwników (oraz ukryte przedmioty) można wyszukiwać przy pomocy seek action. Jest to skanowanie otoczenia, najczęściej wystarczy jedna akcja na przejrzenie całego pomieszaczenia ale w przypadku większego terenu GM może poprosić o wskazanie którą część się przegląda. Jest to check percepcji przeciwko Stealth DC przeciwnika. Tu warto zauważyć, że test nie jest przeciwko wynikowi jaki istota miała na ukrywanie się tylko przeciwko jego Stealth DC. Check jest wykonywany w sekrecie. I tak samo jak wcześniej przy ukrywaniu się – może się okazać że znalazło się tylko część ukrytych przeciwników/rzeczy. Np. przeciwnik A ma +5 do stealthu (stealth DC to 15), a przeciwnik B ma +12 do stealthu (stealth DC to 22), gracz wyrzucił 21, wykrywa tylko przeciwnika A. W przypadku sukcesu istoty które były hidden są observed, a undetected są hidden, natomiast w przypadku krytycznego sukcesu zarówno hidden jak i undetected są observed!

Jest jeszcze akcja point out – można wskazać jedną istotę która jest undetected przez kogoś z sojuszników ale nie jest undetected dla Ciebie i wskazać ją – staje się ona hidden dla sojusznika

Wydajność sprawdzania zawartości kolekcji

Nierzadko w różnego rodzaju aplikacjach można spotkać się z kodem, który sprawdza, czy dana kolekcja zawiera jakieś elementy. W wielu przypadkach, gdy tablica lub lista jest pusta, to można wcześniej przerwać kod. Programiści otrzymują kilka możliwości na sprawdzenie tego warunku. Wydajnością najpopularniejszych rozwiązań zajmiemy się w tym artykule.

Count() == 0 vs Any()

Każda osoba programująca w C# spotkała się z LINQ – pomocniczymi klasami rozszerzającymi funkcjonalność innych klas. Jednymi z najczęściej wykorzystywanych są te, które rozszerzają interfejs IEnumerable. Dzięki nim dostajemy takie metody jak Select(), Count() czy Any(). Tu szczególnie będą nas interesować ostatnie dwie spośród wymienionych.

Count() == 0

Metoda Count() zwraca liczbę elementów znajdujących się w enumeracji, czyli np. liczbę elementów listy, ilość wywołań yield return pewnej metody czy liczbę par klucz-wartość w słowniku. Jednak jeśli zajrzymy do jej wnętrza odkryjemy, że zanim przystąpi do liczenia sprawdza kilka warunków:

if (source == null)
{
    ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}

if (source is ICollection<TSource> collectionoft)
{
    return collectionoft.Count;
}

if (source is IIListProvider<TSource> listProv)
{
    return listProv.GetCount(onlyIfCheap: false);
}

if (source is ICollection collection)
{
    return collection.Count;
}

int count = 0;
using (IEnumerator<TSource> e = source.GetEnumerator())
{
    checked
    {
        while (e.MoveNext())
        {
            count++;
        }
    }
}

return count;

Jak widać, kod najpierw sprawdza, czy to, co próbujemy policzyć, nie jest nullem, następnie czy nie implementuje jednego z interfejsów, które mają prostszą logikę (np. w przypadku listy przechowywana jest informacja o liczbie elementów w jej środku, więc nie trzeba ich za każdym razem liczyć od początku) i dopiero, gdy żaden ze skrótów nie zadziała, uruchamiane jest „manualne” liczenie elementów.

Any()

Metoda Any(), jak nazwa wskazuje, sprawdza, czy w środku enumeracji znajduje się jakikolwiek element. Dodatkowo ta sama metoda pozwala na sprecyzowanie warunków, które musi spełnić element, aby został zaliczony. Tym jednak zajmiemy się innym razem. Podobnie jak w przypadku poprzedniej metody, zajrzyjmy do środka. Tym razem jednak mamy dwie różne odpowiedzi.

Przed .NET 5.0

if (source == null)
{
    ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}        
using (IEnumerator e = source.GetEnumerator())
{
    return e.MoveNext();
}

Już na pierwszy rzut oka widać, że kod jest znacznie krótszy. Czy oznacza to, że jest lepszy (czyt. szybszy)? Zanim przejdziemy do benchmarków, zastanówmy się nad tym. W kodzie nie ma skrótów przechodzących do specyficznych przypadków, przez co nie tracimy czasu, gdy korzystamy ze zwyczajnej enumeracji. Z drugiej strony, jeśli faktycznie odpalamy ten kod np. na liście – będziemy musieli i tak zaalokować enumerator, co może negatywnie wpłynąć na wydajność.

.NET 5.0 wzwyż

if (source == null)
{
    ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
if (source is ICollection collectionoft)
{
    return collectionoft.Count != 0;
}
else if (source is IIListProvider listProv)
{
    // Note that this check differs from the corresponding check in
    // Count (whereas otherwise this method parallels it).  If the count
    // can't be retrieved cheaply, that likely means we'd need to iterate
     // through the entire sequence in order to get the count, and in that
     // case, we'll generally be better off falling through to the logic
     // below that only enumerates at most a single element.
     int count = listProv.GetCount(onlyIfCheap: true);
     if (count >= 0)
     {
         return count != 0;
     }
}
else if (source is ICollection collection)
{
    return collection.Count != 0;
}
using (IEnumerator e = source.GetEnumerator())
{
    return e.MoveNext();
}

W najnowszej wersji .NET zostały wprowadzone pewne skróty. Dzięki nim można przede wszystkim uniknąć alokacji enumeratora, co ma ogromny wpływ na wydajność.

Mierzenie wydajności implementacji dzięki BenchmarkDotNet

Optymalizacja aplikacji to jeden z ważniejszych etapów tworzenia szybkich aplikacji. Praktycznie każdy algorytm można zaimplementować na więcej, niż jeden sposób[1]Maksimum wydajności w aplikacjach .NET dzięki Hardware Intrinsics. Programiści jednak często stają przed dylematem: skąd wiedzieć, która wersja jest szybsza, albo czy zmiany, które w teorii powinny zwiększyć wydajność, nie robią przypadkiem czegoś dokładnie odwrotnego. Ręczne mierzenie czasu wykonywania implementacji wiąże się z wieloma pytaniami i wątpliwościami, jak zrobić to dobrze, aby wyniki były jak najbardziej reprezentatywne. Z odpowiedzią przychodzi BenchmarkDotNet.

Nie wystarczy zmierzyć czas?

Jednym z najprostszych sposobów, które zapewne prawie każdy programista kiedyś użył, jest zmierzenie różnicy między czasem przed i po wykonaniu operacji. Niestety nie zawsze taki pomiar będzie miarodajny – choćby z powodu w jaki współczesne procesory zarządzają zegarem. Standardowo dla oszczędności energii jest on przetaktowywany „w dół” i szybko podnoszony w sytuacji większego zapotrzebowania. Problem polega na tym, że zwiększenie częstotliwości, z jaką działa procesor wpływa na niemiarodajność wyniku czasu wykonania algorytmu. Co więcej, bieżące taktowanie może być nieprzewidywalne, kiedy nie jest wymuszone większym obciążeniem.

Można przeprowadzić operację mierzenia czasu wielokrotnego wykonania algorytmu i uśrednić go. Tu pojawiają się kolejne problemy, np. procesory Intel w trybie Boost są w stanie pracować przez ograniczony czas, a jeśli benchmark trwa dłużej, to wynik zostanie wypaczony. Na Rys. 1. widać symulację takiego zachowania. Przedstawiony program generuje tablicę z milionem elementów, a następnie sortuje ją przy pomocy LINQ. Pierwsze kilka wywołań potrzebuje wyraźnie więcej czasu niż kolejne. Na końcu uruchomiono symulację końca trybu boost poprzez ograniczenie zegara do 80% poprzedniej wartości, co dało prawie trzykrotnie dłuższy czas.

Rys. 1. Symulacja różnicy wykonania algorytmu w zależności od taktowania procesora

Możemy zacząć implementować obliczanie odchylenia standardowego, odrzucać skrajne 5% wyników i korzystać z innych sztuczek statystycznych… Czyli robić to, co BenchmarkDotNet od razu zrobi za nas.

Instalacja i przygotowanie

Przykłady z artykułu są dostępne na naszym GitHubie[2]https://github.com/DawidIzydor/BenchmarkDotNetExamples.

W pierwszym kroku utwórzmy nowy projekt. BenchmarkDotNet napisany jest w .NET Standard 2.0, co oznacza, że możemy z niego korzystać zarówno w .NET Core 2.0 jak i w .NET Framework 4.6 oraz w wersjach nowszych [3]https://benchmarkdotnet.org/articles/overview.html.

Bibliotekę do projektu można dodać przy pomocy Menedżera pakietów Nuget szukając w nim BenchmarkDotNet lub z poziomu Konsoli menedżera pakietów wpisując do niej Install-Package BenchmarkDotNet.

Pierwszy benchmark

public class GenerateSortedArrayBenchmark
{
    [Benchmark]
    public int[] ClassicGenerateAndSortUsingLinq()
    {
        var array = new int[1_000_000];
        var rnd = new Random(42);
        for (int i = 0; i < array.Length; ++i)
        {
            array[i] = rnd.Next();
        }
        return array.OrderBy(i => i).ToArray();
    }
}

Tworzymy klasę GenerateSortedArrayBenchmark, w której umieszczamy pierwszą metodę, nazwijmy ją ClassicGenerateAndSortUsingLinq. Przed metodą dodajemy adnotację [Benchmark]. Ważne, aby zarówno klasa, jak i metoda był widoczne publicznie.

Aby uruchomić test zmodyfikujmy Main startując w nim benchmark.

private static void Main()
{
    BenchmarkRunner.Run<OddValuesSumBenchmark>();
}

Ostatnim krokiem jest skompilowanie kodu w konfiguracji Release. Jest to ważne, ponieważ w ten sposób kompilator zaaplikuje optymalizacje, a wygenerowany kod będzie taki, jak uruchamiany w środowisku produkcyjnym.

W końcu możemy uruchomić benchmark. Ważne, aby nie robić tego przez Visual Studio – dołączy on do programu debuger, który może spowalniać wykonanie. Uruchomienie pliku .exe może dać niechciany rezultat w postaci programu zamykającego się od razu po wykonaniu benchmarku – nie zdążymy obejrzeć wyników. Dodanie Console.ReadKey(); aby temu zapobiec nie zawsze jest dobrym rozwiązaniem – łatwo przypadkiem nacisnąć jakiś klawisz i stracić wyniki – co może być szczególnie dotkliwe w przypadku benchmarków, które wykonują się kilka minut i dłużej. Osobiście uruchamiam je przez PowerShell, natomiast dobrym nawykiem jest korzystanie z dowolnej konsoli – w tym wspomnianej już wcześniej Konsoli menedżera pakietów dostępnej bezpośrednio z poziomu Visual Studio.

Rys. 2. Pierwsze wyniki

Program po uruchomieniu wyświetli dużo informacji. Najważniejszą z nich jest tabelka widoczna na końcu wszystkich wiadomości, która pokazuje podstawowe informacje z benchmarku.

Dobrze byłoby do czegoś porównać…

Benchmarków używamy przede wszystkim do znajdowania szybszych implementacji i porównywania czasów różnych rozwiązań. Patrząc na poprzedni kod możemy pomyśleć, że zamiast tworzyć tablicę a następnie ją sortować, możemy skorzystać z mechanizmu, który od razu przy tworzeniu będzie automatycznie sortował za nas. Sprawdźmy, czy zadziała szybciej.

[Benchmark]
public int[] GenerateUsingSortedDictionary()
{
    var dictionary = new SortedDictionary<int, int>();
    var rnd = new Random(42);
    for (var i = 0; i < 1000; ++i)
    {
        var randomNr = rnd.Next();
        if (dictionary.ContainsKey(randomNr))
        {
            dictionary[randomNr]++;
        }
        else
        {
            dictionary.Add(randomNr, 1);
        }
    }
    return dictionary.Keys.ToArray();
}
Rys. 3. Wyniki dwóch benchmarków

Kod z ostatniego benchmarku ma kilka problemów. Przede wszystkim nie zwróci dobrej tablicy w przypadku, jeśli wylosowane wartości będą się powtarzać (w tej implementacji zobaczymy tylko unikalne wartości). Nie musimy się tym jednak przejmować, ponieważ jest zdecydowanie zbyt wolny, aby w ogóle się nim zajmować.

Dodałem „po cichu” jedną zmianę w kodzie – przy pierwszym benchmarku zmieniłem adnotację [Benchmark] na [Benchmark(Baseline = true)]. Dzięki temu wynik drugiego benchmarka jest porównywany do pierwszego – kolumna ratio to iloczyn średniego czasu danego benchmarka przez wynik porównawczy.

Spróbujmy jednak przyspieszyć kod

Sprawdzając kod przy pomocy profilera (więcej o nim wkrótce) widzimy, że wąskim gardłem kodu jest sortowanie. Spróbujmy skorzystać z klasycznego Quicksortu[4]http://www.algorytm.org/algorytmy-sortowania/sortowanie-szybkie-quicksort/quick-1-cs.html zamiast polegać na LINQ.

[Benchmark]
public int[] GenerateAndQuickSort()
{
    var array = new int[1_000_000];
    var rnd = new Random(42);
    for (var i = 0; i < array.Length; ++i)
    {
        array[i] = rnd.Next();
    }

    QuickSort(array, 0, array.Length-1);
    return array;
}

private static void QuickSort(int[] array, int left, int right)
{
    var i = left;
    var j = right;
    var pivot = array[(left + right) / 2];
    while (i < j)
    {
        while (array[i] < pivot) ++i;
        while (array[j] > pivot) --j;
        if (i <= j)
        {
            var tmp = array[i];
            array[i++] = array[j];
            array[j--] = tmp;
        }
    }
    if(left<j) QuickSort(array, left, j);
    if(i < right) QuickSort(array, i, right);
}
Rys. 4. Wyniki pierwszego i trzeciego benchmarku

Parametryzacja i refaktoryzacja benchmarków

W poprzednim akapicie udało się otrzymać sortowanie trzy razy szybsze od LINQ. BenchmarkDotNet pozwala na łatwe porównanie czasów dla różnych wielkości tablic. W tym celu zrefaktoryzujmy nasze benchmarki i wprowadźmy parametry.

Kontrola wielkości tablicy dzięki parametrowi

BenchmarkDotNet pozwala na uruchomienie każdego z naszych benchmarków dla innej wielkości tablicy. Aby to osiągnąć wprowadźmy parametr N, którego następnie użyjemy w odpowiednich miejscach kodu.

[Params(100, 10_000, 1_000_000)]
public int N;

Adnotacja Params mówi narzędziu, jaką wartość wykorzystać do kolejnych benchmarków. W ten sposób program zamiast trzech benchmarków uruchomi ich tak naprawdę dziewięć.

Refaktoryzacja i GlobalSetup

W każdym z benchmarków powtarza się jedna linijka – inicjalizacja generatora losowych liczb. W ogólnym rozrachunku takiego kodu może być więcej. Chcąc unikać niepotrzebnych powtórzeń, wyciągnijmy go do funkcji inicjalizującej kod. Dzięki temu będziemy mieć pewność, że każdy benchmark będzie korzystał z generatora zainicjalizowanego w taki sam sposób (i nie zapomnieliśmy nigdzie dodać ziarna).

private Random rnd;
[GlobalSetup]
public void Setup()
{
    rnd = new Random(42);
}

Adnotacja GlobalSetup mówi BenchmarkDotNet, aby tej metody użyć przed każdą iteracją każdego benchmarku.

Ostateczne wyniki

Rys. 5. Ostateczne wyniki wszystkich benchmarków

Kod oczywiście może być jeszcze przyspieszony, szczególnie dla większych wartości N, np. poprzez wykorzystanie więcej niż jednego wątku. W wynikach widzimy benchmark, który mimo niewielkiej zmianie w kodzie dał 75% wzrostu wydajności dla dużych tablic.

Sam BenchmarkDotNet oprócz tego umożliwia więcej możliwości konfiguracji, mierzenie ilości alokacji oraz zużycie pamięci przez algorytm. Wkrótce pojawią się kolejne artykuły opisujące tego typu scenariusze.

Maksimum wydajności w aplikacjach .NET dzięki Hardware Intrinsics

Najnowsze funkcjonalności .NET Core 3.0 mimo tego, że istnieją już od ponad roku, wciąż rzadko są stosowane w aplikacjach. W artykule przyjrzyjmy się, jakie korzyści mogą wynikać z wykorzystania instrukcji AVX2 w nowoczesnym kodzie, a także zobaczymy powód, dla którego należy unikać stosowania LINQ w krytycznych częściach kodu.

Niedawno otrzymałem filmik opisujący kilka rad o tym, jak zwiększyć wydajność aplikacji, dzięki pewnym sztuczkom. Zainteresowanych jego treścią odsyłam do źródła[1]5 (Extreme) Performance Tips in C#. Dlaczego wspominam o nim? Autor przedstawia kilka ciekawych sztuczek optymalizacyjnych, jednak całkowicie pomija hardware intrinsics – pewnego rodzaju nowinkę, dodaną w .NET Core 3.0, czyli trochę ponad rok temu.

Przyspieszenie sprzętowe?

Współczesne procesory architektury x86 zawierają w sobie wiele dodatkowych zestawów instrukcji. Jednym z nich jest Advanced Vector Extensions, czyli w skrócie AVX[2]Advanced Vector Extensions – Wikipedia. Umożliwia to wykonywanie operacji na wektorach, czyli zestawach liczb, co przekłada się na mniej cyklów procesora potrzebnych do otrzymania wyniku. Przykładowo, jeśli chcemy dodać do siebie 100 liczb, możemy dodawać je klasycznie, do pierwszej dodać drugą, do ich sumy trzecią, do tej sumy czwartą i tak dalej. Zajmie nam to dokładnie 100 cykli procesora, po jednym na każde dodawanie.

Zamiast tego możemy dodawać te liczby wektorem 8-elementowym – działa to w ten sposób, że w jednym cyklu procesora dodawany jest element pierwszy z dziewiątym, drugi z dziesiątym, trzeci z jedenastym i tak dalej. Dzięki temu potrzebujemy 12 cykli procesora na dodanie 96 elementów. Następnie te elementy sumujemy – co daje 8 cykli, a do ich sumy dodajemy brakujące cztery elementy, co daje kolejne 4 cykle. Łącznie 24 cykle zamiast 100.

Wszystko pięknie… Ale?

Ten wzrost wydajności ma swoje wymagania. Przede wszystkim – procesor, na którym będzie uruchamiany program musi zawierać zestaw rozkazów AVX – na szczęście większość współczesnym procesorów ją posiada. Oprócz tego z powodu, w jaki działają wektory, wymagane są wskaźniki do pamięci (jeśli Cię to dziwi – to tak, w C# można korzystać z wskaźników na tej samej zasadzie, co choćby w C++, więcej o tym wkrótce), co oznacza niebezpieczny (unsafe) kod. Wymaga to też odrobinę wiedzy, na szczęście to ostatnie właśnie Ci dostarczamy 🙂

Wymagania wstępne

Hardware Intrinsics wymaga .NET Core 3.0 lub nowszego oraz ustawienia flagi /unsafe w kompilatorze. Jeśli nie wiesz jak zainstalować .NET Core to najprawdopodobniej ten poradnik nie jest dla Ciebie, jednak nie zniechęcaj się – pobierzesz go bezpośrednio z strony Microsoftu[3]https://dotnet.microsoft.com/download/dotnet-core/.

Flagę /unsafe można dodać modyfikując plik projektu i ustawiając AllowUnsafeBlocks na true lub potwierdzając podpowiedź wyświetlaną przez Visual Studio.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <DebugType>full</DebugType>
    <DebugSymbols>true</DebugSymbols>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>


</Project>
Podpowiedź wygenerowana przez Visual Studio dodająca AllowUnsafeBlocks do pliku projektu

Jeśli nie posiadasz procesora z instrukcjami AVX – niestety nie będziesz w stanie sam sprawdzić, jak działa kod. Jeśli nie jesteś pewien czy go posiadasz, to program CPU-Z[4]CPUID website pokazuje wszystkie informacje na temat procesora, w tym czy implementuje on AVX.

Program CPU-Z z podkreślonymi instrukcjami AVX i AVX2 dla procesora AMD Ryzen 5 2600

Całkiem szybki kod bez Hardware Intrinsics

Najpierw zerknijmy na fragment kodu z wspomnianego filmiku, jeden z najszybszych a przy tym dość prosty. Dla osób, które nie oglądały filmiku – celem jest znalezienie jak najszybszej metody na zsumowanie wszystkich nieparzystych liczb z pewnej tablicy. Pełny kod można obejrzeć na naszym GitHubie[5]GitHub – HwIntrinsincsBenchmark

public int SumOdd()
{
    var counterA = 0;
    var counterB = 0;
    var counterC = 0;
    var counterD = 0;
    unsafe
    {
        fixed (int* data = &array[0])
        {
            var p = data;
            for (var i = 0; i < array.Length; i += 4)
            {
                counterA += (p[0] & 1) * p[0];
                counterB += (p[1] & 1) * p[1];
                counterC += (p[2] & 1) * p[2];
                counterD += (p[3] & 1) * p[3];

                p += 4;
            }
        }
    }

    return counterA + counterB + counterC + counterD;
}

Jest to odrobinę zmodyfikowana wersja przedostatniego przykładu z filmiku. Jedyną zmianą jest explicite dodanie bloku unsafe.

Kod choć niecodzienny, jest dość prosty, mamy cztery liczniki, do których dodajemy liczby, jeśli są nieparzyste. Wynika to z faktu, że operacja bitowego i (&) zwróci 1, jeśli ostatni bit ustawiony jest na 1 (czyli liczbę nieparzystą), w przeciwnym wypadku zwróci 0. Następnie mnożymy sprawdzaną liczbę razy tę zwróconą wartość, jak wiadomo 1 * liczba = liczba, 1 * 0 = 0. Jest to odrobinę szybszy sposób, niż instrukcja if.

Wykorzystanie AVX

public int SumUsingVectors()
{
    int sum = 0;
    var itemsCountUsingVectors = _array.Length - _array.Length % Vector256<int>.Count;
    var template = Enumerable.Repeat(1, Vector256<int>.Count).ToArray();
    var sumVector = Vector256<int>.Zero;
    unsafe
    {
        Vector256<int> templateVector;
        fixed (int* templatePtr = template)
        {
            templateVector = Avx.LoadVector256(templatePtr);
        }

        fixed (int* valuesPtr = _array)
        {
            for (var i = 0; i < itemsCountUsingVectors; i += Vector256<int>.Count)
            {
                var valuesVector = Avx.LoadVector256(valuesPtr + i);
                var andVector = Avx2.And(valuesVector, templateVector);
                var multiplyVector = Avx2.MultiplyLow(andVector, valuesVector);
                sumVector = Avx2.Add(sumVector, multiplyVector);
            }
        }
    }

    for (var iVector = 0; iVector < Vector256<int>.Count; iVector++)
    {
        sum += sumVector.GetElement(iVector);
    }

    for (var i = itemsCountUsingVectors; i < _array.Length; i++)
    {
        sum += (_array[i] & 1) * _array[i];
    }

    return sum;
}

Na pierwszy rzut oka widać, że kod jest dłuższy, więc przejdźmy przez całość.

Na początku wyliczamy, ile maksymalnie elementów jesteśmy w stanie policzyć wektorami – jeśli w jednym wektorze zmieści się 8 elementów, to będzie to podłoga z dzielenia liczby wszystkich elementów przez 8 (np. 100/8 = 12.5, czyli możemy użyć maksymalnie dwunastu ośmioelementowych wektorów).

Następnie tworzymy tablicę zawierającą same „1” – przyda się później.

W kolejnym kroku tworzymy wektor, do którego będziemy sumować.

W tym miejscu wchodzimy do niebezpiecznego (unsafe) kodu. Pierwszym krokiem jest załadowanie tablicy zawierającej same „1” jako wektor. Używamy do tego wskaźnika pokazującego na początek tablicy.

Poniżej pobieramy kolejny wskaźnik – na tablicę, która zawiera dane. Następnie wchodzimy w pętlę, która wykona się tyle razy, ile na początku wyliczyliśmy, że jesteśmy w stanie obliczyć wektorami.

W środku pętli ładujemy do wektora wartości z tablicy, wykonujemy operację AND na nim oraz na wektorze z samymi jedynkami. Wynik tej operacji mnożymy przez wektor wartości, a iloczyn dodajemy do wektora z sumą.

Jeśli przez chwilę zastanowimy się nad zawartością pętli, to szybko zauważymy, że są to dokładnie te same operacje, które są wykorzystywane w środku pętli w filmiku z YouTube, tylko wykonywane na wektorach zamiast na liczbach: Avx2.And to odpowiednik &, Avx2.MultiplyLow to odpowiednik mnożenia, a Avx2.Add to odpowiednik dodawania.

Poza blokiem unsafe sumujemy wartości z wektora wynikowego do jednej liczby, a następnie do niej dodajemy brakujące elementy.

Benchmarki

Poniżej dołączam screenshot z wynikami wygenerowanymi, dzięki Benchmark .NET[6]Mierzenie wydajności implementacji dzięki BenchmarkDotNet, ClassicIfSum to najbardziej standardowy algorytm do tego typu obliczeń, zawierający if i operację modulo 2. Do niego porównujemy pozostałe wyniki. SumFromYoutube to oczywiście kod zaczerpnięty z filmiku, SumUsingVectors to nasza implementacja. Dodałem do tego wywołanie przy pomocy LINQ: _array.Where(t => t % 2 != 0).Sum(); zdecydowanie najprostsze do zakodowania i zdecydowanie najwolniejsze.

Z wyników widać, że optymalizacja YouTubera dała 58-60% wzrostu wydajności w porównaniu do bazy, natomiast nasza dała 61% wzrostu wydajności w porównaniu do kodu YouTubera i aż 84% w porównaniu do klasycznej implementacji.

Dodatkowo LINQ było wolniejsze o odpowiednio 633% do nawet 1610% w przypadku naszego kodu.

Wyniki uruchomionych benchmarków

Na koniec dodam, że kod ten można jeszcze przyspieszyć, wykorzystując wyrównanie pamięci, jednak jest to temat na osobny artykuł. Jeśli jesteście nim zainteresowani to dajcie znać!

Podsumowanie

Najnowsze wersje .NET dają bardzo dużo narzędzi do pogłębienia optymalizacji kodu w współczesnych aplikacjach. Dzięki najnowszym funkcjonalnościom możemy pisać kod szybszy niż kiedykolwiek wcześniej. Z analizy benchmarków dowiedzieliśmy się też, żeby nie używać LINQ w miejscach, które są krytyczne dla wydajności systemu.