【问题标题】:Why is thread local storage so slow?为什么线程本地存储这么慢?
【发布时间】:2010-10-05 02:24:42
【问题描述】:

我正在为 D 编程语言开发一个自定义标记释放样式的内存分配器,它通过从线程本地区域分配来工作。与其他相同的单线程代码版本相比,线程本地存储瓶颈似乎导致从这些区域分配内存的速度大幅下降(~50%),即使在将我的代码设计为每次分配只有一个 TLS 查找之后/解除分配。这是基于在循环中多次分配/释放内存,我试图弄清楚它是否是我的基准测试方法的产物。我的理解是线程本地存储基本上应该只涉及通过额外的间接层访问某些东西,类似于通过指针访问变量。这是不正确的吗?线程本地存储通常有多少开销?

注意:虽然我提到了 D,但我也对不特定于 D 的一般答案感兴趣,因为如果 D 的线程本地存储实现比最佳实现慢,它可能会改进。

【问题讨论】:

  • 您在 Windows 上遇到过这种情况吗?我不知道 D 到底是如何实现 TLS 的,但是 Visual C++ 似乎使用了 FS 寄存器,读取起来相当慢。

标签: multithreading performance d thread-local-storage


【解决方案1】:

我为嵌入式系统设计了多任务处理程序,从概念上讲,线程本地存储的关键要求是使用上下文切换方法保存/恢复指向线程本地存储的指针以及 CPU 寄存器以及它所保存的任何其他内容/恢复。对于一旦启动将始终运行相同代码集的嵌入式系统,最简单的方法是简单地保存/恢复一个指针,该指针指向每个线程的固定格式块。漂亮、干净、简单、高效。

如果不介意在每个线程中为每个线程局部变量分配空间——即使是那些从未真正使用它的变量——并且如果所有内容都将在线程局部存储中块可以定义为单个结构。在这种情况下,访问线程局部变量几乎可以和访问其他变量一样快,唯一的区别是额外的指针取消引用。不幸的是,许多 PC 应用程序需要更复杂的东西。

在某些 PC 框架上,如果使用这些变量的模块已在该线程上运行,则该线程只会为线程静态变量分配空间。虽然这有时可能是有利的,但这意味着不同的线程通常会有不同的本地存储布局。因此,线程可能需要对其变量所在位置具有某种可搜索索引,并通过该索引引导对这些变量的所有访问。

我希望如果框架分配少量的固定格式存储,保留最后访问的 1-3 个线程局部变量的缓存可能会有所帮助,因为在许多情况下甚至是单项缓存可以提供相当高的命中率。

