c++之在 main() 之外初始化 std::vector 会导致性能下降(多线程)

zhenyulu 阅读:63 2025-05-04 20:05:19 评论:0

我正在编写路径跟踪器作为编程练习。昨天我终于决定实现多线程——而且效果很好。然而,一旦我将我在 main() 中编写的测试代码包装在一个单独的 renderer 类中,我注意到一个显着且一致的性能下降。简而言之 - 似乎在 main() 之外的任何地方填充 std::vector 会导致使用其元素的线程性能更差。我设法用简化的代码隔离并重现了这个问题,但不幸的是,我仍然不知道为什么会发生或如何解决它。

性能下降非常明显且一致:

  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,它应该模仿我的 renderer 类的行为,并负责在其构造函数中初始化路径跟踪上下文。 有趣的是,当我删除 foo 的构造函数主体并改为执行此操作时(直接从 main() 初始化 contexts):

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); 

性能恢复正常。但是,如果我将这三行代码包装到一个单独的函数中并从这里调用它,情况又会变得更糟。我在这里能看到的唯一模式是 在 main() 之外填充 contexts vector 会导致问题。

我最初认为这是一个对齐/缓存问题,所以我尝试将 path_tracer 与 Boost 的 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 
 

看起来结果与我的路径追踪器中发生的情况相反,但可见的差异仍然存在。

谢谢

请您参考如下方法:

foo::buf 存在竞争条件 - 一个线程向其中存储数据,另一个线程读取它。这是未定义的行为,但在 x86-64 平台上,此特定代码无害。


我无法重现您在 Intel i9-9900KS 上的观察结果,两种变体打印相同的per sample 统计数据。

使用 gcc-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] 数组进行操作,该数组占用 200MB。这样的数据集不适合 CPU 缓存,程序会导致大量数据缓存未命中,因为它需要从内存中获取数据:

$ 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 CPU 上运行此应用程序,例如具有多个插槽的 AMD Ryzen 和 Intel Xeon,那么您的观察结果可能是由于将线程放置在远程 NUMA 节点上相对于 NUMA 节点的不利位置,其中 foo::buf 已分配。那些最后一级数据缓存未命中必须读取内存,如果该内存位于远程 NUMA 节点中,则需要更长的时间。

要解决这个问题,您可能希望在使用它的线程中分配内存(而不是像代码那样在主线程中分配内存)并使用 NUMA 感知分配器,例如 TCMalloc .参见 NUMA aware heap memory manager了解更多详情。


运行基准测试时,您可能希望固定 CPU 频率,这样它就不会在运行期间进行动态调整,在 Linux 上,您可以使用 sudo cpupower frequency-set --related --governor 来做到这一点性能


标签:多线程
声明

1.本站遵循行业规范,任何转载的稿件都会明确标注作者和来源;2.本站的原创文章,请转载时务必注明文章作者和来源,不尊重原创的行为我们将追究责任;3.作者投稿可能会经我们编辑修改或补充。

关注我们

一个IT知识分享的公众号