Windows线程同步性能问题

9

我在Windows下遇到了线程问题。

我正在开发一个程序,为不同的条件运行复杂的物理模拟。比如每年的每个小时都有一个条件,就需要进行8760次模拟。我将这些模拟按线程分组,使得每个线程运行平均273个模拟的for循环。

我买了一台AMD ryzen 9 5950x电脑,有16个核心(32个线程)用于这个任务。在Linux上,所有线程的利用率似乎都在98%到100%之间,而在Windows下我得到了这个:

enter image description here (第一个条是读取数据的I/O线程,较小的条是进程线程。红色:同步,绿色:进程,紫色:I/O)

这是来自Visual Studio的并发可视化器,它告诉我63%的时间花费在线程同步上。据我所知,我的代码在Linux和Windows的执行中是相同的。

我尽力使对象不可变以避免问题,并且在旧的8线程Intel i7上提供了巨大的收益。但是使用更多的线程,这个问题就出现了。

对于线程,我尝试过自定义的parallel for和taskflow库。对于我想要做的事情,两者表现一致。

是不是Windows线程本质上存在某些会导致这种行为的问题?

自定义的parallel for代码:


    /**
     * parallel for
     * @tparam Index integer type
     * @tparam Callable function type
     * @param start start index of the loop
     * @param end final +1 index of the loop
     * @param func function to evaluate
     * @param nb_threads number of threads, if zero, it is determined automatically
     */
    template<typename Index, typename Callable>
    static void ParallelFor(Index start, Index end, Callable func, unsigned nb_threads=0) {

        // Estimate number of threads in the pool
        if (nb_threads == 0) nb_threads = getThreadNumber();

        // Size of a slice for the range functions
        Index n = end - start + 1;
        Index slice = (Index) std::round(n / static_cast<double> (nb_threads));
        slice = std::max(slice, Index(1));

        // [Helper] Inner loop
        auto launchRange = [&func] (int k1, int k2) {
            for (Index k = k1; k < k2; k++) {
                func(k);
            }
        };

        // Create pool and launch jobs
        std::vector<std::thread> pool;
        pool.reserve(nb_threads);
        Index i1 = start;
        Index i2 = std::min(start + slice, end);

        for (unsigned i = 0; i + 1 < nb_threads && i1 < end; ++i) {
            pool.emplace_back(launchRange, i1, i2);
            i1 = i2;
            i2 = std::min(i2 + slice, end);
        }

        if (i1 < end) {
            pool.emplace_back(launchRange, i1, end);
        }

        // Wait for jobs to finish
        for (std::thread &t : pool) {
            if (t.joinable()) {
                t.join();
            }
        }
    }

这里上传了一个完整的C++项目,涉及到技术问题,请点击此处

Main.cpp:

//
// Created by santi on 26/08/2022.
//
#include "input_data.h"
#include "output_data.h"
#include "random.h"
#include "par_for.h"

void fillA(Matrix& A){

    Random rnd;
    rnd.setTimeBasedSeed();

    for(int i=0; i < A.getRows(); ++i)
        for(int j=0; j < A.getRows(); ++j)
            A(i, j) = (int) rnd.randInt(0, 1000);

}


void worker(const InputData& input_data,
            OutputData& output_data,
            const std::vector<int>& time_indices,
            int thread_index){

    std::cout << "Thread " << thread_index << " [" << time_indices[0]<< ", " << time_indices[time_indices.size() - 1] << "]\n";


    for(const int& t: time_indices){

        Matrix b = input_data.getAt(t);

        Matrix A(input_data.getDim(), input_data.getDim());
        fillA(A);

        Matrix x = A * b;

        output_data.setAt(t, x);
    }

}


void process(int time_steps, int dim, int n_threads){
    InputData input_data(time_steps, dim);
    OutputData output_data(time_steps, dim);

    // correct the number of threads
    if ( n_threads < 1 ) { n_threads = ( int )getThreadNumber( ); }

    // generate indices
    std::vector<int> time_indices = arrange<int>(time_steps);

    // compute the split of indices per core
    std::vector<ParallelChunkData<int>> chunks = prepareParallelChunks(time_indices, n_threads );

    // run in parallel
    ParallelFor( 0, ( int )chunks.size( ), [ & ]( int k ) {
            // run chunk
            worker(input_data, output_data, chunks[k].indices, k );
    } );
}

int main(){

    process(8760, 5000, 0);

    return 0;
}

3
除了join只影响调用线程之外,这里没有展示任何同步。如果出现问题,很可能是您传递的func函数有问题。 - François Andrieux
2
func 是做什么的?乍一看,它似乎只是在 func 中的“用户”代码简单地访问来自其他线程的资源阻塞构造。无论是分页内存页面、等待互斥锁、设备的 IO 等等。如果没有看到 func 的实际操作,或者没有将 func 替换为一个简单的计算,很难得出结论(是的,就像 François 更快地说的那样 :-))。 - Jeffrey
我会尝试复现一些可以发布的东西。从概念上讲,我正在读取存储在RAM中的数据数组(我猜是主线程),在每个线程中进行计算,并将结果存储在2D数组中,我猜这些数组托管在主线程中。所有内容都是预先分配的。@François Andrieux,确实没有任何显式的同步。应该有吗? - Santi Peñate-Vera
@SantiPeñate-Vera,你在询问同步问题,所以我会认为你的代码中包含同步。 - François Andrieux
1
你尝试过使用 std::for_each(std::execution::par_unseq,... 吗?这可能比自己操作产生更好的结果。因为你很可能因为标准库中 std::thread 的工作方式而遇到创建线程的开销问题。标准库可以利用诸如 Windows 线程池之类的东西来降低这种成本。在 Windows 上,线程非常昂贵(由于各种原因,Linus 后悔没有实现它们),因此它们意味着要持续一段时间。 - Mgetz
显示剩余3条评论
2个回答

11
您所看到的性能问题绝对是由许多内存分配引起的,正如Matt在他的回答中已经怀疑的那样。为了详细说明这一点:以下是在具有64个核心(128个线程)的AMD Ryzen Threadripper 3990X上运行的Intel VTune的截图: VTune image 正如您所见,几乎所有时间都花在mallocfree上,这些函数从各种Matrix操作中调用。图像的底部显示了少量线程活动的时间轴:绿色表示线程处于非活动状态,即等待状态。通常只有一个或两个线程实际上是活动的。分配和释放内存会访问共享资源,导致线程彼此等待。
我认为您只有两个真正的选择: 选项1:不再进行动态分配 最有效的方法是重写代码以预先分配所有内容并摆脱所有临时变量。要将其适应于您的示例代码,您可以像这样替换b = input_data.getAt(t);x = A * b;
void MatrixVectorProduct(Matrix const & A, Matrix const & b, Matrix & x) 
{
  for (int i = 0; i < x.getRows(); ++i) {
    for (int j = 0; j < x.getCols(); ++j) {
      x(i, j) = 0.0;
      for (int k = 0; k < A.getCols(); ++k) {
        x(i,j) += (A(i,k) * b(k,j));
      }
    }
  }
}


void getAt(int t, Matrix const & input_data, Matrix & b) {
  for (int i = 0; i < input_data.getRows(); ++i)
    b(i, 0) = input_data(i, t);
}


void worker(const InputData& input_data,
            OutputData& output_data,
            const std::vector<int>& time_indices,
            int thread_index){

    std::cout << "Thread " << thread_index << " [" << time_indices[0]<< ", " << time_indices[time_indices.size() - 1] << "]\n";

    Matrix A(input_data.getDim(), input_data.getDim());
    Matrix b(input_data.getDim(), 1);
    Matrix x(input_data.getDim(), 1);

    for (const int & t: time_indices) {
      getAt(t, input_data.getMat(), b);
      fillA(A);
      MatrixVectorProduct(A, b, x);
      output_data.setAt(t, x);
    }

    std::cout << "Thread " << thread_index << ": Finished" << std::endl;
}

这个方法解决了性能问题。以下是从VTune中截取的屏幕截图,您可以看到更好的利用率:enter image description here

选项2:使用特殊分配器

另一种选择是使用处理在多线程情况下更有效地分配和释放内存的不同分配器。我在使用mimalloc时获得了非常好的经验(还有其他的选择,例如hoard或来自TBB的分配器)。您不需要修改源代码,只需按照文档中描述的内容链接到一个特定的库即可。

我尝试使用您的源代码使用mimalloc,而且没有进行任何代码更改就实现了接近100%的CPU利用率。我还在Intel论坛上找到了一篇类似问题的帖子,并且那里的解决方案也是相同的(使用特殊分配器)。

额外注意事项

  • Matrix::allocSpace()通过使用数组指针来分配内存。最好使用一个连续的数组代替多个独立的数组来存储整个矩阵。这样,所有元素都位于连续的内存地址中,从而实现更有效的访问。
  • 但总体而言,我建议使用专用的线性代数库,例如Eigen,以利用矢量化(SSE2、AVX等)并获得高度优化的库的好处,而不是手动实现矩阵。
  • 确保启用了编译器优化。
  • 如果您不需要它们,请禁用各种交叉检查:assert()(即在预处理器中定义NDEBUG),对于MSVC可能需要/GS-
  • 确保您实际上安装了足够的内存。

2
在这个具体的案例中,使用像Eigen这样的专用库主要是因为它使用表达式模板,而不是手动矢量化,这可以最小化分配的数量。 - Ave Milia
完全同意,在我的大型项目代码中,我使用Armadillo + MKL,但我希望这个虚拟示例是自包含的。@sedenion 在实际代码中,mimalloc路线可能是我需要使用的路线。我也完全同意你的第一种方法。 - Santi Peñate-Vera
Mimalloc 相当大地改善了事情(对于例子来说,它是一颗银弹)。 - Santi Peñate-Vera

9

你说你的所有内存都是预分配的,但在 worker 函数中我看到了这个...

Matrix b = input_data.getAt(t);

该函数会分配并填充一个新的矩阵b,如下...

Matrix A(input_data.getDim(), input_data.getDim());

这个函数将分配并填充一个新的矩阵 A,并且这...

Matrix x = A * b;

创建并填充一个新的矩阵x

堆是一个全局数据结构,因此您看到的线程同步时间可能是内存分配/释放函数的争用问题。

它们处于一个紧密的循环中。您应该修复此循环以通过引用访问b,并在每次迭代中重用其他两个矩阵。


为什么在Linux下这不是一个问题?一般情况下,我无法避免在实际程序中创建和销毁数组,因为任务是非常复杂的物理模拟。 - Santi Peñate-Vera
另外,我刚刚将矩阵A、b、x的声明移出循环,但性能仍然很差。 - Santi Peñate-Vera
在Linux上,你可能使用GCC,而在Windows下可能使用Visual C++?它们有不同的分配器实现。 - Matt Timmermans
在 Windows 系统下,我已经测试过 VS2022 和通过 MinGW 安装的 GCC。效果同样差劲。 - Santi Peñate-Vera
在提供的虚拟示例中是这样的,但在我正在制作的主程序中,有数十个具有不同大小的密集和稀疏结构。抽象地说,将工人视为一个黑匣子,它接受只读数据并以这样一种方式将数据写入预先分配的输出,以使没有其他线程可以竞争相同的字节。某种程度上,问题在于同步,但根据我的有限知识,由于没有资源竞争(至少在概念上),因此不应该进行同步。也许我应该使用不同的库或其他准备好这些任务的东西。 - Santi Peñate-Vera
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接