【问题标题】:What is the most efficient way to represent small values in a struct?在结构中表示小值的最有效方法是什么?
【发布时间】:2015-10-22 06:39:56
【问题描述】:

我经常发现自己必须表示一个由非常小的值组成的结构。例如,Foo 有 4 个值,a, b, c, d,范围从 0 to 3。通常我不在乎,但有时,那些结构是

  1. 用于紧密循环;

  2. 它们的值每秒被读取十亿次,这是程序的瓶颈;

  3. 整个程序由数十亿个Foos 组成;

在这种情况下,我发现自己无法决定如何有效地表示 Foo。我基本上有4个选择:

struct Foo {
    int a;
    int b;
    int c;
    int d;
};

struct Foo {
    char a;
    char b;
    char c;
    char d;
};

struct Foo {
    char abcd;
};

struct FourFoos {
    int abcd_abcd_abcd_abcd;
};

每个Foo 分别使用 128、32、8、8 位,范围从稀疏到密集。第一个示例可能是最语言化的示例,但使用它实际上会使程序大小增加 16 倍,这听起来不太对劲。而且,大部分内存都会被零填充,根本不会被使用,这让我怀疑这不是浪费。另一方面,将它们密集打包会带来额外的阅读开销。

在结构中表示小值的计算“最快”方法是什么?

【问题讨论】:

  • 评论不用于扩展讨论;这个对话是moved to chat
  • 正如一些答案所指出的那样;你没有给我们足够的信息。您似乎自相矛盾“时间效率是目标”“但阅读也很重要。”选择“最有效”的意思。一旦你这样做;您正确地发现您应该进行基准测试,这应该是您的下一步。在不向我们提供形成该意见所需的事实的情况下询问我们的意见,这使这个问题成为一个题外话。如果您希望我们能够回答您的问题,请提供我们需要的事实。
  • @GeorgeStocker 我不同意这被搁置。尽管某些答案是基于意见的,但这并不是问题的内在品质。 dbush 提出了一种 OP 没有想到的技术,我发布了基准测试和通用编写代码的想法。许多其他人发布了有关 CPU 的有用信息。我认为有一些很好的答案不是主要基于意见的,仅此一项就表明问题主要不是基于意见的。
  • @NirFriedman 重新开放获得社区共识的最佳选择是metaC++ Chat room
  • 您是否通常同时想要结构中的所有值?或者你是只关心a,还是只关心一半结构中的d

标签: c struct bit-fields


【解决方案1】:

大量数组和内存不足错误

  1. 整个程序由数十亿个 Foos 组成;

首先,对于 #2,您可能会发现您自己或您的用户(如果其他人运行该软件)通常无法成功分配该数组(如果它跨越千兆字节)。这里的一个常见错误是认为内存不足错误意味着“没有更多可用内存”,而实际上它们通常意味着操作系统找不到连续集未使用与请求的内存大小匹配的页面。正是由于这个原因,当人们请求分配一个 1 GB 的块时,即使他们有 30 GB 的可用物理内存(例如一旦您开始分配的内存大小超过典型可用内存量的 1%,通常是时候考虑避免使用一个巨大的数组来代表整个事物。

所以也许您需要做的第一件事就是重新考虑数据结构。与分配数十亿个元素的单个数组不同,您通常会通过分配较小的块(聚合在一起的较小数组)来显着降低遇到问题的几率。例如,如果您的访问模式本质上是完全顺序的,您可以使用展开列表(链接在一起的数组)。如果需要随机访问,您可以使用诸如指向数组的指针数组,每个数组跨越 4 KB。这需要更多的工作来索引一个元素,但是对于这种数十亿元素的规模,它通常是必要的。

访问模式

问题中未指定的一件事是内存访问模式。这部分对于指导您的决策至关重要。

例如,数据结构是仅按顺序遍历,还是需要随机访问?所有这些字段:abcd 是否一直都需要一起使用,或者一次可以访问一两个或三个?

让我们尝试涵盖所有可能性。在我们谈论的规模上,这是:

struct Foo {
    int a1;
    int b1;
    int c1;
    int d1
};

... 不太可能有帮助。在这种输入规模下,并且在紧密循环中访问,您的时间通常将由内存层次结构的上层(分页和 CPU 缓存)支配。关注层次结构的最低级别(寄存器和相关指令)不再那么重要。换句话说,在处理数十亿个元素时,您最不应该担心的是将此内存从 L1 高速缓存行移动到寄存器的成本以及按位指令的成本,例如(并不是说这根本不是问题,只是说它的优先级要低得多)。

