【问题标题】:Optimize blockwise bit operations: base-4 numbers优化块位操作:base-4 数字
【发布时间】:2015-11-23 04:34:20
【问题描述】:

这应该是一个有趣的问题,至少对我来说。

我的意图是操作 base-4 数字,编码为 无符号整数。然后每个两位块代表一个基数为 4 的数字,从 最低有效位开始:

01 00 11 = base4(301)

我想使用 SSE 指令优化我的代码,因为我不确定我在这里的得分如何,可能很差。

代码从字符串开始(并使用它们来检查正确性),并实现:

  • 将字符串转换为二进制
  • 二进制转字符串
  • 倒数

欢迎任何提示!

uint32_t tobin(std::string s)
{
    uint32_t v, bin = 0;

    // Convert to binary
    for (int i = 0; i < s.size(); i++)
    {
        switch (s[i])
        {
            case '0':
                v = 0;
                break;

            case '3':
                v = 3;
                break;

            case '1':
                v = 1;
                break;

            case '2':
                v = 2;
                break;

            default:
                throw "UNKOWN!";
        }

        bin = bin | (v << (i << 1));
    }

    return bin;
}

std::string tostr(int size, const uint32_t v)
{
    std::string b;

    // Convert to binary
    for (int i = 0; i < size; i++)
    {
        uint32_t shl = 0, shr = 0, q;

        shl = (3 << (i << 1));
        shr = i << 1;
        q   = v & shl;
        q   = q >> shr;

        unsigned char c = static_cast<char>(q);

        switch (c)
        {
            case 0:
                b += '0';
                break;

            case 3:
                b += '3';
                break;

            case 1:
                b += '1';
                break;

            case 2:
                b += '2';
                break;

            default:
                throw "UNKOWN!";
        }
    }

    return b;
}

uint32_t revrs(int size, const uint32_t v)
{
    uint32_t bin = 0;

    // Convert to binary
    for (int i = 0; i < size; i++)
    {
        uint32_t shl = 0, shr = 0, q;

        shl = (3 << (i << 1));
        shr = i << 1;
        q   = v & shl;
        q   = q >> shr;

        unsigned char c = static_cast<char>(q);

        shl = (size - i - 1) << 1;

        bin = bin | (c << shl);
    }

    return bin;
}

bool ckrev(std::string s1, std::string s2)
{
    std::reverse(s1.begin(), s1.end());

    return s1 == s2;
}

int main(int argc, char* argv[])
{
    // Binary representation of base-4 number
    uint32_t binr;

    std::vector<std::string> chk { "123", "2230131" };

    for (const auto &s : chk)
    {
        std::string b, r;
        uint32_t    c;

        binr = tobin(s);
        b    = tostr(s.size(), binr);
        c    = revrs(s.size(), binr);
        r    = tostr(s.size(), c);

        std::cout << "orig " << s << std::endl;
        std::cout << "binr " << std::hex << binr << " string " << b << std::endl;
        std::cout << "revs " << std::hex << c    << " string " << r << std::endl;
        std::cout << ">>> CHK  " << (s == b) << " " << ckrev(r, b) << std::endl;
    }

    return 0;
}

【问题讨论】:

  • 这段代码有效吗?您只是在寻找有关它的反馈吗?
  • 是的,它有效,是的,我正在寻找反馈和优化它的方法,包括用 SSE 重写。我不知道 CodeReview...有没有办法移动它?
  • 将此代码迁移到 SSE 不会由 IMO 的 CodeReview 处理。
  • 在代码审查时,我们可以帮助您清理现有代码,但我们不会帮助您为/使用 SSE 重写它。
  • 我也觉得这个问题被认为离题了。也许它看起来不够具体。我建议以更具体的方式重新制定它,例如“我使用整数的特殊字符串表示形式,我称之为 base4 数字(解释一下)。我想将 uint32_t 数字转换为这种表示形式,反之亦然,使用 SSE 操作.这里是当前标量代码供参考(代码)。不要忘记在您的问题中添加 simd 和 sse 标签。附:我知道二进制->字符串转换的有效解决方案。

标签: c++ optimization x86 bit-manipulation sse


【解决方案1】:

这对 SSE 来说有点挑战,因为几乎没有提供位打包(您希望从每个字符中取出两个位并将它们连续打包)。无论如何,特殊指令_mm_movemask_epi8可以帮助你。

