Odwoływanie się przez referencję/wskaźnik a dodatkowy narzut?

0

Witam,
chciałem poruszyć dość teoretyczny problem, ale jednak dość istotny dla mnie i może w cale nie taki teoretyczny.

Zastanawia mnie, czy jeśli odwołujemy się do referencji to czy jest związany z tym dodatkowy narzut - czy wpływa to w jakiś sposób na wydajność działania programu?
Zdaje się, że jeśli operujemy na wskaźnikach to wyłuskanie zmiennej ma taki wpływ (prosiłbym jeszcze o potwierdzenie), ale czy tak jest również z referencją? Podobno bardzo często referencja jest implementowana jako wskaźnik więc wnioskuję, że tak właśnie powinno być. Z drugiej strony nie koniecznie musi być tu wyłuskiwanie...

Zarówno oryginał zmiennej jak i referencja to jakiś adres w pamięci. Tak więc działając na oryginale odwołujemy się do jakiejś komórki w pamięci i analogicznie jest z referencją - ale czy jest różnica między odwoływaniem się do oryginału a referencji??

Chodzi mi o taki (trochę banalny) przykład:

class Klasa
	{
	public:
		JakasKlasa& ref;
		JakasKlasa orginal;
		JakasKlasa* wsk;

		Klasa(JakasKlasa& x) : ref(x){}

		void do_something()
			{
			//jaki wpływ na wydajność ma typ zmiennej:
			ref.a += 1;			//to vs to niżej
			orginal.a += 1;	//vs
			wsk->a += 1;   //vs
			(*wsk).a += 1; //vs
			}
	};

Mam więc pytanie: czy z teoretycznego punktu widzenia powinna być jakaś różnica między wywołaniem ref.a a orginal.a?
Co jeśli zamiast referencji byłby wskaźnik i bym wyłuskiwał zmienną lub odwoływał się przez operator -> - czy to się różni od referencji?

Z góry dziękuję za wyjaśnienie.

0
stryku napisał(a):

http://4programmers.net/Forum/C_i_C++/245510-wskaznik_vs_referencja_-_wydajnosc tu poczytaj

Dzięki, ale tam jest porównanie referencji vs wskaźnik. Czy tak samo będzie porównując oryginał vs referencja?

0

Kompilator wszystko i tak sprowadzi do jednej postaci, więc nie będzie różnicy. Z tego co wiem

0
stryku napisał(a):

Kompilator wszystko i tak sprowadzi do jednej postaci, więc nie będzie różnicy. Z tego co wiem

Jednak obawiam się, że będzie.
Sprawdziłem jak ten kod wygląda w assemblerze i...

Wersja dla oryginału:

Klasa::do_something():			
	pushq	%rbp	
	movq	%rsp, %rbp	
	movq	%rdi, -8(%rbp)	
	movq	-8(%rbp), %rax	
	movl	8(%rax), %eax	
	leal	1(%rax), %edx	
	movq	-8(%rbp), %rax	
	movl	%edx, 8(%rax)	
	nop		
	popq	%rbp	
	ret		

Wersja dla referencji:

Klasa::do_something():			
	pushq	%rbp	
	movq	%rsp, %rbp	
	movq	%rdi, -8(%rbp)	
	movq	-8(%rbp), %rax	
	movq	16(%rax), %rax	
	movq	-8(%rbp), %rdx	
	movq	16(%rdx), %rdx	
	movl	(%rdx), %edx	
	addl	$1, %edx	
	movl	%edx, (%rax)	
	nop		
	popq	%rbp	
	ret		