在一个足够小的规模上,所有热数据都适合 CPU 缓存并且需要随机访问,这种直接的表示可以显示由于层次结构的最低级别(寄存器和说明),但它需要的输入比我们正在讨论的要小得多。

所以即使这样也可能是一个相当大的改进:

struct Foo {
    char a1;
    char b1;
    char c1;
    char d1;
};

...还有更多:

// Each field packs 4 values with 2-bits each.
struct Foo {
    char a4; 
    char b4;
    char c4;
    char d4;
};

* 请注意,您可以在上述情况下使用位域,但位域往往会根据所使用的编译器与它们相关联的警告。由于通常描述的可移植性问题,我经常小心避免它们,尽管在您的情况下这可能是不必要的。然而,当我们冒险进入下面的 SoA 和热/冷场分割领域时,我们将到达一个无论如何都不能使用位域的地步。

此代码还侧重于水平逻辑,这可以开始更容易探索一些进一步的优化路径(例如:将代码转换为使用 SIMD),因为它已经是微型 SoA 形式。

数据“消费”

尤其是在这种规模下,尤其是当您的内存访问本质上是顺序的时,从数据“消耗”的角度思考(机器可以多快加载数据、执行必要的算术和存储结果)。我觉得有用的一个简单的心理形象是把电脑想象成有一个“大嘴巴”。如果我们一次向它提供足够大的一勺数据,而不是很小的茶匙,并且将更多相关数据紧密地打包成一勺连续的数据,它会变得更快。

热/冷场分割

到目前为止,上面的代码假设所有这些字段都同样热(经常访问),并且一起访问。您可能有一些冷字段或仅在关键代码路径中成对访问的字段。假设您很少访问cd,或者您的代码有一个访问ab 的关键循环,以及另一个访问cd 的关键循环。在这种情况下,将其拆分为两个结构会很有帮助:

struct Foo1 {
    char a4; 
    char b4;
};
struct Foo2 {
    char c4;
    char d4;
};

再次,如果我们正在“输入”计算机数据,并且我们的代码目前只对 ab 字段感兴趣,我们可以将更多内容打包到 ab 字段中,如果我们有连续的块只包含 ab 字段,而不是 cd 字段。在这种情况下,cd 字段将是计算机目前无法消化的数据,但它会混合到ab 字段之间的内存区域中。如果我们希望计算机尽可能快地使用数据,我们现在应该只为其提供感兴趣的相关数据,因此在这些场景中拆分结构是值得的。

用于顺序访问的 SIMD SoA

转向矢量化并假设顺序访问,计算机可以使用数据的最快速率通常是使用 SIMD 并行处理。在这种情况下,我们最终可能会得到这样的表示:

struct Foo1 {
    char* a4n;
    char* b4n;
};

... 仔细注意对齐和填充(对于 AVX,大小/对齐应该是 16 或 32 字节的倍数,对于未来的 AVX-512 甚至是 64),这是使用更快对齐移动到 XMM/YMM 寄存器所必需的(并可能在未来使用 AVX 指令)。

用于随机/多字段访问的 AoSoA

不幸的是,如果ab 经常一起访问,尤其是在随机访问模式下,上述表示可能会开始失去很多潜在的好处。在这种情况下,更优化的表示可以开始如下所示:

struct Foo1 {
    char a4x32[32];
    char b4x32[32];
};

...我们现在正在聚合这个结构。这使得 ab 字段不再分散,允许 32 个 ab 字段组适合单个 64 字节高速缓存行并一起快速访问。我们现在还可以将 128 或 256 个 ab 元素放入 XMM/YMM 寄存器中。

分析

通常我会尽量避免在性能问题中提出一般性的建议,但我注意到这个似乎避免了手头有分析器的人通常会提到的细节。因此,如果这有点傲慢,或者如果分析器已经被积极使用,我深表歉意,但我认为这个问题值得这一节。

作为轶事,我经常在优化生产代码方面做得更好(我不应该!)由比我更了解计算机架构的人编写的生产代码(我与很多来自打孔卡时代,可以一眼看懂汇编代码),并且经常被要求优化他们的代码(这感觉很奇怪)。原因很简单:我“作弊”并使用了分析器(VTune)。我的同龄人通常不这样做(他们对此过敏,并认为他们对热点的了解与分析器一样,并认为分析是浪费时间)。

