38

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

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

4

6 回答 6

38

速度取决于 TLS 实现。

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

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

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

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

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

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

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

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

于 2009-02-03T06:06:59.420 回答
12

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 编译器进行比较很有趣),但即使是现在,恕我直言,事情还是非常好的。

于 2011-04-13T08:56:39.717 回答
9

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

要查看为 tls 生成了哪种代码,请编译并 obj2asm 这段代码:

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

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

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

于 2009-02-03T20:15:04.213 回答
5

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

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

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

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

于 2012-01-07T23:35:16.887 回答
4

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

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

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

用法:

对于类型*: tl_ptr<类型>

对于 const 类型*:tl_ptr<const type>

对于类型* const: const tl_ptr<type>

常量类型* 常量:常量 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);
    }
};
于 2010-02-27T05:44:04.130 回答
2

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

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

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

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

于 2009-12-18T12:45:29.370 回答