如何以及何时对齐缓存行大小

How and when to align to cache line size?

本文关键字:缓存 对齐 何时      更新时间:2023-10-16

在Dmitry Vyukov用c++编写的优秀的有界mpmc队列中参见:http://www.1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue

他添加了一些填充变量。我认为这是为了使它与缓存线对齐以提高性能。

我有几个问题。

  1. 为什么要这样做?
  2. 它是一个可移植的方法吗always work
  3. 在什么情况下最好使用__attribute__ ((aligned (64)))代替。
  4. 为什么在缓冲区指针之前填充有助于提高性能?难道不只是指针被加载到缓存中所以它实际上只是指针的大小吗?

    static size_t const     cacheline_size = 64;
    typedef char            cacheline_pad_t [cacheline_size];
    cacheline_pad_t         pad0_;
    cell_t* const           buffer_;
    size_t const            buffer_mask_;
    cacheline_pad_t         pad1_;
    std::atomic<size_t>     enqueue_pos_;
    cacheline_pad_t         pad2_;
    std::atomic<size_t>     dequeue_pos_;
    cacheline_pad_t         pad3_;
    

这个概念会在gcc下为c代码工作吗?

这样做是为了使修改不同字段的不同内核不必在包含它们的缓存之间反弹缓存行。通常,对于处理器访问内存中的某些数据,包含该数据的整个缓存行必须位于该处理器的本地缓存中。如果要修改该数据,则该缓存项通常必须是系统中任何缓存中的唯一副本(MESI/moesi风格的缓存一致性协议中的独占模式)。当不同的内核试图修改恰好位于同一条缓存线上的不同数据时,这会浪费时间来回移动整条缓存线,这被称为错误共享

在您给出的特定示例中,一个核心可以排队(读(共享)buffer_,只写(独占)enqueue_pos_),而另一个核心可以退出队列(共享buffer_和独占dequeue_pos_),而不会在另一个核心拥有的缓存线上停顿。

开头的填充意味着buffer_buffer_mask_最终在同一条缓存线上,而不是在两条线上分开,从而需要两倍的内存流量来访问。

我不确定这项技术是否完全可移植。假设每个cacheline_pad_t本身将对齐到64字节(其大小)缓存线边界,因此它后面的任何内容都将在下一个缓存线上。据我所知,C和c++语言标准只要求整个结构都这样,这样它们就可以很好地存在于数组中,而不会违反其任何成员的对齐要求。(见注释)

attribute方法将更加特定于编译器,但可能会将该结构的大小减少一半,因为填充将被限制为将每个元素四入到一个完整的缓存行。如果一个人有很多这样的东西,那将是非常有益的。

同样的概念适用于C和c++。

当您处理中断或高性能数据读取时,您可能需要对齐缓存线边界,这通常是每个缓存线64字节,并且在处理进程间套接字时必须使用它们。使用进程间套接字,控制变量不能分散在多个缓存线或DDR RAM字上,否则会导致L1, L2等或缓存或DDR RAM作为低通滤波器并过滤掉中断数据!这太糟糕了!!这意味着当你的算法很好时,你会得到奇怪的错误,它有可能让你发疯!

DDR RAM几乎总是读取128位字(DDR RAM words),这是16字节,所以环缓冲区变量不应该分散在多个DDR RAM字上。有些系统确实使用64位DDR RAM字,技术上你可以在16位CPU上获得32位DDR RAM字,但在这种情况下会使用SDRAM。

在高性能算法中读取数据时,可能只对最小化所使用的缓存行数感兴趣。就我而言,我开发了世界上最快的整数到字符串算法(比之前最快的算法快40%),我正在优化Grisu算法,这是世界上最快的浮点算法。为了打印浮点数,必须打印整数,所以为了优化Grisu,我实现的一个优化是,我将Grisu的查找表(LUT)缓存线对齐到15行缓存中,这是相当奇怪的,它实际上是这样对齐的。这将从.bss部分(即静态内存)获取lut,并将它们放入堆栈(或堆,但stack更合适)。我没有对这个进行基准测试,但这很好,我学到了很多关于这个的知识,加载值的最快方法是从I -cache而不是d-cache中加载它们。不同之处在于i-cache是只读的,并且有更大的缓存行,因为它是只读的(有一次一位教授引用我的话是2KB)。因此,数组索引会降低性能而不是像这样加载变量:

int faster_way = 12345678;

而不是更慢的方式:

int variables[2] = { 12345678, 123456789};
int slower_way = variables[0];

不同之处在于,int variable = 12345678将通过从函数开始偏移到i-cache中的变量来从i-cache行加载,而slower_way = int[0]将使用慢得多的数组索引从较小的d-cache行加载。正如我刚刚发现的,这个微妙的问题实际上减慢了我和其他许多整数到字符串算法的速度。我这样说是因为你可能会认为你是在优化缓存对齐只读数据,而实际上你并没有。

通常在c++中,将使用std::align函数。我建议不要使用这个函数,因为它不能保证最佳工作。这是最快的方式来对齐缓存行,这是在前面我是作者,这是一个无深度的插头:

歌舞伎工具箱内存对齐算法

namespace _ {
/* Aligns the given pointer to a power of two boundaries with a premade mask.
@return An aligned pointer of typename T.
@brief Algorithm is a 2's compliment trick that works by masking off
the desired number of bits in 2's compliment and adding them to the
pointer.
@param pointer The pointer to align.
@param mask The mask for the Least Significant bits to align. */
template <typename T = char>
inline T* AlignUp(void* pointer, intptr_t mask) {
  intptr_t value = reinterpret_cast<intptr_t>(pointer);
  value += (-value ) & mask;
  return reinterpret_cast<T*>(value);
}
} //< namespace _
// Example calls using the faster mask technique.
enum { kSize = 256 };
char buffer[kSize + 64];
char* aligned_to_64_byte_cache_line = AlignUp<> (buffer, 63);
char16_t* aligned_to_64_byte_cache_line2 = AlignUp<char16_t> (buffer, 63);

,这里是更快的std::align替换:

inline void* align_kabuki(size_t align, size_t size, void*& ptr,
                          size_t& space) noexcept {
  // Begin Kabuki Toolkit Implementation
  intptr_t int_ptr = reinterpret_cast<intptr_t>(ptr),
           offset = (-int_ptr) & (align - 1);
  if ((space -= offset) < size) {
    space += offset;
    return nullptr;
  }
  return reinterpret_cast<void*>(int_ptr + offset);
  // End Kabuki Toolkit Implementation
}