【讨论】:

    【解决方案2】:

    在解释基准测试结果时需要非常小心。例如,最近 D 新闻组中的一个线程从基准测试中得出结论,dmd 的代码生成导致执行算术的循环严重减慢,但实际上花费的时间主要由执行长除法的运行时辅助函数控制。编译器的代码生成与减速无关。

    要查看为 tls 生成了什么样的代码,请编译并 obj2asm 这段代码:

    __thread int x;
    int foo() { return x; }
    

    TLS 在 Windows 上的实现与在 Linux 上大不相同,在 OSX 上也将大不相同。但是,在所有情况下,它都会比简单的静态内存位置加载更多的指令。相对于简单访问,TLS 总是会很慢。在紧密循环中访问 TLS 全局变量也会很慢。尝试将 TLS 值缓存在一个临时文件中。

    几年前我写了一些线程池分配代码,并将TLS句柄缓存到池中,效果很好。

    【讨论】:

    • 您能否针对“已接受”的最高答案展开此评论?那里声称,如果涉及 OS/MMU,它可以比间接进行非常快、一样快或更快。就这样的实现而言,D 处于什么位置?
    【解决方案3】:

    我们已经从 TLS(在 Windows 上)看到了类似的性能问题。我们依赖它来完成我们产品“内核”中的某些关键操作。经过一番努力,我决定尝试改进它。

    我很高兴地说,我们现在有一个小型 API,当调用线程不“知道”它的线程 ID 时,它可以将 CPU 时间减少 50% 以上,如果调用它可以减少 65% 以上thread 已经获得了它的 thread-id(可能用于其他一些更早的处理步骤)。

    新函数 ( get_thread_private_ptr() ) 总是返回一个指针,指向我们在内部用来保存所有排序的结构,因此每个线程只需要一个。

    总而言之,我认为 Win32 TLS 支持确实做得很差。

    【讨论】:

    • 不会投反对票,但这个轶事证据并没有太大帮助。您没有提及您测试的 Windows 版本,这可能会对数字产生很大影响。您也没有说过使用您的 get_thread_private_ptr 方法改进的技术。其他可能有助于提高性能的因素是您使用的语言/编译器,或者您是否只使用了 TLS API(TlsAlloc、TlsGetValue、TlsSetValue 和 TlsFree)。它们的工作方式不同,性能特征也不同。
    【解决方案4】:

    D 中的线程局部变量非常快。这是我的测试。

    64 位 Ubuntu,核心 i5,dmd v2.052 编译器选项:dmd -O -release -inline -m64

    // this loop takes 0m0.630s
    void main(){
        int a; // register allocated
        for( int i=1000*1000*1000; i>0; i-- ){
            a+=9;
        }
    }
    
    // this loop takes 0m1.875s
    int a; // thread local in D, not static
    void main(){
        for( int i=1000*1000*1000; i>0; i-- ){
            a+=9;
        }
    }
    

    因此,每 1000*1000*1000 个线程本地访问,我们只损失 1.2 秒的 CPU 内核之一。 使用 %fs 寄存器访问线程本地 - 所以只涉及几个处理器命令:

    使用 objdump -d 反汇编:

    - this is local variable in %ecx register (loop counter in %eax):
       8:   31 c9                   xor    %ecx,%ecx
       a:   b8 00 ca 9a 3b          mov    $0x3b9aca00,%eax
       f:   83 c1 09                add    $0x9,%ecx
      12:   ff c8                   dec    %eax
      14:   85 c0                   test   %eax,%eax
      16:   75 f7                   jne    f <_Dmain+0xf>
    
    - this is thread local, %fs register is used for indirection, %edx is loop counter:
       6:   ba 00 ca 9a 3b          mov    $0x3b9aca00,%edx
       b:   64 48 8b 04 25 00 00    mov    %fs:0x0,%rax
      12:   00 00 
      14:   48 8b 0d 00 00 00 00    mov    0x0(%rip),%rcx        # 1b <_Dmain+0x1b>
      1b:   83 04 08 09             addl   $0x9,(%rax,%rcx,1)
      1f:   ff ca                   dec    %edx
      21:   85 d2                   test   %edx,%edx
      23:   75 e6                   jne    b <_Dmain+0xb>
    

    也许编译器可以更聪明,在循环到寄存器之前缓存本地线程 并在最后将其返回给本地线程(与 gdc 编译器进行比较很有趣), 但即使是现在,事情也很好恕我直言。

    【讨论】:

      【解决方案5】:

      如果您不能使用编译器 TLS 支持,您可以自己管理 TLS。 我为 C++ 构建了一个包装模板,因此很容易替换底层实现。 在这个例子中,我为 Win32 实现了它。 注意:由于您无法为每个进程获得无限数量的 TLS 索引(至少在 Win32 下), 您应该指向足够大的堆块以容纳所有线程特定的数据。 这样您就可以拥有最少数量的 TLS 索引和相关查询。 在“最佳情况”下,每个线程只有 1 个 TLS 指针指向一个私有堆块。

      简而言之:不要指向单个对象,而是指向特定于线程的堆内存/包含对象指针的容器以实现更好的性能。

      如果不再使用,请不要忘记释放内存。 我通过将线程包装到一个类中(就像 Java 一样)并通过构造函数和析构函数处理 TLS 来做到这一点。 此外,我将线程句柄和 ID 等常用数据存储为类成员。

      用法:

      对于类型*: tl_ptr

      对于 const 类型*: tl_ptr

      对于类型* const: 常量 tl_ptr

      常量类型* 常量: const tl_ptr

      template<typename T>
      class tl_ptr {
      protected:
          DWORD index;
      public:
          tl_ptr(void) : index(TlsAlloc()){
              assert(index != TLS_OUT_OF_INDEXES);
              set(NULL);
          }
          void set(T* ptr){
              TlsSetValue(index,(LPVOID) ptr);
          }
          T* get(void)const {
              return (T*) TlsGetValue(index);
          }
          tl_ptr& operator=(T* ptr){
              set(ptr);
              return *this;
          }
          tl_ptr& operator=(const tl_ptr& other){
              set(other.get());
              return *this;
          }
          T& operator*(void)const{
              return *get();
          }
          T* operator->(void)const{
              return get();
          }
          ~tl_ptr(){
              TlsFree(index);
          }
      };
      

      【讨论】:

      • 这个问题是关于 D,而不是 C++,你没有解决 OP 提出的任何问题。
      • 没错,我添加了一些关于我迄今为止一直遵循的概念的更多信息(恕我直言,语言并不重要)。
      • 在 Sam 的支持下,OP 也特别要求 D 的解决方案以外的解决方案。
      【解决方案6】:

      速度取决于 TLS 实现。

      是的,你说得对,TLS 可以像指针查找一样快。在具有内存管理单元的系统上,它甚至可以更快。

      对于指针查找,您需要调度程序的帮助。调度程序必须 - 在任务切换上 - 更新指向 TLS 数据的指针。

      另一种实现 TLS 的快速方法是通过内存管理单元。在这里,TLS 被视为与任何其他数据一样,但 TLS 变量被分配在特殊段中。调度程序将在任务切换时将正确的内存块映射到任务的地址空间。

      如果调度程序不支持任何这些方法,编译器/库必须执行以下操作:

      • 获取当前ThreadId
      • 获取信号量
      • 通过 ThreadId 查找指向 TLS 块的指针(可能使用映射左右)
      • 释放信号量
      • 返回那个指针。

      显然,为每个 TLS 数据访问执行所有这些操作需要一段时间,并且可能需要多达三个操作系统调用:获取 ThreadId、获取和释放信号量。

      信号量是为了确保没有线程从 TLS 指针列表中读取,而另一个线程正在生成一个新线程。 (因此分配一个新的 TLS 块并修改数据结构)。

      不幸的是,在实践中看到缓慢的 TLS 实施并不少见。

      【讨论】:

      • 每个堆栈帧可能只需要一个慢版本,因为它可以被缓存以重复使用(堆栈是一种时尚的 TLS)
      • 在用户空间中保留线程列表是实现线程的一种非常可怕的方式......
      • 任何可以产生threadID的方案,都可以在同样短的时间内产生一个TLS指针(如果不是同时!),所以我不明白为什么需要信号量。
      • @IraBaxter:因为您可能需要多个 TLS 指针……每个共享库一个 TLS 指针。 (除此之外,信号量可能在互锁的原子指令中实现。)
      猜你喜欢
      • 2013-09-19
      • 1970-01-01
      • 2016-06-12
      • 2019-06-10
      • 2023-03-21
      • 2010-09-11
      • 2011-02-24
      • 2011-03-21
      • 1970-01-01
      相关资源
      最近更新 更多