当然,理想的做法是找一个既具备计算机架构专业知识又拥有分析器的人,但缺少其中之一,分析器可以提供更大的优势。优化仍然奖励一种生产力思维方式,这种思维方式取决于最有效的优先级,而最有效的优先级是优化真正最重要的部分。分析器为我们提供了准确的时间和地点的详细细分,以及有用的指标,如缓存未命中和分支错误预测,即使是最先进的人类通常也无法预测接近分析器所揭示的准确度。此外,分析通常是通过追踪热点并研究它们存在的原因来发现计算机体系结构如何以更快的速度工作的关键。对我来说,分析是更好地理解计算机体系结构实际工作方式的最终切入点,而不是我想象中的工作方式。直到那时,像Mysticial 这样在这方面经验丰富的人的著作才开始变得越来越有意义。

界面设计

这里可能开始变得明显的一件事是有许多优化的可能性。这类问题的答案将是关于策略,而不是绝对的方法。在您尝试某些东西之后,事后仍然需要发现很多东西,并且仍然会根据您的需要不断迭代以获得越来越多的最佳解决方案。

在复杂的代码库中,其中一个困难是在界面中留出足够的喘息空间来试验和尝试不同的优化技术,以迭代和迭代以获得更快的解决方案。如果界面为寻求这些优化留出了空间,那么我们可以整天优化,并且即使我们以试错的心态正确地衡量事物,也经常会得到一些惊人的结果。

要经常在实现中留出足够的喘息空间来进行实验和探索更快的技术,通常需要接口设计以批量的形式接受数据。如果接口涉及间接函数调用(例如:通过 dylib 或函数指针)而内联不再是一种有效的可能性,则尤其如此。在这种情况下,在不破坏级联接口的情况下留出优化空间通常意味着设计时要摒弃接收简单标量参数的思维方式,转而将指针传递给整个数据块(如果存在各种交错可能性,可能会大步前进)。因此,尽管这涉及到一个相当广泛的领域,但这里优化的许多首要任务将归结为留出足够的喘息空间来优化实现,而无需在整个代码库中进行级联更改,并且手头有一个分析器来指导您正确的方式。

TL;DR

无论如何,其中一些策略应该可以帮助您正确引导。这里没有绝对的东西,只有指南和要尝试的东西,并且总是最好用手中的分析器完成。然而,在处理如此庞大的数据时,总是值得记住饥饿怪物的形象,以及如何最有效地为它提供这些大小合适且包装好的相关数据。

【讨论】:

  • 文笔优美,抽象而简洁 - 我希望所有 SO 的答案都能这么好。谢谢艾克!
  • 我现在才注意到这个答案。谢谢。
【解决方案2】:

我做了一段时间的视频解压。最快的做法是这样的:

short ABCD; //use a 16 bit data type for your example

并设置一些宏。也许:

#define GETA ((ABCD >> 12) & 0x000F)
#define GETB ((ABCD >> 8) & 0x000F)
#define GETC ((ABCD >> 4) & 0x000F)
#define GETD (ABCD  & 0x000F)  // no need to shift D

实际上,您应该尝试移动 32 位长或 64 位长,因为这是大多数现代处理器的原生 MOVE 大小。

使用结构总是会在编译代码中产生从结构基地址到字段的额外指令的开销。所以如果你真的想收紧你的循环,那就远离它吧。

编辑: 上面的示例为您提供了 4 位值。如果你真的只需要 0..3 的值,那么你可以做同样的事情来提取你的 2 位数字,所以,GETA 可能看起来像这样:

GETA ((ABCD >> 14) & 0x0003)

如果你真的要移动数十亿的东西,我不怀疑,只需填充一个 32 位变量,然后移动并掩盖你的方式。

希望这会有所帮助。

【讨论】:

  • 你确定宏能比函数提高性能吗?
【解决方案3】:

回到问题:

用于紧密循环;

它们的值每秒被读取十亿次,这就是程序的瓶颈;

整个程序由数十亿个 Foos 组成;

这是一个典型的例子,说明您应该编写特定于平台的高性能代码,需要花时间为每个实现平台进行设计,但收益大于成本。

由于它是整个程序的瓶颈,因此您无需寻找通用解决方案,而是认识到这需要针对真实数据测试多种方法并定时,因为最佳解决方案将是特定于平台的 em>。

也有可能,因为它是十亿个 foo 的大数组,OP 应该考虑使用 OpenCLOpenMP 作为潜在的解决方案,以便最大限度地利用运行时硬件上的可用资源。这有点取决于您需要从数据中得到什么,但这可能是这类问题最重要的方面——如何利用可用的并行性。

但这个问题没有唯一的正确答案,IMO。

