Korzystanie z nowoczesnego podejścia w C++

Teraz pokażemy, jak zastosować bardziej nowoczesne podejście przy użyciu C++. Celem tej sekcji jest dostarczenie informacji wystarczających do rozpoczęcia samodzielnego eksperymentowania z C++ w języku R. Przyjrzymy się tylko niewielkiemu fragmentowi tego, co można zrobić, łącząc R z C++ za pośrednictwem pakietu Rcpp (który jest domyślnie instalowany w R), ale powinno wystarczyć, aby zacząć. Jeśli nigdy nie słyszałeś o C++, jest to język używany głównie w przypadku ograniczeń zasobów ważna rola i optymalizacja wydajności ma ogromne znaczenie. Zanim przejdziemy dalej, upewnij się, że masz w swoim systemie kompilator C++. W systemie Linux powinieneś móc używać gcc. Na Macu powinieneś zainstalować Xcode ze sklepu z aplikacjami. W systemie Windows należy zainstalować Rtools. Po przetestowaniu kompilatora i upewnieniu się, że działa, powinieneś być w stanie postępować zgodnie z tą sekcją. C++ jest bardziej czytelny niż kod Fortran, ponieważ jest zgodny z większą liczbą konwencji składniowych, do których jesteśmy przyzwyczajeni. Jednak tylko dlatego, że przykład, którego użyjemy, jest czytelny, nie myśl, że C++ jest ogólnie łatwym w użyciu językiem; to nie jest. Jest to język na bardzo niskim poziomie, a jego prawidłowe używanie wymaga dużej ilości wiedzy. Powiedziawszy to, zacznijmy. Linia #include służy do przenoszenia definicji zmiennych i funkcji z języka R do tego pliku podczas kompilacji. Dosłownie zawartość pliku Rcpp.h jest wklejana dokładnie tam, gdzie znajduje się instrukcja inlude. Pliki kończące się rozszerzeniami .h nazywane są plikami nagłówkowymi i służą do dostarczania niektórych typowych definicji między użytkownikiem kodu a jego twórcami. Odgrywają podobną rolę do tego, co nazwaliśmy interfejsem  poprzednio. Linia using namespace Rcpp pozwala na użycie krótszych nazw funkcji. Zamiast określać Rcpp : : NumericVector, możemy po prostu użyć NumericVector do zdefiniowania typu obiektu data. Zrobienie tego w tym przykładzie może nie być zbyt korzystne, ale kiedy zaczniesz programować dla złożonego kodu C++, naprawdę się to przyda. Następnie zauważysz kod //[[Rcpp::export(sma_delegated_cpp)]]. Jest to znacznik oznaczający funkcję tuż pod nim, aby R wiedział, że powinien ją zaimportować i udostępnić w kodzie R. Argument wysłany do export() to nazwa funkcji, która będzie dostępna w R i niekoniecznie musi odpowiadać nazwie funkcji w C++. W tym przypadku sma_delegated_cpp()będzie to funkcja, którą wywołujemy w R i wywoła funkcję smaDelegated() w C++:

#include

using namespae Rcpp();

//[[Rcpp::export(sma_delegated_cpp)]]

NumericVecto smaDelegated(int period, NumericVector data) {

int position , n = data.size();

NumericVector result(n);

double sma;

for (int end = 0; end < n; end++) {

position = end;

sma = 0;

while (end – position < period && position >= 0) {

sma = sma + data[position];

positin = position -1;

}

if (end – position ==period) {

sma = sma / period;

} else {

sma = NA_REAL;

}

result[end] = sma;

}

return result;

}

Następnie wyjaśnimy rzeczywistą funkcję smaDelegates(). Ponieważ masz dobre pojęcie o tym, co robi w tym momencie, nie wyjaśnimy jego logiki, tylko składnię, która nie jest tak oczywista. Pierwszą rzeczą, na którą należy zwrócić uwagę, jest to, że przed nazwą funkcji znajduje się słowo kluczowe, które jest typem wartości return funkcji. W tym przypadku jest to NumericVetor, co jest zawarte w pliku Rcpp.h. Jest to obiekt przeznaczony do łączenia wektorów między językami R i C++. Inne typy wektorów udostępniane przez  Rcpp.h to,IntegerVector, LogicalVector i CharaterVector. Masz również IntegerMatrix, NumericMatrix, LogicalMatrix i CharacterMatrix dostępne. Następnie należy zauważyć, że parametry otrzymane przez funkcję mają również skojarzone z nimi typy. W szczególności period jest liczbą całkowitą (int)a data jest NumericVector, tak jak wynik funkcji. W tym przypadku nie musieliśmy przekazywać obiektów output  lub length, tak jak to zrobiliśmy z Fortranem. Ponieważ funkcje w C++ mają wartości wyjściowe, ma również dość łatwy sposób obliczania długości obiektów. Pierwsza linia funkcji deklaruje zmienne position i n, i przypisuje długość danych do drugiej. Możesz używać przecinków, tak jak my, do deklarowania różnych obiektów tego samego typu jeden po drugim zamiast dzielenia deklaracji i przypisań na osobne wiersze. Deklarujemy również wektor result z długością n; zwróć uwagę, że notacja ta jest podobna do notacji Fortran. Wreszcie, zamiast używać słowa kluczowego real, tak jak robimy to w Fortranie, używamy float lub double  w celu oznaczenia takich liczb. Z technicznego punktu widzenia istnieje różnica w precyzji dozwolonej przez takie słowa kluczowe i nie są one zamienne, ale nie będziemy się tym martwić. Reszta funkcji powinna być jasna, z wyjątkiem może przypisania sma = NA_REAL. Ten obiekt NA_REAL jest również udostępniany przez Rpp jako sposób na oznaczenie, co powinno zostać wysłane do R jako NA. Wszystko inne powinno wyglądać znajomo. Teraz, gdy nasza funkcja jest gotowa, zapisujemy ją w pliku o nazwie sma-delegated-cpp.cpp i używamy funkcji R sourceCpp, aby skompilować ją za nas i przenieść do R. Rozszerzenie .cpp oznacza zawartość napisaną w języku C++. Należy pamiętać, że funkcji przeniesionych do języka R z plików C++ nie można zapisać w pliku .Rdata do późniejszej sesji. C++ ma być bardzo zależny od sprzętu, na którym jest skompilowany, a zrobienie tego prawdopodobnie spowoduje różne błędy. Za każdym razem, gdy chcesz użyć funkcji C++, powinieneś ją skompilować i załadować funkcją  sourceCpp()w momencie użycia.

library(Rcpp)

sourceCpp(„./sma-delegated-cpp.cpp”)

sma_delegated_cpp <- function(period, symbol, datat)  {

data <- as.numeric(data[which(data$symbol == symbol), „price_usd”])

return(sma_pp(period, data))

}

Jeśli wszystko działało poprawnie, nasza funkcja powinna być użyteczna w R, więc wykonujemy testy porównawcze i testy poprawności. Obiecuję, że to ostatnia:

performance <- microbenchamrk (

sma_13 <- sma_delegated_cpp(period, symboo, data),

unit = „us”

)

all(sma_1$sma = sma_13 <= 0.001, na.rm = TRUE)

#> TRUE

summary(performance)$median

#>[1] 80.6415

Tym razem nasz średni czas wyniósł 80.6415 mikrosekundy, czyli o trzy rzędy wielkości szybciej niż w naszej pierwszej implementacji. Pomyśl o tym w ten sposób: jeśli podasz dane wejściowe dla sma_delegated_cpp(), których wykonanie zajęło około godziny, sma_slow_1() zajmie to około 1000 godzin, czyli około 41 dni. Czy to nie zaskakująca różnica? Kiedy jesteś w sytuacjach, które zajmują tyle czasu na wykonanie, zdecydowanie warto spróbować i zoptymalizować swoje implementacje. Możesz użyć tej funkcji cppFUnction() do napisania kodu C++ bezpośrednio w pliku .R, ale nie powinieneś tego robić. Zachowaj to tylko do testowania małych fragmentów kodu. Rozdzielenie implementacji C++ na osobne pliki pozwala wykorzystać moc wybranego edytora (lub IDE) do prowadzenia użytkownika przez proces programowania, a także do dokładniejszego sprawdzania składni.

Korzystanie ze starej szkoły w Fortran

Zaczniemy od starej szkoły, używając najpierw języka Fortran. Jeśli go nie znasz, Fortran jest najstarszym nadal używanym językiem programowania. Został zaprojektowany do wykonywania wielu obliczeń bardzo wydajnie i przy niewielkich zasobach. Opracowano wiele bibliotek numerycznych i wiele wysokowydajnych systemów obecnie nadal go używają, bezpośrednio lub pośrednio. Oto nasza implementacja, nazwana sma_foortran(). Składnia może Cię zaskoczyć, jeśli nie jesteś przyzwyczajony do pracy z kodem Fortran, ale jest wystarczająco prosty do zrozumienia. Po pierwsze, zwróć uwagę, że aby zdefiniować funkcję technicznie znaną jako subroutine w języku Fortran, używamy słowa kluczowego subroutine przed nazwą funkcji. Podobnie jak nasze poprzednie implementacje, otrzymuje period i data (używamy nazwy dataa z dodatkiem a na końcu, ponieważ Fortran ma zarezerwowane słowo kluczowe data, którego nie powinniśmy używać w tym przypadku) i założymy, że dane są już filtrowane pod kątem prawidłowych symboli w tym miejscu. Następnie zwróć uwagę, że wysyłamy nowe argumenty, których wcześniej nie wysyłaliśmy, a mianowicie smas i n. Fortran jest specyficznym językiem w tym sensie, że nie zwraca wartości, zamiast tego używa efektów ubocznych. Oznacza to, że zamiast oczekiwać czegoś z powrotem po wywołaniu podprogramu w języku Fortran, powinniśmy oczekiwać, że ten podprogram zmieni jeden z przekazanych do niego obiektów i powinniśmy traktować to jako naszą wartość return. W tym przypadku smas spełnia tę rolę; początkowo zostanie wysłany jako tablica niezdefiniowanych wartości rzeczywistych, a celem jest zmodyfikowanie jego zawartości za pomocą odpowiednich wartości SMA. Wreszcie n, reprezentuje liczbę elementów w przesyłanych przez nas danych. Klasyczny Fortran nie ma sposobu na określenie rozmiaru przekazywanej do niego tablicy i wymaga od nas ręcznego określenia rozmiaru; dlatego musimy wysłać n. W rzeczywistości istnieją sposoby obejścia tego problemu, ale ponieważ nie jest to tekst o języku Fortran, postaramy się, aby kod był tak prosty, jak to tylko możliwe. Następnie zwróć uwagę, że musimy zadeklarować typ obiektów, z którymi mamy do czynienia, a także ich rozmiar w przypadku, gdy są to tablice. Przechodzimy do deklaracji pos (która zajmuje miejsce pozycji w naszej poprzedniej implementacji, ponieważ Fortran nakłada ograniczenie na długość każdej linii, której nie chcemy naruszać), n, endd (ponownie end jest to słowo kluczowe w Fortranie, więc użyj zamiast tego nazwy endd) i period jako liczby całkowite. Deklarujemy również dataa(n), smas(n) i sma jako liczby rzeczywiste, ponieważ będą one zawierać części dziesiętne. Zauważ, że określamy rozmiar tablicy z częścią (n) w pierwszych dwóch obiektach. Po zadeklarowaniu wszystkiego, czego będziemy używać, kontynuujemy naszą logikę. Najpierw tworzymy pętlę for , która jest wykonywana za pomocą słowa kluczowego do w języku Fortran, po którym następuje unikalny identyfikator (który zwykle jest nazywany wielokrotnością dziesiątek lub setek), nazwa zmiennej, która będzie używana do iteracji oraz wartości, które przyjmie endd i 1 do n , w tym przypadku odpowiednio. Wewnątrz pętli for przypisujemy pos, które być równe endd i sma równe 0, tak jak to zrobiliśmy w niektórych naszych poprzednich implementacjach. Następnie tworzymy pętlę while z kombinacją słów kluczowych do … while  i podajemy warunek, który należy sprawdzić, aby zdecydować, kiedy z niej wyjść. Zauważ, że Fortran używa zupełnie innej składni dla operatorów porównania. W szczególności operator .lt. oznacza mniejszy niż, podczas gdy operator .ge. oznacza większe niż lub równe. Jeśli którykolwiek z dwóch określonych warunków nie zostanie spełniony, zakończymy pętlę while . To powiedziawszy, reszta kodu powinna być oczywista. Jedyną inną niezwykłą właściwością składni jest wcięcie kodu do szóstej pozycji. To wcięcie ma znaczenie w języku Fortran i powinno pozostać takie, jakie jest. Ponadto identyfikatory liczb podane w pierwszych kolumnach kodu powinny być zgodne z odpowiednimi mechanizmami zapętlenia i powinny znajdować się po lewej stronie kodu logicznego. Aby uzyskać dobre wprowadzenie do języka Fortran, możesz zapoznać się z samouczkiem Stanforda dotyczącym języka Fortran 77 (). Powinieneś wiedzieć, że istnieją różne wersje Fortrana, a wersja 77 jest jedną z najstarszych. Jednak jest to również jeden z lepiej obsługiwanych:

subroutine sma_fortran(period, dataa, smas, n)

integer pos, n , endd, period

real dataa(n), smas(n)., sma

do 10 endd = 1 , n

pos = endd

sma = 0.0

do 20 while ((endd – pos .t. period)  .and. (pos  .ge. 1))

sma = sma + dataa(pos)

pos = pos – 1

end do

if (endd – pos  .eq.  period) then

sma = sma / period

else

sma = 0

 end if

smas(endd) = sma

10 continue

end

Gdy kod jest gotowy, musisz go skompilować, zanim będzie można go wykonać w R. Kompilacja to proces tłumaczenia kodu na instrukcje na poziomie maszyny. Masz dwie opcje podczas kompilowania kodu w języku Fortran: możesz to zrobić ręcznie poza R lub możesz to zrobić w R. Druga opcja jest zalecana, ponieważ możesz skorzystać z narzędzi R. Jednak pokazujemy oba z nich. Pierwszą można osiągnąć za pomocą następującego kodu:

$ gfortran -c sma-delegated-fortran.f -o sma-delegated-fortran.so

Ten kod powinien zostać wykonany w terminalu Bash (który można znaleźć w systemach operacyjnych Linux lub Mac). Musimy upewnić się, że mamy zainstalowany kompilator, który prawdopodobnie został zainstalowany, gdy R był. Następnie wywołujemy go, mówiąc mu, aby skompilował (używając opcji -c) plik sma-delegated-fortran.f (który zawiera kod Fortran, który pokazaliśmy wcześniej) i udostępnił plik wyjściowy (z opcją -o) o nazwie sma-delegated-fortran.so. Naszym celem jest zdobycie tego pliku .so, czyli tego, czego potrzebujemy w R do wykonania kodu Fortran. Sposobem kompilacji w R, który jest zalecany, jest użycie następującego wiersza:

system(„R MD SHLIB sma-delegated-fortran.f”)

Zasadniczo mówi R, aby wykonał polecenie, które tworzy bibliotekę współdzieloną pochodzącą z pliku sma-delegated-fortran.f. Zwróć uwagę, że funkcja system() po prostu wysyła otrzymany ciąg do terminala w systemie operacyjnym, co oznacza, że ​​mogłeś użyć tego samego polecenia w terminalu Bash, który został użyty do ręcznej kompilacji kodu. Aby załadować udostępnioną bibliotekę do pamięci R, używamy funkcji dyn.load(), podając lokalizację pliku .so, którego chcemy użyć, i faktycznie wywołujemy udostępnioną bibliotekę, która zawiera implementację , używamy funkcji Fortran(). Ta funkcja wymaga jawnego wykonania sprawdzenia typu i wymuszenia przez użytkownika przed jej wywołaniem. Aby zapewnić podobny podpis, jak ten dostarczany przez poprzednie funkcje, utworzymy funkcję o nazwie sma-delegated-fortran(), która odbiera parametry period ,symbol i data i tak jak poprzednio, a także filtruje dane tak jak wcześniej, oblicza długość danych i wstawia w n w programie i używa tej funkcji .Fortran() do wywołania podprogramu sma_fortran(), dostarczając odpowiednie parametry. Zwróć uwagę, że otaczamy parametry wokół funkcji, które wymuszają typy tych obiektów zgodnie z wymaganiami naszego kodu w języku Fortran. Lista result utworzona przez funkcję .Fortran() zawiera obiekty period, dataa, smas i n odpowiadające parametrom wysłanym do podprogramu, z zawartością pozostawioną w nich po wykonaniu podprogramu. Jak wspomnieliśmy wcześniej, interesuje nas zawartość obiektu sma, ponieważ zawierają one wartości, których szukamy. Dlatego wysyłamy tylko tę część z powrotem po przekonwertowaniu jej na typ numeric w R. Transformacje, które widzisz przed wysłaniem obiektów do Fortran i po ich odzyskaniu, są czymś, z czym musisz być bardzo ostrożny. Na przykład, jeśli zamiast używać  single() i as.single(data) używamy double(n) i as.double(data), nasza Implementacja Fortran nie zadziała. To jest coś, co można zignorować w R, ale nie można tego zignorować w przypadku Fortrana:

system(„R MD SHLIB sma-delegated-fortran.f”)

dyn.load(„sma-delegated-fortran.so”)

sma_delegated_fortran <- function(period ,symbol, data) {

data <- data[which(data$symbol == symbol), „pric_usd”]

n <- length(data)

results <- .Fortan(

„sma_fortran”,

period = as.integer(period),

dataa = as.single (data),

smas = single(n),

n – as.integer(n)

)

return(as.numeri(result$smas))

}

Tak jak wcześniej, porównujemy i testujemy poprawność:

performance <- microbenchmark (

sma_12 <- sma_delegated_fortran(period, symboo, data),

unit = „us”

)

all(sma_1$sma – sma_12 <= 0.001, na.rm = TRUE)

#> TRUE

summary(perofmane)$median

#> [1] 148.0335

W tym przypadku nasz średni czas wynosi 148.0335 mikrosekundy, co sprawia, że ​​jest to najszybsza implementacja do tego momentu. Zwróć uwagę, że minęło niewiele ponad połowa czasu od najbardziej wydajnej implementacji, jaką mogliśmy wymyślić, używając tylko R. Spójrz na poniższą tabelę:

 

Używanie C++ i Fortran do przyspieszenia obliczeń

Czasami kod R nie jest wystarczająco szybki. Czasami używałeś profilowania, aby dowiedzieć się, gdzie są twoje wąskie gardła i zrobiłeś wszystko, o czym możesz pomyśleć w R, ale twój kod nadal nie jest wystarczająco szybki. W takich przypadkach użyteczną alternatywą może być delegowanie niektórych części implementacji do bardziej wydajnych języków, takich jak Fortran i C++. Jest to zaawansowana technika, która często może okazać się bardzo przydatna, jeśli wiesz, jak programować w takich językach. Delegowanie kodu do innych języków może rozwiązać wąskie gardła, takie jak:

* Pętle, których nie można łatwo wektoryzować ze względu na zależności iteracyjne

* Procesy, które obejmują wywoływanie funkcji miliony razy

* Nieefektywne, ale niezbędne struktury danych, które są powolne w R

Delegowanie kodu do innych języków może zapewnić duże korzyści w zakresie wydajności, ale wiąże się również z kosztem bycia bardziej przejrzystym i ostrożnym w przypadku typów obiektów, które są przenoszone. W R możesz uciec od prostych rzeczy, takich jak nieprecyzyjne określenie liczby będącej liczbą całkowitą lub rzeczywistą. W tych innych językach nie możesz; każdy obiekt musi mieć określony typ i pozostaje niezmienny przez całą realizację.

Praktyczna równoległość z R

W tej sekcji pokażemy, jak wykorzystać wiele rdzeni w R. Pokażemy, jak wykonać pojedynczy system pamięci współdzielonej z podejściem wielu rdzeni. To najprostsza technika równoległa, jaką możesz zastosować. Wdrażanie programów równoległych w R stało się z czasem coraz łatwiejsze, ponieważ jest to temat bardzo interesujący, wiele osób zapewniło i nadal zapewnia lepsze sposoby osiągnięcia tego celu. Obecnie w CRAN jest ponad 70 pakietów, które zapewniają pewnego rodzaju funkcje zrównoleglania. Wybór odpowiedniego pakietu dla właściwego problemu lub po prostu świadomość, że istnieje wiele opcji, pozostaje wyzwaniem. W tym przypadku użyjemy pakietu  parallel , który jest preinstalowany w ostatnich wersjach R. Inne bardzo popularne pakiety to doSNOW i foreach ale to naprawdę zależy od tego, jakiego rodzaju zrównoleglenie chcesz wykonać. Najbardziej powszechną techniką zrównoleglania w języku R jest użycie zrównoleglonych zamienników funkcji lapply(), sapply() i apply(). W przypadku pakietu parallel mamy odpowiednio dostępne funkcje parLapply(),parSapply() i parApply(). Fakt, że sygnatury wśród tych par funkcji są bardzo podobne, sprawia, że ​​bariera w stosowaniu tej formy zrównoleglenia jest bardzo mała, dlatego postanowiłem zaprezentować tę technikę. Implementacja techniki zrównoleglania, którą pokażemy, jest dość prosta i obejmuje następujące trzy główne kroki po załadowaniu pakietu:

  1. Utwórz klaster z funkcją makeCluster()
  2. Zastąp funkcję apply() jedną par*pply()
  3. Zatrzymaj klaster utworzony w pierwszym kroku

W naszym przypadku zastąpimy funkcję lapply() parLapply() w naszej implementacji sma_efficient_2(). Należy jednak unikać częstego błędu popełnianego przez ludzi, którzy dopiero rozpoczynają pracę z równoległością. Zwykle tworzą, a później niszczą klaster w ramach funkcji wywoływanej do wykonania zadania, zamiast odbierać klaster z zewnątrz i używać go wewnątrz. Stwarza to problemy z wydajnością, ponieważ klaster będzie potencjalnie uruchamiany wiele razy, a uruchomienie klastra z równoległością może wiązać się z dużym narzutem. Funkcją, która popełnia taki błąd, jest funkcja sma_parallel_inefficient(), jak następuje:

librat(parallel)

sma_parallel_inefficient <- function(period, symbol, data) {

data <- as.numeric(data[data$symbol = = symbol, „price_usd”])

cluster <- makeCluster(detectCores())

result <- unlist(parLapply(

cluster , 1:length(data) , sma_from_position_2, period, data))

stopCluster(cluster)

return(result)

}

Jak widać, sma_parallel)inefficient() jest tylko sma_efficient_2() z dodaną logiką do tworzenia i usuwania klastra oraz zamiany lapply() za pomocą parLapply(). Tak naprawdę nie powinieneś używać tej funkcji, ale została umieszczona tutaj, aby pokazać, jak źle może to wpłynąć na wydajność, jeśli to zrobisz. Jak zawsze wykonujemy testy porównawcze i sprawdzamy poprawność, jak pokazano w poniższym fragmencie kodu:

performance <- microbenchmark (

sma_10 <- sma_parallel_inefficient(period, symbol, data),

unit = „us”

)

all(sma_1$sma – sma_10 <- 0.001, na.rm =- TRUE)

#> TRUE

summary(performance)$median

#> [1] 1197329.3980

W tym przypadku nasza mediana czasu to 1,197,329.398 mikrosekundy, co nie powinno być zbyt zaskakujące, gdy wspomnimy, że wielokrotne tworzenie i niszczenie klastra może być dość nieefektywne. Teraz przystępujemy do usunięcia logiki, która tworzy i niszczy klaster z funkcji, a zamiast tego otrzymaj cluster  jako parametr do sma_parallel(). W tym wypadku, nasza implementacja wygląda tak samo jak poprzednio, z wyjątkiem użycia parLapply(). Fajnie jest móc osiągnąć coś tak złożonego jak zrównoleglenie za pomocą prostej zmiany, ale tak naprawdę jest to produkt uproszczenia naszego kodu do tego, co mamy teraz. Gdybyśmy spróbowali zrównoleglać naszą początkową implementację sma_slow_1(), byłoby to trudne. Spójrz na następujący fragment kodu:

sma_parallel <- function(period, symbol, data, cluster) {

data <- as.numeric(data[data$symbol == symbol, „price_usd”])

return(unlist(parLapply(

cluster, 1:length(data), sma_from_position_2(), period, data )))

}

Ponownie wykonujemy testy porównawcze i sprawdzamy poprawność, jak pokazano w poniższym fragmencie kodu:

cluster <- makeCluster(detectCluster() )

performance <- microbenchmark (

sma_11 <- sma_parallel(period, symbol, data, cluster).

unit=”us”

)

all(sma_1$sma-sma_11 <= 0.001, na.rm = TRUE)

#> TRUE

summary(performance)$median

#> [1] 44825.9355

W tym przypadku nasza mediana czasu to 44,825. 9355 mikrosekundy, czyli mniej więcej gorzej niż byliśmy w stanie osiągnąć  ze sma_slow_2(). Czy zrównoleglenie nie miało być znacznie szybsze? Odpowiedź brzmi: tak, podczas pracy z większymi nakładami. Kiedy używamy danych, które mają miliony obserwacji (a nie 100 obserwacji, których używaliśmy do tych testów), będzie to szybsze, ponieważ czas ich wykonania nie wydłuży się tak bardzo, jak w przypadku innych wdrożeń. W tej chwili sma_parallel() płaci duży stały koszt, który nie jest dobrą inwestycją w przypadku pracy z małymi zbiorami danych, ale gdy zaczynamy pracować z większymi zbiorami danych, stały koszt zaczyna być mały w porównaniu ze wzrostem wydajności. Aby sfinalizować sekcję, pamiętaj, aby wywołać stopCluster(cluster), gdy chcesz przestać korzystać z klastra. W takim przypadku zostawimy ją uruchomioną, ponieważ będziemy nadal wykonywać więcej testów porównawczych.

Jak głęboka jest królicza nora równoległa?

W przypadku równoległości i algorytmu jest wiele decyzji, które należy podjąć. Przede wszystkim musimy zdecydować, które części algorytmu będą implementowane równolegle, a które seryjnie oraz jak zarządzać tymi częściami, aby poprawnie działały między sobą. Następnie musimy zdecydować, czy to jawnie, czy niejawnie, czy zrównoleglone części będą miały pamięć współdzieloną czy rozproszoną, czy będziemy wykonywać zrównoleglenie danych lub zadań, czy musimy wprowadzić jakiś rodzaj mechanizmu rozproszonego czy współbieżnego, a jeśli tak, to jaki protokół będzie być używane do ich koordynowania. Po podjęciu decyzji na wysokim poziomie musimy zająć się szczegółowymi decyzjami dotyczącymi liczby i architektury procesorów, których będziemy używać, a także ilości pamięci i uprawnień kontrolnych. Nie przejmuj się zbytnio koncepcjami wspomnianymi wcześniej; są przeznaczone do bardziej zaawansowanych zastosowań niż planowany poziom w tej książce. Podam tutaj bardzo ogólne i proste wyjaśnienia, aby upewnić się, że rozumiesz typ równoległości, który sami wdrożymy, ale możesz pominąć tę sekcję, jeśli chcesz. Systemy pamięci współdzielonej współużytkują obiekty przechowywane w pamięci w różnych procesach, co może być bardzo wydajne pod względem zasobów, ale także niebezpieczne, ponieważ jeden proces może modyfikować obiekt, który jest używany przez inny proces, nie wiedząc, że to się stało. Inną wadą takich systemów jest to, że nie skalują się dobrze. Mocniejszą, ale także bardziej złożoną alternatywą jest pamięć rozproszona, która tworzy kopie danych potrzebnych do różnych procesów, które mogą znajdować się w różnych systemach. To podejście można skalować do tysięcy procesorów, ale odbywa się kosztem złożonej koordynacji między procesami. Paralelizm danych występuje wtedy, gdy dane są podzielone na partycje, a każde zadanie jest wykonywane przy użyciu innej partycji. Te typy zrównoleglania pomagają skalować algorytm w miarę gromadzenia większej ilości danych, ponieważ możemy po prostu utworzyć więcej partycji. Należy zauważyć, że użycie równoległości danych niekoniecznie oznacza pamięć rozproszoną i na odwrót. Równoległość zadań występuje, gdy zadania są wysyłane do różnych procesorów w celu wykonania równoległego i mogą, ale nie muszą, działać na wierzchu tych samych danych. Wadą obliczeń równoległych jest to, że ludzie uruchamiają kod na różnych maszynach, a jeśli piszesz oprogramowanie, które chcesz udostępnić innym, musisz uważać, aby implementacja była użyteczna nawet wtedy, gdy jest wykonywana na różnych konfiguracjach sprzętowych. Wszystkie wspomniane wcześniej decyzje wymagają do prawidłowego podjęcia głębokiej wiedzy technicznej, a jeśli wydają się skomplikowane, to dlatego, że tak naprawdę są. Wdrażanie równoległości może być dość złożoną czynnością, w zależności od poziomu kontroli, jaki chcesz mieć nad nią. Co najważniejsze, pamiętaj, że R jest językiem interpretowanym, więc wzrost szybkości wynikający z używania języków kompilowanych prawie zawsze będzie przekraczał przyrost prędkości wynikający z równoległego tworzenia pętli lub innych funkcji ukrywania pętli.

