如何避免多个线程写入共享数组的缓存行失效?

时间:2017-10-24 11:56:11

标签: c multithreading caching x86-64

问题的背景:

我正在编写一个创建32个线程的代码,并将它们与我的多核多处理器系统中的32个核心中的每个核心设置密切关系。

线程只是执行RDTSCP指令,并且值存储在非重叠位置的共享数组中,这是共享数组:

uint64_t rdtscp_values[32];

因此,每个线程都将根据其核心编号写入特定的阵列位置。

最重要的是,一切都正常,但我知道我可能没有使用正确的数据结构来避免 缓存行反弹

P.S: 我已经检查过我的处理器缓存行是64字节宽。

因为我使用的是简单的 uint64_t 数组,所以它意味着由于预读,单个缓存行将存储此数组的8个位置。

问题:

由于这个简单的数组,虽然线程写入不同的索引,但我的理解是,每次写入此数组都会导致所有其他线程的缓存失效?

如何创建与缓存行对齐的结构?

编辑1

我的系统是: 2x Intel Xeon E5-2670 2.30GHz(8核,16线程)

2 个答案:

答案 0 :(得分:3)

是的,你绝对想避免"虚假分享"和缓存行乒乓。  但这可能没有意义:如果这些内存位置比其他线程收集的线程更私密,它们应该与其他每线程数据一起存储,这样你就不会浪费缓存占用56个字节的填充。另见Cache-friendly way to collect results from multiple threads。 (没有很好的答案;如果可以的话,避免设计一个需要真正细粒度收集结果的系统。)

但是,让我们假设一分钟,不同线程的插槽之间未使用的填充实际上就是你想要的。

是的,您需要将步幅设为64个字节(1个缓存行),但实际上您并不需要使用每个 start 的8B缓存行。因此,只要uint64_t对象自然对齐(因此它们不会在缓存行边界上分割),您就不需要任何额外的对齐。

如果每个线程都写入其缓存行的第3个qword而不是第1个qword,那就没问题了。 OTOH,对齐到64B确保没有其他东西与第一个元素共享一个缓存行,这很容易,所以我们也可以。

静态存储:使用alignas()或使用编译器特定的东西在ISO C11中对齐静态存储非常容易。

使用结构,隐式填充是为了使大小成为所需对齐的倍数。让一个成员具有对齐要求意味着整个结构需要至少那么多对齐。编译器通过静态和自动存储为您处理此问题,但您必须使用aligned_alloc或替代方案来进行过度对齐的动态分配。

#include <stdalign.h>   // for #define alignas _Alignas  for C++ compat
#include <stdint.h>     // for uint64_t

// compiler knows the padding is just padding
struct { alignas(64) uint64_t v; } rdtscp_values[32];

int foo(unsigned t) {
    rdtscp_values[t].v = 1;
    return sizeof(rdtscp_values[0]);  // yes, this is 64
}

或使用数组as suggested by @ Eric Postpischil

alignas(64) // optional, stride will still be 64B without this.
uint64_t rdtscp_values_2d[32][8];  // 8 uint64_t per cache line

void bar(unsigned t) {
    rdtscp_values_2d[t][0] = 1;
}

alignas()是可选的,如果您不关心64B对齐的整个事物,只需要在您使用的元素之间进行64B跨步。你也可以在GNU C或C ++中使用__attribute__((aligned(64))),或者在__declspec(align(64))用于MSVC,使用#ifdef来定义可在主要x86编译器中移植的ALIGN宏。

无论哪种方式都产生相同的asm。我们可以检查编译器输出以验证我们得到了我们想要的东西。 I put it up on the Godbolt compiler explorer。我们得到:

foo:   # and same for bar
    mov     eax, edi              # zero extend 32-bit to 64-bit
    shl     rax, 6                # *64 is the same as <<6
    mov     qword ptr [rax + rdtscp_values], 1    # store 1

    mov     eax, 64               # return value = 64 = sizeof(struct)
    ret

两个数组的声明方式相同,编译器从汇编器/链接器with the 3rd arg to .comm请求64B对齐:

    .comm   rdtscp_values_2d,2048,64
    .comm   rdtscp_values,2048,64

动态存储空间

如果线程数不是编译时常量,那么您可以使用对齐的分配函数来获得对齐的动态分配内存(特别是如果您想支持非常高数量的线程)。请参阅How to solve the 32-byte-alignment issue for AVX load/store operations?,但实际上只使用C11 aligned_alloc。它非常适合这种情况,并返回一个与free()兼容的指针。

struct { alignas(64) uint64_t v; } *dynamic_rdtscp_values;
void init(unsigned nthreads) {
    size_t sz = sizeof(dynamic_rdtscp_values[0]);
    dynamic_rdtscp_values = aligned_alloc(nthreads*sz, sz);
}

void baz(unsigned t) {
    dynamic_rdtscp_values[t].v = 1;
}


 baz:
    mov     rax, qword ptr [rip + dynamic_rdtscp_values]

    mov     ecx, edi            # same code as before to scale by 64 bytes
    shl     rcx, 6
    mov     qword ptr [rax + rcx], 1
    ret

数组的地址不再是链接时间常量,因此有一个额外的间接级别来访问它。但是指针在初始化后是只读的,因此它将保持在每个核心的缓存中共享,并在需要时重新加载非常便宜。

脚注:在i386 System V ABI中,uint64_t默认情况下只有4B对齐内部结构(没有alignas(8)__attribute__((aligned(8)))),所以如果你放intuint64_t之前并且没有对整个结构进行任何对齐,就有可能获得缓存行分割。但编译器尽可能将它与8B对齐,因此你的struct-with padding仍然没问题。

答案 1 :(得分:0)

所以,我遵循了这里的评论,我必须感谢所有的贡献。

最后我得到了我的预期:每个线程正确使用缓存行。

这是共享结构:

typedef struct align_st {
    uint64_t v;
    uint64_t padding[7];
} align_st_t __attribute__ ((aligned (64)));

当结构加载到L1缓存时,我在结构中使用填充uint64_t padding[7]来填充缓存行中的剩余字节。尽管如此,我要求编译器在编译__attribute__ ((aligned (64)))时使用64字节内存对齐。

因此,我根据核心数动态分配此结构,使用memalign()

align_st_t *al = (align_st_t*) memalign(64, n_cores * sizeof(align_st_t));

为了比较它,我编写了一个使用这些对齐机制的代码版本( V1 ),以及使用简单数组方法的其他代码版本( V2 )。 / p>

通过执行perf,我得到了这些数字:

  • V1 :7.184 cache-miss;
  • V2 :2.621.347缓存未命中。

P.S。每个帖子都写了1千次到共享结构的同一地址只是为了增加数字