[C++] Sposób odwołania się do elementów tablicy a wydajność

0

Witam !

Dziś odpowiedziałem koledze w tym wątku http://4programmers.net/Forum/viewtopic.php?id=162998, lecz potem zacząłem się zastanawiać nad zapisem tego kodu który mu podesłałem.

Pierwotny kod :


srand ( time(NULL) );

int tab[20];
int pom,j;

pom=j=0;

for(int i=0 ; i<10 ; i++)
{
   pom=rand()%10;
   tab[j]=pom;
   tab[j+1]=pom;

   j+=2;
 
}

Generalnie kod losuje 10 par losowych liczb w przedziale do 10.

Pomyślałem jednak, że użyłem niepotrzebne zmienne,i postanowiłem to zoptymalizować. Żeby podjąć się tej próby postanowiłem zmierzyć czas wykonania się programu. Jak się pewnie już domyślacie wyszło mi 0.00sec. Postanowiłem w takim razie zwiększyć liczbę losowanych liczb, wszakże w projektach o dużej ilości pętli tak śmiecąc zmiennymi można by teoretycznie nieźle go zwolnić. Problem polega na tym, że ta pętla nie potrafi wykonać się więcej niż 1000 razy.

Moje pierwsze pytanie - dlaczego ? Próbowałem powiększać liczbę iteracji samej pętli oraz rzecz jasna rozmiar tablicy i wywala mi błąd pamięci i odwołuje się do jakiejś wstawki aseamblerowej - dlaczego ?

Tak czy siak rozwiązałem ten problem zapisując ten kod tak :

KOD 1


for(int q=0;q<1000;q++)
{
  for(int i=0 ; i<1000 ; i++)
  {
     pom=rand()%10;
     tab[j]=pom;
     tab[j+1]=pom;

     j+=2;
 
  }
  
j=0;

}

Tak zapisana operacja wykonuje się w :

0.172sec

Ok! Usuwam zbędne zmienne i o dziwo okazuje się,że program potrafi wykonać się więcej niż 1000 razy a mianowicie potrafi wykonać się 100000 razy ,a wygląda tak :

KOD 2

for(int q=0;q<10;q++)
for(int i=0;i<100000;i+=2)
   tab[i]=tab[i+1]=rand()%10;

Co lepsze wykonuje się w :

0.078sec

Pomyślałem, że skoro pętla może wykonać się 100000 razy to mogę po prostu zapisać to 10 razy zamiast pakować to w pętle ( wiem wbrew DRY ). Chodzi mi o taki zapis :

KOD 3

for(int i=0;i<100000;i+=2) //1 petla
   tab[i]=tab[i+1]=rand()%10;

for(int i=0;i<100000;i+=2)//2 petla
   tab[i]=tab[i+1]=rand()%10;
...

for(int i=0;i<100000;i+=2)//10 petla
   tab[i]=tab[i+1]=rand()%10;

Takie cudo wykonało się w :

0.094 sec

W zasadzie zamiast w pętli która powtarza napisałem 10 razy to samo i czym to się różni, że czas wykonania jest inny ?

Podążając tropem wymyśliłem że zapiszę inaczej operacje na tablicach i wyszło mi takie coś :

KOD 4

for(int q=0;q<10;q++)
for(int i=0;i<100000;i+=2)
   *(tab+i)=*(tab+i+1)=rand()%10;

Wykonało się w:
0.078sec

Wynik mnie nie zdziwił ponieważ czytałem, że takie odwołanie się do tablicy jest szybsze, jednak nowe kompilatory same sobie tak robią, w procesie optymalizacji i nie ma sensu tego ręcznie pisać ( wskaźnikowo ).

Jednak skoro poprzedni kod zapisałem bez pętli to i ten postanowiłem tak wypróbować :

KOD5

for(int i=0;i<100000;i+=2) //petla 1
   *(tab+i)=*(tab+i+1)=rand()%10;

