为什么线程本地存储这么慢?
-
21-08-2019 - |
题
我正在为 D 编程语言开发一个自定义标记释放样式的内存分配器,它通过从线程局部区域进行分配来工作。与代码的其他相同的单线程版本相比,线程本地存储瓶颈似乎导致从这些区域分配内存的速度大幅减慢(约 50%),即使在将我的代码设计为每次分配仅进行一次 TLS 查找之后/解除分配。这是基于在循环中多次分配/释放内存,我试图弄清楚这是否是我的基准测试方法的产物。我的理解是,线程本地存储基本上应该只涉及通过额外的间接层访问某些内容,类似于通过指针访问变量。这是不正确的吗?线程本地存储通常有多少开销?
笔记:虽然我提到了 D,但我也对不特定于 D 的一般答案感兴趣,因为如果 D 的线程本地存储实现比最佳实现慢,它可能会得到改进。
解决方案
速度取决于 TLS 实施。
是的,您是对的,TLS 可以与指针查找一样快。在具有内存管理单元的系统上甚至可以更快。
对于指针查找,您需要调度程序的帮助。调度程序必须在任务切换时更新指向 TLS 数据的指针。
实现 TLS 的另一种快速方法是通过内存管理单元。此处,TLS 的处理方式与任何其他数据相同,但 TLS 变量分配在特殊段中。调度程序将在任务切换时将正确的内存块映射到任务的地址空间中。
如果调度程序不支持任何这些方法,编译器/库必须执行以下操作:
- 获取当前线程Id
- 拿一个信号量
- 通过 ThreadId 查找指向 TLS 块的指针(可以使用映射等)
- 释放信号量
- 返回该指针。
显然,为每个 TLS 数据访问执行所有这些操作需要一段时间,并且可能需要最多三个操作系统调用:获取ThreadId,获取并释放信号量。
顺便说一句,需要信号量来确保当另一个线程正在生成新线程时没有线程从 TLS 指针列表中读取。(并因此分配一个新的 TLS 块并修改数据结构)。
不幸的是,在实践中 TLS 实现速度缓慢的情况并不少见。
其他提示
在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;
}
}
因此,我们失去了只有1.2每秒1000 * 1000 * 1000线程本地访问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编译器进行比较), 但即使是现在的问题是非常好的恕我直言。
一需要在解释基准结果非常小心。例如,在d新闻组最近线程基准得出的结论是DMD的代码生成是导致一个循环,做算术的主要放缓,但实际上所花的时间是通过做长除法运行助手功能为主。编译器的代码生成不得不无关放缓。
要看到用于TLS产生什么样的代码,编译和obj2asm此代码:
__thread int x;
int foo() { return x; }
TLS实现非常不同的Windows比Linux和将再次有很大的不同在OSX。但是,在所有情况下,它会比静态内存位置的简单的负载更多的指令。 TLS总是会相对简单的访问速度变慢是。在紧密循环访问TLS全局将是缓慢的,太。尝试在临时缓存TLS值来代替。
我写了一些线程池分配代码年前,和缓存的TLS处理池,它运作良好。
如果您不能使用编译器TLS支持,你可以自己管理TLS。 我内置了C ++的包装模板,所以很容易更换的底层实现。 在这个例子中,我实现它为Win32。 注:由于无法获得每个进程TLS指数无限数量的(至少在Win32下) 您应该指向堆大到足以容纳所有线程特定的数据块。 这样,您有TLS指数和相关查询的最小数量。 在“最佳情况”,你必须仅有1个TLS指针指向每线程私有堆块。
在简而言之:不要指向单个对象,而不是指向特定的线程,堆内存/容器保持对象指针,以实现更好的性能
不要忘了,如果它不被再次用来释放内存。 我这样做是通过包装一个线程进入一个类(像Java一样),并通过构造函数和析构函数处理TLS。 此外,我存储频繁使用的像线程句柄和ID的作为类成员的数据。
用法:
为类型*: tl_ptr <类型>
有const型*: tl_ptr
为类型* const的: 常量tl_ptr <类型>
const型* 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);
}
};
我设计多一心用于嵌入式系统,并在概念上为线程局部存储器中的关键要求是具有上下文切换方法保存/恢复指针到线程局部存储器与CPU寄存器和其他任何它的保存沿着/恢复。对于那些将永远运行同一套代码后,他们已经启动了嵌入式系统中,最简单的方法简单地保存/恢复一个指针,它指向为每个线程固定格式的块。好,清洁,简单,高效。
这种方法效果很好,如果不介意的每个线程中分配的每个线程局部变量有空间 - 即使是那些从来没有真正使用它 - 如果这是怎么回事一切是线程本地存储中块可以被定义为一个单一的结构。在这种情况下,访问线程局部变量几乎可以以最快的速度进入其他的变量,唯一的区别是一个额外的指针引用。不幸的是,许多PC应用需要更复杂的东西。
在一些框架为PC,一个线程只会有空间线程静态变量分配如果使用这些变量的模块已经上线运行。虽然有时这可能是有利的,这意味着不同的线程将往往有自己的本地存储异地布置。因此,可能是必要的线程有某种其中它们的变量位于可搜索的索引的,并指示所有通过该索引访问那些变量。
我希望,如果框架分配固定格式存储量小,它可能有助于保持上次访问的1-3线程局部变量的高速缓存,因为在很多情况下,即使单项高速缓存可以提供一个相当高的命中率。
我们已经看到了类似的性能问题从TLS(在Windows上)。我们依靠它为我们的产品的“内核”内部的某些关键操作。经过一番努力,我决定尝试和改进这一点。
我很高兴地说,我们现在有一个很小的API,在CPU时间提供减少> 50%的等效操作时,呼唤线程并不“知道”它的线程ID和减少> 65%,如果在调用线程已经获得的它的线程ID(也许对于一些其它较早处理步骤)。
新的功能(get_thread_private_ptr())总是返回一个指向我们内部使用,以举办多种形式的结构体,所以我们只需要每个线程之一。
总而言之,我认为在Win32 TLS支持,制作不好真的。