【问题标题】:Does the order of class members affect access speed?班级成员的顺序会影响访问速度吗?
【发布时间】:2020-07-03 12:28:02
【问题描述】:

我正在编写一个应该绝对没有开销的委托库。因此,尽可能快地访问函数指针很重要。

所以我的问题是:访问速度是否取决于成员在班级中的位置?我听说最重要的成员应该是成员声明中的第一个,这对我来说很有意义,因为这意味着类的 this 指针指向与重要成员相同的地址(假设非虚拟类)。而如果重要成员位于任何其他位置,CPU 必须通过添加 this 和类布局中的偏移量来计算它的位置。

另一方面,我知道编译器将该地址表示为qword-ptr,其中包含偏移量的信息。

所以我的问题归结为:解析qword-ptr 需要恒定时间还是如果偏移量不是0 会增加?行为在不同平台上是否保持不变?

【问题讨论】:

  • 大多数常见的架构可以在不增加运行时成本的情况下添加小位移,尽管例如在 x86 上您需要更长的机器代码。
  • 是的,这很重要,因为如果您的班级成员占用两个缓存行而不是一个缓存行,您实际上是在加倍工作集

标签: c++ performance micro-optimization class-members addressing-mode


【解决方案1】:

大多数机器都有一个加载指令或寻址模式,可以包含一个小的恒定位移,而无需额外费用。

在 x86 上,[reg][reg + disp8] 的寻址模式的 8 位位移部分需要额外的 1 个字节。在类似 RISC 的机器上,例如ARM,固定宽度指令意味着加载/存储指令总是有一些位移位(可以简单地全为零来访问给定指向对象开始的指针的第一个成员)。


将最热门的成员放在类的前面,最好按大小排序以避免填充间隙 (How do I organize members in a struct to waste the least space on alignment?)希望热门成员都在同一个缓存行中。 (如果您的类/结构扩展到第二个缓存行,希望大多数时间只有第一行必须在缓存中保持热状态,从而减少工作集的占用空间。)

如果成员与对象的开头不在同一页面中,如果this 也从内存中加载,Sandybridge-family 的指针追踪优化可能会导致额外的延迟。 Is there a penalty when base+offset is in a different page than the base? 通常,它通过乐观地将寄存器值作为 TLB 的输入,将 L1d 加载使用延迟从 5 个周期减少到 4 个周期,例如 [rdi + 0..2047],但如果猜错了就必须重试。 (不是管道刷新,只是在没有快捷方式的情况下重试加载 uop。)


请注意,函数指针主要依赖于分支预测以提高效率,访问延迟仅对检查预测很重要(如果错误则开始分支恢复) .即推测执行 + 分支预测隐藏了 CPU 中具有乱序执行的延迟控制依赖关系。

【讨论】:

  • 惊人的答案!如果我理解正确,缓存行不必与我的结构对齐,所以如果我的结构中有 2 个指针,访问第二个可能已经导致缓存未命中,对吗?如果是这样,不会增加我的结构保证的对齐方式,我的结构的更大部分在同一个缓存行中吗?
  • @JackSabbath:对。在 64 位机器上,动态分配通常至少 16 字节对齐,因此前 2 个成员倾向于对齐。但在静态和自动存储中,它可能仅按其最小值 alignof() 对齐,除非您给第一个成员更多对齐,否则可能为 8。 (但是,如果它是虚拟的,则不要这样做;这会导致 vtable 指针和第一个成员之间的填充)。即使在 C++17 中,保证超过 alignof(max_align_t) 也可能很不方便,所以 除非分析实际上显示大量缓存未命中,否则我不会做任何复杂的事情。
【解决方案2】:

类成员的顺序可能影响性能,但通常不会由于偏移。因为如上所述,几乎所有架构都有带偏移量的加载/存储。对于小型结构,您需要在 x86 上多出 1 个字节,在固定宽度的 ISA 上多出 0 个字节(但即使有了额外的字节,x86 指令通常仍然比那些 ISA 中的固定 4 字节指令短)。如果结构很大,那么 x86-64 中的 4 字节位移可能还需要 4 个字节,但指令计数仍然是 1。在固定宽度的 ISA 上,您至少需要另一条指令才能获得 32 位位移,但是与缓存未命中的影响相比,偏移计算成本只是很小的,这是更改成员位置时可能导致性能下降的主要因素。

因此类成员的顺序会影响字段在缓存中的位置,并且您会希望重要成员位于缓存中并位于同一缓存行中。通常,您会将最大的热成员放在开头以避免填充。但是,如果最热门的成员很小,那么如果它们不会导致填充,则将它们移到开头可能会更好。例如

struct mystruct
{
    uint32_t extremely_hot;
    uint8_t  very_hot[4];
    void* ptr;
}

如果 ptr 不经常被访问,最好在这样的热门字段之后保留它

但移动字段并不总是更好的解决方案。在许多情况下,您可能会考虑将班级分成两份,一份用于热成员,另一份用于冷成员。事实上,我在某处读到英特尔编译器有一个特性,当运行配置文件引导优化时,它会自动将类的热成员和冷成员拆分为单独的类。可惜我现在找不到源

举个简单的例子

struct game_player
{
    int32_t id;
    int16_t positionX;
    int16_t positionY;
    int16_t health;
    int16_t attribute;
    game_player* spouse;
    time_t join_time;
};
game_player[MAX_PLAYERS];

在屏幕上渲染对象时,只有前5个字段是常用的,因此我们可以将它们拆分为一个热门类

struct game_player_hot
{
    int32_t id;
    int16_t positionX;
    int16_t positionY;
    int16_t health;
    int16_t attribute;
};
struct game_player_cold
{
    game_player* spouse;
    time_t join_time;
};
game_player_hot players_hot[MAX_PLAYERS];
game_player_cold players_cold[MAX_PLAYERS];

如果同时或以相同方式访问不同对象的相同字段,有时建议使用 SoA(数组结构)而不是 AoS(结构数组)或混合使用。例如,如果我们有一个向量列表来求和,而不是

struct my_vector
{
    uint16_t x, y, z;
    uint16_t attribute; // very rarely used
}
my_vector vectors[MAX];

我们将使用

struct my_vector
{
    uint16_t x[MAX];    // hot
    uint16_t y[MAX];    // hot
    uint16_t z[MAX];    // hot
    uint16_t attribute[MAX];
}

这样,所有维度值都保持热度并彼此靠近。现在我们也有了更简单更好的矢量化,而且还能让热点保持热度。

更多信息请阅读

【讨论】:

    猜你喜欢
    • 2014-06-05
    • 2011-11-14
    • 2011-08-09
    • 1970-01-01
    • 1970-01-01
    • 2015-05-31
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多