【问题标题】:Bit Aligning for Space and Performance Boosts位对齐以提高空间和性能
【发布时间】:2012-02-23 14:12:33
【问题描述】:

Game Coding Complete, 3rd Edition, 一书中,作者提到了一种既可以减小数据结构大小提高访问性能的技术。本质上,它依赖于在成员变量与内存对齐时获得性能这一事实。这是编译器可以利用的一个明显的潜在优化,但是通过确保每个变量对齐,它们最终会膨胀数据结构的大小。

或者至少这是他的主张。

他指出,真正的性能提升是通过动用大脑并确保正确设计结构以利用速度提升的优势,同时防止编译器膨胀。他提供了如下代码sn-p:

#pragma pack( push, 1 )

struct SlowStruct
{
    char c;
    __int64 a;
    int b;
    char d;
};

struct FastStruct
{
    __int64 a;
    int b;
    char c;
    char d;  
    char unused[ 2 ]; // fill to 8-byte boundary for array use
};

#pragma pack( pop )

在未指定的测试中使用上述struct 对象,他报告15.6% 的性能提高(222ms192ms 相比)并且FastStruct 的大小更小。这一切在纸上对我来说都是有道理的,但在我的测试下却无法坚持:

同时结果大小(计算char unused[ 2 ])!

现在,如果 #pragma pack( push, 1 ) 仅与 FastStruct 隔离(或完全删除),我们确实会看到不同之处:

所以,最后,这里有一个问题:现代编译器(特别是 VS2010)是否已经针对位对齐进行了优化,因此缺乏性能提升(但增加了结构大小作为副作用,就像 Mike Mcshaffry 所说)?还是我的测试不够密集/不确定,无法返回任何重要结果?

对于测试,我对未对齐的__int64 成员执行了数学运算、列主要多维数组遍历/检查、矩阵运算等各种任务。这两种结构都没有产生不同的结果。

最后,即使它们没有提高性能,这仍然是一个有用的花絮,需要牢记以将内存使用量降至最低。但是,如果我没有看到性能提升(无论多么小),我会很高兴。

【问题讨论】:

  • 您在所有测试中得到完全相同的时间这一事实表明您运行的时间不够长。时序代码的分辨率可能不够高,无法显示任何差异。
  • 也许在你的测试过程中,有问题的变量被使用了太多,它被缓存在一个寄存器中。让 int64 变量跨越内存边界需要两条汇编指令才能获取它必然会更慢。
  • @BoPersson:更有可能的是,编译器只是简单地优化了它们以生成相同的代码。
  • @Bo Persson 我正在使用 Boost::Chrono 进行时间测量,如果这有影响的话。
  • 为了扩展上面@BoPersson 的评论,您获得相同的微秒时间,无论是逐个案例还是逐个运行,这一事实非常可疑。你的时间框架有缺陷。

标签: c++ performance visual-studio-2010 optimization compiler-construction


【解决方案1】:

高度依赖硬件。

让我演示一下:

#pragma pack( push, 1 )

struct SlowStruct
{
    char c;
    __int64 a;
    int b;
    char d;
};

struct FastStruct
{
    __int64 a;
    int b;
    char c;
    char d;  
    char unused[ 2 ]; // fill to 8-byte boundary for array use
};

#pragma pack( pop )

int main (void){

    int x = 1000;
    int iterations = 10000000;

    SlowStruct *slow = new SlowStruct[x];
    FastStruct *fast = new FastStruct[x];



    //  Warm the cache.
    memset(slow,0,x * sizeof(SlowStruct));
    clock_t time0 = clock();
    for (int c = 0; c < iterations; c++){
        for (int i = 0; i < x; i++){
            slow[i].a += c;
        }
    }
    clock_t time1 = clock();
    cout << "slow = " << (double)(time1 - time0) / CLOCKS_PER_SEC << endl;
    
    //  Warm the cache.
    memset(fast,0,x * sizeof(FastStruct));
    time1 = clock();
    for (int c = 0; c < iterations; c++){
        for (int i = 0; i < x; i++){
            fast[i].a += c;
        }
    }
    clock_t time2 = clock();
    cout << "fast = " << (double)(time2 - time1) / CLOCKS_PER_SEC << endl;



    //  Print to avoid Dead Code Elimination
    __int64 sum = 0;
    for (int c = 0; c < x; c++){
        sum += slow[c].a;
        sum += fast[c].a;
    }
    cout << "sum = " << sum << endl;


    return 0;
}

Core i7 920 @ 3.5 GHz

slow = 4.578
fast = 4.434
sum = 99999990000000000

好吧,差别不大。但它在多次运行中仍然保持一致。
因此对齐方式在 Nehalem Core i7 上产生了微小的差异。


Intel Xeon X5482 Harpertown @ 3.2 GHz(Core 2 - generation Xeon)

slow = 22.803
fast = 3.669
sum = 99999990000000000

现在看看...

快 6.2 倍!!!


结论:

您会看到结果。您决定是否值得花时间进行这些优化。


编辑:

相同的基准,但没有 #pragma pack:

Core i7 920 @ 3.5 GHz

slow = 4.49
fast = 4.442
sum = 99999990000000000

英特尔至强 X5482 Harpertown @ 3.2 GHz

slow = 3.684
fast = 3.717
sum = 99999990000000000
  • Core i7 编号没有改变。显然它可以处理 在这个基准测试中没有问题。
  • Core 2 Xeon 现在显示两个版本的相同时间。这证实了错位是 Core 2 架构上的一个问题。

摘自我的评论:

如果您忽略 #pragma pack,编译器将保持所有内容对齐,因此您不会看到此问题。所以这实际上是一个例子,说明如果你滥用#pragma pack会发生什么。

【讨论】:

  • 啊,一个实际显示结果的测试!在我的旧工作计算机上,我收到了71% 100 次测试的平均性能提升。随着尺寸的减小以及诸如此类的结果,不进行这些优化是不可能的,尤其是在它们如此简单的情况下。
  • 如果您省略了#pragma pack,编译器将保持所有内容对齐,因此您不会看到此问题。所以这实际上是一个例子,说明如果你滥用#pragma pack会发生什么。
  • 您是说没有#pragma pack 就看不到性能提升吗?我对上一条评论的测试已经没有它了。使用 #pragma pack 导致 FastStruct 实际上平均执行 slower 50-200ms。 EDIT 重新运行测试后,结果与没有#pragma pack 相同。不知道那是关于什么的。
  • @ssell:取决于您是否真的有性能问题。例如,如果你只制作其中两个,那么打包这个结构是没有意义的。
  • 我只是在没有#pragma pack 的情况下重新运行了测试。 Core i7 的编号是相同的。但 Core 2 Xeon 的结果是3.6843.717。 (FastStruct 更慢。)我怀疑这是因为FastStruct 正好是 16 个字节 - 所以迭代到迭代的步幅可能会导致缓存库冲突。
【解决方案2】:

这种手动优化通常早已失效。如果您正在为空间打包,或者如果您有像 SSE 类型这样的强制对齐类型,对齐只是一个重要的考虑因素。显然,编译器的默认对齐和打包规则旨在最大限度地提高性能,虽然手动调整它们可能是有益的,但通常不值得。

可能,在您的测试程序中,编译器从未在堆栈中存储任何结构,而只是将成员保存在寄存器中,这些寄存器没有对齐,这意味着结构大小或对齐是多少无关紧要。

事情是这样的:访问子词可能会出现别名和其他问题,访问整个词并不比访问子词慢。所以一般来说,如果你只访问一个成员,那么在时间上,压缩得比字长更有效并没有效率。

【讨论】:

  • 所以,简而言之,除非我绝对需要那些额外的几个字节,否则不值得付出努力?我也没有想到编译器只是将它们保存在寄存器中。
  • @ssell:这种优化只会变得越来越普遍。是的,一般来说不值得。
【解决方案3】:

在优化方面,Visual Studio 是一款出色的编译器。但是,请记住,当前游戏开发中的“优化战”并不在 PC 领域。虽然这样的优化在 PC 上很可能已经死了,但在控制台平台上它是完全不同的一双鞋。

也就是说,你可能想在专业的gamedev stackexchange site 上转发这个问题,你可能会直接从“现场”得到一些答案。

最后,您的结果完全相同精确到微秒,这在现代多线程系统上是完全不可能的——我很确定您要么使用分辨率非常低的计时器,要么使用您的计时代码被破坏了。

【讨论】:

  • 对于时间我使用Boost::Chrono 并且只是减去系统时间。由于作者的结果差异很大(30 毫秒!),我没想到需要更精确的东西。另外,感谢您指出有关控制台编程的事实。有时我忘记了他们必须做多少才能从这些古老的系统中挤出他们可能的一切。
【解决方案4】:

现代编译器根据成员的大小在不同的字节边界上对齐成员。见底部this

通常你真的不应该关心结构填充,但如果你有一个将有 1000000 个实例的对象或其他东西,那么经验法则就是将你的成员从最大到最小排序。我不建议使用 #pragma 指令来搞乱填充。

【讨论】:

    【解决方案5】:

    编译器将优化大小或速度,除非你明确告诉它你不会知道你得到了什么。但是,如果您遵循那本书的建议,您将在大多数编译器上双赢。将最大的对齐的东西首先放在你的结构中,然后是一半大小的东西,然后是单字节的东西(如果有的话),添加一些虚拟变量来对齐。无论如何,将字节用于不必要的事情可能会对性能造成影响,作为一种折衷方案,对所有事情都使用整数(必须知道这样做的利弊)

    x86 为许多糟糕的程序员和编译器制造了麻烦,因为它允许未对齐的访问。使许多人很难转移到其他平台(正在接管)。尽管未对齐的访问在 x86 上工作,但您会严重影响性能。这就是为什么了解编译器的一般工作方式以及您正在使用的特定编译器的工作方式很重要的原因。

    拥有缓存,并且与依赖缓存来获得任何性能的现代计算机平台一样,您需要对齐和打包。所教的简单规则给你两个.​​.....一般来说。这是非常好的建议。添加编译器特定的 pragma 几乎没有那么好,使代码不可移植,并且不需要通过 SO 或谷歌搜索来找出编译器忽略 pragma 或不执行您真正想要的操作的频率。

    【讨论】:

    • 如果你使用#pragma pack 来阻止编译器完成它的工作,你只需要虚拟变量来对齐。如果你只写struct FastStruct { __int64 a; int b; char c; char d; }; 而没有任何#pragmas,编译器将正确对齐所有内容。
    • 我泛泛而谈。并特别避免编译指示,通常不要依赖它们。
    【解决方案6】:

    在某些平台上,编译器没有选项:大于char 类型的对象通常严格要求位于适当对齐的地址。通常,对齐要求与对象的大小相同,最高可达 CPU 原生支持的最大字的大小。即short 通常需要位于偶数地址,long 通常需要位于可被 4 整除的地址,double 通常需要位于可被 8 整除的地址,例如可被 16 整除的地址处的 SIMD 向量。

    由于 C 和 C++ 要求按照声明的顺序对成员进行排序,因此在相应的平台上结构的大小会有很大的不同。由于更大的结构会有效地导致更多的缓存未命中、页面未命中等,因此在创建更大的结构时会显着降低性能。

    因为我看到一个声称它无关紧要:它对我使用的大多数(如果不是全部)系统都很重要。有一个显示不同尺寸的简单示例。这对性能的影响程度显然取决于结构的使用方式。

    #include <iostream>
    
    struct A
    {
        char a;
        double b;
        char c;
        double d;
    };
    
    struct B
    {
        double b;
        double d;
        char a;
        char c;
    };
    
    int main()
    {
        std::cout << "sizeof(A) = " << sizeof(A) << "\n";
        std::cout << "sizeof(B) = " << sizeof(B) << "\n";
    }
    
    ./alignment.tsk 
    sizeof(A) = 32
    sizeof(B) = 24
    

    【讨论】:

      【解决方案7】:

      C 标准规定结构中的字段必须分配在递增的地址。一个结构包含八个类型为“int8”的变量和七个类型为“int64”的变量,按此顺序存储,将占用 64 个字节(几乎不管机器的对齐要求如何)。如果这些字段按“int8”、“int64”、“int8”、...“int64”、“int8”排序,则该结构在“int64”字段在 8 字节边界上对齐的平台上将占用 120 个字节。自己重新排序字段将使它们包装得更紧密。然而,编译器不会在没有明确许可的情况下重新排序结构中的字段,因为这样做可能会改变程序语义。

      【讨论】:

        猜你喜欢
        • 2013-09-27
        • 2013-02-03
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2010-11-26
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多