在单臂霓虹灯寄存器中有效地将 8 位数字扩展到 12 位

Posted

技术标签:

【中文标题】在单臂霓虹灯寄存器中有效地将 8 位数字扩展到 12 位【英文标题】:Efficiently extend 8-bit numbers to 12-bits in a single arm neon register 【发布时间】:2018-04-25 22:31:05 【问题描述】:

我在霓虹灯寄存器中加载了 4 个字节。如何有效地将其转换为 12 位,例如我需要在第一个字节之后插入 4 个零位,在第二个字节之后插入 8 个零位,依此类推。例如,如果我有这 4 个十六进制字节:

01 02 03 04

It would end up with this in hex:

01 20 00 03 40

表示为一个简单的 c 函数的相同操作,该函数对代表 4 个输入字节的 32 位变量进行操作:

uint64_t expand12(uint32_t i)

    uint64_t r = (i & 0xFF);
    r |= ((i & 0x0000ff00) << 4); // shift second byte by 4 bits
    r |= ((i & 0x00ff0000) << 8); // shift third byte by 8 bits
    r |= (((uint64_t)(i & 0xff000000)) << 12); // 4th by 12
    return r;

那么,如果我在uint8x8_t neon 寄存器中有这些字节,那么在 neon 中实现相同操作的好方法是什么,以便相同的寄存器最终得到这些移位值?

请注意,如果有任何帮助,所有四个字节的前 4 位都有零。

更新: 在我的情况下,我有 4 个 uint16x8_t 寄存器,对于每个寄存器,我需要计算所有通道的总和 (vaddv_u16),然后对该总和执行 vclz_u16,然后将这四个总和组合在一个霓虹灯寄存器中,将它们分开 12 位:

uint64_t compute(uint16x8_t a, uint16x8_t b, uint16x8_t c, uint16x8_t d)

    u16 a0 = clz(vaddv(a));
    u16 b0 = clz(vaddv(b));
    u16 c0 = clz(vaddv(c));
    u16 d0 = clz(vaddv(d));
    return (a0 << 36) | (b0 << 24) | (c0 << 12) | (d0);

请注意,这是伪代码,我需要将结果保存在霓虹灯寄存器中。

如果这很重要,在我的代码中,我有一个函数可以在 4 个 uint16x8_t 寄存器中查找最大元素的索引。在该函数中,这四个寄存器是vanded,最大元素在所有通道上重复,然后结果是vorred,位掩码1&lt;&lt;15, 1&lt;&lt;14, ... 1&lt;&lt;0;然后,我对所有通道进行成对添加,其中的 clz 为我提供了每个寄存器的最大元素的索引。所有这些我都需要在元素之间插入额外的 4 个零位并存储到霓虹灯寄存器中。 C 中的示例:

void compute(uint16_t *src, uint64_t* dst)

    uint64_t x[4];
    for (int i = 0; i < 4; ++i, src+=16)
    
        int max = 0;
        for (int j = 0; j < 16; ++j)
        
            if (src[j] > src[max])
                max = j;
        
        x[i] = max;
    
    *dst = (x[0] << 36) | (x[1] << 24) | (x[2] << 12) | (x[3]);

此函数是大型函数的一部分,该函数在循环中进行数百万次计算,并且使用此函数的结果并且必须在霓虹灯寄存器中。将其视为描述算法的伪代码,如果不清楚这意味着什么:这意味着只有算法很重要,没有需要优化的加载或存储

【问题讨论】:

在带有 BMI2 的 x86-64 上,您可以使用标量 pdep 执行 32->48 位,在 Intel 上为 1 uop,但在当前 AMD 上速度较慢。在带有 NEON 的 ARM 上,我想我们可能需要字节洗牌 + 每元素移位,也许需要可变计数移位?也许 shuffle / shift / shuffle 以在相关字节的前 4 位中获得零? 也许杰克在其他问题之一中建议的位移插入有类似的东西。也许我可以做类似的事情。我会用相关信息更新问题 01 02 03 04怎么又变成01 20 00 03 40了?我从您的代码 sn-p 中看不到它,它应该执行以下操作:0x01 -> 0x01, 0x02 -> 0x20, 0x03 -> 0x0300, @987654342 @ -> 0x4000。我猜你的意思是01 20 00 03 00 40? “将其转换为 12 位”是什么意思?最后四位移动 12 得到 16 位值。 将其视为在四个字节之间插入 4 个零位。 Run the code查看结果 【参考方案1】:

您必须跳出框框思考。不要拘泥于数据类型和位宽。

uint32_t 只不过是一个由 4 个 uint8_t 组成的数组,您可以在加载时通​​过 vld4 轻松传播。

问题因此变得更易于管理。


void foo(uint32_t *pDst, uint32_t *pSrc, uint32_t length)

    length >>= 4;
    int i;
    uint8x16x4_t in, out;
    uint8x16_t temp0, temp1, temp2;

    for (i = 0; i < length; ++i)
    
        in = vld4q_u8(pSrc);
        pSrc += 16;

        temp0 = in.val[1] << 4;
        temp1 = in.val[3] << 4;
        temp1 += in.val[1] >> 4;

        out.val[0] = in.val[0] | temp0;
        out.val[1] = in.val[2] | temp1;
        out.val[2] = in.val[3] >> 4;
        out.val[3] = vdupq_n_u8(0);

        vst4q_u8(pDst, out);
        pDst += 16;
    


请注意,我省略了剩余处理,如果你展开更深,它会运行得更快。

更重要的是,我会不假思索地在汇编中编写这个函数,因为我认为编译器不会如此巧妙地管理寄存器,以至于 out.val[3] 仅在循环外被初始化为零一次。

而且我也怀疑temp1 += in.val[1] &gt;&gt; 4; 会转换为vsra,因为指令的性质是非单独的目标操作数。谁知道?

编译器很烂。


更新:好的,这里有满足您需求的代码,用汇编编写,适用于两种架构。


aarch32

vtrn.16     q0, q1
vtrn.16     q2, q3
vtrn.32     q0, q2
vtrn.32     q1, q3

vadd.u16    q0, q1, q0
vadd.u16    q2, q3, q2

adr     r12, shift_table

vadd.u16    q0, q2, q0

vld1.64     q3, [r12]


vadd.u16    d0, d1, d0
vclz.u16    d0, d0          // d0 contains the leading zeros

vmovl.u16   q0, d0

vshl.u32    q1, q0, q3

vpadal.u32  d3, d2          // d3 contains the final result


.balign 8
shift_table:
    .dc.b   0x00, 0x00, 0x00, 0x00,     0x0c, 0x00, 0x00, 0x00,     0x18, 0x00, 0x00, 0x00,     0x04, 0x00, 0x00, 0x00 // 0, 12, 24, 4

aarch64

trn1        v16.8h, v0.8h, v1.8h
trn1        v18.8h, v2.8h, v3.8h
trn2        v17.8h, v0.8h, v1.8h
trn2        v19.8h, v2.8h, v3.8h

trn2        v0.4s, v18.4s, v16.4s
trn1        v1.4s, v18.4s, v16.4s
trn2        v2.4s, v19.4s, v17.4s
trn1        v3.4s, v19.4s, v17.4s

add         v0.8h, v1.8h, v0.8h
add         v2.8h, v3.8h, v2.8h

adr     x16, shift_table

add         v0.8h, v2.8h, v0.8h

ld1         v3.2d, [x16]

mov         v1.d[0], v0.d[1]

add         v0.4h, v1.4h, v0.4h

clz         v0.4h, v0.4h                // v0 contains the leading zeros

uxtl        v0.4s, v0.4h

ushl        v0.4s, v0.4s, v3.4s

mov         v1.d[0], v0.d[1]

uadalp      v1.1d, v0.2s                // v1 contains the final result


.balign 8
shift_table:
.dc.b   0x00, 0x00, 0x00, 0x00,     0x0c, 0x00, 0x00, 0x00,     0x18, 0x00, 0x00, 0x00,     0x04, 0x00, 0x00, 0x00 // 0, 12, 24, 4

** 您可能需要在 Clang 中将 .dc.b 更改为 .byte

【讨论】:

即使是-O1 的 5 岁 gcc 也会展开循环和 moves out out.val[3] = vdupq_n_u8(0); 编译器比你想象的要聪明得多。即使 10 年前霓虹内在函数完全是垃圾,他们仍然可以很好地完成这个基本部分。不在循环之外out.val[3]will be inited once。 temp1 += in.val[1] &gt;&gt; 4 没有转换为 vsra,但 vsra 不一定更好:编译器总是知道所有的数据依赖关系和操作码时序,并且可以很好地优化它,尤其是当函数很大时。 基本上,函数的输入已经交错,如果这很重要的话,就可以在盒子外面。 @Pavel 你的问题更新真的越来越乏味了。看起来,您的最后两个问题实际上是一个问题。谁知道接下来会发生什么?你为什么不从一开始就要求一个算法同时做这两个?为什么你从来不提问题的总规模?即使是我的客户为一个功能付了几个大礼包也没有这么不体贴。 是的,我同意,对此感到抱歉 :) 当我偶然发现合并 4 个结果的实际问题并只问了那部分时,我发布的问题很少。

以上是关于在单臂霓虹灯寄存器中有效地将 8 位数字扩展到 12 位的主要内容,如果未能解决你的问题,请参考以下文章

有效地将 CPU 寄存器中的所有位设置为 1

有效地将 YMM 寄存器的低 64 位设置为常数

在 arm neon 中有效地重新洗牌和组合 16 个 3 位数字

如何在具有霓虹内在函数的多核处理器中有效地使用所有霓虹灯单元

符号从 8 位扩展到 16 位寄存器以获得 0xff00

你如何使用霓虹内在函数加载 3 个浮点数