【讨论】:

    【解决方案4】:

    如果您追求的是空间效率,那么您应该考虑完全避免使用structs。编译器将根据需要将填充插入到您的结构表示中,以使其大小成为其对齐要求的倍数,这可能多达 16 个字节(但更有可能是 4 或 8 个字节,并且毕竟可能小到1 个字节)。

    如果你仍然使用结构,那么使用哪个取决于你的实现。如果@dbush 的位域方法产生单字节结构,那么它很难被击败。但是,如果您的实现无论如何都要将表示填充到至少四个字节,那么这可能是要使用的:

    struct Foo {
        char a;
        char b;
        char c;
        char d;
    };
    

    或者我想我可能会使用这个变体:

    struct Foo {
        uint8_t a;
        uint8_t b;
        uint8_t c;
        uint8_t d;
    };
    

    由于我们假设您的结构至少占用四个字节,因此将数据打包到更小的空间是没有意义的。事实上,这会适得其反,因为它还会让处理器做额外的工作来打包和解包其中的值。

    对于处理大量数据,有效利用 CPU 缓存比避免几个整数运算要大得多。如果您的数据使用模式至少有点系统化(例如,如果在访问您以前的结构数组的一个元素后,您可能会访问下一个元素),那么您可能会通过打包来提高空间效率和速度尽可能紧密的数据。根据您的 C 实现(或者如果您想避免实现依赖),您可能需要以不同的方式实现这一点——例如,通过整数数组。对于四个字段的特定示例,每个字段都需要两位,我会考虑将每个“结构”表示为 uint8_t,每个字段总共 1 个字节。

    可能是这样的:

    #include <stdint.h>
    
    #define NUMBER_OF_FOOS 1000000000
    #define A 0
    #define B 2
    #define C 4
    #define D 6
    
    #define SET_FOO_FIELD(foos, index, field, value) \
        ((foos)[index] = (((foos)[index] & ~(3 << (field))) | (((value) & 3) << (field))))
    #define GET_FOO_FIELD(foos, index, field) (((foos)[index] >> (field)) & 3)
    
    typedef uint8_t foo;
    
    foo all_the_foos[NUMBER_OF_FOOS];
    

    字段名称宏和访问宏提供了一种比直接操作数组更易读且可调整的方式来访问各个字段(但请注意,这些特定的宏会多次评估其某些参数) .每一位都被使用,通过单独选择数据结构为您提供尽可能好的缓存使用率。

    【讨论】:

      【解决方案5】:

      对于不会导致大量读取开销的密集打包,我建议使用带有位域的结构。在您的示例中,您有四个从 0 到 3 的值,您可以按如下方式定义结构:

      struct Foo {
          unsigned char a:2;
          unsigned char b:2;
          unsigned char c:2;
          unsigned char d:2;
      }
      

      它的大小为 1 字节,可以简单地访问字段,即foo.afoo.b 等。

      通过使你的结构更密集,这应该有助于提高缓存效率。

      编辑:

      总结一下cmets:

      位域仍然存在一些问题,但它是由编译器完成的,并且很可能比您手动编写的更有效(更不用说它使您的源代码更简洁,更不容易引入错误) .鉴于您将要处理大量的结构,通过使用这样的打包结构减少缓存未命中可能会弥补结构强加的位操作开销。

      【讨论】:

      • 根据编译器的不同,位域通常会引入“读取开销”——唯一的区别是编译器生成的是位摆弄代码,而不是程序员手动生成,并且编译器能够针对特定的目标机器进行更好的调整。
      • 我最喜欢这种方法,但我肯定会进行一些分析和测试,以确保不会对速度造成太大影响。
      • 使用“billions of Foos”可能会在缓存效率方面提高性能,缓存未命中比对齐位的少数指令要昂贵得多
      • 建立在这一点上:单个缓存未命中通常可能花费大约 200 个周期。即使是 L1/L2 未命中 L3 缓存也会花费我认为大约 20 个周期。如果您有 1% 的时间缓存丢失,那么您的大部分时间都花在了缓存丢失上。另一方面,如果提取多个自变量,处理器可能能够在每个周期中退出多条指令,那么进行位旋转的额外周期可能具有低于表观的成本。
      【解决方案6】:

      将数据集放入缓存中至关重要。越小越好,因为超线程竞争性地在硬件线程之间共享每核缓存(在 Intel CPU 上)。对this answer 的评论包括一些关于缓存未命中成本的数字。

      在 x86 上,将带符号或零扩展名的 8 位值加载到 32 位或 64 位寄存器(movzxmovsx)实际上与普通的 mov 字节或32 位双字。存储 32 位寄存器的低字节也没有开销。 (参见 Agner Fog 的 instruction tables and C / asm optimization guides here)。

      仍然是特定于 x86 的:[u]int8_t 临时对象也可以,但请避免使用 [u]int16_t 临时对象。 (在内存中从/到[u]int16_t 加载/存储是可以的,但是在寄存器中使用 16 位值会因 Intel CPU 上的操作数大小前缀解码缓慢而受到很大的惩罚。)如果您想将 32 位临时对象用作数组索引。 (使用 8 位寄存器不会将高 24/56 位归零,因此需要额外的指令归零或符号扩展,将 8 位寄存器用作数组索引,或者在具有更广泛类型的表达式中(例如将其添加到一个int。)

      我不确定 ARM 或其他体系结构可以从单字节加载或单字节存储中进行有效的零/符号扩展。

      鉴于此,我的建议是打包存放,使用int 临时存放。 (或 long,但这会在 x86-64 上略微增加代码大小,因为需要 REX 前缀来指定 64 位操作数大小。)例如

      int a_i = foo[i].a;
      int b_i = foo[i].b;
      ...;
      foo[i].a = a_i + b_i;
      

      位域

      打包成位域会产生更多开销,但仍然值得。在一个字节或 32/64 位内存块中测试编译时常量位位置(或多个位)很快。如果您确实需要将一些位域解压缩到ints 并将它们传递给非内联函数调用或其他东西,则需要一些额外的指令来移位和屏蔽。如果这甚至可以减少缓存未命中的一小部分,那么这是值得的。

      使用ORAND 可以有效地测试、设置(为1)或清除(为0)一个位或一组位,但是将未知的布尔值分配给位域takes more instructions 以合并新位与其他字段的位。如果您经常将变量分配给位域,这可能会使代码显着膨胀。所以在你的结构中使用int foo:6 和类似的东西,因为你知道foo 不需要前两位,不太可能有帮助。如果与将每件事放在自己的字节/短/整数中相比,您没有节省很多位,那么缓存未命中的减少不会超过额外的指令(这可能会增加 I-cache / uop-cache 未命中,以及指令的直接额外延迟和工作。)

      x86 BMI1 / BMI2 (Bit-Manipulation) instruction-set extensions 将使copying data from a register into some destination bits(不破坏周围位)更高效。 BMI1:哈斯韦尔,打桩机。 BMI2:Haswell,挖掘机(未发布)。请注意,与 SSE/AVX 一样,这意味着您需要函数的 BMI 版本,以及不支持这些指令的 CPU 的备用非 BMI 版本。 AFAIK,编译器没有选项来查看这些指令的模式并自动使用它们。它们只能通过内在函数(或 asm)使用。

      Dbush's answer,打包成位域可能是一个不错的选择,这取决于您如何使用域。您的第四个选项(将四个单独的 abcd 值打包到一个结构中)可能是一个错误,除非您可以对四个连续的 abcd 值(向量样式)做一些有用的事情。

      通用代码,尝试两种方式

      对于您的代码广泛使用的数据结构,进行设置以便您可以从一种实现切换到另一种实现并进行基准测试是有意义的。 Nir Friedman's answer, with getters/setters 是个好方法。但是,仅使用 int 临时变量并将字段作为结构的单独成员使用应该可以正常工作。由编译器生成代码来测试字节的正确位,用于打包位域。

      准备 SIMD(如果需要)

      如果您有任何代码只检查每个结构的一个或几个字段,尤其是。循环遍历顺序结构值,然后the struct-of-arrays answer given by cmaster 将很有用。 x86 向量指令以单个字节作为最小粒度,因此每个值都位于单独字节中的数组结构可以让您使用PCMPEQB / PTEST 快速扫描a == something 所在的第一个元素。

      【讨论】:

      • 我仍然不确定我是否遵循单独的 abcd 推理。即使abcd 值很可能被一起访问,这是否适用?也就是说,如果我访问了b,我可能很快就会阅读cd。顺便说一句,答案很好。
      • 如果您在访问b 后通常需要cd,那么将它们存储在一起是可行的方法。唯一的例外是,如果您按顺序访问它们,并且可以对向量做一些巧妙的事情(例如,如果您可以进行打包比较以找到 Foos 其中c > d)。如果您按顺序访问Foos 数组,那么它是abcds 的一个流还是4 个单独的流都没有关系。最近的 Intel CPU 中的硬件预取器可以跟踪大约 10 个单独的内存流。
      • 您链接到我的答案,但将其称为“dstark 的答案”。你说的是哪一个?
      • REX 前缀会将指令大小增加一个字节,但是您将能够一次处理两倍数量的值。但是,如果 OP 可以像这样一次对元素执行多个类似的操作,那么 SIMD 是更好的选择。 AVX2 或 AVX512 将大大增加一个指令中可以做的事情的数量
      • @LưuVĩnhPhúc:我在谈论将单个字段解压缩为 32 位(或 64 位)临时文件。我同意 SSE/AVX 将是比 SIMD-within-a-(gp)-register 更好的选择。
      【解决方案7】:

      您已经陈述了常见且模棱两可的 C/C++ 标记。

      假设 C++,将数据设为私有并添加 getter/setter。 不,这不会导致性能下降 - 前提是优化器已打开。

      然后,您可以更改实现以使用替代方案,而无需更改调用代码 - 因此更容易根据基准测试的结果优化实现。

      作为记录,根据您的描述,我希望struct 与@dbush 中的位字段最有可能是最快的。

      请注意,所有这些都是围绕将数据保存在缓存中 - 您可能还想看看调用算法的设计是否可以帮助解决这个问题。

      【讨论】:

        【解决方案8】:

        Foo 有 4 个值,a、b、c、d,范围从 0 到 3。通常我不会 关心,但有时,这些结构......

        还有另一种选择:由于值 0 ... 3 可能表示某种状态,您可以考虑使用“标志”

        enum{
          A_1 = 1<<0,
          A_2 = 1<<1,
          A_3 = A_1|A_2,
          B_1 = 1<<2,
          B_2 = 1<<3,
          B_3 = B_1|B_2, 
          C_1 = 1<<4,
          C_2 = 1<<5,
          C_3 = C_1|C_2,
          D_1 = 1<<6,
          D_2 = 1<<7,
          D_3 = D_1|D_2,
          //you could continue to  ... D7_3 for 32/64 bits if it makes sense
        }
        

        这与在大多数情况下使用位域没有太大区别,但可以大大减少您的条件逻辑。

        if ( a < 2 && b < 2 && c < 2 && d < 2) // .... (4 comparisons)
        //vs.
        if ( abcd & (A_2|B_2|C_2|D_2) !=0 ) //(bitop with constant and a 0-compare)
        

        根据您将对数据执行的操作类型,使用 4 或 8 组 abcd 并根据需要用 0 填充结尾可能是有意义的。这可以允许最多 32 个比较被替换为 bitop 和 0-compare。 例如,如果您想在 64 位变量中的所有 8 组 4 上设置“1 位”,您可以执行 uint64_t abcd8 = 0x5555555555555555ULL; 然后设置您可以执行的所有 2 位 abcd8 |= 0xAAAAAAAAAAAAAAAAULL; 现在将所有值设为 3


        附录: 进一步考虑,您可以使用联合作为您的类型,或者使用 char 和@dbush 的位域进行联合(这些标志操作仍然适用于无符号字符),或者对每个 a、b、c、d 和联合使用 char 类型他们用无符号整数。这将允许紧凑的表示和高效的操作,具体取决于您使用的联合成员。

        union Foo {
          char abcd; //Note: you can use flags and bitops on this too
          struct {
            unsigned char a:2;
            unsigned char b:2;
            unsigned char c:2;
            unsigned char d:2;
          };
        };
        

        甚至进一步扩展

        union Foo {
          uint64_t abcd8;  //Note: you can use flags and bitops on these too
          uint32_t abcd4[2];
          uint16_t abcd2[4];
          uint8_t  abcd[8];
          struct {
            unsigned char a:2;
            unsigned char b:2;
            unsigned char c:2;
            unsigned char d:2;
          } _[8];
        };
        union Foo myfoo = {0xFFFFFFFFFFFFFFFFULL};
        //assert(myfoo._[0].a == 3 && myfoo.abcd[0] == 0xFF);
        

        这种方法确实会引入一些字节顺序差异,如果您使用联合来覆盖其他方法的任何其他组合,这也会是一个问题。

        union Foo {
          uint32_t abcd;
          uint32_t dcba; //only here for endian purposes
          struct { //anonymous struct
            char a;
            char b;
            char c;
            char d;
          };
        };
        

        您可以使用不同的联合类型和算法进行试验和衡量,以查看联合的哪些部分值得保留,然后丢弃无用的部分。您可能会发现同时对多个 char/short/int 类型进行操作会自动针对 AVX/simd 指令的某种组合进行优化,而使用位域则不会,除非您手动展开它们...在您 测试之前无法知道并测量它们。

        【讨论】:

          【解决方案9】:

          ints 编码

          将字段视为ints。

          blah.x 在您的所有代码中,除了声明将是您将要做的一切。整体提升会处理大多数情况。

          完成后,拥有 3 个等效的包含文件:一个使用 ints 的包含文件,一个使用 char 和一个使用位域。

          然后配置文件。在这个阶段不要担心它,因为它过早的优化,除了你选择的包含文件之外什么都不会改变。

          【讨论】:

            【解决方案10】:

            我认为唯一真正的答案是通用地编写您的代码,然后用所有这些来分析整个程序。我认为这不会花费太多时间,尽管它可能看起来有点尴尬。基本上,我会这样做:

            template <bool is_packed> class Foo;
            using interface_int = char;
            
            template <>
            class Foo<true> {
                char m_a, m_b, m_c, m_d;
             public: 
                void setA(interface_int a) { m_a = a; }
                interface_int getA() { return m_a; }
                ...
            }
            
            template <>
            class Foo<false> {
              char m_data;
             public:
                void setA(interface_int a) { // bit magic changes m_data; }
                interface_int getA() { // bit magic gets a from m_data; }
            }
            

            如果您只是像这样编写代码而不是公开原始数据,那么切换实现和配置文件将很容易。函数调用将被内联并且不会影响性能。请注意,我只是写了 setA 和 getA 而不是返回引用的函数,这实现起来比较复杂。

            【讨论】:

              【解决方案11】:

              假设您有一条稍旧的内存总线,可以提供 10 GB/s 的速度。现在以 2.5 GHz 的 CPU 为例,您会发现每个周期至少需要处理四个字节才能使内存总线饱和。因此,当您使用

              的定义时
              struct Foo {
                  char a;
                  char b;
                  char c;
                  char d;
              }
              

              并在每次传递数据时使用所有四个变量,您的代码将受 CPU 限制。你无法通过更密集的包装获得任何速度。

              现在,当每次传递仅对四个值之一执行微不足道的操作时,情况就不同了。在这种情况下,最好使用数组结构:

              struct Foo {
                  size_t count;
                  char* a;    //a[count]
                  char* b;    //b[count]
                  char* c;    //c[count]
                  char* d;    //d[count]
              }
              

              【讨论】:

              • 我不确定我是否遵循数组结构示例。您的意思是将每个 Foo 的数据存储在一个结构中吗?这不会导致更多的缓存未命中,因为现在“关闭”数据(即来自同一个 Foo 的 ab)位于遥远的地方?
              • @Viclib:是的,你已经正确理解了布局。如果您访问顺序的Foos,但只需要ab,而不需要其他字段,那么使用这种数组结构方法是一个胜利。这允许像c[i] = a[i] + b[i] 这样的东西用向量指令来实现。例如x86 可以执行两个 128b 加载和一个 PADDB
              • @cmaster:“你无法通过更密集的包装获得任何速度。”这仅适用于顺序访问,并且系统中的其他内核不使用任何内存带宽。对于随机访问(您可能再次触摸一个元素并发现它仍在缓存中),将更多元素放入相同的缓存大小很重要。
              • 有一种叫做field splittingstructure splitting的技术,将结构/对象中访问频率更高的字段拆分为一个单独的对象和另一个冷字段,以实现更高的缓存利用率。 Intel Profiler 能够做到这一点,但我现在找不到这篇文章
              【解决方案12】:

              最高效的,性能/执行,是使用处理器的字长。不要让处理器执行额外的打包或拆包工作。

              一些处理器有不止一种有效尺寸。许多 ARM 处理器可以在 8/32 位模式下运行。这意味着处理器针对处理 8 位数量或 32 位数量进行了优化。对于这样的处理器,我建议使用 8 位数据类型。

              您的算法与效率有很大关系。如果您正在移动数据或复制数据,您可能需要考虑一次移动 32 位数据(4 个 8 位数量)。这里的想法是减少处理器的获取次数。

              为了提高性能,编写代码以利用寄存器,例如使用更多的局部变量。从内存中获取寄存器比直接使用寄存器更昂贵。

              最重要的是,检查您的编译器优化设置。将编译设置为最高性能(速度)设置。接下来,生成函数的汇编语言列表。查看清单以了解编译器如何生成代码。调整您的代码以提高编译器的优化能力。

              【讨论】:

              • 不能做出这样的笼统陈述;有时,打包/解包的额外工作可以通过减少缓存未命中来获得回报。
              • 在 x86 上,零或符号将 8 位负载扩展到 32 位临时文件是免费的。存储 32 位寄存器的低 8 位也是如此。在存储数组中使用小类型,但在临时数组中使用 int。
              • 使用局部变量的好建议,以确保编译器不会不断地从内存中重新加载相同的值,因为它不知道哪些指针可能会相互别名。复制整个结构应该会生成高效的代码,但您应该检查一下。我想我见过 gcc 发出的代码一次复制一个字段。
              【解决方案13】:

              首先,准确定义“最有效”的含义。最佳内存利用率?最佳性能?

              然后以两种方式实现您的算法,并在交付后在您打算运行它的实际条件下,在您打算运行它的实际硬件上对其进行实际分析。

              选择更符合您对“最有效”的原始定义的那个。

              其他的只是猜测。无论您选择什么都可能正常工作,但如果不实际测量在您使用该软件的确切条件下的差异,您将永远不知道哪种实现会“更有效”。

              【讨论】:

              • 我认为可以对某些事情做出相当准确的预测,例如将每个字段存储在单独的int32_t 中会比将每个字段存储在自己的int8_t 中要慢。 x86 上的开销为零,其他答案说使用 8 位值在 ARM 上是有效的。我认为这并不能真正回答这个问题。显然,您可以尝试所有方法然后进行基准测试,但有时如果您可以自信地排除您有充分理由相信会变慢的选项,您可以节省开发时间。
              • @PeterCordes 编译器有时可能仍需要对 int8_t 进行符号/零扩展,即使在 x86 上也是如此,尽管在 ARM 或其他 RISC 架构上会发生更多
              • @LưuVĩnhPhúc:作为 x86 负载的一部分,零/符号扩展完全没有开销。 (movzx/movsz 的成本与 mov 负载相同)。如果您使用 int8_t 本地临时作为数组索引,或者将其用作具有更广泛类型的表达式,您只会获得开销。有关详细信息,请参阅我的答案。
              • @PeterCordes 否,在 x86 上,如果您想在 eax 上进行算术运算,则必须将 ax/al 扩展为 eax。在 ARM 上,负载会自动扩展它。但是例如对于像char c = ...; int x = somevalue; y = c + x; 这样的一些代码,如果 c 之前没有从内存中加载而是一些中间表达式的结果,则 c 需要在两个平台上进行符号扩展
              • @LưuVĩnhPhúc:对。不要使用 8 位本地临时对象(除非您希望它们用于 8 位 int 溢出)。仅在 RAM 中使用 8 位值。您的示例是在具有更广泛类型的表达式中使用 char 的情况,这是我说 x86 有开销的情况之一。如果c 是您从char 字段加载的int,则不会有此开销(因为编译器将使用movsx 而不是mov,无需额外费用。)我对此进行了更详细的解释在我的回答中。 (除非我说不清楚。如果我的答案可以改进,请告诉我。)
              【解决方案14】:

              没有明确的答案,您也没有提供足够的信息来做出“正确”的选择。需要权衡取舍。

              您关于“主要目标是时间效率”的说法是不充分的,因为您没有指定 I/O 时间(例如从文件中读取数据)是否比计算效率更受关注(例如,某些设置需要多长时间)在用户点击“开始”按钮后进行的计算)。

              因此,可能适合将数据写入单个字符(以减少读取或写入时间),但将其解压缩为四个 int 的数组(这样后续计算会更快)。

              此外,不能保证 int 是 32 位(您在声明中假设第一个打包使用 128 位)。 int 可以是 16 位。

              【讨论】:

              • (回答你的问题,时间效率我的意思是“用户点击 Go 按钮后计算需要多长时间”。程序是一个黑盒,你点击播放,它会不断移动位,直到找到一个答案。)
              • 解包到int 的数组不太可能有用。不过,在使用前将单个字段加载到本地 temp int 变量是一个好主意,除非您需要 8 位整数溢出。
              【解决方案15】:

              仅在考虑空间时才打包它们 - 例如,包含 1,000,000 个结构的数组。否则,执行移位和屏蔽所需的代码将大于为数据节省的空间。因此,与 D-cache 相比,I-cache 更有可能发生缓存未命中。

              【讨论】:

              • 简单明了的回答。这通常是位域的黄金法则。
              • 这是常识,我发现通常是不正确的。 stackoverflow.com/questions/16738579/…。如果您使用eratosthenes 筛子查看基准,您会发现vector 在任何不完全适合缓存的大小下都轻松击败vector。引用 Walter Bright 的话:测量可以帮助您获得优秀到无法测量的专家的支持。
              • 正如我所说,如果数据大小是一个问题,请打包它们。我遇到的问题是打包单个结构的人 - 执行读取-修改写入的代码最终比节省的空间大得多。您链接到的测试是在 1 亿个元素上进行的。
              • 你写的东西听起来完全像你在说:只有当你想节省空间时才打包,而不是:如果它真的很大,打包它以节省时间。用户确实说他有数十亿个结构,但我猜他是在你回答后添加的。
              • 大多数处理器都有一个与“load int”同时执行的“load byte”指令,所以第二种形式(chars,没有位打包)应该总是比第一个(ints,没有位打包)。
              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2017-06-30
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2019-10-20
              • 1970-01-01
              相关资源
              最近更新 更多