应用程序的缓存友好设计

Posted

技术标签:

【中文标题】应用程序的缓存友好设计【英文标题】:Cache Friendly Design of Applications 【发布时间】:2015-05-01 19:48:19 【问题描述】:

我已经编写了一个用 C++ 编写的 application,现在我必须深入研究代码并使其对缓存友好。

在阅读了presentation by Tony Albrecht 之后,我很快意识到,只要从设计阶段就应用这些原则,我就可以在第一时间做到这一点。

另一篇由 Ulrich Drepper 撰写的题为 What Every Programmer Should Know About Memory 的论文的优点基本上是告诉像我这样的开发人员要注意编写正确的内存布局以便缓存友好。

但是,感觉反直觉,因为:

    一般而言,考虑内存布局并不自然。 按照集合和行来布置代码和数据并不自然。 根据具有属性和动作的对象进行思考是很自然的。

一个很好的例子,当我坐下来编写一个自定义分配器时,我将立即面对一个,分配器将处理两个结构,如下所示。

还要注意,一旦线程工作者释放了一个元素,就必须将同一个元素投入使用,这样才能继续下去。

    typedef struct
    
        OVERLAPPED Overlapped;
        WSABUF DataBuf;
        CHAR Buffer[DATA_BUFSIZE];
        byte *LPBuffer;
        vector<byte> byteBuffer;
        DWORD BytesSEND;
        DWORD BytesRECV;
     PER_IO_OPERATION_DATA, *LPPER_IO_OPERATION_DATA;

    typedef struct
    
        SOCKET Socket;
     PER_HANDLE_DATA, *LPPER_HANDLE_DATA;

请注意,WSABUF.buf 和向量可能是一个挑战,它们将如何在内存中布局。 WSABUF.buf 和向量缓冲区分配是动态的,它不适合固定大小的连续布局。我想必须为这种情况创建一个单独的分配器。

PER_HANDLE_DATA 很简单,可以很容易地以连续的方式布局。

我必须设置另一个结构来存储 IsActive,以便将其布置在一个连续的块中,与 PER_IO_OPERATION_DATA 分开。

    typedef struct 
        bool isActive;
     IODATA_STAT, *LPIODATA_STAT

无论如何,我只是想得到一些反馈,说明为什么在编写应用程序后可以在启动时注意缓存?

另外,您对根据动态/固定缓冲区大小和指针重新组织数据有何看法?

【问题讨论】:

过早优化。 你真的必须让你的代码缓存友好吗?这是一个很好的教程,关于在现有代码上获得更多功能而无需担心缓存:daniweb.com/software-development/cpp/tutorials/492425/… 我有一种强烈的感觉,写你的内存分配器,让它运行在 O(1) (这代表它正在跟踪的分配数量)或尽可能接近它会产生很多比对齐更大的差异。即链表样式跟踪系统与固定大小的单元分配系统和管理内存大小/碎片权衡。 “过早优化”绝对符合要求,我在这个方向上做了更多阅读,发现一个值得在这里发布的标题为 The ‘premature optimization is evil’ myth 的文章基本上不会是一个只知道语言,但要成为可以编写用户级应用程序的操作系统工程师,这意味着对 C、C++ 甚至汇编有深入的了解。 【参考方案1】:

优化

关于过早的优化,我想说,如果您事后可以应用优化来响应分析器而不在您的代码库中进行一系列级联更改,那么现在还为时过早。您必须在本地和非侵入性地相当地交换表示的喘息空间越大,您第一次就不必担心如何使该表示达到最佳状态。

因此,您首先要关注的关键是界面设计,而不是实施,尤其是在您构建大型软件时。在适当的抽象级别建模的良好、稳定的接口将允许您分析代码并优化热点,而不会在整个代码中级联损坏:理想情况下,只需对源文件进行一些调整。

生产力和可维护性仍然是开发人员最有价值的特征,除了最低级别的核心之外,绝大多数代码库都将依赖于这些特征,而不是您实现微高效设计的能力,更不用说任务的最佳算法。编程世界现在非常饱和且竞争激烈,能够快速生产出可维护应用程序的人通常是赢得并生存下来以优化另一天的人。

如果您不使用分析器并且担心的不仅仅是广泛的算法复杂性,那么您首先绝对需要一个分析器。测量两次,优化一次。分析器可以让您进行选择性的、离散的优化,这不仅可以让您在第一次以更有价值的方式花费您的时间,而且可以确保您不会将整个代码库降级为维护的噩梦.

