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

Posted

技术标签:

【中文标题】如何以及何时与缓存行大小对齐?【英文标题】:How and when to align to cache line size? 【发布时间】:2012-01-18 03:48:06 【问题描述】:

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

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

我有一些问题。

    为什么会这样? 它是一种可移植的方法吗? 一直在工作 在什么情况下最好改用__attribute__ ((aligned (64)))

    为什么缓冲区指针之前的填充有助于提高性能?不只是加载到缓存中的指针,所以它真的只有指针的大小吗?

    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 for c 代码下可以工作吗?

【问题讨论】:

为什么使用 cacheline_size 作为 pad size,而不是 (cacheline_size - sizeof( std::atomic ) ) ? 【参考方案1】:

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

在您给出的特定示例中,一个核心可以将一个条目排入队列(读取(共享)buffer_ 并仅写入(独占)enqueue_pos_),而另一个出列(共享buffer_ 和独占dequeue_pos_)没有一个核心在另一个拥有的高速缓存行上停止。

开头的填充意味着buffer_buffer_mask_ 最终位于同一缓存行上,而不是分成两行,因此需要双倍的内存流量才能访问。

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

attribute 方法将更加特定于编译器,但可能会将此结构的大小减半,因为填充将仅限于将每个元素四舍五入为完整的缓存行。如果一个人有很多这些,那可能会非常有益。

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

【讨论】:

@MattH:为了可移植性,C++11 引入了std::aligned_storage,它允许您要求存储定义的大小和对齐方式。 char [N] 的默认对齐方式为 1,否则。 如果不使用填充变量,为什么链接器不会优化它们? 其实并没有假设“cacheline_pad_t 本身会对齐到 64 字节;”实际上不需要对齐。填充只是保证唯一的目标,即之前和之后的变量在不同的缓存行中。 而更现代的 C++11 标准有 alignas 声明修饰符来实现这一点。几乎所有积极开发的 C++ 编译器都支持这一点。 @hrr 你的评论很有趣。因此,如果mpmc_bounded_queue 在其声明中包含alignas(64),则似乎不需要cacheline_pad_t pad0_,因为buffer_ 将与缓存行对齐(假设缓存行大小为64)?在那种情况下,填充可以更“紧凑”而不会遇到错误共享?提前致谢!【参考方案2】:

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

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

人们也可能只对在高性能算法中读取数据时最小化正在使用的缓存行数感兴趣。就我而言,我开发了世界上最快的整数到字符串算法(比之前最快的算法快 40%),并且我正在优化 Grisu 算法,这是世界上最快的浮点算法。为了打印浮点数,您必须打印整数,因此为了优化 Grisu,我实施的一项优化是,我将 Grisu 的查找表 (LUT) 缓存行对齐为 15 个缓存行,即奇怪的是它实际上是这样对齐的。这会从 .bss 部分(即静态内存)获取 LUT,并将它们放在堆栈(或堆,但堆栈更合适)上。我没有对此进行基准测试,但提出来很好,我对此学到了很多,加载值的最快方法是从 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 函数。我建议不要使用此功能,因为it is not guaranteed to work optimally。这是对齐缓存行的最快方法,首先我是作者,这是一个无耻的插件:

Kabuki Toolkit 内存对齐算法

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

【讨论】:

您能否详细说明“这将导致 L1、L2 等或缓存或 DDR RAM 用作低通滤波器”,或者发布解释的链接?我很难理解,信号的频率是如何影响这个的

以上是关于如何以及何时与缓存行大小对齐?的主要内容,如果未能解决你的问题,请参考以下文章

何时以及如何使用休眠二级缓存?

何时(以及如何)布局 Win32 窗口的子窗口以响应调整大小?

结构体大小计算以及sizeof与strlen的区别

结构体大小计算以及sizeof与strlen的区别

在 GraphQL 服务器设置中何时使用 Redis 以及何时使用 DataLoader

如何从 Java 进程中找到 L1 缓存行的大小?