Używanie równoległości do dzielenia i podbijania

Do tej pory poznaliśmy różne sposoby optymalizacji wydajności programów R uruchamianych szeregowo, czyli w jednym wątku. Nie wykorzystuje to wielu rdzeni procesora, które większość komputerów ma obecnie. Obliczenia równoległe pozwalają nam je wykorzystać, dzieląc nasze implementacje na wiele części, które są niezależnie wysyłane do tych procesorów, i mogą przyspieszyć działanie programów, gdy jeden wątek jest ważnym wąskim gardłem. Równoległe tworzenie aplikacji w świecie rzeczywistym może być bardzo trudnym zadaniem i wymaga głębokiej wiedzy na temat oprogramowania oraz sprzętu. Zakres możliwej równoległości zależy od konkretnego algorytmu, z którym pracujemy, i jest dostępnych wiele typów równoległości. Ponadto zrównoleglenie nie jest decyzją tak / nie; wymaga ciągłej skali. Po jednej stronie skali mamy żenująco równoległe zadania, w których nie ma zależności między równoległymi podzadaniami, co czyni je doskonałymi kandydatami do zrównoleglenia. Z drugiej strony mamy zadania, których w ogóle nie można zrównoleglać, ponieważ każdy krok zadania zależy od wyników poprzednich kroków. Większość algorytmów mieści się między tymi dwoma skrajnościami, a większość aplikacji równoległych w świecie rzeczywistym wykonuje niektóre zadania szeregowo, a inne równolegle. Niektóre zadania, które są stosunkowo łatwe do wykonania równolegle (niektóre z nich zostałyby sklasyfikowane jako zawstydzająco równoległe), to konwertowanie setek obrazów z kolorów na skalę szarości, dodawanie milionów liczb, wyszukiwanie siłowe i symulacje Monte Carlo. Wspólną właściwością wśród nich jest to, że każde podzadanie można wykonać niezależnie od pozostałych. Na przykład każdy obraz może być przetwarzany niezależnie lub możemy dodać różne podgrupy liczb, a następnie zsumować wyniki i tak dalej. W momencie, gdy wprowadzamy zależność od kolejności, następuje zrównoleglenie.

Unikanie wysyłania struktur danych z narzutami

Wiemy, że w miarę możliwości należy unikać operowania na ciężkich strukturach danych, takich jak ramki danych, a tutaj wydaje się, że nadal jest to możliwe. A co, jeśli zamiast przekazywać naszą ramkę danych, wyodrębnimy interesującą nas zmienną price_usd i po prostu jej użyjemy? To wydaje się obiecujące. Aby to osiągnąć, w górnej części funkcji nie tylko filtrujemy obserwacje, które zawierają symbol nam potrzebne, ale także wyodrębniamy zmienną price_usd w tym miejscu. Teraz możemy wysłać tę strukturę danych o niższym narzucie do naszej nieco zmodyfikowanej funkcji sma_from_position_2(). Jest po prostu zmodyfikowany, aby działał z tym wektorem zamiast z pełną ramką danych:

sma_efficient_2 <- function(period, symbol, data) {

data <- data[datat$symbol = = symbol, „price_usd”]

return(unlist(lapply(1:length(data),

sma_from_position_2,

period, data)))

}

sma_from_position_2 <- function(end, period, data) {

start <- end – period +1

return(ifelse(start >= 1, sum(data[start:end]) / period, NA))

}

Ponownie wykonaj test porównawczy i sprawdź poprawność, jak pokazano w poniższym fragmencie kodu:

performance <- microbenchmark (

sma_9 <- sma_efficient_2(period, symbol, data),

unit = „us”

)

all(sma_1$sma – sma-9 <= 0.001 , na.rm = TRUE)

#> TRUE

summary(performance)$median

#> [1] 238.2425

Tym razem nasz średni czas to 238.2425 mikrosekundy. To duża zmiana. W rzeczywistości jest to największa poprawa wydajności, jaką byliśmy w stanie osiągnąć, biorąc pod uwagę ilość wymaganych zmian w porównaniu z poprzednio najszybszą implementacją. Czy zdajesz sobie sprawę, jak drastyczna była poprawa wydajności? Wykonanie naszej pierwszej implementacji zajmuje około 33 900% więcej czasu. I odwrotnie, nasze wdrożenie sum_efficient_2() zajmuje tylko około 0,2% czasu, jaki zajęło nasze wdrożenie. Czy spodziewaliście się tak dużej redukcji czasu, pisząc tylko lepszy kod R, kiedy zaczynaliśmy? Załóżmy, że jesteśmy bardzo wybredni i chcemy dalej poprawiać wydajność. Co powinniśmy zrobić? Cóż, sprofilujmy ponownie nasz kod, aby się dowiedzieć. Jak widać tutaj, liczba wywołań funkcji jest zredukowana do jednego w tabeli $by.self i tylko do pięciu w tabeli $by.total. Niestety, te wyniki nie pokazują nam żadnej drogi, którą możemy dalej poprawić wydajność, ponieważ wszystkie pokazane funkcje są już wysoce zoptymalizowane. Jedyne, co możesz spróbować, to zastąpić funkcję mean() jedną z szybszych alternatyw pokazanych wcześniej, ale nie zrobimy tego w tym przypadku, ponieważ efekt tego został już pokazany wcześniej:

Rprof()

sma_9 <- sma_efficient_2(periodm symbol, data_original[1:10000, ])

Rprof(NULL)