for(int i=0;i<100000;i+=2)// petla 2
   *(tab+i)=*(tab+i+1)=rand()%10;

...

for(int i=0;i<100000;i+=2)//petla 10
   *(tab+i)=*(tab+i+1)=rand()%10;

Wynik jest zaskakujący dla mnie ponieważ myślałem, że wykona się zupełnie tak jak KOD3 ponieważ, zaszła analogia między KOD2 a KOD4, jednak tak się nie stało.

Program wykonał się w:

0.078 sec

Co jest takim samym osiągiem jak KOD2 i KOD4 mimo, zastosowania ręcznego powtarzania kodu zamiast pętli. Znów nasuwa się pytanie - dlaczego ?

Na koniec postanowiłem jeszcze coś sprawdzić. Skoro odwołanie do tablicy przez operator indeksacji ,a przez arytmetyke wskaźnika przy tej samej konstrukcji pętli nie ma różnicy w wydajności postanowiłem sprawdzić taki kod :

KOD 6

for(int q=0;q<1000;q++)
{
for(int i=0 ; i<1000 ; i++)
{
   pom=rand()%10;
   *(tab+j)=pom;
   *(tab+(j+1))=pom;

   j+=2;
 
}
j=0;
}

Ten kod wykonał sie w :

0.188sec

Czyli dłużej niż jakikolwiek wcześniej.

Reasumując popatrzmy na wnioski:

KOD1 0.172sec Moim zdaniem najmniej optymalny co jednak jest nieprawdą, jednak nie najlepszy.

KOD2 0.078sec Ten kod należy do trójcy najbardziej optymalnych. Nie zawiera niepotrzebnych zmiennych, 2 zagnieżdżone pętle.

KOD3 0.094sec Jak powyżej jednak zamiast pętli ręczne przeklepywanie co go zwolniło.

KOD4 0.078sec Zastosowane odwołanie jak do wskaźnika , pętle jak w kodzie 2 żadnej zmiany wydajności.

KOD5 0.078sec Kod z odwołaniem wskaźnikowym, oraz ręcznie przeklepany zamiast pętli. Zamiast zwolnić jak KOD3 zachował swoją wydajność.

KOD6 0.188sec Kod który w moim zamyśle miał być szybszy od 1 przez odwołanie się do tablicy jak do wskaźnika, jednak ta operacja znacznie go zwolniła - dlaczego ? Przecież różni się jedynie sposobem odwołania jak kody 2 i 4 a w tamtych nie widać różnic.

W zależności od zastosowanej ilości zmiennych, oraz sposobu odwołań kod zwalnia lub przyspiesza, potrafi ktoś wytłumaczyć mi dlaczego dzieje się tak w poszczególnych przypadkach ?

Nie wiem dlaczego w niektórych przypadkach kod zachowuje się inaczej niż oczekuję ( chodzi o prędkość wykonania ), jednak widzę zmiany. Zastanawia mnie czy warto dla takich zmian w odpowiednich sytuacjach zmieniać konwencje odwołania przez operator indeksacji na rzecz wskaźnikowego i odwrotnie aby zyskać parę sekund, kosztem zaciemnienia kodu ?

Dalej - w zasadzie przedstawiłem 2 konwencje główne - długą i krótką, nie wspominając o różnym sposobie odwołania do tablicy.

W konwencji długiej widać w zasadzie która zmienna odpowiedzialna jest za losowanie kolejnej liczby a która za zapis do odpowiedniej komórki tablicy. Można przed zapisem zmiennej do tablicy sprawdzić jaka wartość zostanie zapisana.

Moim zdaniem ten kod jest czytelniejszy, bezpieczniejszy i łatwiejszy do ewentualnego debuggowania czy dopisania testu. Stan ten jest okupiony znacznie gorszą wydajnością , niemal 0.1sec.

