【GAD翻译馆】无状态的、分层的多线程渲染(四):内存管理与同步
翻译:赵菁菁(轩语轩缘)审校:李笑达(DDBC4747)
本系列的最后一篇文章基本上总结了以下问题:在多个线程情况下向同一个桶添加指令时,我们如何高效地为每个指令包分配内存?在整个存储和提交指令包的过程中,我们如何确保良好的缓存利用率?
这就是我们今天要处理的问题。我想说明糟糕的指令包的分配行为是如何影响整个多线程渲染过程的性能的,以及我们的替代方案是什么。
之前的系列
这是一系列相关博客文章的第四篇帖子。其余帖子如下:
第一部分:基础
第二部分:无状态API设计
第三部分:设计细节
第四部分:内存管理与同步
安装
先做第一件事。我们想要执行的检测程序配置如下:
所有的测试丢在如下配置的机器上进行:英特尔内核i7-2600k,3.40 GHz四核CPU。CPU有4个内核,因为启用超线程有8个逻辑内核。
在使用分子任务调度器在多线程环境中运行时,每个可用逻辑内核有一个工作线程,在本例中为8。
检测程序的一次运行提交了10000个网格组件和10000个光组件。每个网格组件被添加到两个桶中:GBuffer桶和ShadowMap桶,所以每个桶有10000个绘制调用。每个光组件需要被映射,为的是更新一些常数(带来10000个映射操作),并向Lighting桶添加一个绘制调用。总之,这些总共会带来40000 个AddCommand()/AppendCommand()操作。
网格和光组件已经到位,所以没有其他进行中的工作,如截头体剔除或类似的工作。但是,将指令添加到桶中会产生一些人为的额外工作,以便更接近实际工作负载。
使用GenerateKey()生成的键以确保排序后的方式分配,数据包不能以单纯连续的方式遍历。
API提交不与像D3D这样的渲染API交互,只计算所有输入参数的哈希值。检查此哈希值以确保所有实现方法正确工作。
测试的时间结果是检测程序运行1000次的平均值。通过随机访问(读或写)16MB与检测程序无关的数据,在两次运行之间清理缓存。
版本一
为了获得检测程序在单线程场景中运行的时长,我们使用最后一篇文章中描述的实现方法来运行它。我们感兴趣的两件事如下:
如何分配单个数据包?
1 2 3 4 5 | template <typename t= "" > CommandPacket Create(size_t auxMemorySize) { return :: operator new (GetSize<t>(auxMemorySize)); } |
在这种情况下,我们使用全局运算符new来为数据包分配内存。
如何将指令添加到桶中?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | template <typename u= "" > U* AddCommand(Key key, size_t auxMemorySize) { CommandPacket packet = commandPacket::Create<u>(auxMemorySize); { const unsigned int current = m_current ; m_keys[current] = key; m_packets[current] = packet; } commandPacket::StoreNextCommandPacket(packet, nullptr); commandPacket::StoreBackendDispatchFunction(packet, U::DISPATCH_FUNCTION); return commandPacket::GetCommand<u>(packet); } |
注意,AddCommand() 的实现方法不是线程安全的,但在这种情况下是可以的,因为我们在此版本中只处理单线程。
下面是版本一的计时结果:
添加指令花费:6.65ms。
提交指令花费:0.7ms。
让我们看看我们能做些什么!
版本二
对我们的性能不利的最明显的事情就是使用全局操作符new来分配每个包。首先,不能保证每个数据包在内存中彼此接近,这会在提交数据包时有损缓存命中率。第二,全局操作符是缓慢的——这不是实现的错,而是我们的错误。new、malloc等等,是通用分配器,试图在平均情况下以最佳方式完成工作。然而,我们确切地知道我们分配了什么,分配的生命周期是什么:一帧。我们可以在一帧之后丢弃掉所有的东西。
因此,我们用一个线性内存分配器替换对于全局操作符new的调用,这只是在单线程的情况下碰到指针:
1 2 3 | void * alloc = m_current; m_current = size; return alloc; |
我们实现为桶简单地分配了一块内存(比如4MB),在每一帧内为桶分配使用这块内存。
下面是版本二的计时结果:
添加指令花费:4.55ms。
提交指令花费:0.7ms。
现在添加指令是版本一的1.46倍快!那很容易。现在,让我们再添一些线程。
版本三
在这个多线程场景中,我们使用分子任务调度器将工作片段分配给8个不同的工作线程。每50个组件被添加到一个桶中,这就会创建一个任务。这意味着我们正在为调度器创建400个任务(对于每个网格和光组件是10000/50),让它们并行运行,直到它们全部完成为止。这是通过调度程序API发布的简单父/子关系完成的。
为了为一个包分配内存,我们回去使用全局操作符new,因为它已经是线程安全的了。
为了向桶中添加指令,我们对实现方法做了一个简单的更改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | template <typename u= "" > U* AddCommand(Key key, size_t auxMemorySize) { CommandPacket packet = commandPacket::Create<u>(auxMemorySize); { const int32_t current = core::atomic::Increment(&m_current); m_keys[current] = key; m_packets[current] = packet; } commandPacket::StoreNextCommandPacket(packet, nullptr); commandPacket::StoreBackendDispatchFunction(packet, U::DISPATCH_FUNCTION); return commandPacket::GetCommand<u>(packet); } |
不是做m_current ,我们使用一个原子操作,原子地增加m_current的值,在调用函数之前返回其初始值。在x86-64平台上,这可以通过使用汇编代码中的InterlockedIncrement,,InterlockedExchangeAdd或LOCK XADD实现。
旁注:当在弱有序内存模型和/或CPU重排序平台上(如Xbox360, PlayStation 3,和Wii U中发现的PowerPC内核)开发时,你要确保原子指令不会因插入正确的编译器屏障(或使用获取/释放语义)而被编译器重排序。此外,你需要确保其他内核可以利用适当的内存屏障(如lwsync或sync)看到内存中原子操作的结果。
有了这些改变,计时结果如下:
添加指令花费:1.85ms。
提交指令花费:0.7ms。
从单线程与多线程提交使用相同的分配给我们带来了3.54倍的加速,这在四核CPU上是可以预料到的。任务调度器确实有开销,而且它的可伸缩性本可以更好,但目前我们不需要担心它。
再次,让我们尝试线性分配器,但这一次是在多线程环境中。
版本四
让线性分配器线程安全是很容易做到的,我们只需要使用一个原子操作来分配分配器的指针:
1 2 | const int32_t currentOffset = core::atomic::Add(&m_offset, size); return m_start currentOffset; |
从使用全局操作符new到使用线性分配器,让我们看看是否我们也能收获在多线程情况下的1.46倍加速:
添加指令花费:1.72ms。
提交指令花费:0.7ms。
嗯……这只是1.07倍——我们忘了什么?
实际上有几件事。首先,原子操作不像锁一样昂贵,但它们也不是免费的。第二,现在存在一些伪共享,因为所有内核都写到了同一高速缓存线上的内存目的地中,至少在某些时候是这样。它并不是那么糟糕,因为我们的分配大小比缓存行大小(分别是20/36字节和64字节)要大得多。但是,问题依旧存在。
版本五
以牺牲更多内存为代价,我们可以通过将每个分配的大小舍入到缓存行大小的倍数来排除伪共享,从而确保每个线程/内核完全拥有整个缓存行:
1 2 | const int32_t currentOffset = core::atomic::Add(&m_offset, RoundUp(size, 64)); return m_start currentOffset; |
现在计时结果如下:
添加指令花费:1.65ms。
提交指令花费:0.9ms。
由于消除了伪共享,添加指令现在更快了。然而,我们本来在添加指令时得到的东西现在却在提交时丢掉了。原因是,我们现在必须比以前更多地接触内存,就这样简单。
版本六
我们需要找到一个避免伪共享的解决方案,但同时也不会造成提交更多指令来接触内存。解决方案是使用线程本地存储(TLS)——或更具体地说:线程局部线性分配器。
因为每一个线程都有自己的线性分配器,它不会被其他线程所触碰,所以我们不需要使用原子操作。此外,由于每个分配器都有自己的内存块,所以伪共享不会发生。当然,这种解决方案的一个缺点是,我们需要事先分配更多的内存,因为每个线程局部分配器都需要一块内存。
实现方法很简单,假设你的引擎/代码库支持TLS:
1 2 3 4 5 6 7 | char * memory = tlsLinearMemory; void * alloc = memory; memory = size; //写回 tlsLinearMemory = memory; return alloc; |
有了这种方法,计时结果如下:
添加指令花费:1.45ms。
提交指令花费:0.7ms。
不太糟。提交指令又变快了,添加指令是我们原来用全局操作符new方法的1.27倍。
不过还有一件事要做。还有伪共享的发生,虽然在不同的地方:当在AddCommand() 中把键和包存在数组中时,它们一个接一个地紧挨着存储,但它们可能是由完全不同的线程写入的!
版本七
这是实现方法的最后一个版本,消除了AddCommand()中发生的伪共享。再一次,我们可以使用TLS来帮助我们,但这次并不是那么容易。
其基本思想如下:添加一个新的指令时不占用一个入口(entry),每个进入AddCommand() 的线程都会得到几个入口(比如,32个),这些入口可以用于该线程随后添加的指令。这意味着,当一个线程进入桶的AddCommand() 时,它获得32个入口,并立即使用这些入口。该线程也会调用余下的31 AddCommand() ,因此不需要任何同步或原子操作,而能够使用剩下的31个入口。一旦剩余入口的数目为零,则线程将分配下一个32个入口。
由于每个线程现在都写入数组中的32个相邻入口,因此完全消除了伪共享,因为每个存储键至少占用2字节,而缓存行大小为64字节。此外,对于每个线程、每个桶,每调用AddCommand() 32次我们只需要做一个原子操作。
但是实现并不那么简单,因为本质上每个桶都需要线程本地存储,而不是全局的。在代码中,它看起来像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | template <typename u= "" > U* AddCommand(Key key, size_t auxMemorySize) { CommandPacket packet = commandPacket::Create<u>(auxMemorySize); // 存储键和指向数据的指针 { int32_t id = tlsThreadId; //为此ID的线程获取此层中剩余包的数量 int32_t remaining = m_tlsRemaining[id]; int32_t offset = m_tlsOffset[id]; if (remaining == 0) { //块中没有剩余的存储了,获得新的 offset = core::atomic::Add(&m_current, 32); remaining = 32; // 写回 m_tlsOffset[id] = offset; } int32_t current = offset (32-remaining); m_keys[current] = key; m_packets[current] = packet; --remaining; // 写回 m_tlsRemaining[id] = remaining; } commandPacket::StoreNextCommandPacket(packet, nullptr); commandPacket::StoreBackendDispatchFunction(packet, U::DISPATCH_FUNCTION); return commandPacket::GetCommand<u>(packet); } |
在这个实现方法中,每个AddCommand() 我们都用一个原子操作获得了一点伪共享,而不是每32次调用一个原子操作,也不是每个AddCommand() 访问线程本地存储两到三次。
这在性能方面带来了什么?
添加指令花费:1.25ms。
提交指令花费:0.7ms。
最后,在最后的实现方法中,在多线程场景中与原始实现方法比,我们快了1.48倍。这意味着从全局操作符new到线性分配器给我们带来了与单线程情况下完全相同的加速比——但只有在我们去掉了大多数同步点和伪共享之后才加速了!
总结
ST:单线程single-threaded
MT:多线程
版本 | 添加 | 提交 |
版本一(ST, 全局 new) | 6.65 | 0.7 |
版本二(ST, 线性) | 4.55 | 0.7 |
版本三(MT, 全局 new) | 1.85 | 0.7 |
版本四(MT, 线性) | 1.72 | 0.7 |
版本五(MT, 线性, 大小与64对齐) | 1.65 | 0.9 |
版本六(MT, 线程本地线性) | 1.45 | 0.7 |
版本七(MT, 线程本地线性&桶) | 1.25 | 0.7 |
从全局new到线性(ST)加速:1.46倍
从全局new到线性(MT)加速:1.48倍
从简单单线程(版本一)到多线程(版本七)加速:5.32倍
结论
我们可以通过看上面不同的实现方法来总结什么呢?有几件事在脑海中出现:
要知道内存是如何分配的,以及如何在代码中访问它。
注意伪共享。
原子操作不是免费的。
最后但并非最不重要的一点:不要试图尽可能缩短共享数据的同步点,最初就不要尝试使用共享数据。不共享可变数据比进行任何类型的同步要好。双缓冲可能有用。线程本地数据可能有用。
这些是我在设计第三部分中介绍设计API时所牢记的事情。我试图使分配方案尽可能简单和不干扰,因为我知道,一旦我们切换到完全多线程实现,这些东西将起到作用。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;