Załóżmy a1
, b1
, c1
, i d1
punkt do pamięci sterty i kodzie numerycznym ma następującą pętlę rdzenia.
const int n = 100000;
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
c1[j] += d1[j];
}
Ta pętla jest wykonywana 10 000 razy przez inną zewnętrzną for
pętlę. Aby przyspieszyć, zmieniłem kod na:
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
Skompilowany na MS Visual C ++ 10.0 z pełną optymalizacją i włączonym SSE2 dla 32-bitów na Intel Core 2 Duo (x64), pierwszy przykład zajmuje 5,5 sekundy, a przykład podwójnej pętli zajmuje tylko 1,9 sekundy. Moje pytanie brzmi: (Proszę odnieść się do mojego przeformułowanego pytania na dole)
PS: Nie jestem pewien, czy to pomoże:
Demontaż pierwszej pętli wygląda następująco (ten blok jest powtarzany około pięć razy w pełnym programie):
movsd xmm0,mmword ptr [edx+18h]
addsd xmm0,mmword ptr [ecx+20h]
movsd mmword ptr [ecx+20h],xmm0
movsd xmm0,mmword ptr [esi+10h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [edx+20h]
addsd xmm0,mmword ptr [ecx+28h]
movsd mmword ptr [ecx+28h],xmm0
movsd xmm0,mmword ptr [esi+18h]
addsd xmm0,mmword ptr [eax+38h]
Każda pętla w przykładzie z podwójną pętlą wytwarza ten kod (następujący blok jest powtarzany około trzy razy):
addsd xmm0,mmword ptr [eax+28h]
movsd mmword ptr [eax+28h],xmm0
movsd xmm0,mmword ptr [ecx+20h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [ecx+28h]
addsd xmm0,mmword ptr [eax+38h]
movsd mmword ptr [eax+38h],xmm0
movsd xmm0,mmword ptr [ecx+30h]
addsd xmm0,mmword ptr [eax+40h]
movsd mmword ptr [eax+40h],xmm0
Pytanie okazało się nie mieć znaczenia, ponieważ zachowanie w znacznym stopniu zależy od rozmiarów tablic (n) i pamięci podręcznej procesora. Więc jeśli jest dalsze zainteresowanie, przeformułowuję pytanie:
Czy mógłbyś zapewnić rzetelny wgląd w szczegóły, które prowadzą do różnych zachowań pamięci podręcznej, co ilustruje pięć regionów na poniższym wykresie?
Interesujące może być również wskazanie różnic między architekturami procesorów / pamięci podręcznej poprzez zapewnienie podobnego wykresu dla tych procesorów.
PPS: Oto pełny kod. Używa TBB Tick_Count
dla taktowania o wyższej rozdzielczości, które można wyłączyć, nie definiując TBB_TIMING
makra:
#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>
//#define TBB_TIMING
#ifdef TBB_TIMING
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif
using namespace std;
//#define preallocate_memory new_cont
enum { new_cont, new_sep };
double *a1, *b1, *c1, *d1;
void allo(int cont, int n)
{
switch(cont) {
case new_cont:
a1 = new double[n*4];
b1 = a1 + n;
c1 = b1 + n;
d1 = c1 + n;
break;
case new_sep:
a1 = new double[n];
b1 = new double[n];
c1 = new double[n];
d1 = new double[n];
break;
}
for (int i = 0; i < n; i++) {
a1[i] = 1.0;
d1[i] = 1.0;
c1[i] = 1.0;
b1[i] = 1.0;
}
}
void ff(int cont)
{
switch(cont){
case new_sep:
delete[] b1;
delete[] c1;
delete[] d1;
case new_cont:
delete[] a1;
}
}
double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
allo(cont,n);
#endif
#ifdef TBB_TIMING
tick_count t0 = tick_count::now();
#else
clock_t start = clock();
#endif
if (loops == 1) {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
}
} else {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
}
}
double ret;
#ifdef TBB_TIMING
tick_count t1 = tick_count::now();
ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
clock_t end = clock();
ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif
#ifndef preallocate_memory
ff(cont);
#endif
return ret;
}
void main()
{
freopen("C:\\test.csv", "w", stdout);
char *s = " ";
string na[2] ={"new_cont", "new_sep"};
cout << "n";
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
cout << s << i << "_loops_" << na[preallocate_memory];
#else
cout << s << i << "_loops_" << na[j];
#endif
cout << endl;
long long nmax = 1000000;
#ifdef preallocate_memory
allo(preallocate_memory, nmax);
#endif
for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
{
const long long m = 10000000/n;
cout << n;
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
cout << s << plain(n, m, j, i);
cout << endl;
}
}
(Pokazuje FLOP / s dla różnych wartości n
.)
źródło
restrict
słowo kluczowe dla takich sytuacji. Nie wiem, czy MSVC ma coś podobnego. Oczywiście, gdyby to był problem, kod SSE nie byłby poprawny.d1[j]
można użyć aliasua1[j]
, aby kompilator mógł wycofać się z pewnych optymalizacji pamięci. Nie dzieje się tak, jeśli oddzielisz zapisy do pamięci w dwóch pętlach.Odpowiedzi:
Po dalszej analizie tego uważam, że jest to (przynajmniej częściowo) spowodowane wyrównaniem danych czterech wskaźników. Spowoduje to pewien poziom konfliktów banku / drogi pamięci podręcznej.
Jeśli poprawnie zgadłem, w jaki sposób alokujesz tablice, prawdopodobnie zostaną one wyrównane do linii strony .
Oznacza to, że wszystkie twoje dostępy w każdej pętli spadną na tę samą pamięć podręczną. Jednak procesory Intel już od pewnego czasu mają 8-kierunkową asocjację pamięci podręcznej L1. Ale w rzeczywistości wydajność nie jest całkowicie jednolita. Dostęp do 4-kierunkowego jest wciąż wolniejszy niż powiedzmy 2-kierunkowy.
EDYCJA: Wygląda na to, że wszystkie tablice przydzielane są osobno. Zwykle, gdy wymagane są tak duże alokacje, alokator poprosi o nowe strony z systemu operacyjnego. Dlatego istnieje duże prawdopodobieństwo, że duże alokacje pojawią się przy tym samym przesunięciu względem granicy strony.
Oto kod testowy:
Wyniki testu:
EDYCJA: Wyniki na rzeczywistej maszynie architektury Core 2:
2 x Intel Xeon X5482 Harpertown @ 3,2 GHz:
Obserwacje:
6,206 sekund przy jednej pętli i 2.116 sekund przy dwóch pętlach. Dokładnie odtwarza to wyniki PO.
W pierwszych dwóch testach tablice są przydzielane osobno. Zauważysz, że wszystkie mają takie same wyrównanie względem strony.
W dwóch drugich testach tablice są pakowane razem, aby przełamać to wyrównanie. Tutaj zauważysz, że obie pętle są szybsze. Co więcej, druga (podwójna) pętla jest teraz wolniejsza, jak można się normalnie spodziewać.
Jak zauważył @Stephen Cannon w komentarzach, jest bardzo prawdopodobne, że to wyrównanie spowoduje fałszywe aliasing w jednostkach load / store lub cache. Poszukałem tego w Google i zauważyłem, że Intel ma sprzętowy licznik do częściowego blokowania aliasingu adresów :
http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html
5 regionów - objaśnienia
Region 1:
Ten jest łatwy. Zestaw danych jest tak mały, że wydajność jest zdominowana przez koszty ogólne, takie jak zapętlenie i rozgałęzienie.
Region 2:
Tutaj, wraz ze wzrostem wielkości danych, maleje względny narzut i wydajność „nasyca się”. Tutaj dwie pętle są wolniejsze, ponieważ mają dwa razy więcej pętli i rozgałęzienia nad głową.Nie jestem pewien, co dokładnie się tutaj dzieje ... Wyrównanie może nadal odgrywać rolę, ponieważ Agner Fog wspomina o konfliktach banków pamięci podręcznej . (Ten link dotyczy Sandy Bridge, ale pomysł powinien nadal dotyczyć rdzenia 2.)
Region 3:
W tym momencie dane nie mieszczą się już w pamięci podręcznej L1. Tak więc wydajność jest ograniczona przez przepustowość pamięci podręcznej L1 <-> L2.
Region 4:
Obserwujemy spadek wydajności w pojedynczej pętli. Jak wspomniano, wynika to z wyrównania, które (najprawdopodobniej) powoduje fałszywe zatrzymanie aliasingu w jednostkach ładowania / przechowywania procesora.
Aby jednak mogło dojść do fałszywego aliasingu, między zestawami danych musi być wystarczająco duży krok. Dlatego nie widzisz tego w regionie 3.
Region 5:
W tym momencie nic nie mieści się w pamięci podręcznej. Więc jesteś związany przepustowością pamięci.
źródło
OK, właściwa odpowiedź zdecydowanie musi coś zrobić z pamięcią podręczną procesora. Jednak użycie argumentu pamięci podręcznej może być dość trudne, szczególnie bez danych.
Istnieje wiele odpowiedzi, które doprowadziły do wielu dyskusji, ale spójrzmy prawdzie w oczy: problemy z pamięcią podręczną mogą być bardzo złożone i nie są jednowymiarowe. Zależą one w dużej mierze od wielkości danych, więc moje pytanie było niesprawiedliwe: okazało się, że jest to bardzo interesujący punkt na wykresie pamięci podręcznej.
Odpowiedź @ Mysticial przekonała wiele osób (w tym mnie), prawdopodobnie dlatego, że jako jedyna zdawała się polegać na faktach, ale był to tylko jeden „punkt danych” prawdy.
Właśnie dlatego połączyłem jego test (przy użyciu ciągłego vs osobnego przydziału) i porady @Jamesa.
Poniższe wykresy pokazują, że większość odpowiedzi, a zwłaszcza większość komentarzy do pytania i odpowiedzi, można uznać za całkowicie błędną lub prawdziwą, w zależności od dokładnego scenariusza i zastosowanych parametrów.
Zauważ, że moje początkowe pytanie brzmiało n = 100 000 . Ten punkt (przez przypadek) wykazuje szczególne zachowanie:
Ma największą rozbieżność między wersją jedno- i dwupętlową (prawie trzykrotnie)
Jest to jedyny punkt, w którym jedna pętla (czyli z ciągłym przydziałem) pokonuje wersję z dwiema pętlami. (To w ogóle umożliwiło odpowiedź Mysticial.)
Wynik przy użyciu zainicjowanych danych:
Wynik przy użyciu niezainicjowanych danych (właśnie to przetestował Mysticial):
Jest to trudne do wytłumaczenia: Zainicjowane dane, które są przydzielane raz i ponownie wykorzystywane dla każdego kolejnego przypadku testowego o innym rozmiarze wektora:
Wniosek
Każde pytanie związane z wydajnością niskiego poziomu w przypadku przepełnienia stosu powinno być wymagane w celu dostarczenia informacji MFLOPS dla całego zakresu odpowiednich wielkości pamięci podręcznej! Strata czasu dla wszystkich na zastanawianie się nad odpowiedziami, a zwłaszcza omawianie ich z innymi osobami bez tych informacji.
źródło
n
i pokazuje on tę samą lukę w wydajności dlan = 80000, n = 100000, n = 200000
itp.VirtualAlloc
wywołanie blokuje się, dopóki nie będzie w stanie wyzerować wystarczająco, aby zaspokoić żądanie. W przeciwieństwie do tego, Linux po prostu mapuje zerową stronę jako tyle, ile potrzeba, a podczas zapisu kopiuje nowe zera na świeżą stronę przed zapisaniem nowych danych. Tak czy inaczej, z perspektywy procesu trybu użytkownika, strony są zerowane, ale pierwsze użycie niezainicjowanej pamięci będzie zwykle droższe w systemie Linux niż w systemie Windows.Druga pętla wymaga znacznie mniejszej aktywności pamięci podręcznej, więc procesorowi łatwiej jest nadążyć za wymaganiami dotyczącymi pamięci.
źródło
a[i]
,b[i]
,c[i]
ad[i]
W drugim wariancie, potrzebuje tylko dwóch. To sprawia, że znacznie bardziej opłacalne jest uzupełnianie tych wierszy podczas dodawania.x += y
) są dwa odczyty i jeden zapis. Dotyczy to obu wariantów. Wymagania dotyczące przepustowości procesora <-> pamięci podręcznej są zatem takie same. Tak długo, jak nie ma konfliktów, wymagania dotyczące przepustowości pamięci podręcznej <-> RAM są również takie same ..Wyobraź sobie, że pracujesz na komputerze, na którym
n
była odpowiednia wartość, aby możliwe było tylko utrzymanie dwóch twoich tablic w pamięci jednocześnie, ale całkowita dostępna pamięć, dzięki buforowaniu dysku, była nadal wystarczająca do przechowywania wszystkich czterech.Zakładając prostą politykę buforowania LIFO, ten kod:
najpierw przyczyny
a
ib
być ładowane do pamięci RAM, a następnie poddawać obróbce w całości w pamięci RAM. Gdy druga pętla się uruchomi,c
ad
następnie zostanie załadowana z dysku do pamięci RAM i uruchomiona na.druga pętla
będzie przewijać dwie tablice i przewijać pozostałe dwie za każdym razem wokół pętli . To oczywiście byłoby znacznie wolniejsze.
Prawdopodobnie nie widzisz buforowania dysku w swoich testach, ale prawdopodobnie widzisz skutki uboczne jakiejś innej formy buforowania.
Wydaje się, że jest tu trochę zamieszania / nieporozumień, więc postaram się trochę rozwinąć na przykładzie.
Powiedz
n = 2
i pracujemy z bajtami. W moim scenariuszu mamy więc tylko 4 bajty pamięci RAM, a reszta naszej pamięci jest znacznie wolniejsza (powiedzmy 100 razy dłuższy dostęp).Zakładając dość głupie zasady buforowania, jeśli bajt nie znajduje się w pamięci podręcznej, umieść go tam i uzyskaj następujący bajt, gdy będziemy na nim , otrzymasz scenariusz podobny do tego:
Z
cache,
a[0]
aa[1]
potemb[0]
ib[1]
i ustawionea[0] = a[0] + b[0]
w cache - w pamięci podręcznej są teraz cztery bajty,a[0], a[1]
ib[0], b[1]
. Koszt = 100 + 100.a[1] = a[1] + b[1]
w pamięci podręcznej. Koszt = 1 + 1.c
id
.Koszt całkowity =
(100 + 100 + 1 + 1) * 2 = 404
Z
cache,
a[0]
aa[1]
potemb[0]
ib[1]
i ustawionea[0] = a[0] + b[0]
w cache - w pamięci podręcznej są teraz cztery bajty,a[0], a[1]
ib[0], b[1]
. Koszt = 100 + 100.a[0], a[1], b[0], b[1]
z pamięci podręcznej i pamięci podręcznej,c[0]
ac[1]
następnied[0]
id[1]
i ustawc[0] = c[0] + d[0]
w pamięci podręcznej. Koszt = 100 + 100.(100 + 100 + 100 + 100) * 2 = 800
Jest to klasyczny scenariusz thrashu z pamięcią podręczną.
źródło
a1
ib1
dla pierwszego zadania, a nie tylko na pierwszej stronie każdej z nich? (Zakładasz, że strony są 5-bajtowe, więc strona stanowi połowę pamięci RAM? To nie tylko skalowanie, to zupełnie odmienne od prawdziwego procesora.)Nie wynika to z innego kodu, ale z buforowania: pamięć RAM jest wolniejsza niż rejestry procesora, a pamięć podręczna znajduje się wewnątrz procesora, aby uniknąć zapisywania pamięci RAM za każdym razem, gdy zmienia się zmienna. Ale pamięć podręczna nie jest duża, ponieważ pamięć RAM jest mapowana tylko ułamek tego.
Pierwszy kod modyfikuje odległe adresy pamięci, zmieniając je naprzemiennie w każdej pętli, co wymaga ciągłego unieważniania pamięci podręcznej.
Drugi kod nie zmienia się: po prostu przepływa dwa razy na sąsiednie adresy. To powoduje, że wszystkie zadania zostaną zakończone w pamięci podręcznej, co unieważnia je dopiero po uruchomieniu drugiej pętli.
źródło
Nie mogę powtórzyć omawianych tutaj wyników.
Nie wiem, czy winny jest słaby kod testu porównawczego, czy co, ale te dwie metody znajdują się w odległości 10% od siebie na moim komputerze za pomocą następującego kodu, a jedna pętla jest zwykle nieco szybsza niż dwie - tak jak byś oczekiwać.
Rozmiary tablic wahały się od 2 ^ 16 do 2 ^ 24, przy użyciu ośmiu pętli. Ostrożnie zainicjowałem tablice źródłowe, więc
+=
zadanie nie wymagało od FPU dodania śmieci pamięci interpretowanych jako podwójne.Grałem około z różnych systemów, takich jak umieszczenie przypisania
b[j]
,d[j]
doInitToZero[j]
wewnątrz pętli, a także z wykorzystaniem+= b[j] = 1
a+= d[j] = 1
i mam dość spójne wyniki.Jak można się spodziewać, zainicjowanie
b
id
użycie w pętliInitToZero[j]
dało przewagę podejścia łączonego, ponieważ wykonano je pojedynczo przed przypisaniami doa
ic
, ale nadal w granicach 10%. Domyśl.Sprzęt to Dell XPS 8500 z generacją 3 Core i7 @ 3,4 GHz i 8 GB pamięci. Dla 2 ^ 16 do 2 ^ 24, przy użyciu ośmiu pętli, łączny czas wynosił odpowiednio 44,987 i 40,965. Visual C ++ 2010, w pełni zoptymalizowany.
PS: Zmieniłem pętle, aby odliczać do zera, a łączona metoda była nieznacznie szybsza. Drapie mnie po głowie. Zwróć uwagę na nowy rozmiar tablicy i liczbę pętli.
Nie jestem pewien, dlaczego zdecydowano, że MFLOPS jest odpowiednią miarą. Myślałem, że chodziło o skupienie się na dostępie do pamięci, więc starałem się zminimalizować czas obliczeń zmiennoprzecinkowych. Wyszedłem w
+=
, ale nie jestem pewien, dlaczego.Proste przypisanie bez obliczeń byłoby czystszym testem czasu dostępu do pamięci i stworzyłoby test, który byłby jednolity niezależnie od liczby pętli. Może coś mi umknęło w rozmowie, ale warto pomyśleć dwa razy. Jeśli plusa nie ma w przypisaniu, łączny czas jest prawie identyczny po 31 sekund.
źródło
Jest tak, ponieważ procesor nie ma tak wielu braków pamięci podręcznej (gdzie musi czekać na dane z macierzy RAM). Interesujące byłoby ciągłe dostosowywanie rozmiaru tablic, aby przekraczać rozmiary pamięci podręcznej poziomu 1 (L1), a następnie pamięci podręcznej poziomu 2 (L2) procesora i wykreślić czas potrzebny na kod wykonać w stosunku do rozmiarów tablic. Wykres nie powinien być linią prostą, jak można się spodziewać.
źródło
Pierwsza pętla naprzemiennie zapisuje w każdej zmiennej. Drugi i trzeci wykonują tylko niewielkie skoki wielkości elementu.
Spróbuj napisać dwie równoległe linie po 20 krzyżyków za pomocą pióra i papieru oddzielonych 20 cm. Spróbuj raz ukończyć jedną, a następnie drugą linię i spróbuj innym razem, wpisując krzyżyk w każdej linii na przemian.
źródło
Pierwotne pytanie
Wniosek:
Przypadek 1 to klasyczny problem interpolacji, który okazuje się nieefektywny. Myślę też, że był to jeden z głównych powodów, dla których wiele architektur maszyn i programistów zakończyło budowę i projektowanie systemów wielordzeniowych z możliwością wykonywania aplikacji wielowątkowych, a także programowania równoległego.
Patrząc na to z tego rodzaju podejścia, bez angażowania sposobu, w jaki sprzęt, system operacyjny i kompilator (y) współpracują ze sobą, aby dokonać alokacji sterty obejmującej pracę z pamięcią RAM, pamięcią podręczną, plikami stron itp. matematyka, która leży u podstaw tych algorytmów, pokazuje nam, które z tych dwóch jest lepszym rozwiązaniem.
Możemy użyć analogii
Boss
bytu,Summation
który będzie reprezentował cośFor Loop
, co musi podróżować między pracownikamiA
iB
.Łatwo zauważyć, że przypadek 2 jest co najmniej o połowę szybszy, jeśli nie nieco większy niż przypadek 1, ze względu na różnicę w odległości potrzebnej do podróży i czas między pracownikami. Ta matematyka pokrywa się niemal wirtualnie i doskonale zarówno z BenchMark Times, jak i liczbą różnic w instrukcjach montażu.
Teraz zacznę wyjaśniać, jak to wszystko działa poniżej.
Ocena problemu
Kod PO:
I
Rozważanie
Biorąc pod uwagę pierwotne pytanie OP dotyczące 2 wariantów pętli for i jego poprawione pytanie dotyczące zachowania pamięci podręcznej, a także wiele innych doskonałych odpowiedzi i użytecznych komentarzy; Chciałbym spróbować zrobić tutaj coś innego, stosując inne podejście do tej sytuacji i problemu.
Podejście
Biorąc pod uwagę dwie pętle i całą dyskusję na temat pamięci podręcznej i archiwizacji stron, chciałbym przyjąć inne podejście, patrząc na to z innej perspektywy. Ten, który nie wymaga pamięci podręcznej i plików stron ani wykonywania alokacji pamięci, w rzeczywistości takie podejście nawet nie dotyczy rzeczywistego sprzętu ani oprogramowania.
Perspektywa
Po dłuższym spojrzeniu na kod stało się jasne, na czym polega problem i co go generuje. Rozłóżmy to na problem algorytmiczny i spójrzmy na to z perspektywy używania notacji matematycznych, a następnie zastosuj analogię do problemów matematycznych, a także do algorytmów.
Co wiemy
Wiemy, że ta pętla będzie działać 100 000 razy. Wiemy również, że
a1
,b1
,c1
id1
są wskaźnikami na architekturze 64-bitowej. W C ++ na maszynie 32-bitowej wszystkie wskaźniki mają 4 bajty, a na maszynie 64-bitowej mają 8 bajtów, ponieważ wskaźniki mają stałą długość.Wiemy, że w obu przypadkach mamy 32 bajty. Jedyną różnicą jest to, że przydzielamy 32 bajty lub 2 zestawy 2-8 bajtów na każdą iterację, przy czym w drugim przypadku przydzielamy 16 bajtów na każdą iterację dla obu niezależnych pętli.
Obie pętle nadal mają 32 bajty w całkowitej alokacji. Dzięki tym informacjom przejdźmy teraz do przedstawienia ogólnej matematyki, algorytmów i analogii tych pojęć.
Wiemy, ile razy ten sam zestaw lub grupa operacji musi być wykonana w obu przypadkach. Wiemy, ile pamięci trzeba przydzielić w obu przypadkach. Możemy ocenić, że całkowity nakład pracy związany z przydziałami między obydwoma przypadkami będzie w przybliżeniu taki sam.
Czego nie wiemy
Nie wiemy, jak długo potrwa dla każdego przypadku, chyba że ustawimy licznik i przeprowadzimy test porównawczy. Jednak testy porównawcze zostały już uwzględnione w pierwotnym pytaniu, a także w niektórych odpowiedziach i komentarzach; i widzimy znaczącą różnicę między nimi i to jest całe uzasadnienie tej propozycji tego problemu.
Zbadajmy
Widać już, że wielu już to zrobiło, patrząc na przydział sterty, testy porównawcze, pamięć RAM, pamięć podręczną i pliki stronicowania. Uwzględniono także określone punkty danych i konkretne wskaźniki iteracji, a różne rozmowy na temat tego konkretnego problemu powodują, że wiele osób zaczyna kwestionować inne powiązane kwestie. Jak zacząć patrzeć na ten problem, stosując algorytmy matematyczne i stosując do niego analogię? Zaczynamy od kilku twierdzeń! Następnie stamtąd budujemy nasz algorytm.
Nasze twierdzenia:
F1()
,F2()
,f(a)
,f(b)
,f(c)
if(d)
.Algorytmy:
Pierwszy przypadek: - Tylko jedno sumowanie, ale dwa niezależne wywołania funkcji.
Drugi przypadek: - Dwa podsumowania, ale każde ma swoje własne wywołanie funkcji.
Jeśli zauważyłeś,
F2()
istnieje tylkoSum
zCase1
którymF1()
zawarta jest wSum
odCase1
a zarównoSum1
iSum2
odCase2
. Będzie to widoczne później, gdy zaczniemy wnioskować, że w drugim algorytmie odbywa się optymalizacja.Iteracje przez pierwsze
Sum
wywołania przypadkuf(a)
, które dodadzą do siebie,f(b)
a następnie wywołaniaf(c)
, które zrobią to samo, ale dodadząf(d)
do siebie dla każdej100000
iteracji. W drugim przypadku mamySum1
iSum2
oba działają tak samo, jakby były tą samą funkcją wywoływaną dwa razy z rzędu.W tym przypadku możemy traktować
Sum1
iSum2
jak zwykły stary,Sum
gdzieSum
w tym przypadku wygląda to tak:Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
a teraz wygląda to na optymalizację, w której możemy po prostu uznać, że jest to ta sama funkcja.Podsumowanie z analogią
Z tym, co widzieliśmy w drugim przypadku, wygląda na to, że istnieje optymalizacja, ponieważ obie pętle mają tę samą dokładną sygnaturę, ale to nie jest prawdziwy problem. Problemem nie jest praca, która jest wykonywana przez
f(a)
,f(b)
,f(c)
, if(d)
. W obu przypadkach i porównaniu między nimi, różnica w odległości, jaką musi pokonać Podsumowanie w każdym przypadku, daje różnicę w czasie wykonania.Myśleć o
For Loops
jak będącSummations
które wykonuje iteracje jak bycieBoss
, który wydawał rozkazy do dwóch osóbA
iB
, a ich praca jest do mięsaC
iD
odpowiednio i odebrać od nich jakąś paczkę i odesłać go. W tej analogii same pętle for lub iteracje sumowania i kontrole warunków nie reprezentują w rzeczywistościBoss
. To, co tak naprawdę reprezentuje,Boss
nie pochodzi z rzeczywistych algorytmów matematycznych, ale z rzeczywistej koncepcjiScope
iCode Block
procedury, podprogramu, metody, funkcji, jednostki tłumaczeniowej itp. Pierwszy algorytm ma 1 zakres, w którym drugi algorytm ma 2 kolejne zakresy.W pierwszym przypadku na każdym odcinku połączenia
Boss
idzie doA
i wydaje zamówienie, a następnieA
pobieraB's
paczkę, a następnieBoss
idzie doC
i daje rozkazy, aby zrobić to samo i odebrać paczkę zD
każdej iteracji.W drugim przypadku
Boss
działa bezpośrednio,A
aby przejść i pobraćB's
pakiet, aż wszystkie pakiety zostaną odebrane. NastępnieBoss
działaC
tak samo, aby uzyskać wszystkieD's
pakiety.Ponieważ pracujemy z 8-bajtowym wskaźnikiem i zajmujemy się przydzielaniem sterty, rozważmy następujący problem. Powiedzmy, że
Boss
jest 100 stóp od,A
a toA
jest 500 stóp odC
. Nie musimy się martwić o to, jak dalekoBoss
początkowo jest to zC
powodu kolejności wykonywania. W obu przypadkachBoss
początkowo podróżuje odA
pierwszego do następnegoB
. Ta analogia nie oznacza, że odległość ta jest dokładna; jest to po prostu przydatny scenariusz testowy, który pokazuje działanie algorytmów.W wielu przypadkach podczas przydzielania sterty i pracy z pamięcią podręczną i plikami stron odległości te między lokalizacjami adresów mogą się nie różnić tak bardzo lub mogą się znacznie różnić w zależności od rodzaju typów danych i rozmiarów tablicy.
Przypadki testowe:
Pierwszy przypadek: przy pierwszej iteracji
Boss
musi początkowo przejść 100 stóp, aby przekazać dowód zamówienia,A
iA
znika, i robi swoje, ale potemBoss
musi przebyć 500 stóp,C
aby dać mu dowód zamówienia. Następnie przy następnej iteracji i każdej innej iteracji po tymBoss
musi iść w tę iz powrotem 500 stóp między nimi.Drugi przypadek:
Boss
ma do przebycia 100 stóp na pierwszej iteracji doA
, ale po tym, on już tam jest i tylko czekaA
, aby wrócić aż wszystkie zrazy są wypełniane. NastępnieBoss
musi przejechać 500 stóp przy pierwszej iteracji do,C
ponieważC
jest 500 stóp odA
. PonieważBoss( Summation, For Loop )
jest to wywoływane zaraz po pracy,A
on po prostu czeka tam, tak jak to zrobił,A
aż do wykonania wszystkichC's
odcinków zamówienia.Różnica w przebytych odległościach
Porównanie wartości arbitralnych
Łatwo zauważyć, że 600 to znacznie mniej niż 10 milionów. Nie jest to do końca dokładne, ponieważ nie znamy faktycznej różnicy odległości między adresem pamięci RAM lub z którego pamięci podręcznej lub pliku strony każde wywołanie każdej iteracji będzie spowodowane wieloma innymi niewidocznymi zmiennymi. Jest to tylko ocena sytuacji, na którą należy zwrócić uwagę, i spojrzenie na nią z najgorszego scenariusza.
Z tych liczb wyglądałoby to prawie tak, jakby Algorytm pierwszy był
99%
wolniejszy niż algorytm drugi; Jest to jednak tylkoBoss's
część lub odpowiedzialność algorytmów i nie uwzględnia rzeczywistych pracownikówA
,B
,C
, iD
, a co mają robić na każdej iteracji pętli. Tak więc praca szefa stanowi jedynie około 15 - 40% całkowitej wykonanej pracy. Większość pracy wykonanej przez pracowników ma nieco większy wpływ na utrzymanie stosunku różnic prędkości między prędkościami około 50-70%Obserwacja: - Różnice między dwoma algorytmami
W tej sytuacji jest to struktura procesu wykonywanej pracy. Pokazuje to, że przypadek 2 jest bardziej efektywny zarówno od częściowej optymalizacji posiadania podobnej deklaracji funkcji, jak i definicji, gdy tylko zmienne różnią się nazwą i przebytą odległością.
Widzimy również, że całkowita odległość przebyta w przypadku 1 jest znacznie większa niż w przypadku 2 i możemy rozważyć tę odległość przebytą przez nasz współczynnik czasu między dwoma algorytmami. Przypadek 1 wymaga znacznie więcej pracy niż przypadek 2 .
Można to zaobserwować na podstawie dowodów
ASM
instrukcji pokazanych w obu przypadkach. Wraz z tym, co już powiedziano o tych przypadkach, nie oznacza to, że w przypadku 1 szef będzie musiał poczekać na obaA
iC
wrócić, zanim będzie mógł wrócić doA
każdej iteracji. Nie bierze również pod uwagę faktu, że jeśliA
lubB
zajmuje to bardzo dużo czasu, zarówno obaj, jakBoss
i pozostali robotnicy czekają na wykonanie.W przypadku 2 jedyną bezczynnością jest czas
Boss
powrotu pracownika. Nawet to ma wpływ na algorytm.Zmienione pytania PO
Odnośnie tych pytań
Jak wykazałem bez wątpienia, istnieje problem leżący u podstaw jeszcze przed zaangażowaniem sprzętu i oprogramowania.
Teraz jeśli chodzi o zarządzanie pamięcią i buforowaniem wraz z plikami stron itp., Które wszystkie działają razem w zintegrowanym zestawie systemów między następującymi elementami:
The Architecture
{Sprzęt, oprogramowanie układowe, niektóre sterowniki wbudowane, jądra i zestawy instrukcji ASM}.The OS
{Systemy zarządzania plikami i pamięcią, sterowniki i rejestr}.The Compiler
{Jednostki tłumaczeniowe i optymalizacje kodu źródłowego}.Source Code
sam z zestawem charakterystycznych algorytmów.Już teraz widać, że nie jest wąskim gardłem, co dzieje się w ciągu pierwszego algorytmu zanim jeszcze zastosować go do dowolnego urządzenia z dowolnym
Architecture
,OS
orazProgrammable Language
w stosunku do drugiego algorytmu. Wystąpił już problem przed zaangażowaniem się we współczesny komputer.Końcowe wyniki
Jednak; nie można powiedzieć, że te nowe pytania nie mają znaczenia, ponieważ same są i w końcu odgrywają pewną rolę. Wpływają one na procedury i ogólną wydajność, co widać na podstawie różnych wykresów i ocen wielu osób, które udzieliły odpowiedzi i / lub komentarzy.
Jeśli zwrócić uwagę na analogię
Boss
, a dwóch pracownikówA
iB
którzy mieli pójść i pobrać pakietyC
iD
odpowiednio i rozważa matematycznej notacji dwóch algorytmów w pytaniu; widać bez udziału sprzętu komputerowego, a oprogramowanieCase 2
jest w przybliżeniu60%
szybsze niżCase 1
.Gdy spojrzysz na wykresy i wykresy po zastosowaniu tych algorytmów do jakiegoś kodu źródłowego, skompilowaniu, zoptymalizowaniu i wykonaniu przez system operacyjny w celu wykonania ich operacji na danym sprzęcie, możesz nawet zauważyć nieco większą degradację między różnicami w tych algorytmach.
Jeśli
Data
zestaw jest dość mały, na pierwszy rzut oka może się wydawać, że nie ma aż takiej różnicy. Ponieważ jednakCase 1
jest o wiele60 - 70%
wolniejszy niżCase 2
możemy spojrzeć na wzrost tej funkcji pod względem różnic w wykonywaniu czasu:To przybliżenie jest średnią różnicą między tymi dwiema pętlami, zarówno algorytmicznymi, jak i operacjami maszyny obejmującymi optymalizacje oprogramowania i instrukcje maszynowe.
Gdy zbiór danych rośnie liniowo, rośnie również różnica czasu między nimi. Algorytm 1 ma więcej pobrań niż algorytm 2, co jest oczywiste, gdy
Boss
musi podróżować do przodu i do tyłu maksymalną odległość międzyA
iC
dla każdej iteracji po pierwszej iteracji, podczas gdy Algorytm 2Boss
musi jechaćA
raz, a następnie poA
zakończeniu musi podróżować maksymalna odległość tylko jeden raz, jadąc odA
doC
.Próba
Boss
skupienia się na robieniu dwóch podobnych rzeczy naraz i żonglowaniu nimi w jedną i drugą stronę zamiast skupiania się na podobnych kolejnych zadaniach sprawi, że pod koniec dnia będzie dość zły, ponieważ musiał dwukrotnie podróżować i pracować. Dlatego nie trać zakresu sytuacji, pozwalając szefowi dostać się do interpolowanego wąskiego gardła, ponieważ małżonek szefa i dzieci nie doceniliby tego.Poprawka: Zasady projektowania oprogramowania
- Różnica między
Local Stack
iHeap Allocated
obliczenia zasięgu iteracyjny dla pętli, a różnica między ich zwyczajów, ich wydajności i skuteczności -Algorytm matematyczny, który zaproponowałem powyżej, dotyczy głównie pętli, które wykonują operacje na danych przydzielonych na stercie.
Kiedy więc pracujesz z danymi, które muszą znajdować się na stercie, i przechodzisz przez nie w pętlach, bardziej efektywne jest utrzymywanie każdego zestawu danych i odpowiadających mu algorytmów w ramach jednej pojedynczej pętli. Uzyskasz lepsze optymalizacje w porównaniu do próby wyodrębnienia kolejnych pętli poprzez umieszczenie wielu operacji różnych zestawów danych znajdujących się na stercie w jednej pętli.
Można to zrobić z danymi znajdującymi się na stosie, ponieważ są one często buforowane, ale nie w przypadku danych, które muszą mieć sprawdzany adres pamięci podczas każdej iteracji.
Tutaj zaczyna się gra Inżynieria oprogramowania i Projektowanie architektury oprogramowania. Jest to umiejętność wiedzieć, jak zorganizować dane, wiedzieć, kiedy je buforować, wiedzieć, kiedy przydzielić dane na stercie, jak zaprojektować i wdrożyć algorytmy oraz wiedzieć, kiedy i gdzie je wywołać.
Możesz mieć ten sam algorytm, który dotyczy tego samego zestawu danych, ale możesz chcieć jednego projektu implementacji dla jego wariantu stosu, a drugiego dla jego wariantu alokacji sterty tylko z powodu powyższego problemu, który wynika z jego
O(n)
złożoności algorytmu podczas pracy z kupą.Z tego, co zauważyłem przez lata, wiele osób nie bierze tego pod uwagę. Będą mieli tendencję do projektowania jednego algorytmu, który działa na określonym zbiorze danych i będą go używać niezależnie od tego, czy zbiór danych jest lokalnie buforowany na stosie, czy też został przydzielony na stercie.
Jeśli chcesz prawdziwej optymalizacji, tak, może się to wydawać duplikacją kodu, ale uogólnienie byłoby bardziej wydajne, gdyby dwa warianty tego samego algorytmu. Jedna do operacji na stosie, a druga do operacji na stosie, które są wykonywane w pętlach iteracyjnych!
Oto pseudo przykład: dwie proste struktury, jeden algorytm.
Do tego miałem na myśli osobne implementacje wariantów stosu w porównaniu do wariantów stosu. Same algorytmy nie mają większego znaczenia, to struktury zapętlające, w których będziesz ich używał.
źródło
Może to być stary C ++ i optymalizacje. Na moim komputerze uzyskałem prawie taką samą prędkość:
Jedna pętla: 1,577 ms
Dwie pętle: 1,507 ms
Używam Visual Studio 2015 na procesorze E5-1620 3,5 GHz z 16 GB pamięci RAM.
źródło