繁体   English   中英

为什么在 64 位机器上使用 uint8_t 编写的这段代码比使用 uint32_t 或 uint64_t 编写的类似代码运行得更快?

[英]Why does this piece of code written using uint8_t run faster than analogous code written with uint32_t or uint64_t on a 64bit machine?

由于隐式提升,64 位系统上的数学运算在 32/64 位数据类型上比 short 等较小的数据类型运行得更快,这不是常识吗? 然而,在测试我的位集实现时(大部分时间取决于按位运算),我发现使用 uint8_t 比 uint32_t 提高了约 40%。 我特别惊讶,因为几乎没有任何复制可以证明差异是合理的。 无论 clang 优化级别如何,都会发生同样的事情。

8位:

#define mod8(x) x&7
#define div8(x) x>>3

template<unsigned long bits>
struct bitset{
private:
    uint8_t fill[8] = {};
    uint8_t clear[8];
    uint8_t band[(bits/8)+1] = {};

public:
    template<typename T>
    inline bool operator[](const T ind) const{
        return band[div8(ind)]&fill[mod8(ind)];
    }

    template<typename T>
    inline void store_high(const T ind){
        band[div8(ind)] |= fill[mod8(ind)];
    }


    template<typename T>
    inline void store_low(const T ind){
        band[div8(ind)] &= clear[mod8(ind)];

    }
    bitset(){
        for(uint8_t ii = 0, val = 1; ii < 8; ++ii){
            fill[ii] = val;
            clear[ii] = ~fill[ii];
            val*=2;
        }
    }
};

32 位:

#define mod32(x) x&31
#define div32(x) x>>5

template<unsigned long bits>
struct bitset{
private:
    uint32_t fill[32] = {};
    uint32_t clear[32];
    uint32_t band[(bits/32)+1] = {};

public:
    template<typename T>
    inline bool operator[](const T ind) const{
        return band[div32(ind)]&fill[mod32(ind)];
    }

    template<typename T>
    inline void store_high(const T ind){
        band[div32(ind)] |= fill[mod32(ind)];
    }


    template<typename T>
    inline void store_low(const T ind){
        band[div32(ind)] &= clear[mod32(ind)];

    }
    bitset(){
        for(uint32_t ii = 0, val = 1; ii < 32; ++ii){
            fill[ii] = val;
            clear[ii] = ~fill[ii];
            val*=2;
        }
    }
};

这是我使用的基准(只是将一个 1 从 position 0 迭代地移动到最后):

const int len = 1000000;   
bitset<len> bs;

    {
        auto start = std::chrono::high_resolution_clock::now();
        bs.store_high(0);
        for (int ii = 1; ii < len; ++ii) {
            bs.store_high(ii);
            bs.store_low(ii-1);
        }
        auto stop = std::chrono::high_resolution_clock::now();
        std::cout << std::chrono::duration_cast<std::chrono::microseconds>((stop-start)).count()<<std::endl;
    }

由于隐式提升,64 位系统上的数学运算在 32/64 位数据类型上比 short 等较小的数据类型运行得更快,这不是常识吗?

这不是一个普遍的真理。 一如既往,合身取决于细节。

为什么这段使用 uint_8 编写的代码在 64 位机器上比使用 uint_32 或 uint_64 编写的类似代码运行得更快?

标题与问题不符。 没有uint_X这样的类型,您也没有使用uintX_t 您正在使用uint_fastX_t uint_fastX_t至少X 字节的 integer 类型的别名,语言实现者认为它可以提供最快的操作。

如果我们将您之前提到的假设视为理所当然,那么从逻辑上讲,语言实现者会选择使用 32/64 位类型作为uint_fast8_t 也就是说,您不能假设他们已经这样做了,并且用于做出该选择的任何通用测量(如果有的话)不一定适用于您的情况。

也就是说,无论uint_fast8_t是哪种类型的别名,您的测试对于比较可能不同的 integer 类型的相对计算速度都是不公平的:

uint_fast8_t fill[8] = {};
uint_fast8_t clear[8];
uint_fast8_t band[(bits/8)+1] = {};

uint_fast32_t fill[32] = {};
uint_fast32_t clear[32];
uint_fast32_t band[(bits/32)+1] = {};

不仅类型(可能)不同,arrays 的大小也不同。 这肯定会对效率产生影响。

TL:DR:bitset 的大“桶”意味着您在线性迭代时重复访问同一个桶,从而创建更长的依赖链,乱序执行不能有效重叠。

较小的桶提供指令级并行性,使对单独字节中的位的操作彼此独立。


可能的原因是你在位上线性迭代,所以同一个band[]元素中的所有操作形成一个&=|=操作的长依赖链,加上存储和重新加载(如果编译器没有设法优化它远离循环展开)。

对于uint32_t band[] ,这是一个 2x 32 操作链,因为ii>>5会在那么长的时间内给出相同的索引。