内存布局

但除此之外:

1.一般来说,考虑内存布局并不自然。

在这里,我会推荐一些类似 C 的思维方式。当您在职业生涯中面临更多热点时,它会变得更加自然。在您的示例中,可变长度结构技巧变得非常有效。

struct PER_IO_OPERATION_DATA

    ...
    byte byteBuffer[]; // size N
;

只需使用 malloc(或您自己的分配器)和结构大小 + 使 byteButter 侧足够大所需的额外 N 字节来获取 PER_IO_OPERATION_DATA*。由于您掌握了 C++,因此您可以使用这种低级结构作为符合 RAII 的安全类背后的实现细节,在调试构建中应用必要的边界检查断言,以及异常安全等等。在 C++ 中,至少尝试这样做:如果您在任何地方需要不安全的低级位和字节操作代码,请将其设置为对公共接口隐藏的非常私密的实现细节。

这通常是内存局部性的第一遍:使用堆识别对象的运行时大小的聚合成员,并将它们与对象本身融合到一个连续的块中。

当您尝试针对局部性进行优化(以及消除新/删除/malloc/free 热点)时,标准中缺少另一种有用的通用容器类型,例如 std::vector 具有静态已知的“常见案例”大小。基本示例:

struct Usually32ElementsOrLess

    char buf[32];
    char* ptr;
    int num_elements;
;

初始化结构以使ptr 指向buf,除非元素数量超过固定大小(32)。在这种罕见的情况下,使ptr 指向堆分配的动态数组。通过ptr而不是buf访问结构,并确保实现正确的复制构造函数。

使用 C++,如果您喜欢使用模板参数来确定固定大小,您可以将其变成一个通用的 STL 兼容容器,如果您引入一个成员来跟踪当前内存容量,甚至可以使用 push_backs 进行可变大小除了尺寸。

拥有这种结构,经过充分测试,尤其是在成熟的通用 STL 形式中,将真正帮助您更多地利用堆栈,并从您的日常代码中获得更多的内存局部性,而无需更多比使用std::vector 更耗时或有风险。它适用于大多数情况下,数据大小在常见情况下有上限,而堆则为那些罕见的例外情况保留。

2.根据集合和行来布局代码和数据并不自然。

确实,从组织聚合和访问模式以对齐和适应缓存行的角度来看,这是非常不自然的。我建议您只考虑最关键的关键热点。

3.根据具有属性和动作的对象进行思考是很自然的。

这不会妨碍其他两件事。这就是公共接口设计,而且您理想的公共接口不会将这些低级优化细节泄漏到使用该接口的客户端中(除非它只是用作高级设计构建块的低级数据结构)。

回到界面设计,如果您想在不破坏界面设计的情况下为有效优化表示留出更多空间,那么跨步设计将大有帮助。查看 OpenGL API 以及它如何支持各种传递事物表示的各种方式。例如,它不假设顶点位置存储在与顶点法线分开的连续内存块中。因为它在设计中使用了步幅,所以顶点法线可以与顶点位置交错,也可能不交错。这无关紧要,也不需要更改界面,因此可以在不破坏任何内容的情况下尝试内存布局。

在 C++ 中,您甚至可以像 StrideIterator&lt;T&gt;(ptr, stride_size) 一样创建,以便在设计中更容易地传递和返回它们,从而可以从改变传递和返回的事物的内存布局中受益。

更新(固定分配器)

由于您对自定义分配器感兴趣,请尝试使用以下大小:

#include <iostream>
#include <cassert>
#include <ctime>

using namespace std;

class Pool

