【问题标题】:AMD SMT or Intel HT performanceAMD SMT 或 Intel HT 性能
【发布时间】:2020-03-23 20:26:50
【问题描述】:

我真的不明白为什么双逻辑处理器的处理器比单逻辑处理器贵得多。据我所知,对于 6 核/12 线程 CPU,在 6 或 12 线程上运行代码没有区别。

正如猴子所问,这里是模拟每个线程上的重负载的 C# 示例:

static void Main(string[] args)
    {
        if (IntPtr.Size != 8)
            throw new Exception("use only x64 code, 2020 is coming...");

        //6 for physical cores, 12 for logical cores
        const int limit_threads = 12; 
        const int limit_actions = 256;
        const int limit_loop = 1000 * 1000 * 10;
        const double power = 1.0 / 17.0;

        long result = 0;
        var action = new Action(() =>
        {
            long value = 0;
            for (int i = 0; i < limit_loop; i++)
                value += (long)Math.Pow(i, power);

            Interlocked.Add(ref result, value);
        });

        var actions = Enumerable.Range(0, limit_actions).Select(x => action).ToArray();
        var sw = Stopwatch.StartNew();

        Parallel.Invoke(new ParallelOptions()
        {
            MaxDegreeOfParallelism = limit_threads
        }, actions);

        Console.WriteLine($"done in {sw.Elapsed.TotalSeconds}s\nresult={result}\nlimit_threads={limit_threads}\nlimit_actions={limit_actions}\nlimit_loop={limit_loop}");
    }

6 线程 (AMD Ryzen 2600) 的结果:

done in 13,7074543s
result=5086445312
limit_threads=6
limit_actions=256
limit_loop=10000000

12 个线程的结果(AMD Ryzen 2600):

done in 11,3992756s
result=5086445312
limit_threads=12
limit_actions=256
limit_loop=10000000

使用所有逻辑内核而不是仅使用物理内核大约可以提高 10% 的性能,这几乎是零。你现在能说什么?

与仅使用物理内核相比,有人可以提供使用处理器多线程(AMD SMT 或 Intel HT)更快有价值的示例代码吗?

【问题讨论】:

  • 什么测试?在哪些 CPU、什么机器、什么补丁上?您是在要求人们猜测为什么 您的 代码运行速度不够快?甚至不显示代码?
  • 阅读答案。您甚至没有提到您使用哪种语言,但我现在可以告诉您 1) .NET 的优化并没有那么深入。 2)那个Parallel.Invoke是错误的,加上那个Interlocked会增加一个同步点。 Parallel 用于 data 并行性。如果您真的想衡量性能,您必须使用Parallel.ForEachLOT 数据(数百万个数字)。否则,分区和并行化开销将超过收益。 内存传输的影响不会出现在 CPU 缓存中可以容纳的数据太少时
  • 最后,您的代码根本无法控制 HT。它只是创建了额外的线程,然而,有 16% 的改进可能是由于暂时的原因,也可能不是。 11 秒的持续时间太短,并且像这样使用秒表也会测量系统延迟。使用 BenchmarkDotNet 获得有意义的结果。
  • 要实际测量 HT 的效果,请在机器的 BIOS 中禁用 HT 之前和之后运行相同的基准测试。不要强制线程数量,让 .NET 完成它的工作。默认情况下,它会创建与核心一样多的分区和工作线程。
  • @AlekDepler 考虑到有缺陷的逻辑,您已经证明 HT 提高了性能 (1​​6%),尽管我认为这是糟糕的基准测试和完全不合适的代码的产物。 HT 根本没有关闭,它在两种情况下都使用过。我是在告诉你如何提高性能,而不是如何降低性能。

标签: multithreading cpu


【解决方案1】:

TLDR:SMT/HT 是一种用于抵消大规模多线程成本的技术,而不是通过更多内核来加速计算。

您误解了 SMT/HT 的作用。

“据我所知,对于 6cores-12threads CPU,在 6 或 12 线程上运行代码没有区别”。

如果这是真的,那么 SMT/HT 正在工作。

要了解原因,您需要了解现代操作系统内核和内核线程。今天的操作系统使用所谓的抢占线程。

操作系统内核将每个内核划分为称为“量子”的时间片,并使用中断以复杂的循环方式调度各种进程。

我们要查看的部分是中断。当一个 CPU 内核被调度切换运行另一个线程时,我们称这个过程为“上下文切换”。上下文切换是昂贵且缓慢的过程,因为高度流水线化的 CPU 的整个状态和流程必须停止、保存并换出另一个状态(以及其他缓存、寄存器、查找表等)。根据this answer,上下文切换时间以微秒(数千个时钟周期)为单位;随着 CPU 变得越来越复杂,情况只会变得更糟。

SMT/HT 的目的是作弊,让每个 CPU 内核能够同时存储两种状态(想象一下有两台显示器而不是一台,您仍然一次只使用一台,但您的工作效率更高因为您不需要在每次切换任务时重新排列窗口)。所以 SMT/HT 处理器的 Context Switch 必须比非 SMT/HT 处理器快。

