【问题标题】:Optimizing Array Compaction优化数组压缩
【发布时间】:2011-12-14 18:10:11
【问题描述】:

假设我有一个数组 k = [1 2 0 0 5 4 0]

我可以如下计算掩码 m = k > 0 = [1 1 0 0 1 1 0]

仅使用掩码 m 和以下操作

  1. 左/右移动
  2. 和/或
  3. 加/减/乘

我可以将 k 压缩成以下 [1 2 5 4]

这是我目前的做法(MATLAB 伪代码):

function out = compact( in )
    d = in
    for i = 1:size(in, 2) %do (# of items in in) passes
        m = d > 0
        %shift left, pad w/ 0 on right
        ml = [m(2:end) 0] % shift
        dl = [d(2:end) 0] % shift

        %if the data originally has a gap, fill it in w/ the 
        %left shifted one
        use = (m == 0) & (ml == 1) %2 comparison  

        d = use .* dl + ~use .* d

        %zero out elements that have been moved to the left
        use_r = [0 use(1:end-1)]
        d = d .* ~use_r
    end

    out = d(1 : size(find(in > 0), 2)) %truncate the end
end

直觉

每次迭代,我们将掩码向左移动并比较掩码。如果我们发现在这个移位之后,原来是 void(mask[i] = 0) 的索引现在是有效的(mask[i] = 1),我们设置一个索引来让数据左移。

问题

上面的算法有 O(N * (3 shift + 2 comparison + AND + add + 3 multiplis))。有没有办法提高效率?

【问题讨论】:

  • 这是一个 C++ 问题吗?
  • 它与 SSE / C+ 相关 :) Array = __m256
  • 在 SSE 中获取掩码是微不足道的。打包不是...
  • 是的,上面的算法在 8 次昂贵的计算中进行了压缩:(尽管它不会分支或索引到 __m256。
  • 我们允许使用哪些版本的 SSE?数组是什么类型? (我希望是字节)

标签: algorithm matlab sse simd


【解决方案1】:

阅读原始问题下方的 cmets,在实际问题中,数组包含 32 位浮点数,掩码是(一个?)32 位整数,所以我不明白为什么要移位等用于压缩数组。简单的压缩算法(在 C 中)是这样的:

float array[8];
unsigned int mask = ...;
int a = 0, b = 0;
while (mask) {
  if (mask & 1) { array[a++] = array[b]; }
  b++;
  mask >>= 1;
}
/* Size of compacted array is 'a' */
/* Optionally clear the rest: */
while (a < 8) array[a++] = 0.0;

较小的变化可能是由于掩码的位顺序造成的,但唯一需要的 ALU 操作是索引变量更新和移位以及掩码与运算。因为原始数组至少有 256 位宽,所以普通 CPU 无法按位移动整个数组。

【讨论】:

    【解决方案2】:

    原始代码一次只移动一个数组元素。这可能会得到改善。可以对数组元素进行分组并一次将它们移动 2^k 步。

    该算法的第一部分计算每个元素应移动多少步。第二部分移动元素 - 首先一步,然后是 2,然后是 4,等等。这可以正常工作并且元素不会混合,因为每次移动后都有足够的空间来执行 2 倍大的移动。

    Matlab,代码未测试:

    function out = compact( in )
        m = in <= 0
        for i = 1:size(in, 2)-1
            m = [0 m(1:end-1)]
            s = s + m
        end
    
        d = in
        shift = 1
        for j = 1:ceil(log2(size(in, 2)))
            s1 = rem(s, 2)
            s = (s - s1) / 2
            d = (d .* ~s1) + ([d(1+shift:end) zeros(1,shift)] .* [s1(1+shift:end) zeros(1,shift)])
            shift = shift*2
        end
        out = d
    end
    

    上述算法的复杂度为 O(N * (1 shift + 1 add) + log(N) * (1 rem + 2 add + 3 mul + 2 shift))。

    【讨论】:

    • 当我的第一个答案准备好时,问题中提到了 Matlab。随之而来的是对问题的更好理解。所以我决定将此算法添加为单独的答案。该代码未经测试,可能包含一些错误,因为我没有 Matlab 经验。
    • 你有两个循环。与单循环选项相比,这肯定不会很快。
    【解决方案3】:

    假设您想要的只是在 C++ 中以最少的步骤存储数组中的正整数,这是一个示例代码:

    int j = 0;
    int arraysize = (sizeof k)/4;
    int store[arraysize];
    for(int i = 0; i<arraysize; i++)
    {
        if(k[i] > 0)
        {
            store[j] = k[i];
            j++;
        }
    }
    

    如果不想使用for循环,也可以直接使用k[]的元素。

    【讨论】:

      【解决方案4】:

      原始伪代码中没有太多需要优化的地方。我在这里看到了一些小的改进:

      • 循环可以少执行一次迭代(即 size-1),
      • 如果 'use' 为零,您可能会提前中断循环,
      • use = (m == 0) &amp; (ml == 1) 大概可以简化为use = ~m &amp; ml
      • 如果~被算作单独操作,最好使用倒置形式:use = m | ~mld = ~use .* dl + use .* duse_r = [1 use(1:end-1)]d = d .*use_r

      但是有可能发明更好的算法。而算法的选择取决于所使用的 CPU 资源:

      • 加载-存储单元,即将算法直接应用于内存字。在芯片制造商将高度并行的 SCATTER 指令添加到他们的指令集之前,这里什么都做不了。
      • SSE 寄存器,即在寄存器的整个 16 字节上工作的算法。像提议的伪代码这样的算法在这里无济于事,因为我们已经有各种 shuffle/permute 指令可以使工作更好。将各种比较指令与 PMOVMSKB 一起使用,将结果按 4 位分组并在 switch/case 下应用各种 shuffle 指令(如 LastCoder 所述)是我们能做的最好的事情。
      • 具有最新指令集的 SSE/AVX 寄存器提供了更好的方法。我们可以直接使用 PMOVMSKB 的结果,将其转换为控制寄存器,例如 PSHUFB。
      • 整数寄存器,即 GPR 寄存器或同时在 SSE/AVX 寄存器的多个 DWORD/QWORD 部分上工作(允许执行多个独立的压缩)。提议的应用于整数寄存器的伪代码允许压缩任何长度(从 2 到 20 位)的二进制子集。这是我的算法,它的性能可能会更好。

      C++,64 位,子集宽度 = 8:

      typedef unsigned long long ull;
      const ull h = 0x8080808080808080;
      const ull l = 0x0101010101010101;
      const ull end = 0xffffffffffffffff;
      
      // uncompacted bytes
      ull x = 0x0100802300887700;
      
      // set hi bit for zero bytes (see D.Knuth, volume 4)
      ull m = h & ~(x | ((x|h) - l));
      
      // bitmask for nonzero bytes
      m = ~(m | (m - (m>>7)));
      
      // tail zero bytes need no special treatment
      m |= (m - 1);
      
      while (m != end)
      {
        ull tailm = m ^ (m + 1); // bytes to be processed
        ull tailx = x & tailm; // get the bytes
        tailm |= (tailm << 8); // shift 1 byte at a time
        m |= tailm; // all processed bytes are masked
        x = (x ^ tailx) | (tailx << 8); // actual byte shift
      }
      

      【讨论】:

      • 对于 SSSE3,一种常见的技术是从 LUT 中查找 PSHUFB shuffle control mask,基于 PCMPEQD -> MOVMSKPS(对于 32 位整数元素,根据需要使用 PCMPEQB 适应较小的元素/PMOVMSKB)。
      • 对于 AVX2+BMI2,可以基于向量比较掩码,通过一些指令即时生成随机掩码(用于 VPERMD 或 VPERMPS)。 My answer on this question 有一个有效的 C++ 实现,可以编译成非常理想的 asm。
      【解决方案5】:

      因此,您需要确定额外的并行性、移位/改组开销对于这样一个简单的任务是否值得。

      for(int inIdx = 0, outIdx = 0; inIdx < inLength; inIdx++) {
       if(mask[inIdx] == 1) {
        out[outIdx] = in[inIdx];
        outIdx++;
       }
      }
      

      如果您想走并行 SIMD 路线,最好的选择是 SWITCH CASE,其中包含掩码的下 4 位的所有可能排列。为什么不是8?因为 PSHUFD 指令只能在 XMMX m128 而不是 YMMX m256 上随机播放。

      所以你做了 16 个案例:

      • [1 1 1 1], [1 1 1 0], [1 1 0 0], [1 0 0 0], [0 0 0 0] 不需要任何特殊的移位/随机播放,您只需复制输入到输出 MOVDQU,输出指针分别递增 4、3、2、1、0。
      • [0 1 1 1], [0 0 1 1], [0 1 1 0], [0 0 0 1], [0 1 0 0], [0 0 1 0] 你只需要使用 PSRLx (逻辑右移)并将输出指针分别递增 3、2、2、1、1、1
      • [1 0 0 1], [1 0 1 0], [0 1 0 1], [1 0 1 1], [1 1 0 1] 你使用 PSHUFD 来打包你的输入然后增加你的输出指针分别乘以 2、2、2、3、3。

      因此,每种情况都是最少的处理量(1 到 2 个 SIMD 指令和 1 个输出指针相加)。 case 语句的周围循环将处理常量输入指针加法(4)和 MOVDQA 以加载输入。

      【讨论】:

      • 感谢您的回答。我应该澄清直接索引到数组不是一种选择:)
      • 查找表选项是在另一个 stackoverflow 问题中提出的。 (在我的问题上链接到我的 cmets)
      • @Mike DeSimone - DQU 中的 U 代表未对齐。 LDDQU 是一个功能类似的 SSE 指令。
      猜你喜欢
      • 2017-04-15
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-04-26
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-06-30
      相关资源
      最近更新 更多