如果这些长链的延迟和指令数对于 ROB(重新排序缓冲区)和 RS(保留站,又名调度程序)来说太大,则乱序执行只能部分重叠这些长链的执行。 64 个操作可能包括存储/重新加载延迟(在现代 x86 上为 4 或 5 个周期),这是一个深度链长度可能为 6 x 64 = 384 个周期,可能至少由 128 uops 组成,具有加载的一些并行性(或更好地计算) 1U<<(n&31)rotl(-1U, n&31)掩码可以“用完”管道中一些浪费的执行槽。

但是对于uint8_t band[] ,你移动到一个新元素的频率是 4x ,仅经过 2x 8 = 16 次操作,所以 dep 链的长度是 1/4。

另请参阅了解 lfence 对具有两个长依赖链的循环的影响,以增加现代 x86 CPU 重叠两个长依赖链(一个简单的imul链,没有其他指令级并行性)的另一种情况的长度,尤其是部分关于单个 dep 链变得比 RS(未执行的 uops 的调度程序)更长的时间点,在这个点上我们开始失去一些独立工作执行的重叠。 (针对没有lfence人为遮挡重叠的情况。)

另请参阅现代微处理器 90 分钟指南! https://www.realworldtech.com/sandy-bridge/了解现代 OoO exec CPU 如何解码和查看指令的一些背景知识。


小桶与大桶

大桶只有在扫描第一个非零位,或填满整个东西时才有用 当然,您真的想要使用 SIMD 对其进行矢量化,一次检查 16 或 32 个字节以查看其中的任何位置是否存在非零元素。 当前的编译器将在填充整个数组的循环中为您矢量化,但不会搜索循环(或任何具有无法在第一次迭代之前计算的行程计数的东西),除了可以处理的 ICC。 回复:对位向量使用快速操作,请参阅Howard Hinnant 的文章(在vector<bool>的上下文中,这是一个有时有用的数据结构的不幸名称。)

不幸的是,C++ 通常不会让对相同数据使用不同大小的访问变得容易,除非您使用g++ -O3 -fno-strict-aliasing或类似的东西进行编译。

虽然unsigned char总是可以别名任何其他东西,所以你可以将它用于你的单位访问,只使用uintptr_t (它可能与寄存器一样宽,除了 ILP32-on-64 位 ISA)用于 init 或其他任何东西。 或者在这种情况下, uint_fast32_t在许多 x86-64 C++ 实现上都是 64 位类型,这将使它对此有用,不像通常情况下那样糟糕,当您仅使用 32 位的值范围时浪费缓存占用空间一些 CPU 上的非常数除法比较慢。

在 x86 CPU 上,字节存储自然是完全有效的,但即使在 ARM 或其他处理器上,存储缓冲区中的合并仍然可以使相邻字节 RMW 完全有效。 是否有任何现代 CPU 的缓存字节存储实际上比字存储慢? )。 而且您仍然会获得 ILP; 较慢的缓存提交仍然没有将负载耦合到如果更窄则可能独立的存储那么糟糕。 对于具有较小无序调度程序缓冲区的低端 CPU 尤其重要。

(x86 字节加载需要使用movzx进行零扩展以避免虚假依赖,但大多数编译器都知道这一点。Clang 对此鲁莽,偶尔会造成伤害。)

(彼此靠近的不同大小的访问会导致存储转发停顿,例如字节存储和与该字节重叠的unsigned long重载将有额外的延迟: What are the costs of failed store-to-load forwarding on x86?


代码审查:

在大多数 CPU 上,存储掩码数组可能比仅根据需要计算1u32<<(n&31))更糟糕。 如果你真的很幸运,一个聪明的编译器可能会管理从构造函数到基准循环的持续传播,并意识到它可以在循环内旋转或移位以生成位掩码,而不是在已经执行其他 memory 操作的循环中索引 memory .

(一些非 x86 ISA 具有更好的位操作指令并且可以廉价地实现1<<n ,尽管 x86 也可以在 2 条指令中完成,如果编译器很聪明的话。 xor eax,eax / bts eax, esi ,隐含的 BTS通过操作数大小屏蔽移位计数。但这只适用于 32 位操作数大小,而不是 8 位。没有 BMI2 shlx ,x86 变量计数移位在 Intel CPU 上以 3-uops 运行,而 1在 AMD 上。)

几乎肯定不值得同时存储fill[]clear[]常量。 一些 ISA 甚至有一个andn指令,它不能是动态操作数之一,即在一条指令中实现(~x) & y 例如,具有 BMI1 扩展名的 x86 具有andn ( gcc -march=haswell )。

此外,您的宏是不安全的:将表达式包装在()中,这样如果您使用foo[div8(x) - 1] ,运算符优先级就不会影响您。 #define div8(x) (x>>3)

但实际上,您无论如何都不应该将 CPP 宏用于此类内容。 即使在现代 C 中,也只需定义static const shift = 3; 班次计数和掩码。 在 C++ 中,在结构/类 scope 中执行此操作,并使用band[idx >> shift]或其他东西。 (当我输入ind时,我的手指想输入intidx可能是一个更好的名字。)

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM