如何优化大量整数的 C++/C 代码

10

我已经写了下面的代码。这段代码检查每个字节的第一位。如果每个字节的第一位相等于0,则将该值与前一个字节连接起来,并将结果存储在另一个变量var1中。在我的实现中,pos指向整数的字节。一个整数的类型是uint64_t,最多可以占用8个字节。

uint64_t func(char* data)
{
    uint64_t var1 = 0; int i=0;
    while ((data[i] >> 7) == 0) 
    {
        variable = (variable << 7) | (data[i]);
        i++;
    }   
   return variable; 
}

由于我要对数万亿个整数重复调用 func() 函数,所以运行速度很慢,有没有办法可以优化这段代码?

编辑:感谢 Joe Z.,这确实是 uleb128 解包的一种形式。


3
如果你有一款优秀的优化编译器,它可能会自动将其重写。告诉编译器在此模块上进行速度优化,如果它感到慷慨,还可能为您进行向量化操作。您还可以使用“inline”关键字,通知编译器您将频繁调用它,不想要调用开销。 - awiebe
@awiebe 我很担心..我的编译器没有做太多事情。即使是ugoren建议的技巧似乎只减少了200毫秒。所以,如果你有任何优化建议,那真的会帮助我。 - Rose Beck
1
我也推荐使用内联。但是不要忘记在编译器设置中启用内联的使用,并将优化调整到最大(例如,在 MSVC 中,如果您以调试模式构建,则不会进行优化以便于调试)。 - akaltar
我很好奇下面的函数是否真正起到了帮助作用。我意识到我稍微修改了数据格式,使其符合标准的uleb128。这确实有利于更快的解码,因为你总是知道当你到达一个字节时应该将它移动到哪些位,而不管其他早期的字节如何。此外,你可以一直推迟掩码操作直到最后。 - Joe Z
为什么 func 不通知使用了多少字节?我认为这种设计需要牺牲效率... - Mooing Duck
另外,你为什么要调用它数万亿次?那至少是四个TB的数据。这么大的硬盘仍然要170美元以上。 - Mooing Duck
6个回答

15

我只进行了最基本的测试;如果有任何问题,我很乐意修复。现代处理器需要将代码严重偏向易于预测的分支。此外,如果您可以安全地读取下一个10个字节的输入,通过条件分支来保护它们的读取是没有意义的。这使我写出以下代码:

// fast uleb128 decode
// assumes you can read all 10 bytes at *data safely.
// assumes standard uleb128 format, with LSB first, and 
// ... bit 7 indicating "more data in next byte"

uint64_t unpack( const uint8_t *const data )
{
    uint64_t value = ((data[0] & 0x7F   ) <<  0)
                   | ((data[1] & 0x7F   ) <<  7)
                   | ((data[2] & 0x7F   ) << 14)
                   | ((data[3] & 0x7F   ) << 21)
                   | ((data[4] & 0x7Full) << 28)
                   | ((data[5] & 0x7Full) << 35)
                   | ((data[6] & 0x7Full) << 42)
                   | ((data[7] & 0x7Full) << 49)
                   | ((data[8] & 0x7Full) << 56)
                   | ((data[9] & 0x7Full) << 63);

    if ((data[0] & 0x80) == 0) value &= 0x000000000000007Full; else
    if ((data[1] & 0x80) == 0) value &= 0x0000000000003FFFull; else
    if ((data[2] & 0x80) == 0) value &= 0x00000000001FFFFFull; else
    if ((data[3] & 0x80) == 0) value &= 0x000000000FFFFFFFull; else
    if ((data[4] & 0x80) == 0) value &= 0x00000007FFFFFFFFull; else
    if ((data[5] & 0x80) == 0) value &= 0x000003FFFFFFFFFFull; else
    if ((data[6] & 0x80) == 0) value &= 0x0001FFFFFFFFFFFFull; else
    if ((data[7] & 0x80) == 0) value &= 0x00FFFFFFFFFFFFFFull; else
    if ((data[8] & 0x80) == 0) value &= 0x7FFFFFFFFFFFFFFFull;

    return value;
}
基本思路是小值很常见(因此大多数if语句不会被执行),但是组装需要掩码的64位值可以有效地进行流水线处理。通过一个好的分支预测器,我认为上述代码应该能够工作得很好。您还可以尝试删除else关键字(而不改变其他任何内容)来查看是否有所区别。分支预测器是微妙的生物,您的数据的确切性质也很重要。如果没有其他办法,您应该能够看到else关键字在逻辑上是可选的,并且只是为了指导编译器的代码生成并为优化硬件的分支预测器行为提供一条途径。
最终,这种方法是否有效取决于您数据集的分布。如果您尝试使用此函数,我将有兴趣了解其结果。这个特定的函数专注于标准的uleb128,其中值首先发送LSB,第7位等于1表示数据继续。
虽然存在SIMD方法,但它们都不适用于7位数据。
另外,如果您可以在头文件中将其标记为inline,那么可能也会有所帮助。这完全取决于此函数被从哪些位置调用以及这些位置是否在不同的源文件中。但总的来说,在可能的情况下进行内联是强烈推荐的。

5
@ugoren说:“没有区别。0x7Full == ((unsigned long long)0x7F)。” - orlp
你甚至可以形成 0xBull - user123

5

你的代码有问题

uint64_t func(const unsigned char* pos)
{
    uint64_t var1 = 0; int i=0;
    while ((pos[i] >> 7) == 0) 
    {
        var1 = (var1 << 7) | (pos[i]);
        i++;
    }
    return var1;    
}

首先是一个小问题:i 应该是无符号的。

其次:您没有断言您不会读取超出 pos 边界的内容。例如,如果您的 pos 数组的所有值都是 0,那么您将到达 pos[size],其中 size 是数组的大小,因此您会调用未定义的行为。您应该将数组的大小传递给函数,并检查 i 是否小于此大小。

第三点:如果对于 i=0,..,k,其中 k>10pos[i] 的最高有效位等于零,则之前的工作将被丢弃(因为您将旧值推出了 var1)。

实际上,第三点帮助了我们:

uint64_t func(const unsigned char* pos, size_t size)
{
    size_t i(0);
    while ( i < size && (pos[i] >> 7) == 0 )
    {
       ++i;
    }
    // At this point, i is either equal to size or
    // i is the index of the first pos value you don't want to use.
    // Therefore we want to use the values
    // pos[i-10], pos[i-9], ..., pos[i-1]
    // if i is less than 10, we obviously need to ignore some of the values
    const size_t start = (i >= 10) ? (i - 10) : 0;
    uint64_t var1 = 0;
    for ( size_t j(start); j < i; ++j )
    {
       var1 <<= 7;
       var1 += pos[j];
    }
    return var1; 
}

总之,我们将逻辑分离并丢弃所有废弃的条目。加速取决于实际数据。如果有很多条目被丢弃,则采用这种方法可以节省很多对var1的写操作。
另外一件事:通常,如果一个函数被大量调用,最好的优化方法就是减少调用次数。也许你可以想出一个额外的条件,使得这个函数的调用变得无用。
请记住,如果您实际使用了10个值,则第一个值最终将被截断。
64位意味着有9个值以它们的完整7位信息表示,只留下一位来处理第十个值。您可能需要切换到uint128_t

我刚刚修复了代码中的一个小问题:由于您只是移动了7位而不是8位,因此可以容纳9个项目和一个位。我在这里假设 CHAR_BIT == 8,但这通常是正常的事情。如果您想要真正实现平台无关性,您可能需要更改它。 - stefan
我不明白为什么i应该是无符号的;它并没有什么特别之处,所以通常的“良好实践”是使用int。(当然,在C++中通常惯用指针。) - James Kanze
语言设计是使用int,除非没有特别强烈的理由不使用它。在这里,没有理由不使用int,所以最佳实践是使用它。对于当前的实际代码,除了(除了任何除了 int 之外的类型会向读者传递错误信息之外)没有任何实质性的区别,但是 C++ 中的无符号类型具有相当反直觉的语义,并且最好避免在算术值中使用它们。 - James Kanze
1
@JamesKanze,您能否提供一个(最新的)声明,例如面试链接等?我只是不明白为什么要使用有符号整数。无符号整数更好:没有性能损失,但增加了平台独立性(您提到了16位机器)。对于无符号类型,除法甚至可能更快。int表示“它也可能是负数”。无符号整数天生就是索引。 - stefan
1
@JamesKanze 嗯,是的,你必须小心使用无符号类型,但你只是将需要小心的时间向前移动了。unsigned本身可以用于位操作和模算术,但不是唯一的用途。作为一名经验丰富的C++程序员,我很少仅仅为此目的使用它们,也就是说,我只有一个这样的应用程序。大多数情况下,我使用它们来表示具有自然数意义的东西(例如索引、大小等)。我想这是我们意见不同的问题。 - stefan
显示剩余5条评论

4

一个小的优化方法是:

while ((pos[i] & 0x80) == 0) 

按位与通常比移位运算更快。当然,这取决于平台,并且编译器也有可能自己进行此优化。


除此之外...你能否提供一些其他的优化建议...我真的很需要。 - Rose Beck
如果现代编译器还没有做到这一点,我会感到非常震惊。 - Mooing Duck
@MooingDuck 我已经用g++4.6检查过我的机器了,它没有替换指令。也许它们的代价是相等的? - stefan
这将是我的猜测。如果优化器更好的话,它足够聪明以执行这种操作。 - Mooing Duck
在我的G++ 4.6.4上,它用pos[i]>=0的等效条件替换了整个条件。 - Mooing Duck

2

你能改变编码吗?如你所发现,使用每个字节上的一位来指示是否有另一个字节跟随,对处理效率真的很不好。

更好的方法是采用UTF-8模型,将完整int的长度编码到第一个字节中:

0xxxxxxx // one byte with 7 bits of data
10xxxxxx 10xxxxxx // two bytes with 12 bits of data
110xxxxx 10xxxxxx 10xxxxxx // three bytes with 16 bits of data
1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx // four bytes with 22 bits of data
// etc.

但是UTF-8有特殊的属性,使其与ASCII更容易区分。这会使数据膨胀,而你并不关心ASCII,所以你需要修改它,使其看起来像这样:

0xxxxxxx // one byte with 7 bits of data
10xxxxxx xxxxxxxx // two bytes with 14 bits of data.
110xxxxx xxxxxxxx xxxxxxxx // three bytes with 21 bits of data
1110xxxx xxxxxxxx xxxxxxxx xxxxxxxx // four bytes with 28 bits of data
// etc.

这个方法的压缩级别与你的方法相同(最多64位=9字节),但对CPU的处理更加容易。通过这个方法,您可以构建一个查找表,用于获取第一个字节的掩码和长度:
// byte_counts[255] contains the number of additional
// bytes if the first byte has a value of 255.
uint8_t const byte_counts[256]; // a global constant.

// byte_masks[255] contains a mask for the useful bits in
// the first byte, if the first byte has a value of 255.
uint8_t const byte_masks[256]; // a global constant.

然后进行解码:

// the resulting value.
uint64_t v = 0;

// mask off the data bits in the first byte.
v = *data & byte_masks[*data];

// read in the rest.
switch(byte_counts[*data])
{
    case 3: v = v << 8 | *++data;
    case 2: v = v << 8 | *++data;
    case 1: v = v << 8 | *++data;
    case 0: return v;
    default:
        // If you're on VC++, this'll make it take one less branch.
        // Better make sure you've got all the valid inputs covered, though!
        __assume(0);
}

无论整数的大小如何,它只会命中一个分支点:开关语句,这通常会被放入跳转表中。您可以通过不让每个情况都落空来进一步优化它,从而为指令级并行(ILP)做出贡献。

当我看到 v = v << 8 | *++data; 这行代码时,我会停下来想:“需要加上更多的括号”。我不确定操作顺序是否正确。 - Mooing Duck
1
这相当于 (v << 8) | *++data。是正确的,但为了可读性可能需要加上一些括号。 - Cory Nelson

2

你能改变编码吗?

Google遇到过同样的问题,Jeff Dean在他的演讲第55页上描述了一个非常棒的解决方案:

基本想法是,在现代架构中,读取几个字节的第一个比特位支持得很差。相反,让我们取其中8个比特位,并将它们打包成数据前面的单个字节。然后,我们使用前缀字节作为索引来查找256项查找表,该表保存描述如何从其余数据中提取数字的掩码。

我认为这就是协议缓冲区当前的编码方式。


0

首先,你可以对相关位进行按位测试,而不是进行位移。其次,你可以使用指针而不是索引(但编译器应该自动进行这种优化)。因此:

uint64_t
readUnsignedVarLength( unsigned char const* pos )
{
    uint64_t results = 0;
    while ( (*pos & 0x80) == 0 ) {
        results = (results << 7) | *pos;
        ++ pos;
    }
    return results;
}

至少,这符合您的代码所做的事情。对于无符号整数的可变长度编码,它是不正确的,因为1)可变长度编码是小端字节序,而您的代码是大端字节序,2)您的代码没有在高位字节中加入或运算。最后,维基页面建议您已经反转了测试。(我主要从BER编码和Google协议缓冲区中了解到这种格式,两者都将第7位设置为指示另一个字节将跟随。

我使用的例程是:

uint64_t
readUnsignedVarLen( unsigned char const* source )
{
    int shift = 0;
    uint64_t results = 0;
    uint8_t tmp = *source ++;
    while ( ( tmp & 0x80 ) != 0 ) {
        *value |= ( tmp & 0x7F ) << shift;
        shift += 7;
        tmp = *source ++;
    }
    return results | (tmp << shift);
}

对于其余部分,这并不是为了性能而编写的,但我怀疑你能做得更好。另一种解决方案是先获取所有字节,然后按相反的顺序处理它们:

uint64_t
readUnsignedVarLen( unsigned char const* source )
{
    unsigned char buffer[10];
    unsigned char* p = std::begin( buffer );
    while ( p != std::end( buffer ) && (*source & 0x80) != 0 ) {
        *p = *source & 0x7F;
        ++ p;
    }
    assert( p != std::end( buffer ) );
    *p = *source;
    ++ p;
    uint64_t results = 0;
    while ( p != std::begin( buffer ) ) {
        -- p;
        results = (results << 7) + *p;
    }
    return results;
}

检查缓冲区溢出的必要性可能会使其稍微慢一些,但在某些体系结构中,通过常量进行移位比通过变量进行移位快得多,因此在这些体系结构上可能更快。

然而,总体而言,不要期望奇迹。使用可变长度整数的动机是为了减少数据大小,以换取解码和编码的运行时间成本


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接