【问题标题】:Multi-threaded GEMM slower than single threaded one?多线程 GEMM 比单线程慢?
【发布时间】:2013-02-11 11:55:44
【问题描述】:

我写了一些朴素的 GEMM 代码,我想知道为什么它比等效的单线程 GEMM 代码慢得多。

使用 200x200 矩阵,单线程:7 毫秒,多线程:108 毫秒,CPU:3930k,线程池中有 12 个线程。

    template <unsigned M, unsigned N, unsigned P, typename T>
    static Matrix<M, P, T> multiply( const Matrix<M, N, T> &lhs, const Matrix<N, P, T> &rhs, ThreadPool & pool )
    {
        Matrix<M, P, T> result = {0};

        Task<void> task(pool);
        for (auto i=0u; i<M; ++i)
            for (auto j=0u; j<P; j++)
                task.async([&result, &lhs, &rhs, i, j](){
                    T sum = 0;
                    for (auto k=0u; k < N; ++k)
                        sum += lhs[i * N + k] * rhs[k * P + j];
                    result[i * M + j] = sum;
            });

        task.wait();

        return std::move(result);
    }

【问题讨论】:

  • 我希望你测量了至少 5 秒以上的长时间重复运行,平均运行时间是 7 毫秒和 128 毫秒?否则,您的时间安排可能会不准确且意义不大。

标签: c++ multithreading


【解决方案1】:

我没有使用 GEMM 的经验,但您的问题似乎与各种多线程场景中出现的问题有关。

当使用多线程时,您会引入一些潜在的开销,其中最常见的通常是

  1. 开始/结束线程的创建/清理
  2. 在(线程数)>(CPU 内核数)时切换上下文
  3. 资源锁定,等待获取锁
  4. 缓存同步问题

第 2 项和第 3 项可能在您的示例中不起作用:您在 12 个(超线程)内核上使用 12 个线程,并且您的算法不涉及锁。

但是,1. 可能与您的情况相关:您正在创建总共 40000 个线程,每个线程相乘和相加 200 个值。我建议尝试不那么细粒度的线程,也许只在第一个循环之后拆分。最好不要将问题拆分成不必要的小块。

另外 4. 在您​​的情况下很可能很重要。虽然在将结果写入数组时不会遇到竞争条件(因为每个线程都在写入自己的索引位置),但您很可能会引发大量的缓存同步开销。

“为什么?”您可能会想,因为您正在写入内存中的不同位置。这是因为典型的 CPU 缓存是按缓存线组织的,在当前的 Intel 和 AMD CPU 型号上,缓存线的长度为 64 字节。这是更改某些内容时可用于从缓存传输到缓存的最小大小。现在所有 CPU 内核都在读取和写入相邻的内存字,这会导致在所有内核之间同步 64 字节,只要您只写入 4 个字节(或 8 个,取决于您使用的数据类型的大小)。

如果内存不是问题,您可以简单地用“虚拟”数据“填充”每个输出数组元素,这样每个高速缓存行只有一个输出元素。如果您使用 4 字节数据类型,这意味着每 1 个实际数据元素跳过 15 个数组元素。当您减少线程的细粒度时,缓存问题也会得到改善,因为每个线程实际上都会访问自己在内存中的连续区域,而不会干扰其他线程的内存。

编辑:Herb Sutter(C++ 大师之一)更详细的描述可以在这里找到:http://www.drdobbs.com/parallel/maximize-locality-minimize-contention/208200273

Edit2:顺便说一句,建议在 return 语句中避免 std::move,因为这可能会妨碍返回值优化和复制省略规则,而标准现在要求这些规则自动发生。见Is returning with `std::move` sensible in the case of multiple return statements?

【讨论】:

  • 至于#1,我在程序期间保持线程处于活动状态,因此创建成本是一次性的。至于开始和结束线程,成本是调用条件变量以唤醒线程和每个线程的互斥锁以从任务容器中获取任务。也就是说,通过仅在第一个循环后拆分的建议,时间大大提高(500x500 矩阵上为 40 毫秒,而单线程版本为 114 毫秒)。虽然它不是我希望的 12 倍加速
【解决方案2】:

多线程意味着总是同步、上下文切换、函数调用。这一切都加起来并消耗 CPU 周期,您可以将其花费在主要任务本身上。

如果你只有第三个嵌套循环,你可以保存所有这些步骤,并且可以内联而不是子例程进行计算,在子例程中你必须设置堆栈、调用、切换到不同的线程、返回结果并切换回到主线程。

仅当这些成本与主要任务相比较小时,多线程才有用。我想,当矩阵大于 200x200 时,您会看到多线程的效果更好。

【讨论】:

    【解决方案3】:

    一般来说,多线程非常适用于需要大量时间的任务,最有利的是因为复杂性而不是设备访问。您向我们展示的循环需要很短的执行时间才能有效地并行化。

    您必须记住,创建线程有很多开销。同步也有一些(但明显更少)开销。

    【讨论】:

      猜你喜欢
      • 2012-09-05
      • 1970-01-01
      • 2020-08-15
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-07-21
      • 2011-03-01
      相关资源
      最近更新 更多