对于字符串到二进制的转换,您可以进行如下操作:

  • 加载16个字符的字符串(如果需要,加载后用零填充或清除);

  • 按字节减去 ASCII 零。

  • 将按字节计算的“无符号大于”与 16 个“3”字节的字符串进行比较;这将在任何有无效字符的地方设置字节 0xFF

  • 使用_mm_movemask_epi8 检测打包短值中的此类字符

如果一切正常,您现在需要打包位对。为此,您需要

  • 复制 16 个字节

  • 将权重 1 和 2 的位左移 7 或 6 个位置,以使它们最重要(_mm_sll_epi16。没有 epi8 版本,但是来自一个元素的位在另一个元素的低位中变成垃圾对此并不重要。)

  • 将它们交错(_mm_unpack..._epi8,一次用 lo,一次用 hi)

  • 使用 _mm_movemask_epi8 将这两个向量的高位存储到短裤中。

对于二进制到字符串的转换,我想不出有意义的 SSE 实现,因为没有 _mm_movemask_epi8 的对应物可以让您有效地解包。

【讨论】:

  • 我在一些澄清中进行了编辑,并添加了一段关于使用 shift/mask/或将它们组合起来的段落。对于二进制->字符串,您可以将一个字节广播到向量的所有 16 个位置,并使用 AVX2 变量移位将元素 0 移位 0、元素 1 移位 2、元素 2 移位 4,等等。不幸的是,vpsrlvd仅适用于 32 和 64b 元素,因此拆包/重新打包的数量会很糟糕。
  • 可能加载 64b,然后 odd = unpacklo_epi8(zero, input); even = unpacklo_epi8(input, zero) 以获取与零混合的输入字节。然后将odd 右移 4b,然后混合奇数和偶数。现在,您的 64b 输入中的每 4b 都有自己的字节。 (如果需要,可以屏蔽 even 的高 4b)。您可以重复解包零过程以在 2 个向量的每个字节中获取 2b 个整数。或者,如果您不介意阅读 base4 数字对的 base16 表示,您可以将这些 4b 数字映射到十六进制数字。
【解决方案2】:

我将解决在 SSE 上将 32 位整数转换为 base4 字符串的问题。 不考虑去除前导零的问题,即base4字符串的长度总是16。

一般通关

显然,我们必须以矢量化形式提取比特对。 为了做到这一点,我们可以执行一些字节操作和按位操作。 让我们看看我们可以用 SSE 做什么:

  1. 单个内在 _mm_shuffle_epi8(来自 SSSE3)允许以您想要的任何方式随机播放 16 个字节。 显然,一些结构良好的混洗和寄存器混合可以使用来自 SSE2 的更简单的指令来完成, 但重要的是要记住,任何寄存器内改组都可以通过一条廉价指令完成。

  2. 混洗无助于更改字节中位的索引。 为了移动位块,我们通常使用位移位。 不幸的是,在 SSE 中没有办法将 XMM 寄存器的不同元素移动不同的数量。 正如@PeterCorder 在 cmets 中提到的,AVX2 中有这样的指令(例如_mm_sllv_epi32),但它们至少在 32 位粒度上运行。

从远古时代开始,我们就不断地被教导移位快而乘法慢。今天,算术速度如此之快,以至于不再如此。在 SSE 中,移位和乘法似乎具有相同的吞吐量,尽管乘法具有更多延迟。

  1. 使用乘以 2 的幂,我们可以将单个 XMM 寄存器的不同元素左移不同的量。有很多指令,如_mm_mulhi_epi16,允许 16 位粒度。还有一条指令_mm_maddubs_epi16 允许 8 位粒度的移位。 右移可以通过左移完成,就像人们做division via multiplication 一样:左移16-k,然后右移两个字节(回想一下,任何字节混洗都很便宜)。李>

我们实际上想要进行 16 次不同的位移。如果我们使用 16 位粒度的乘法,那么我们将必须使用至少两个 XMM 寄存器进行移位,然后它们可以合并在一起。此外,我们可以尝试使用 8 位粒度的乘法来在单个寄存器中完成所有操作。

16 位粒度

首先,我们必须将 32 位整数移动到 XMM 寄存器的低 4 字节。然后我们打乱字节,使 XMM 寄存器的每个 16 位部分包含一个输入字节:

|abcd|0000|0000|0000|   before shuffle (little-endian)
|a0a0|b0b0|c0c0|d0d0|   after shuffle (to low halves)
|0a0a|0b0b|0c0c|0d0d|   after shuffle (to high halves)

然后我们可以调用_mm_mulhi_epi16 将每个部分右移k = 1..16。实际上,将输入字节放入 16 位元素的高半部分更方便,这样我们就可以左移 k = -8..7。因此,我们希望看到 XMM 寄存器的一些字节包含定义一些 base4 数字的位对(作为它们的低位)。之后,我们可以通过_mm_and_si128 删除不必要的高位,并将有价值的字节洗牌到适当的位置。

由于 16 位粒度一次只能完成 8 次移位,因此我们必须执行两次移位部分。然后我们将两个 XMM 寄存器合二为一。

下面你可以看到使用这个想法的代码。它有点优化:位移后没有字节洗牌。

__m128i reg = _mm_cvtsi32_si128(val);
__m128i bytes = _mm_shuffle_epi8(reg, _mm_setr_epi8(-1, 0, -1, 0, -1, 1, -1, 1, -1, 2, -1, 2, -1, 3, -1, 3));
__m128i even = _mm_mulhi_epu16(bytes, _mm_set1_epi32(0x00100100));  //epi16:  1<<8,  1<<4  x4 times
__m128i odd  = _mm_mulhi_epu16(bytes, _mm_set1_epi32(0x04004000));  //epi16: 1<<14, 1<<10  x4 times
even = _mm_and_si128(even, _mm_set1_epi16(0x0003));
odd  = _mm_and_si128(odd , _mm_set1_epi16(0x0300));
__m128i res = _mm_xor_si128(even, odd);
res = _mm_add_epi8(res, _mm_set1_epi8('0'));
_mm_storeu_si128((__m128i*)s, res);

8 位粒度

当然,首先我们将 32 位整数移动到 XMM 寄存器中。然后我们打乱字节,使结果的每个字节等于包含该位置所需的两个位的输入字节:

|abcd|0000|0000|0000|   before shuffle (little-endian)
|aaaa|bbbb|cccc|dddd|   after shuffle

现在我们使用_mm_and_si128 过滤位:在每个字节中,必须保留所需的两个位。之后,我们只需将每个字节右移 0/2/4/6 位。这应该通过固有的_mm_maddubs_epi16 来实现,它允许一次移动 16 个字节。不幸的是,我看不到如何仅使用该指令正确移动所有字节,但至少我们可以将每个奇数字节向右移动 2 位(偶数字节保持原样)。然后索引为 4k+24k+3 的字节可以通过单个 _mm_madd_epi16 指令右移 4 位。

这是生成的代码:

__m128i reg = _mm_cvtsi32_si128(val);
__m128i bytes = _mm_shuffle_epi8(reg, _mm_setr_epi8(0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3));
__m128i twobits = _mm_and_si128(bytes, _mm_set1_epi32(0xC0300C03));         //epi8: 3<<0, 3<<2, 3<<4, 3<<6  x4 times
twobits = _mm_maddubs_epi16(twobits, _mm_set1_epi16(0x4001));               //epi8: 1<<0, 1<<6  x8 times
__m128i res = _mm_madd_epi16(twobits, _mm_set1_epi32(0x10000001));          //epi16: 1<<0, 1<<12  x4 times
res = _mm_add_epi8(res, _mm_set1_epi8('0'));
_mm_storeu_si128((__m128i*)s, res);

附言

两种解决方案都使用大量的编译时常数 128 位值。它们没有被编码成 x86 指令,因此处理器必须在每次使用它们时从内存(很可能是 L1 缓存)加载它们。但是,如果您要在一个循环中运行许多转换,那么编译器会在循环之前将所有这些常量加载到寄存器中(我希望如此)。

Here 你可以找到完整的代码(没有计时),包括@YvesDaoust 实现的str2bin 解决方案。

【讨论】:

    猜你喜欢
    • 2014-11-14
    • 1970-01-01
    • 2011-05-14
    • 2013-12-17
    • 1970-01-01
    • 1970-01-01
    • 2017-10-14
    • 1970-01-01
    • 2021-11-13
    相关资源
    最近更新 更多