Kod dla referencji jest dłuższy, a więc wnioskuje, że jest dodatkowy narzut ;-(

0

Za bardzo się przejmujesz mikro optymalizacjami. Różnice, jeśli będą, to na poziomie kilku taktów zegara.
Tego typu błędy jakie mogą mieć znaczący wpływ na wydajność, jedynie jeśli przekazywane są obiekty do funkcji/metody przez wartość, zamiast przez referencję (bo wykonywana jest wtedy kopia obiektu).
Jak że wciąż jesteś newbie radzę byś sobie na razie odpuścił takie dywagację, bo niewiele cię nauczą, a mogą nawet ci namieszać w głowie.

0
    movq    -8(%rbp), %rdx    
    movq    16(%rdx), %rdx

To mi wygląda na brak włączonej optymalizacji, bo kompilator nie wyplułby raczej tych dwóch instrukcji pod rząd przy -O3.
A bez optymalizacji nie ma w ogóle co porównywać ani badać prędkości.

0

Wiem, że to nie ten język programowania ale korci mnie żeby pokazać:

Kod dla referencji jest dłuższy, a więc wnioskuje, że jest dodatkowy narzut ;-(

Kod dłuższy wcale nie oznacza mniej optymalny:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.IO;

namespace po2
{
    class Program
    {
        public static void WypiszBuilder(string napis)
        {
            var builder = new StringBuilder();
            for (int i = 0; i < 10000; i++)
            {
                builder.Append(napis).Append('\n');
            }
            Console.WriteLine(builder.ToString());
        }

        public static void WypiszIO(string napis)
        {
            for (int i = 0; i < 10000; i++)
            {
                Console.WriteLine(napis);
            }
        }

        static void Main(string[] args)
        {
            var benchmark = new Stopwatch();

	    // Warto zakomentować któryś wariant, ponieważ konsola w Windowsie nie pomieści 10 000 stringów. :)
	    // ...dwukrotnie wyrzuconych na ekran :)
            benchmark.Start();
            WypiszBuilder("Grzegorz");
            benchmark.Stop();
            Console.WriteLine("Wersja StringBuilder: " + benchmark.Elapsed.ToString());

            benchmark.Start();
            WypiszIO("Grzegorz");
            benchmark.Stop();
            Console.WriteLine("Wersja IO: " + benchmark.Elapsed.ToString());

            Console.ReadKey();
        }
    }
}

Wyniki:

 
Wersja StringBuilder: 00:00:00.3613254
Wersja IO:  	      00:00:00.5339317

...A przecież funkcja WypiszBuilder jest dłuższa :) Wszystko zależy od tego co i jak się napisze. Dłuższe wcale nie oznacza szybsze.

0
Azarien napisał(a):
    movq    -8(%rbp), %rdx    
    movq    16(%rdx), %rdx

To mi wygląda na brak włączonej optymalizacji, bo kompilator nie wyplułby raczej tych dwóch instrukcji pod rząd przy -O3.
A bez optymalizacji nie ma w ogóle co porównywać ani badać prędkości.

@Azarien ten kod był wygenerowany przy pomocy tej strony: http://gcc.godbolt.org/ jednak później sprawdziłem jeszcze jak VS C++ radzi sobie z tym problemem przy włączonej optymalizacji i również kod Assemblera był łącznie o 2 linijki dłuższy. Tak swoją drogą to niezbyt się znam na tych optymalizacjach, ale u mnie w VS nie ma optymalizacji O3 :-/. Jest tylko: O1, O2, Full Optimization (Ox) ewentualnie custom.
Oczywiście nie obyło się bez problemów, bo jak włączy się optymalizację to VS nie pozwala z disasseblerować tego w czasie rzeczywistym. Musiałem to zapisywać do pliku i wyszukiwać odpowiednie fragmenty.

Kod, który sprawdzałem to (taki banalny trochę):

class JakasKlasa
	{
	public:
		int a;
	};
class Klasa
	{
	public:
		JakasKlasa& ref;
		JakasKlasa orginal;
		JakasKlasa* wsk;

		Klasa(JakasKlasa& x) : ref(x), wsk(&x), orginal(x)
			{
			}

		void do_something()
			{
			/*
			Tutaj wstawiamy wybrane rozwiązanie:
			(*wsk).a += 1;
			ref.a += 1;	
			wsk->a += 1;
			orginal.a += 1;
			np.
			*/
			orginal.a += 1;
			}
	};

int main()
{
	cout << " Hello word" << endl;

	JakasKlasa a;
	a.a = 5;
	Klasa k(a);

	k.do_something();

	getchar();
   return 0;
}

I teraz tak:

W przypadku operowania na oryginalne kod w assemblerze jest następujący:
inc DWORD PTR [ecx+4]
Dla referencji:

mov	eax, DWORD PTR [ecx]
inc	DWORD PTR [eax]

A dla wskaźnika:

mov	eax, DWORD PTR [ecx+8]
inc	DWORD PTR [eax]

Dodatkowo kod jeszcze w jednym miejscu się różni, ale nie chcę już tutaj mieszać.

A tak z ciekawości zapytam: jeśli w assemblerze porównamy zapis: [ecx] vs [ecx+8] to ten pierwszy powinien się szybciej wykonać, bo nie ma dodatkowego dodawania?!

grzesiek51114 napisał(a):

Wszystko zależy od tego co i jak się napisze. Dłuższe wcale nie oznacza szybsze.

Nie porównuj instrukcji assemblera z kodem wysoko poziomowym w dodatku zawierającym komentarze.

0
Wojtek321 napisał(a):
stryku napisał(a):

Kompilator wszystko i tak sprowadzi do jednej postaci, więc nie będzie różnicy. Z tego co wiem

Jednak obawiam się, że będzie.
Sprawdziłem jak ten kod wygląda w assemblerze i...

Wersja dla oryginału:
Klasa::do_something():
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl 8(%rax), %eax
leal 1(%rax), %edx
movq -8(%rbp), %rax
movl %edx, 8(%rax)
nop
popq %rbp
ret

> Wersja dla referencji:
> <code>Klasa::do_something():			
	pushq	%rbp	
	movq	%rsp, %rbp	
	movq	%rdi, -8(%rbp)	
	movq	-8(%rbp), %rax	
	movq	16(%rax), %rax	
	movq	-8(%rbp), %rdx	
	movq	16(%rdx), %rdx	
	movl	(%rdx), %edx	
	addl	$1, %edx	
	movl	%edx, (%rax)	
	nop		
	popq	%rbp	
	ret		

Kod dla referencji jest dłuższy, a więc wnioskuje, że jest dodatkowy narzut ;-(

tylko że zapomniałeś podać źródło tych obu wersji, durniu.
Tym samym nie ma czego porównywać.

2

A tak z ciekawości zapytam: jeśli w assemblerze porównamy zapis: [ecx] vs [ecx+8] to ten pierwszy powinien się szybciej wykonać, bo nie ma dodatkowego dodawania?!

Dodawanie nie jest dodatkowe, bo jest zakodowane jako offset instrukcji mov.

Co prawda mov eax,[ecx+8] wymaga trzech bajtów, a mov eax,[ecx] można zakodować na dwóch (albo też na trzech, jeśli potraktować to jako mov eax,[ecx+0], to dodawanie to nie jest osobną instrukcją, tylko częścią mov.
Czy wykonanie dwubajtowej instrukcji będzie szybsze niż trzybajtowej to już będzie zależało od konkretnego procesora, ale nie wydaje mi się, by to była istotna różnica.

W ogóle cały problem narzutu wskaźnika czy referencji jest bez sensu. Trzeba po prostu zmierzyć czas wykonania kodu (wydajność programu) a nie przejmować się instrukcjami asemblera.
Ale spodziewam się, że różnica będzie tak mała że w granicy błędu.

5

Wcześniej już dużo osób napisało, że nie powinno się na takie rzeczy zwracać uwagi w przypadku 99.99% tworzonych programów - i mają rację. Przychodzą mi do głowy jedynie dwa wyjątki, które sprowadzają się albo do synchronizowania procesora co do cyklu z jakimś zewnętrznym zdarzeniem (kłaniają się czasy Atari 2600 ;>), albo próba manualnego zoptymalizowania czegoś do jak najkrótszego/najszybszego kodu (co się robi dla zabawy).

Niemniej jednak, skoro już sobie dywagujemy, to się przyłącze ;)

Jest jedna zasadnicza różnica między operowaniem na "oryginale" (tj. bezpośrednio na obiekcie), a na wskaźnikach/referencjach: w tym drugim wypadku adres obiektu w którymś momencie musi zostać pobrany z pamięci do rejestru.
Zazwyczaj takie coś jest szybkie - 4-7 cykli procesora (te cykle prosiłbym traktować jako jakieś tam poglądowe wartości wzięte z sufitu) żeby coś trafiło z cache L1 do rejestru. Jeśli nie ma w L1, to dochodzi z 10 cykli, żeby to z L2 ściągnąć, a potem kilkanaście-kilkadziesiąt kolejnych, żeby to z L3 ściągnąć.
Jeśli nie było tego w cache, no to pointer musi być ściągnięty z RAM, co może trwać 100-200 cykli w sumie.
Chyba, że mamy całkowitego pecha i procesor w swoim look-up table (TLB) nie ma przetłumaczonego adresu virtualnego na fizyczny, i musi najpierw pobrać base-offset z 3-4 poziomowej tablicy stron, co wiąże się z kolejnymi 3-4 wycieczkami do cache, a w najgorszym wypadku do RAM. W najgorszym razie dochodzimy do 400-800 cykli.
Nooo chyba, że z jakiegoś powodu ten pointer nie był wyrównany w pamięci do naturalnego adresu (tj. podzielnego przez 4 albo 8) i tak jakoś nieszczęśliwie się stało, że leżał na złączeniu dwóch stron - wtedy może nam się z tego i 1500 cykli zrobić (ponownie: liczby z sufitu; ale rząd wielkości afair mam dobry).
Jeszcze gorzej jest jeśli czarny kod przebiegł nam drogę i stłukliśmy jakieś lustro - wtedy obie strony były wyswapowane na dysk twardy, i z cykli przechodzimy na milisekundy laga ;)

W praktyce nie ma co rozpaczać. Po pierwsze, nawet jeśli dany logiczny procesor sobie zawiśnie na czekaniu na te wszystkie dane z RAMu, to współczesne procesory mają Hyper Threading albo podobne technologie, tj. w tym samym czasie na tym samym rdzeniu inny wątek może sobie do woli hulać i nie być blokowanym przez oczekujący wątek (co normalnie by miało co jakiś czas miejsce z uwagi na to jak HT działa).
Po drugie, w zależności od tego co tam dalej sobie leży w kodzie, procesor może sobie to od razu wykonać nie czekając na wynik danej operacji (ot taka specyfika procesorów z mechanizmem out-of-order execution z którymi mamy do czynienia). Oczywiście za daleko pewnie w kodzie nie zajdzie, bo pojawią się jakieś wzajemne zależności, ale zawsze jest to trochę cykli, które nie do końca były stracone.
Po trzecie, nawet jeśli tak by się stało raz, to wszystkie kolejne odwołania (aż do wywłaszczenia wątku ofc) będą bardzo szybkie (raz, że pointer pewnie i tak będzie już w rejestrze; a dwa, że nawet jeśli nie będzie, to będzie przynajmniej w którymś cache'u).

Z powyższego w sumie wynika jeszcze jedna rzecz - w przypadku obecnych procesorów desktopowych/serwerowych nie ma co liczyć cykli dla pojedynczych instrukcji. Jak już, to trzeba je liczyć dla całych dużych bloków kodu, a idealnie dla całego programu (z uwagi na HT, superskalarność, out-of-order execution, pre-fetch w cache'u, re-ordering przy dostępie do pamięci, i kilka innych fajnych mechanizmów).
Długością instrukcji też bym się nie przejmował - obecne pipeline'y procesorów są na tyle długie, że instrukcja jest już daawno odczytana i zdekodowana jak dochodzi do jej wykonania.

0

Dziękuję wszystkim za wyjaśnienie problemu ;-)

0
Gynvael Coldwind napisał(a):

Zazwyczaj takie coś jest szybkie - 4-7 cykli procesora (te cykle prosiłbym traktować jako jakieś tam poglądowe wartości wzięte z sufitu) żeby coś trafiło z cache L1 do rejestru. Jeśli nie ma w L1, to dochodzi z 10 cykli, żeby to z L2 ściągnąć, a potem kilkanaście-kilkadziesiąt kolejnych, żeby to z L3 ściągnąć.

Mało istotnie, poza tym o ile w ogóle jest L3...

Gynvael Coldwind napisał(a):

Długością instrukcji też bym się nie przejmował - obecne pipeline'y procesorów są na tyle długie, że instrukcja jest już daawno odczytana i zdekodowana jak dochodzi do jej wykonania.

No, tak do 4 instrukcji naraz trza liczyć, o ile są niezależne, np.:

z = ab + cd;

pójdzie w jednym rzucie naraz, bo na pewno są dwie jednostki mnożące w procesorze...

albo takie coś:
z = abc*d;

na piechotę tak będzie:
((a*b) * c) * d;

no ale można też tak:
(ab) * (cd); co pójdzie szybciej z 30%, bo oba nawiasy są tu robione naraz...

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