summaryRptof()

#> $ by.self

#> self.time self.pct total.time total.pct

#> “ifelse” 0,02 100 0,02 100

#>

#> $ by.total

#> total.time total.pct self.time self.pct

#> “ifelse” 0,02 100 0,02 100

#> „ZABAWA” 0,02 100 0,00 0

#> “lapply” 0,02 100 0,00 0

#> “sma_efficient_2” 0,02 100 0,00 0

#> “unlist” 0,02 100 0,00 0

Aby jeszcze bardziej skrócić czas wykonania naszej implementacji, będziemy musieli skorzystać z bardziej zaawansowanych technik, takich jak zrównoleglanie i delegowanie, które są przedmiotem kolejnych sekcji.

Zauważ, że w tym miejscu Rprof() przestanie być użyteczne przez większość czasu, ponieważ zaczniemy używać zaawansowanych narzędzi, poza R, aby nadal poprawiać wydajność, a takie narzędzia wymagają własnych technik profilowania i wiedzy, którą nie będziemy się zajmować

Efektywne korzystanie ze sposobu iteracji w języku R

Efektywne korzystanie ze sposobu iteracji w języku R.

W tym momencie pozostaje nam pojedyncza pętla for, którą chcielibyśmy usunąć. Jednak jest w tym trochę logiki, która przeszkadza. W tym miejscu funkcja  lapply() jest przydatna. Funkcja ta otrzymuje listę obiektów, które zostaną wysłane do funkcji podanej jako drugi argument i zwróci wyniki takich wywołań funkcji na liście. Dodatkową zaletą tej funkcji jest to, że zajmuje się ona wstępną alokacją pamięci za nas, co jest bardzo wydajnym sposobem na skrócenie czasu wykonywania w R. W tym przypadku zamykamy logikę wewnątrz naszej pętli for w oddzielnej funkcji o nazwie sma_from_position_1() i używamy jej w ramach naszego wywołania funkcji lapply() . Nasza funkcja  sma_from_position_1() otrzymuje obiekty end, period i data , z którymi pracowaliśmy, i zachowują one to samo znaczenie i wykonują te same obliczenia średniej wektorowej, które robiliśmy wcześniej. Jednak zamiast używać jawnego warunku if … else , używa funkcji ifelse(),  która przyjmuje warunek do sprawdzenia jako pierwszy argument, pożądany wynik w przypadku spełnienia warunku jako drugi argument, a pożądany wynik w przypadku, gdy warunek nie zostanie spełniony jako trzeci argument. W naszym przypadku są to odpowiednio start >= 1, mean(data[start:end], price_usd i NA , odpowiednio. Wynik, który otrzymujemy z wywołań funkcji sma_from_position_1(), jest nielistowany w jednym wektorze, dzięki czemu otrzymujemy wynik w postaci wektora zamiast listy, który z kolei jest zwracany przez. Zwróć uwagę na zmianę nazwy? Na tym etapie implementację tę można uznać za skuteczną. Hurra! Spójrz na następujący fragment kodu:

sma_efficient_1 <- function(period , symbol, data) {

data <- data[data$symbol = = symbol,  ]

return(unlsit(lapply(1;nrow(data),

sms_from_position_1,

period, data)))

}

sma_from_position_1 <- function(end, period, data) {

start <- end – period + 1

return(ifelse(start >= 1,

mean(data[start:end, „price_usd”]), NA))

}

Na wypadek, gdybyś nie pamiętał mechaniki funkcji lapply() i był trochę zdezorientowany sposobem jej użycia, przypomnę, że weźmie każdy element z listy podanej jako pierwszy argument, i podać je jako pierwszy argument funkcji podanej w drugim argumencie. Jeśli wspomniana funkcja wymaga większej liczby parametrów, można je również przekazać po dostarczeniu obiektu funkcji do funkcji lapply(), co ma miejsce w przypadku argumentów period i data, które widzisz na końcu. Ponownie wykonaj test porównawczy i sprawdź poprawność, jak pokazano w poniższym fragmencie kodu:

performance <- micronbenchmark(

sma_8 <- sma_efficient_1(period, symbol, data),

unit = „us”

)

all(sma_1$sma – sma_8 <= 0.001, na.rm = TRUE)

#> TRUE

summary(performance)$median

#> [1] 1137.704

Tym razem nasz średni czas to 1,137.704 mikrosekundy. To więcej niż nasza dotychczas najszybsza implementacja. Co się stało? Jeśli chcesz poznać szczegóły, powinieneś sprofilować funkcję, ale w istocie problem polega na tym, że dodajemy wywołanie funkcji, które jest wykonywane wiele razy (sma_from_position_1()), a wywołania funkcji mogą być drogie, a także dodawanie transformacji z list do wektora, którego nie robiliśmy wcześniej (unlist()). Jednak wolimy przejść dalej z wersją z powodów, które zostaną wyjaśnione w dalszej części. Istnieje wiele innych funkcji wektoryzowanych w R, które mogą pomóc przyspieszyć twój kod. Niektóre przykłady to which(), where(), any(), all(), cumsum() i cumprod(). Podczas pracy z macierzami możesz używać rowSums(), colSums() lower.tri() , upper.tri() i innych, a podczas pracy z kombinacjami możesz używać combin(). Jest ich o wiele więcej, a kiedy mamy do czynienia z czymś, co wydaje się być wektoryzowane, istnieje szansa, że ​​istnieje już funkcja do tego.

Jeśli możesz, w ogóle unikaj iteracji

W poprzedniej sekcji zdaliśmy sobie sprawę, jak duży wpływ na wydajność naszej implementacji może mieć niepotrzebny narzut w ramach iteracji. A co by było, gdybyśmy w ogóle mogli uniknąć iteracji? Teraz byłoby lepiej, prawda? Cóż, jak wspomnieliśmy wcześniej, robienie tego jest osiągalne dzięki wektoryzacji. W takim przypadku usuniemy pętlę while i zastąpimy ją wektoryzowaną średnią nad pozycjami start i end , gdzie nadal jest definiowana tak, jak dotychczas, a start jest definiowana jako pozycja  end minus period otrzymana jako parametr plus jeden. Gwarantuje to, że otrzymamy dokładną liczbę cen, których potrzebujemy, i możemy utworzyć przedział start:end, który będzie pobierał określony podzbiór, którego potrzebujemy z data, abyśmy mogli zastosować do niego funkcję mean():

sma_slow_7 <- funtion(period, symbol, data) {

data <- data[data$symbol = = symbol, ]

result <- NULL

for (end in 1:nrow(data)) {

start <- end – period + 1

if (start >= 1) {

sma <- mean(data[start:end, „price_usd”])

} else {

sma <-NA

}

result<- c(result, sma)

}

return(result)

}

Zauważ, że ta zmiana nie byłaby możliwa, gdybyśmy nie przefiltrowali danych u góry funkcji, ponieważ mielibyśmy obserwacje, które odpowiadają różnym symbolom zmieszanym między sobą, a nasz przedział start:end wybrałby obserwacje zawierające inne symbole. To pokazuje, że czasami optymalizacje zależą od siebie nawzajem, a jednej nie można zastosować bez zastosowania poprzedniej, a te relacje często występują przypadkowo. Jak zawsze wykonujemy testy porównawcze i sprawdzamy poprawność, jak pokazano w poniższym fragmencie kodu:

performance <- microbenchmark (

sma_7 <- sma_slow_7(period, symbol, data),

unit= „us”

all(sma_1$sma – sma_7 <= 0.001, na.rm = TRUE)

#> TRUE

summary(performance_$median

#>  [1] 910.793

Mediana czasu to teraz 910. 793 mikrosekundy. Spodziewaliśmy się tego, ponieważ wiemy, że usunięcie jawnych pętli może spowodować znaczną poprawę wydajności. W tym przypadku udało nam się skrócić do nieco poniżej jednej trzeciej czasu w porównaniu z poprzednio najszybszym wdrożeniem. Zauważ, że mamy teraz do czynienia z setkami mikrosekund zamiast tysięcy mikrosekund. Oznacza to, że osiągnęliśmy poprawę wydajności o rzędy wielkości. Spójrz na poniższą tabelę:

Przenoszenie sprawdzeń z procesów iteracyjnych

Załóżmy, że utknęliśmy w naszym procesie optymalizacji i nie wiemy, co powinniśmy teraz zmienić. Co powinniśmy zrobić? Cóż, jak wspomnieliśmy wcześniej, powinniśmy sprofilować nasz kod, aby poznać nasze obecne wąskie gardła i to właśnie robimy tutaj. Używamy tej funkcji Rprof() ponownie do profilowania naszej implementacji sma_slow_5(). Wyniki pokazują, że funkcje [.data.frame i [  są naszymi największymi wąskimi gardłami i chociaż ich nazwy są nieco tajemnicze, możemy się domyślić, że są one związane z podziałem ramek danych (którymi są). Oznacza to, że naszym obecnie najważniejszym wąskim gardłem jest sprawdzenie, czy znajdujemy się w obserwacji, która odpowiada używanej przez nas obserwacji, i wykonujemy takie sprawdzenia w różnych miejscach w naszym kodzie:

Rpof()

sma_5 <- sma_slow_5(period, symbol, data_original[1:10000, ] )

Rprof (NULL}

summaryRprof()

#> $ by.self

#> self.time self.pct total.time total.pct

#> „[.data.frame” 0,54 26,21 1,24 60,19

#> “[” 0,22 10,68 1,34 65,05

#> „NextMethod” 0,20 9,71 0,20 9,71

#> “sma_slow_5” 0,14 6,80 2,06 100,00

#> “Ops.factor” 0,12 5,83 0,52 25,24

#> (Obcięte wyjście)

#>

#> $ by.total

#> total.time total.pct self.time self.pct

#> “sma_slow_5” 2,06 100,00 0,14 6,80

#> “[” 1,34 65,05 0,22 10,68

#> “[.data.frame” 1,24 60,19 0,54 26,21

#> „Ops.factor” 0,52 25,24 0,12 5,83

#> „NextMethod” 0,20 9,71 0,20 9,71

#> (Obcięte wyjście)

Teraz, gdy znamy nasze obecne największe wąskie gardło, możemy je usunąć, unikając sprawdzania, czy bieżąca obserwacja odpowiada symbol, którą otrzymujemy jako parametr. Aby to osiągnąć, po prostu wprowadzamy filtr na początku funkcji, który przechowuje tylko obserwacje zawierające prawidłowy symbol. Zwróć uwagę, że ten prosty filtr pozwala nam usunąć dwie kontrole, które wykonywaliśmy wcześniej, ponieważ jesteśmy pewni, że wszystkie obserwacje mają poprawny symbol. Zmniejsza to dwa poziomy wcięć w naszym kodzie, ponieważ te sprawdzenia zostały zagnieżdżone. To wspaniałe uczucie, prawda? Teraz wydaje się, że mamy bardzo prostą implementację, która intuicyjnie będzie działać znacznie lepiej. Aby to zweryfikować, przechodzimy do benchmarku i sprawdzamy poprawność, jak wcześniej:

performance <- microbenchmark {

sma+6 <- sma_slow_6(perid, symbol, data),

unit =”us”

)

all(sma_1$sma – sma_6 <- 0.001, na.rm = TRUE)

#> TRUE

summary(performance)$median

#> [1] 2991.5720

Potwierdza się również nasza intuicja; nasz średni czas dla sma_slow_6()  to 2, 991.57. To jest tylko 17% w porównaniu z wcześniejszą najszybszą implementacją, którą mieliśmy dla sma_slow_5() , i zajmuje to tylko 3% czasu, jaki zajmowała nasza pierwsza implementacja. Czy to jest niesamowite, czy co? Spójrz na poniższą tabelę: