【问题标题】:Is lock-free multithreading slower than a single-threaded program?无锁多线程比单线程程序慢吗?
【发布时间】:2017-11-17 13:59:36
【问题描述】:

我考虑过并行化一个程序,以便在第一阶段将项目分类到桶中,以并行工作者的数量为模,这样可以避免第二阶段的冲突。并行程序的每个线程使用std::atomic::fetch_add在输出数组中保留一个位置,然后使用std::atomic::compare_exchange_weak更新当前桶头指针。所以它是无锁的。但是,我对多个线程为单个原子而苦苦挣扎的性能表示怀疑(我们所做的那个fetch_add,因为桶头数等于线程数,因此平均而言没有太多争用),所以我决定测量这个。代码如下:

#include <atomic>
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>
#include <vector>

std::atomic<int64_t> gCounter(0);
const int64_t gnAtomicIterations = 10 * 1000 * 1000;

void CountingThread() {
  for (int64_t i = 0; i < gnAtomicIterations; i++) {
    gCounter.fetch_add(1, std::memory_order_acq_rel);
  }
}

void BenchmarkAtomic() {
  const uint32_t maxThreads = std::thread::hardware_concurrency();
  std::vector<std::thread> thrs;
  thrs.reserve(maxThreads + 1);

  for (uint32_t nThreads = 1; nThreads <= maxThreads; nThreads++) {
    auto start = std::chrono::high_resolution_clock::now();
    for (uint32_t i = 0; i < nThreads; i++) {
      thrs.emplace_back(CountingThread);
    }
    for (uint32_t i = 0; i < nThreads; i++) {
      thrs[i].join();
    }
    auto elapsed = std::chrono::high_resolution_clock::now() - start;
    double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
    printf("%d threads: %.3lf Ops/sec, counter=%lld\n", (int)nThreads, (nThreads * gnAtomicIterations) / nSec,
      (long long)gCounter.load(std::memory_order_acquire));

    thrs.clear();
    gCounter.store(0, std::memory_order_release);
  }
}

int __cdecl main() {
  BenchmarkAtomic();
  return 0;
}

这是输出:

1 threads: 150836387.770 Ops/sec, counter=10000000
2 threads: 91198022.827 Ops/sec, counter=20000000
3 threads: 78989357.501 Ops/sec, counter=30000000
4 threads: 66808858.187 Ops/sec, counter=40000000
5 threads: 68732962.817 Ops/sec, counter=50000000
6 threads: 64296828.452 Ops/sec, counter=60000000
7 threads: 66575046.721 Ops/sec, counter=70000000
8 threads: 64487317.763 Ops/sec, counter=80000000
9 threads: 63598622.030 Ops/sec, counter=90000000
10 threads: 62666457.778 Ops/sec, counter=100000000
11 threads: 62341701.668 Ops/sec, counter=110000000
12 threads: 62043591.828 Ops/sec, counter=120000000
13 threads: 61933752.800 Ops/sec, counter=130000000
14 threads: 62063367.585 Ops/sec, counter=140000000
15 threads: 61994384.135 Ops/sec, counter=150000000
16 threads: 61760299.784 Ops/sec, counter=160000000

CPU 为 8 核 16 线程(Ryzen 1800X @3.9Ghz)。 因此,在使用 4 个线程之前,每秒所有操作线程的总数会急剧下降。然后缓慢下降,并略有波动。

那么这种现象是否常见于其他 CPU 和编译器?是否有任何解决方法(除了求助于单线程)?

【问题讨论】:

  • 同步总是有开销。 Amdahl 定律告诉我们并行化并不能解决所有问题。如果您可以运行多个单线程进程并执行相同的工作,那么您应该尝试一下。
  • 我没有看到将线程数增加到大于 4 的任何实际好处(在这种情况下)。原因可能是您的 CPU 将在上下文中花费越来越多的时间当线程数增加时切换/同步线程。
  • @AbdusSalamKhazi 程序除了切换和同步之外没有做任何事情
  • 请注意,单线程实现根本不使用原子,并且比单线程版本快很多for (int64_t i = 0; i &lt; iters; i++) { non_atomic_count++; } 可以将计数器保存在整个循环的寄存器中,然后再存储。编译器甚至可以将其优化为non_atomic_count += iters。 (实际上是编译器are allowed to do that with your atomic version,但目前没有。)
  • 无论如何,就像其他人所说的那样,您只是在测量高争用同步开销随着线程的增加而增加。必须有一些独立的工作,每个线程可以做的并行化是值得的,但是你同步。

标签: c++ multithreading performance atomic lock-free


【解决方案1】:

这取决于具体的工作量。

查看阿姆达尔定律

                     100 % (whole workload in percentage)
speedup =  -----------------------------------------------------------
            (sequential work load in %) + (parallel workload in %) / (count of workers)

您程序中的并行工作负载为0 %,因此加速比为1。也就是没有加速。 (您正在同步以递增相同的存储单元 因此在任何给定时间只有一个线程可以增加单元格。)

粗略解释,为什么它的表现甚至比speedup=1更差:

包含gCounter 的缓存行留在cpu 缓存中,只有一个线程。

多个线程被调度到不同的 cpu 或核心,包含gCounter 的缓存行将在 cpus 核心的不同缓存中反弹。

因此,与每次递增操作访问内存相比,差异有点类似于仅使用一个线程递增寄存器。 (有时它比内存访问更快,因为在现代 cpu 架构中存在缓存到缓存传输。)

【讨论】:

  • 没错,但它解释了这个具体程序的性能下降,这可能对提问者有帮助。
【解决方案2】:

无锁多线程程序并不比单线程程序慢。让它变慢的是数据争用。您提供的示例实际上是一个极具争议的人工程序。在实际程序中,您将在每次访问共享数据之间做大量工作,因此缓存失效等情况会更少。 Jeff Preshing 的 CppCon talk 可以比我更好地解释您的一些问题。

添加:尝试修改 CountingThread 并偶尔添加一个 sleep 以假装您正忙于增加原子变量 gCounter 之外的其他事情。然后继续在 if 语句中使用值,看看它将如何影响程序的结果。

void CountingThread() {
  for (int64_t i = 0; i < gnAtomicIterations; i++) {
    // take a nap every 10000th iteration to simulate work on something
    // unrelated to access to shared resource
    if (i%10000 == 0) {
        std::chrono::milliseconds timespan(1);
        std::this_thread::sleep_for(timespan);
    }
    gCounter.fetch_add(1, std::memory_order_acq_rel);
  }
}

通常每次调用gCounter.fetch_add 时,都意味着在其他内核的缓存中标记该数据无效。这迫使他们将数据伸入远离核心的缓存中。这种影响是导致程序性能下降的主要原因。

local  L1 CACHE hit,                              ~4 cycles (   2.1 -  1.2 ns )
local  L2 CACHE hit,                             ~10 cycles (   5.3 -  3.0 ns )
local  L3 CACHE hit, line unshared               ~40 cycles (  21.4 - 12.0 ns )
local  L3 CACHE hit, shared line in another core ~65 cycles (  34.8 - 19.5 ns )
local  L3 CACHE hit, modified in another core    ~75 cycles (  40.2 - 22.5 ns )

remote L3 CACHE (Ref: Fig.1 [Pg. 5])        ~100-300 cycles ( 160.7 - 30.0 ns )

local  DRAM                                                   ~60 ns
remote DRAM                                                  ~100 ns

上表取自Approximate cost to access various caches and main memory?

无锁并不意味着您可以免费在线程之间交换数据。无锁意味着您无需等待其他线程解锁互斥锁即可读取共享数据。事实上,即使是无锁程序也使用锁定机制来防止数据损坏。

只需遵循简单的规则。尝试尽可能少地访问共享数据,以便从多核编程中获得更多收益。

【讨论】:

  • 你的第一句话太宽泛了。无锁原子操作仍然比单线程程序可以使用的非原子操作慢。例如将条目添加到无锁哈希表比添加到简单哈希表更昂贵。非共享计数器可以在寄存器中,增加它的成本为1 cycle of latency instead ~17 for lock add on Ryzen。另外,您假设无锁程序经过优化设计以尽可能少地同步。这需要做很多工作。
  • 更不用说当没有任何重新排序障碍要求值在某些点位于内存中时,编译器可以更好地优化。无论如何,对于并行化有意义的问题,多核 CPU 上的良好无锁实现通常很棒。但是一个糟糕的无锁实现肯定会失​​败,特别是如果问题的并行性非常细粒度,因此需要大量同步。
  • 问:我的保时捷比我朋友的小型货车慢吗?答:没有保时捷比你朋友的小型货车慢。你的朋友在比赛中打你的原因是你每50m就停下来检查一下胎压。 @PeterCordes 你还在坚持我的第一句话太宽泛了吗?适用于互斥同步程序的相同规则也适用于无锁程序。只是无锁的开销更少。如果您阅读OP程序,那么您会看到他制作的程序,除了访问共享数据之外什么都没有。因此,整个程序较慢。不仅是单线程。
  • 我只是不喜欢在没有黄鼠狼的情况下进行概括。我的意思是,在这种情况下,OP 创建了一个多线程程序来将计数器递增 N 倍,这比简单的单线程实现要慢得多。因为他并行化了错误的方式(所有线程竞争共享计数器而不是增加本地计数器并在完成后将其添加到共享计数器)。在你的类比中,这就像把保时捷造错了,或者别的什么。
  • 适用于互斥同步程序的相同规则也适用于无锁程序。只是无锁的开销更少。我同意。但我只是比较无锁与单线程。如果你打算使用多线程,那么如果你做对了,并且如果可以实现正确性而不使对共享数据的操作比假设独占访问慢很多,那么无锁通常会很棒.
【解决方案3】:

像大多数非常宽泛的更快问题一样,唯一完全通用的答案是取决于

一个好的心智模型是,当并行化现有任务时,并行版本在N 线程上的运行时间将由大致三个贡献组成:

  1. 串行和并行算法共有的仍然串行部分。 IE,。未并行化的工作,例如设置或拆除工作,或由于任务分区不准确而未并行运行的工作1

  2. 一个 parallel 部分,在 N 工作人员之间有效地并行化。

  3. 一个 overhead 组件,表示在串行版本中不存在的并行算法中完成的额外工作。几乎总是有少量的开销来划分工作、委托给工作线程和组合结果,但在某些情况下,开销会淹没实际工作。

所以总的来说,你有这三个贡献,让我们分别分配T1pT2pT3p。现在T1p 组件在串行和并行算法中都存在并且花费相同的时间,所以我们可以忽略它,因为它会为了确定哪个更慢而取消。

当然,如果您使用更粗粒度的同步,例如,在每个线程上增加一个局部变量,并且只定期(可能只在最后一次)更新共享变量,情况就会相反。


1 这也包括工作负载被很好地划分,但一些线程在单位时间内完成更多工作的情况,这在现代 CPU 和现代操作系统中很常见。

【讨论】:

    猜你喜欢
    • 2021-09-23
    • 2012-09-05
    • 1970-01-01
    • 2020-08-15
    • 1970-01-01
    • 2020-05-16
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多