Czy warto zredukować ilość zmiennych jedynie do sterujących pętlami i zyskać tą wydajność kosztem bezpieczeństwa, łatwości diagnozy i utrzymania wiedząc, że ten kod prawie na pewno jest prawidłowy i raczej nie będzie się go testować/zmieniać ?

Wiem, że problem jest trywialny jednak chodzi mi o analogie do dużych problemów. Jak zachować się wówczas i jakie konwencje przyjąć.

Gwoli ścisłości powyższe kody wykonują się 1kk razy - myślę, że jest to dostateczna ilość, żeby sprawdzić ich wydajność.

Ponadto mam nadzieje, że komuś będzie się chciało poświęcić mi parę chwil na wyjaśnienie problemu.</cpp></cpp></cpp>

0

Odpowiedź nie jest taka oczywista i może się róznić w zalezności od kompilatora. Wynik twoich obserwacji też zależeć będzie od użytego kompilatora. Wiekszość jednak pozwala na zapisanie asemblerowego zapisu generowanego kodu którego analiza odpowie ci na twoje pytania. Open Watcom np twoje petle genreuje tak - kod bedzie się różnił od ustawionych otymalizacji i typu platformy - ten jest kompilowany pod DOS 16bit. Nie podejmuje się dalszej analizy :)

; 
; for(int q=0;q<10;q++)
L$2:
    mov         word ptr 63beH[bp],0 
    jmp         L$4 
L$3:
    mov         ax,word ptr 63beH[bp] 
    inc         word ptr 63beH[bp] 
L$4:
    cmp         word ptr 63beH[bp],0aH 
    jge         L$9 

; for(int i=0;i<100000;i+=2)
L$5:
    mov         word ptr 63b0H[bp],0 
    jmp         L$7 
L$6:
    add         word ptr 63b0H[bp],2 

;    tab[i]=tab[i+1]=rand()%10;
L$7:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63b0H[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63b0H[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$6 
L$8:
    jmp         L$3 

;    
; }

a druga tak

; 
; 
; for(int i=0;i<100000;i+=2)
L$2:
    mov         word ptr 63beH[bp],0 
    jmp         L$4 
L$3:
    add         word ptr 63beH[bp],2 

;    tab[i]=tab[i+1]=rand()%10;
L$4:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63beH[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63beH[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$3 

; 
; for(int i=0;i<100000;i+=2)
L$5:
    mov         word ptr 63b0H[bp],0 
    jmp         L$7 
L$6:
    add         word ptr 63b0H[bp],2 

;    tab[i]=tab[i+1]=rand()%10;
L$7:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63b0H[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63b0H[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$6 

; 
; for(int i=0;i<100000;i+=2)
L$8:
    mov         word ptr 63aeH[bp],0 
    jmp         L$10 
L$9:
    add         word ptr 63aeH[bp],2 

;    tab[i]=tab[i+1]=rand()%10;
L$10:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63aeH[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63aeH[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$9 

; 
; for(int i=0;i<100000;i+=2)
L$11:
    mov         word ptr 63acH[bp],0 
    jmp         L$13 
L$12:
    add         word ptr 63acH[bp],2 

;    tab[i]=tab[i+1]=rand()%10;
L$13:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63acH[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63acH[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$12 

; 
;    
; }
0

@javauser - dzięki wielkie za fatygę. Widzę, że faktycznie kod z wykorzystaniem indeksacji, oraz klepania ręcznego funkcji zamiast pętli wykonuje się dłużej, z powodu obszernej ilości operacji. Szczerze myślałem, że mądry kompilator zauważy ten fakt i sobie to tak jakby "zwinie" i w asm'ie będzie już jako spójna całość, jednak tak nie jest. Tak czy siak pozostaje kwestia, dlaczego z użyciem arytmetyki wskaźników te 2 konwencje ( z pętlą i z powtarzaniem kodu ) nie różnią się wydajnością ?

Mógł byś (lub ktokolwiek inny znający się na sprawie ) pobieżnie wypunktować operacje asm'a w tym kodzie? Ostatnio interesowałem się asm'em jednak jestem na etapie zapoznawania się z rejestrami i systemami liczbowymi, więc totalny wstęp i nie rozumiem tego kodu aczkolwiek coś tam coś tam widzę, jednak nie potrafię odczytać intencji kodu.

Zachęcam innych do podjęcia głosu w tym i pozostałych dylematach jakie przedstawiłem.

Jeszcze raz dzięki za fatygę.

0

I jeszcze jedno. Żeby byc pewnym twoich wyników musisz wykonać kilka pomiarów. Pamietaj że Widows to środowisko wielowatkowe z wywłaszczeniem i priorytetami procesów.

0

Szczerze powiedziawszy to Twoje wyniki nie są ani trochę miarodajne - różnice liczone w milisekundach mogą występować z powodu czynników ubocznych, aktywności innych procesów, nawet cache'owania plików jeżeli liczyć całość trwania procesu. Takie testy przeprowadza się przy sensownej rozdzielczości, wyniki w sekundach lub wręcz dziesiątkach sekund - zwiększ liczbę testów.

Tak naprawdę te rozważania nie znaczą nic - spora część tego to kwestia optymalizacji i dobrego humoru kompilatora.

Z definicji ptr[index] jest skróconą formą *(ptr+index), całkowicie równoważną. Różnica mniej/więcej taka jak pomiędzy (*ptr).mem a ptr->mem.

Jako ciekawostkę wspomnę jeszcze o słowie kluczowym restrict wprowadzonym w C99, które wpływa na sposób traktowania pointerów przy odwołaniach - kolejny czynnik wydajnościowy, tym razem realny.

0

Obie linie (z indeksacja i w oparciu o arytmetykę wskaźików generja taki sam kod wynikowy). Nie może być różnic w czasie wykonywania.

;    *(tab+i)=*(tab+i+1)=rand()%10;
L$7:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63b0H[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63b0H[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$6 
L$8:
    jmp         L$3 

;    tab[i]=tab[i+1]=rand()%10;
L$14:
    call        far ptr rand_ 
    mov         bx,0aH 
    cwd         
    idiv        bx 
    mov         si,word ptr 63acH[bp] 
    shl         si,1 
    mov         word ptr 63c2H[bp+si],dx 
    mov         di,word ptr 63acH[bp] 
    shl         di,1 
    mov         ax,word ptr 63c2H[bp+si] 
    mov         word ptr 63c0H[bp+di],ax 
    jmp         L$13 
L$15:
    jmp         L$10 
0
lukas_gab napisał(a)

Widzę, że faktycznie kod z wykorzystaniem indeksacji, oraz klepania ręcznego funkcji zamiast pętli wykonuje się dłużej, z powodu obszernej ilości operacji. Szczerze myślałem, że mądry kompilator zauważy ten fakt i sobie to tak jakby "zwinie".

Własnie kod klepany ręcznie w tym przypadku bedzie wykonywany szybciej :). Zwróc uwagę że procesor nie musi nic liczyć w takim przyapdku - kończy jedna petle i zaczyna drugą. Jesli wprowadzisz zewnętrzna petle procesor musi ja obsłużyć i wykonac dodatkowe obliczenia i sprawdzenie warunku wyjścia z tej petli - ergo ma wiecej pracy.

0

Mała uwaga - poza specyficznymi sytuacjami (jak użycie volatile) kod źródłowy nie ma bezpośredniego przełożenia na kod maszynowy - obecnie sensowne kompilatory C++ potrafią łączyć funkcje znajdujące się w różnych jednostkach kompilacji w jedną większą operację, np. jeżeli całość stanowiła podzieloną na fragmenty pętlę.

0

Postaram się przeanalizować asm'a wypluwanego przez kompilator, jednak to później jak będę trochę tego asma znał. pÓÓÓÓki co postaram się przeprowadzić dokładniejsze testy przy różnej ilosci wykonywanych obliczen w pętli i wiekszej ilości pomiarów. Przeprowadze testy na paru platformach i konfiguracjach .Poddam obróbce wyniki i je później przedstawie, jednak zajmie mi to chwilkę, bo pÓÓÓÓki co muszę skupić się na sesji. Temat miał być małą odskocznią, taką jednodniową od nauki, myślałem, że ktoś mądrzejszy mi prosto odpowie, jednak widzę, że należy się zagłębić w to bo to ciekawy problem. Myślicie, że warto w to brnąć, czy raczej ma to sens jak liczenie ziaren piasku ? IMHO będzie to jakieś fajne doświadczenie i praktyka w przetestowaniu jakiegoś tam algorytmu i wydajności paru instrukcji, chociaż może jest to przerost formy nad treścią... sam nie wiem już.

0

chociaż może jest to przerost formy nad treścią

właśnie tak [green]
za miesiąc jakiś skacowany programista poprzestawia coś w nowej wersji systemu lub kompilatora
i wszystkie analizy szlag trafi ..
Powinieneś uwzględnić jeszcze statystyczną ilość komarów rodzaju żeńskiego danej okolicy w której
znajduje się twój komputer oraz bieżące notowania kandydatów na prezydenta XXx RP .

0

Moje pierwsze pytanie - dlaczego ? Próbowałem powiększać liczbę iteracji samej pętli oraz rzecz jasna rozmiar tablicy i wywala mi błąd pamięci i odwołuje się do jakiejś wstawki aseamblerowej - dlaczego ?

Tablice masz stworzona jako zmienna lokalna funkcji main. zmienne lokalne sa tworzone na stosie, stos ma scisle ograniczona dlugosc - 1/2/4/.. mb, zaleznie od ustawien kompilatora. Jezeli u Ciebie jest 1mb, to proba stworzenia tablicy int (4b) wiekszej od (110241024 - ramki funkcji runtime'u) / 4b wywali Ci program zaraz po jego starcie, tuz-przed-main(), w momencie gdy wygenerowany kod bedzie probowal zaalokowac na stosie ową gigantyczną tablice w ramach ramki main'a. Zakladajac zero 'ramek funkcji runtime'u i 1mb stosu wychodzi [262144] intow. Moze probowales stworzyc wieksza? Pozostaje new/malloc

KOD2,3,4,5 sa rownowazne i znacza to samo, wiec zaden dziw ze ten sam czas. Rozbieznosc w KOD3 sadze ze wynika chwilowego zajecia procka czyms innym, jakas usluga systemowa mogla sie wtracic i gmerac po sieci za mailami, update'ami, albo chociazby system stwierdzil ze teraz koniecznie trzeba pulpit repaintowac..

KOD6 jest rownowazny KOD1 i powinien sie wykonac w tym samym czasie. Tutaj rowniez sadze ze opoznienie w K6 bylo 'z systemowego powodu'

A co do sensownosci testu.. hm..

  1. W przypadku kodow ktore są/powinybyć rownoważne wg Ciebie, troche nie ma sensu testowac eksprymentalnie.. Faktycznie wez wygeneruj listing ASM. Nie musisz go rozumiec. Jezeli kompilator dwa dwoch kodow np. KOD3 i KOD4 wypluje taki sam listing ASM, to znaczy ze sa rownowazne i czasy beda takie same. Twierdzenia odwrotne i przeciwne nie są prawdziwe:)
  2. W przypadku nie-rownowaznych, jak np. KOD1 i KOD2 - jesli Ci sie chce, czemu nie. Pobawic sie mozna. Jednak nie wkladalbym w to za wiele czasu i srodkow, gdyz, jakby to rzecz miękko, kod ktory testujesz jest małociekawy i wyniki takich testow nawet szerokich, nie pokażą wiecej ponad to, ktory kompilator na ktora platforme lepiej maszynowy generuje dla tego specyficznego przypadku.

1 użytkowników online, w tym zalogowanych: 0, gości: 1