Инициализация std :: vector вне main () вызывает падение производительности (многопоточность) - PullRequest
3 голосов
/ 09 июля 2020

Я пишу трассировщик пути в качестве упражнения по программированию. Вчера я наконец решил реализовать многопоточность - и это хорошо сработало. Однако, как только я обернул тестовый код, который я написал внутри main(), в отдельный класс renderer, я заметил значительное и последовательное падение производительности. Короче говоря, может показаться, что заполнение std::vector где-либо за пределами main() приводит к ухудшению работы потоков, использующих его элементы. Мне удалось изолировать и воспроизвести проблему с помощью упрощенного кода, но, к сожалению, я до сих пор не знаю, почему это происходит и что делать, чтобы исправить это.

Падение производительности довольно заметное и последовательное:

  97 samples - time = 28.154226s, per sample = 0.290250s, per sample/th = 1.741498
  99 samples - time = 28.360723s, per sample = 0.286472s, per sample/th = 1.718832
 100 samples - time = 29.335468s, per sample = 0.293355s, per sample/th = 1.760128

vs.

  98 samples - time = 30.197734s, per sample = 0.308140s, per sample/th = 1.848841
  99 samples - time = 30.534240s, per sample = 0.308427s, per sample/th = 1.850560
 100 samples - time = 30.786519s, per sample = 0.307865s, per sample/th = 1.847191

Код, который я изначально разместил в этом вопросе, можно найти здесь: https://github.com/Jacajack/rt/tree/mt_debug или в истории редактирования.

Я создал структуру foo, которая должна mimi c поведение моего renderer класса и отвечает за инициализацию контекстов трассировки пути в его конструкторе. Интересно то, что когда я удаляю тело конструктора foo и вместо этого делаю это (инициализирую contexts непосредственно из main()):

std::vector<rt::path_tracer> contexts; // Can be on stack or on heap, doesn't matter
foo F(cam, scene, bvh, width, height, render_threads, contexts); // no longer fills `contexts`

contexts.reserve(render_threads);
for (int i = 0; i < render_threads; i++)
    contexts.emplace_back(cam, scene, bvh, width, height, 1000 + i);

F.run(render_threads);

производительность возвращается к норме. Но потом, если я вынесу эти три строки в отдельную функцию и вызову ее отсюда, снова станет хуже. Единственная закономерность, которую я здесь вижу, это то, что заполнение вектора contexts за пределами main() вызывает проблему.

Сначала я думал, что это проблема с выравниванием / кешированием, поэтому я попытался выровнять path_tracer s с Boost's aligned_allocator и TBB cache_aligned_allocator безрезультатно. Оказывается, эта проблема сохраняется даже тогда, когда работает только один поток. Я подозреваю, что это какая-то дикая оптимизация компилятора (я использую -O3), хотя это всего лишь предположение. Знаете ли вы какие-либо возможные причины такого поведения и что можно сделать, чтобы этого избежать?

Это происходит как в gcc 10.1.0, так и в clang 10.0.0. В настоящее время я использую только -O3.

Мне удалось воспроизвести аналогичную проблему в этом автономном примере:

#include <iostream>
#include <thread>
#include <random>
#include <algorithm>
#include <chrono>
#include <iomanip>

struct foo
{
    std::mt19937 rng;
    std::uniform_real_distribution<float> dist;
    std::vector<float> buf;
    int cnt = 0;
    
    foo(int seed, int n) :
        rng(seed),
        dist(0, 1),
        buf(n, 0)
    {
    }
    
    void do_stuff()
    {
        // Do whatever
        for (auto &f : buf)
            f = (f + 1) * dist(rng);
        cnt++;
    }
};

int main()
{
    int N = 50000000;
    int thread_count = 6;
    
    struct bar
    {
        std::vector<std::thread> threads;
        std::vector<foo> &foos;
        bool active = true;
        
        bar(std::vector<foo> &f, int thread_count, int n) :
            foos(f)
        {
            /*
            foos.reserve(thread_count);
            for (int i = 0; i < thread_count; i++)
                foos.emplace_back(1000 + i, n);
            //*/
        }
        
        void run(int thread_count)
        {
            auto task = [this](foo &f)
            {
                while (this->active)
                    f.do_stuff();
            };

            threads.reserve(thread_count);
            for (int i = 0; i < thread_count; i++)
                threads.emplace_back(task, std::ref(foos[i]));
        }
    };
    
    
    std::vector<foo> foos;
    bar B(foos, thread_count, N);
    
    ///*
    foos.reserve(thread_count);
    for (int i = 0; i < thread_count; i++)
        foos.emplace_back(1000 + i, N);
    //*/
    
    B.run(thread_count);
    
    std::vector<float> buffer(N, 0);
    int samples = 0, last_samples = 0;
    
    // Start time
    auto t_start = std::chrono::high_resolution_clock::now();
    
    while (1)
    {
        last_samples = samples;
        samples = 0;
        for (auto &f : foos)
        {
            std::transform(
                f.buf.cbegin(), f.buf.cend(),
                buffer.begin(),
                buffer.begin(),
                std::plus<float>()
            );
            samples += f.cnt;
        }
        
        if (samples != last_samples)
        {
            auto t_now = std::chrono::high_resolution_clock::now();
            std::chrono::duration<double> t_total = t_now - t_start;
            std::cerr << std::setw(4) << samples << " samples - time = " << std::setw(8) << std::fixed << t_total.count() 
                << "s, per sample = " << std::setw(8) << std::fixed << t_total.count() / samples 
                << "s, per sample/th = " << std::setw(8) << std::fixed << t_total.count() / samples * thread_count << std::endl;
        }
    }
}

и результаты:

For N = 100000000, thread_count = 6

In main():
 196 samples - time = 26.789526s, per sample = 0.136681s, per sample/th = 0.820088
 197 samples - time = 27.045646s, per sample = 0.137288s, per sample/th = 0.823725
 200 samples - time = 27.312159s, per sample = 0.136561s, per sample/th = 0.819365


vs.
In foo::foo():
 193 samples - time = 22.690566s, per sample = 0.117568s, per sample/th = 0.705406
 196 samples - time = 22.972403s, per sample = 0.117206s, per sample/th = 0.703237
 198 samples - time = 23.257542s, per sample = 0.117462s, per sample/th = 0.704774
 200 samples - time = 23.540432s, per sample = 0.117702s, per sample/th = 0.706213

It кажется, что результаты противоположны тому, что происходит в моем трассировщике пути, но видимая разница все еще здесь.

Спасибо

1 Ответ

5 голосов
/ 09 июля 2020

Имеется состояние гонки с foo::buf - один поток сохраняет в него, другой читает его. Это неопределенное поведение, но на платформе x86-64 оно безвредно в данном конкретном коде.

Я не могу воспроизвести ваши наблюдения на Intel i9-9900KS, оба варианта печатают одинаковую per sample статистику.

Скомпилировано с g cc -8.4, g++ -o release/gcc/test.o -c -pthread -m{arch,tune}=native -std=gnu++17 -g -O3 -ffast-math -falign-{functions,loops}=64 -DNDEBUG test.cc

С int N = 50000000; каждый поток работает со своим собственным массивом float[N], который занимает 200 МБ. Такой набор данных не помещается в кеш-память ЦП, и программа часто пропускает кеш-память, потому что ей необходимо получить данные из памяти:

$ perf stat -ddd ./release/gcc/test
[...]
      71474.813087      task-clock (msec)         #    6.860 CPUs utilized          
                66      context-switches          #    0.001 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
           341,942      page-faults               #    0.005 M/sec                  
   357,027,759,875      cycles                    #    4.995 GHz                      (30.76%)
   991,950,515,582      instructions              #    2.78  insn per cycle           (38.43%)
   105,609,126,987      branches                  # 1477.571 M/sec                    (38.40%)
       155,426,137      branch-misses             #    0.15% of all branches          (38.39%)
   150,832,846,580      L1-dcache-loads           # 2110.294 M/sec                    (38.41%)
     4,945,287,289      L1-dcache-load-misses     #    3.28% of all L1-dcache hits    (38.44%)
     1,787,635,257      LLC-loads                 #   25.011 M/sec                    (30.79%)
     1,103,347,596      LLC-load-misses           #   61.72% of all LL-cache hits     (30.81%)
   <not supported>      L1-icache-loads                                             
         7,457,756      L1-icache-load-misses                                         (30.80%)
   150,527,469,899      dTLB-loads                # 2106.021 M/sec                    (30.80%)
        54,966,843      dTLB-load-misses          #    0.04% of all dTLB cache hits   (30.80%)
            26,956      iTLB-loads                #    0.377 K/sec                    (30.80%)
           415,128      iTLB-load-misses          # 1540.02% of all iTLB cache hits   (30.79%)
   <not supported>      L1-dcache-prefetches                                        
   <not supported>      L1-dcache-prefetch-misses                                   

      10.419122076 seconds time elapsed

Если вы запускаете это приложение на процессорах NUMA, например как AMD Ryzen и Intel Xeon с несколькими сокетами, то ваши наблюдения, вероятно, можно объяснить неправильным размещением потоков на удаленных узлах NUMA относительно узла NUMA, где выделено foo::buf. Эти промахи кэша данных последнего уровня должны считывать память, и если эта память находится на удаленном узле NUMA, что занимает больше времени.

Чтобы исправить это, вы можете выделить память в потоке, который ее использует (не в основной поток, как и код) и использовать распределитель с поддержкой NUMA, например TCMallo c. См. диспетчер памяти кучи с учетом NUMA для получения дополнительных сведений.

При запуске теста вы можете исправить частоту процессора, чтобы она не изменялась динамически во время выполнения, на Linux вы можете сделать это с помощью sudo cpupower frequency-set --related --governor performance.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...