回到你的例子。如果您在 Ryzen 2600 上关闭 SMT,然后使用 12 线程运行相同的工作负载,您会发现它的执行速度明显慢于使用 6 线程。

另外,请注意,更多的线程不会让事情变得更快。

【讨论】:

    【解决方案2】:

    我认为,根据 SMT/HT 技术的可用性来改变处理器的价格只是营销策略的问题。
    硬件可能在每种情况下都相同,但制造商禁用了其中一些功能以提供廉价型号。

    这项技术依赖于这样一个事实,即在一个单一的 指令必须等待某些东西被执行;所以与其只是等待, 同一个核心使用它的电路在微操作上取得一些进展 来自另一个线程。
    从粗略的角度来看,我们可以感知两个(或更多 某些模型)来自两个不同线程执行的微操作序列 在单个硬件上(除了一些冗余部分,如寄存器......)

    这项技术的效率取决于问题。
    经过各种测试后,我注意到如果问题是 compute bound,即 限制因素是计算(加法、乘法...)所需的时间,但不是 内存绑定(数据已经可用,无需等待内存), 那么这项技术不会带来任何好处。
    这是因为没有 gap 可以填充两个序列 微操作,因此两个线程的交织执行并不好 比两个独立的串行执行。
    在完全相反的情况下,当问题是 memory bound 但不是 计算绑定,没有更多好处,因为两个线程都必须等待 对于来自内存的数据。
    当问题混合在一起时,我才注意到性能有所改善 数据访问和计算;在这种情况下,当一个线程正在等待数据时, 同一个核心可以在另一个线程的计算中取得一些进展,并且 反之亦然。

    编辑
    下面给出一个例子来说明这些情况,我得到了 以下结果(多次运行时相当稳定, 双 Xeon E5-2697 v2,Linux 5.3.13)。

    在这种内存受限的情况下,HT 没有帮助。

    $ ./prog_ht mem
    24 threads running memory_task()
    result: 1e+17
    duration: 13.0383 seconds
    $ ./prog_ht mem ht
    48 threads (ht) running memory_task()
    result: 1e+17
    duration: 13.1096 seconds
    

    在这种计算受限的情况下,HT 有帮助(几乎 30% 的增益)
    (我不确切知道硬件中隐含的细节 计算 cos 时,但必须有一些未到期的延迟 内存访问)

    $ ./prog_ht
    24 threads running compute_task()
    result: -260.782
    duration: 9.76226 seconds
    $ ./prog_ht ht
    48 threads (ht) running compute_task()
    result: -260.782
    duration: 7.58181 seconds
    

    在这种混合情况下,HT 的帮助更大(大约 70% 增益)

    $ ./prog_ht mix
    24 threads running mixed_task()
    result: -260.782
    duration: 60.1602 seconds
    $ ./prog_ht mix ht
    48 threads (ht) running mixed_task()
    result: -260.782
    duration: 35.121 seconds
    

    这是源代码(在 C++ 中,我不习惯 C#)

    /*
      g++ -std=c++17 -o prog_ht prog_ht.cpp \
          -pedantic -Wall -Wextra -Wconversion \
          -Wno-missing-braces -Wno-sign-conversion \
          -O3 -ffast-math -march=native -fomit-frame-pointer -DNDEBUG \
          -pthread
    */
    
    #include <iostream>
    #include <vector>
    #include <string>
    #include <algorithm>
    #include <thread>
    #include <chrono>
    #include <cstdint>
    #include <random>
    #include <cmath>
    
    #include <pthread.h>
    
    bool // success
    bind_current_thread_to_cpu(int cpu_id)
    {
      /* !!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!
      I checked the numbering of the CPUs according to the packages and cores
      on my computer/system (dual Xeon E5-2697 v2, Linux 5.3.13)
         0 to 11 --> different cores of package 1
        12 to 23 --> different cores of package 2
        24 to 35 --> different cores of package 1
        36 to 47 --> different cores of package 2
      Thus using cpu_id from 0 to 23 does not bind more than one thread
      to each single core (no HT).
      Of course using cpu_id from 0 to 47 binds two threads to each single
      core (HT is used).
      This numbering is absolutely NOT guaranteed on any other computer/system,
      thus the relation between thread numbers and cpu_id should be adapted
      accordingly.
      */
      cpu_set_t cpu_set;
      CPU_ZERO(&cpu_set);
      CPU_SET(cpu_id, &cpu_set);
      return !pthread_setaffinity_np(pthread_self(), sizeof(cpu_set), &cpu_set);
    }
    
    inline
    double // seconds since 1970/01/01 00:00:00 UTC
    system_time()
    {
      const auto now=std::chrono::system_clock::now().time_since_epoch();
      return 1e-6*double(std::chrono::duration_cast
                         <std::chrono::microseconds>(now).count());
    }
    
    constexpr auto count=std::int64_t{20'000'000};
    constexpr auto repeat=500;
    
    void
    compute_task(int thread_id,
                 int thread_count,
                 const int *indices,
                 const double *inputs,
                 double *results)
    {
      (void)indices; // not used here
      (void)inputs; // not used here
      bind_current_thread_to_cpu(thread_id);
      const auto work_begin=count*thread_id/thread_count;
      const auto work_end=std::min(count, count*(thread_id+1)/thread_count);
      auto result=0.0;
      for(auto r=0; r<repeat; ++r)
      {
        for(auto i=work_begin; i<work_end; ++i)
        {
          result+=std::cos(double(i));
        }
      }
      results[thread_id]+=result;
    }
    
    void
    mixed_task(int thread_id,
               int thread_count,
               const int *indices,
               const double *inputs,
               double *results)
    {
      bind_current_thread_to_cpu(thread_id);
      const auto work_begin=count*thread_id/thread_count;
      const auto work_end=std::min(count, count*(thread_id+1)/thread_count);
      auto result=0.0;
      for(auto r=0; r<repeat; ++r)
      {
        for(auto i=work_begin; i<work_end; ++i)
        {
          const auto index=indices[i];
          result+=std::cos(inputs[index]);
        }
      }
      results[thread_id]+=result;
    }
    
    void
    memory_task(int thread_id,
                int thread_count,
                const int *indices,
                const double *inputs,
                double *results)
    {
      bind_current_thread_to_cpu(thread_id);
      const auto work_begin=count*thread_id/thread_count;
      const auto work_end=std::min(count, count*(thread_id+1)/thread_count);
      auto result=0.0;
      for(auto r=0; r<repeat; ++r)
      {
        for(auto i=work_begin; i<work_end; ++i)
        {
          const auto index=indices[i];
          result+=inputs[index];
        }
      }
      results[thread_id]+=result;
    }
    
    int
    main(int argc,
         char **argv)
    {
      //~~~~ analyse command line arguments ~~~~
      const auto args=std::vector<std::string>{argv, argv+argc};
      const auto has_arg=
        [&](const auto &a)
        {
          return std::find(cbegin(args)+1, cend(args), a)!=cend(args);
        };
      const auto use_ht=has_arg("ht");
      const auto thread_count=int(std::thread::hardware_concurrency())
                              /(use_ht ? 1 : 2);
      const auto use_mix=has_arg("mix");
      const auto use_mem=has_arg("mem");
      const auto task=use_mem ? memory_task
                              : use_mix ? mixed_task
                                        : compute_task;
      const auto task_name=use_mem ? "memory_task"
                                   : use_mix ? "mixed_task"
                                             : "compute_task";
    
      //~~~~ prepare input/output data ~~~~
      auto results=std::vector<double>(thread_count);
      auto indices=std::vector<int>(count);
      auto inputs=std::vector<double>(count);
      std::generate(begin(indices), end(indices),
        [i=0]() mutable { return i++; });
      std::copy(cbegin(indices), cend(indices), begin(inputs));
      std::shuffle(begin(indices), end(indices), // fight the prefetcher!
                   std::default_random_engine{std::random_device{}()});
    
      //~~~~ launch threads ~~~~
      std::cout << thread_count << " threads"<< (use_ht ? " (ht)" : "")
                << " running " << task_name << "()\n";
      auto threads=std::vector<std::thread>(thread_count);
      const auto t0=system_time();
      for(auto i=0; i<thread_count; ++i)
      {
        threads[i]=std::thread{task, i, thread_count,
                               data(indices), data(inputs), data(results)};
      }
    
      //~~~~ wait for threads ~~~~
      auto result=0.0;
      for(auto i=0; i<thread_count; ++i)
      {
        threads[i].join();
        result+=results[i];
      }
      const auto duration=system_time()-t0;
      std::cout << "result: " << result << '\n';
      std::cout << "duration: " << duration << " seconds\n";
      return 0;
    }
    

    【讨论】:

    • @AlekDepler 我刚刚添加了一个示例来比较不同情况下使用/不使用 HT 的性能。
    • 嗯,这很有趣......看起来你是对的,混合模式会带来巨大的性能提升。而且,这个提升是可变值,取决于具体的算法。我认为 std::cos() 实现也会在内存中进行某种混合数据,因此在启用 HT 时也会导致 30% 的加速。
    • @AlekDepler 我认为内核中的浮点机制比我们想象的要复杂得多,并且在这种情况下存在与内存访问无关的延迟。这些延迟是 HT 的好机会。
    猜你喜欢
    • 2014-06-02
    • 1970-01-01
    • 2018-02-16
    • 2015-10-30
    • 2021-06-26
    • 2016-03-06
    • 1970-01-01
    • 2015-08-21
    • 1970-01-01
    相关资源
    最近更新 更多