性能优化 - 伪共享
背景
上一篇关于https://xie.infoq.cn/article/b598bcd94338e3b5a75bd7b4b,讲了因为 CPU 缓存、Cache Line、局部性而导致的性能差异。我们接着分析因为缓存、Cache Line 和缓存一致性,在多线程并发编程中所带来的另一个问题:伪共享。
伪共享,False Sharing,没找到中文的标准翻译。参考一下英文版的[wiki](False sharing - Wikipedia):
In computer science, false sharing is a performance-degrading usage pattern that can arise in systems with distributed, coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts to periodically access data that is not being altered by another party, but that data shares a cache block with data that is being altered, the caching protocol may force the first participant to reload the whole cache block despite a lack of logical necessity. The caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead required by true shared access of a resource.
By far the most common usage of this term is in modern multiprocessor CPU caches, where memory is cached in lines of some small power of two word size (e.g., 64 aligned, contiguous bytes). If two processors operate on independent data in the same memory address region storable in a single line, the cache coherency mechanisms in the system may force the whole line across the bus or interconnect with every data write, forcing memory stalls in addition to wasting system bandwidth. In some cases, the elimination of false sharing can result in order-of-magnitude performance improvements. False sharing is an inherent artifact of automatically synchronized cache protocols and can also exist in environments such as distributed file systems or databases, but current prevalence is limited to RAM caches.
简单地说,CPU 每次会读取一个 Cache Line 的数据(64 字节)进入缓存。如果两个线程分别运行在 Core0、Core1,这两个 Core 读取了同一块 64 字节的内存进入各自的 L1 缓存。Core0 去修改前 4 个字节的内容;Core1 去修改接下去的 4 个字节的内容。这两个看似不冲突的操作,因为 CPU 的[缓存一致性](MESI协议 - 维基百科,自由的百科全书),会带来对性能的冲击。
实验
我们可以通过两个简单的代码来进行对比。两个代码都是一样的目的,创建两个线程,分别对同一个结构体内的不同数据进行操作。线程 A 操作数据 a,线程 B 操作数据 b,看起来河水不犯井水。
第一个 C 文件 - False_sharing_hit.c:
第二个 C 文件 - False_sharing_avoid.c:
分别编译这两个代码:
接着分别执行编译出来的程序:
可以发现,第二个程序只花了第一个程序 1/3 的时间。
结论
第一个代码触发了伪共享的问题。线程 A 和线程 B 分别运行在两个 Core 上,假设 Core0 运行线程 A,Core1 运行线程 B。data_thread_a 和 data_thread_b 紧紧挨着,会一起被读入两个 Core 的缓存。当 Core0 上的线程 A 修改了 data_thread_a 的数值之后,Core0 的 Cache Line 变成了 dirty 状态。需要写回内存后,Core1 上的线程 B 才能继续修改该 Cache Line。反之亦然,当 Core1 的线程 B 修改完 data_thread_b 之后,Cache Line 又变成 dirty 状态,需要写回内存后,Core0 上的线程 A 才能修改 data_thread_a。所以,为了保证缓存一致性,不管哪个线程发生了修改操作,都会触发缓存回写内存的操作。像不像 A、B 两人住在一起,A 阳性了,B 作为密接被关起来;等到 A、B 都放出来后,B 又阳性了,A 作为密接又被关起来了?子子孙孙无穷尽也……
而第二个代码 data_thread_a 和 data_thread_b 之间加入了补齐的元素,使得 data_thread_a 和 data_thread_b 不会进入同一个 Cache Line。每个 Core 上的 Cache Line 修改不需要立即写回内存。从而带来了 3 倍的性能收益。承接上面的比喻,就是 A、B 不住在一起,不是时空伴随者,所以 A 阳性了,B 不用被关……
所以在程序开发中,需要理解代码背后发生了什么,才能写出高效的代码。
评论