public:
    Pool(int element_size, int num_reserve)
    
        if (sizeof(Chunk) > element_size)
            element_size = sizeof(Chunk);

        // This should use an aligned malloc.
        mem = static_cast<char*>(malloc((num_reserve+1) * element_size));

        char* ptr = static_cast<char*>(mem);
        free_chunk = reinterpret_cast<Chunk*>(ptr);
        free_chunk->next = 0;

        Chunk* last_chunk = free_chunk;
        for (int j=1; j < num_reserve+1; ++j)
        
            ptr += element_size;
            Chunk* chunk = reinterpret_cast<Chunk*>(ptr);
            chunk->next = 0;
            last_chunk->next = chunk;
            last_chunk = chunk;
        
    

    ~Pool()
    
        // This should use an aligned free.
        free(mem);
    

    void* allocate()
    
        assert(free_chunk && free_chunk->next && "Reserve memory exhausted!");
        Chunk* chunk = free_chunk;
        free_chunk = free_chunk->next;
        return chunk->mem;
    

    void deallocate(void* mem)
    
        Chunk* chunk = static_cast<Chunk*>(mem);
        chunk->next = free_chunk;
        free_chunk = chunk;
    

    template <class T>
    T* create(const T& other)
    
        return new(allocate()) T(other);
    

    template <class T>
    void destroy(T* mem)
    
        mem->~T();
        deallocate(mem);
    

private:
    union Chunk
    
        Chunk* next;

        // This should be max aligned.
        char mem[1];
    ;
    char* mem;
    Chunk* free_chunk;
;

static double sys_time()

    return static_cast<double>(clock()) / CLOCKS_PER_SEC;


int main()

    enum num = 20000000;
    Pool alloc(sizeof(int), num);

    // 'Touch' the array to reduce bias in the testing.
    int** elements = new int*[num];
    for (int j=0; j < num; ++j)
        elements[j] = 0;

    for (int k=0; k < 5; ++k)
    
        // new/delete (malloc/free)
        
            double start_time = sys_time();
            for (int j=0; j < num; ++j)
                elements[j] = new int(j);
            for (int j=0; j < num; ++j)
                delete elements[j];
            cout << (sys_time() - start_time) << " seconds for new/delete" << endl;
        

        // Branchless Fixed Alloc
        
            double start_time = sys_time();
            for (int j=0; j < num; ++j)
                elements[j] = alloc.create(j);
            for (int j=0; j < num; ++j)
                alloc.destroy(elements[j]);
            cout << (sys_time() - start_time) << " seconds for branchless alloc" << endl;
        
        cout << endl;
    
    delete[] elements;

我的机器上的结果:

1.711 seconds for new/delete
0.066 seconds for branchless alloc

1.681 seconds for new/delete
0.058 seconds for branchless alloc

1.668 seconds for new/delete
0.06 seconds for branchless alloc

1.68 seconds for new/delete
0.057 seconds for branchless alloc

1.663 seconds for new/delete
0.065 seconds for branchless alloc

这是一个无分支池分配器。不安全,但快疯了。它要求您提前预留最大内存量,因此最好将其用作分配器的构建块,该分配器执行分支并动态创建多个这些预留池。

【讨论】:

我终于得到了custom allocator 的编码,它在当前的测试阶段工作得很好,尽管内存布局的声明是从一个具有特定缓冲区大小的结构数组开始的。我想对此进行迭代并将布局演变为您在上面描述的内容,更像是一个顶点缓冲区声明。 我用一些无分支分配器的代码更新了这篇文章,因为您似乎对这些感兴趣。它的工作时间约为 65 毫秒,而对于相同的数据,新/删除需要约 1680 毫秒。 我复制了代码并使用低得多的 num 值 200,000 和 here is the snapshot of the result 运行它。看到这样的数字真的令人印象深刻,看到传播是从我的具有 i7 多核的机器上获取的。 @benG 另一件事是,原始形式的分配器在给定时间不应被多个线程使用。你可以在它周围放一些轻量级的锁——但是考虑到它有多便宜,当你在它周围加锁时速度会迅速下降。因此,它最适合作为给定线程的本地分配器的场景使用。 在以最有效/最快速的方式表达硬件可以提供什么之间存在微妙的平衡,就像没有什么能阻止它一样,一方面是高层抽象应该如何映射到这些超快速架构。您提到如何使用代码也是一件好事,因为它来自您,所以我一直在考虑这一点。我一定会在参考部分写下你的名字以及如何联系你。我需要你编写的代码让我进入下一个自定义分配器阶段,当然从代码中学到了很多东西。

以上是关于应用程序的缓存友好设计的主要内容,如果未能解决你的问题,请参考以下文章

为啥我无法通过使其对缓存友好来加速我的程序?

哪个缓存最友好?

缓存友好的顶点定义

一种NVMe SSD友好的数据存储系统设计

C#中的缓存友好性

使内存密集型后台应用程序“友好”