benchmark for vs while

0

Jest jakaś większa różnica pomiędzy użyciem
for (auto _ : state)
a
while (state.KeepRunning())
?

generalnie for (auto _ : state) zawsze jest szybszy
i czasami wynik wychodzi mi "0ns" moze optymalizator , tak zoptymalizował ze nic nie zostało

Albo test durny, to i wyniki też durne ?

https://godbolt.org/z/6rd3cYe3e

#include <benchmark/benchmark.h>
#include <spdlog/spdlog.h>


static void i32_addition(benchmark::State &state) {
    int32_t a, b, c;
    a=1;
    b=2;
    c=3;
    for (auto _ : state)
    {
        c = c+ a + b;
    }
    (void)c;//spdlog::info("{}",c);
}

BENCHMARK(i32_addition)->Repetitions(3)->Iterations(100000);

static void i32_addition2(benchmark::State &state) {
    int32_t a, b, c;
    a=1;
    b=2;
    c=3;
    while (state.KeepRunning())
    {
        c = c+ a + b;
    }
    (void)c;// spdlog::info("{}",c);
    
}

BENCHMARK(i32_addition2)->Repetitions(3)->Iterations(100000);

BENCHMARK_MAIN();
-------------------------------------------------------------------------------------------
Benchmark                                                 Time             CPU   Iterations
-------------------------------------------------------------------------------------------
i32_addition/iterations:100000/repeats:3               2.20 ns         2.18 ns       100000
i32_addition/iterations:100000/repeats:3               2.74 ns         2.74 ns       100000
i32_addition/iterations:100000/repeats:3               1.95 ns         1.95 ns       100000
i32_addition/iterations:100000/repeats:3_mean          2.30 ns         2.29 ns            3
i32_addition/iterations:100000/repeats:3_median        2.20 ns         2.18 ns            3
i32_addition/iterations:100000/repeats:3_stddev       0.404 ns        0.408 ns            3
i32_addition/iterations:100000/repeats:3_cv           17.59 %         17.81 %             3
i32_addition2/iterations:100000/repeats:3              5.38 ns         5.38 ns       100000
i32_addition2/iterations:100000/repeats:3              3.13 ns         3.13 ns       100000
i32_addition2/iterations:100000/repeats:3              3.58 ns         3.59 ns       100000
i32_addition2/iterations:100000/repeats:3_mean         4.03 ns         4.03 ns            3
i32_addition2/iterations:100000/repeats:3_median       3.58 ns         3.59 ns            3
i32_addition2/iterations:100000/repeats:3_stddev       1.19 ns         1.19 ns            3
i32_addition2/iterations:100000/repeats:3_cv          29.49 %         29.49 %             3
3

Co do benchmarku: jest z d**y. Obsługa pętli stanowią lwią część całego benchmarku, nie ma sensu testować takiego małego kodu.

i czasami wynik wychodzi mi "0ns" moze optymalizator , tak zoptymalizował ze nic nie zostało

Używaj DoNotOptimize , nie mam pojęcia jak ten (void)c ma działać

1

https://quick-bench.com/q/JqOytMSdbnN2-tnMFBwXzH8NJwk
https://quick-bench.com/q/qIXWxhYXFucMcymA1IQIfVon9C8


int underTest(int x) {
  return x + 2;
}

static void CheckKeepRunning(benchmark::State& state)
{
    while (state.KeepRunning()) {
        int x = 0;
        benchmark::DoNotOptimize(x);
        auto r = underTest(x);
        benchmark::DoNotOptimize(r);
    }
}
BENCHMARK(CheckKeepRunning);

static void CheckRangeBasedLoop(benchmark::State& state)
{
    for (auto _ : state) {
        int x = 0;
        benchmark::DoNotOptimize(x);
        auto r = underTest(x);
        benchmark::DoNotOptimize(r);
    }
}
BENCHMARK(CheckRangeBasedLoop);

screenshot-20241103140532.png

W sumie to jestem zaskoczony, że jest aż tak duża różnica.

0

@MarekR22 może to dlatego że testowana funkcja jest tak krótka że drobna zmiana w ASM pomiędzy wersjami powoduje przekłamywanie wyniku

0

Moze branch prediction glupieje czy cos

1

W pierwszym przykładzie wywołujesz funkcję, która może zwrócić dowolną wartość tego branch predictor nie przewidzi, w drugim masz jakąś tablicę elementów o stałej ilości elementów i procesor wie, plus optymalizacje nawet jakby ich nie było to zrobią to lepiej.

1

popatrzyłem do tego co @MarekR22 podał, tylko zmieniłem składnię z at&t na intela, bo do intelowej jestem bardziej przyzwyczajony:
https://quick-bench.com/q/cRJFuigv0goZrP1x3enNrdwz8nk

główna część pętli w poniższym kodzie:

int underTest(int x) {
  return x + 2;
}

static void CheckKeepRunning(benchmark::State& state)
{
    while (state.KeepRunning()) {
        int x = 0;
        benchmark::DoNotOptimize(x);
        auto r = underTest(x);
        benchmark::DoNotOptimize(r);
    }
}
BENCHMARK(CheckKeepRunning);

kompiluje się do:

11.98% dec    rax
       mov    QWORD PTR [rbx],rax
24.24% mov    DWORD PTR [rsp+0x8],0x0
       mov    eax,DWORD PTR [rsp+0x8]
       add    eax,0x2
0.03%  mov    DWORD PTR [rsp+0xc],eax
12.59% mov    rax,QWORD PTR [rbx]
51.17% test   rax,rax
       jne    137e0 <CheckKeepRunning(benchmark::State&)+0x10>

natomiast w poniższym kodzie główna część pętli:

int underTest(int x) {
  return x + 2;
}

static void CheckRangeBasedLoop(benchmark::State& state)
{
    for (auto _ : state) {
        int x = 0;
        benchmark::DoNotOptimize(x);
        auto r = underTest(x);
        benchmark::DoNotOptimize(r);
    }
}
BENCHMARK(CheckRangeBasedLoop);

kompiluje się do czegoś prostszego:

3.59%  mov    DWORD PTR [rsp+0x8],0x0
21.38% mov    eax,DWORD PTR [rsp+0x8]
23.55% add    eax,0x2
24.56% mov    DWORD PTR [rsp+0xc],eax
26.93% dec    r14
       jne    13860 <CheckRangeBasedLoop(benchmark::State&)+0x30>

jak widać ten kawałek zostaje taki sam:

       mov    DWORD PTR [rsp+0x8],0x0
       mov    eax,DWORD PTR [rsp+0x8]
       add    eax,0x2
       mov    DWORD PTR [rsp+0xc],eax

w pierwszych dwóch instrukcjach najprawdopodobnie zadziała store-to-load forwarding, bo trywialnie można wykazać, że odczyt jest z tej samej komórki co zapis i że nic się w międzyczasie nie zmieniło, więc odczyt nie powoduje tutaj znacznego opóźnienia. zapis też nie powoduje znacznego opóźnienia, bo zapisy lecą do kolejki zapisów, gdzie są asynchronicznie przetwarzane.

do analizy zostają więc główne części pętli bez tej części wspólnej.

mamy więc:

11.98% dec    rax
       mov    QWORD PTR [rbx],rax
       (...)
12.59% mov    rax,QWORD PTR [rbx]
51.17% test   rax,rax
       jne    137e0 <CheckKeepRunning(benchmark::State&)+0x10>

vs

       (...)
26.93% dec    r14
       jne    13860 <CheckRangeBasedLoop(benchmark::State&)+0x30>

pary typu dec/cond-jump (i test/cond-jump pewnie też) są łączone do jednej mikrooperacji w procesorze.

problemy w CheckKeepRunning:

11.98% dec    rax
       mov    QWORD PTR [rbx],rax

w powyższym kodzie mov musi czekać na dec

       mov    QWORD PTR [rbx],rax
       (...)
12.59% mov    rax,QWORD PTR [rbx]
51.17% test   rax,rax

w powyższym kodzie prawdopodobnie nie zadziała store-to-load forwarding, bo są dodatkowe instrukcje pośrodku. bez store-to-load forwarding musimy czekać na pełen zapis do cache'u i późniejszy odczyt. nawet zapis i odczyt z L1 cache jest stosunkowo powolny w porównaniu do reszty instrukcji z tych super-prostych pętli.

to taka analiza na kolanie. dużo rzeczy może się nie zgadzać. nie chce mi się dużo weryfikować.

moim zdaniem branch prediction ma tu zerowe znaczenie jeśli chodzi o rozbieżność wyników. pomiajając prolog i epilog funkcji (czyli te rzeczy których nie pokazałem), skoki są dokładnie takie same. przy nietrywialnych liczbach przebiegów pętli (czyli np. więcej niż kilkanaście) przewidywanie tych skoków jest trywialne, w zasadzie predyktor może cały czas zakładać, że pętla będzie kontynuowana i pomyli się tylko raz na wywołanie całej funkcji.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.