Инструкция SSE для проверки того, является ли массив байтов нулями С#

Предположим, что у меня есть byte[] и вы хотите проверить, все ли байты нули. Для цикла это очевидный способ сделать это, и LINQ All() является причудливым способом сделать это, но высокая производительность имеет решающее значение.

Как я могу использовать Mono.Simd, чтобы ускорить проверку, если массив байтов полон нулей? Я ищу передний край, а не просто правильное решение.

Ответ 1

Лучший код представлен ниже. Другие методы и измерение времени доступны в полном источнике.

static unsafe bool BySimdUnrolled (byte[] data)
{
    fixed (byte* bytes = data) {
        int len = data.Length;
        int rem = len % (16 * 16);
        Vector16b* b = (Vector16b*)bytes;
        Vector16b* e = (Vector16b*)(bytes + len - rem);
        Vector16b zero = Vector16b.Zero;

        while (b < e) {
            if ((*(b) | *(b + 1) | *(b + 2) | *(b + 3) | *(b + 4) |
                *(b + 5) | *(b + 6) | *(b + 7) | *(b + 8) |
                *(b + 9) | *(b + 10) | *(b + 11) | *(b + 12) | 
                *(b + 13) | *(b + 14) | *(b + 15)) != zero)
                return false;
            b += 16;
        }

        for (int i = 0; i < rem; i++)
            if (data [len - 1 - i] != 0)
                return false;

        return true;
    }
}

В конце концов он был избит этим кодом:

static unsafe bool ByFixedLongUnrolled (byte[] data)
{
    fixed (byte* bytes = data) {
        int len = data.Length;
        int rem = len % (sizeof(long) * 16);
        long* b = (long*)bytes;
        long* e = (long*)(bytes + len - rem);

        while (b < e) {
            if ((*(b) | *(b + 1) | *(b + 2) | *(b + 3) | *(b + 4) |
                *(b + 5) | *(b + 6) | *(b + 7) | *(b + 8) |
                *(b + 9) | *(b + 10) | *(b + 11) | *(b + 12) | 
                *(b + 13) | *(b + 14) | *(b + 15)) != 0)
                return false;
            b += 16;
        }

        for (int i = 0; i < rem; i++)
            if (data [len - 1 - i] != 0)
                return false;

        return true;
    }
}

Измерения времени (на массиве 256 МБ):

LINQ All(b => b == 0)                   : 6350,4185 ms
Foreach over byte[]                     : 580,4394 ms
For with byte[].Length property         : 809,7283 ms
For with Length in local variable       : 407,2158 ms
For unrolled 16 times                   : 334,8038 ms
For fixed byte*                         : 272,386 ms
For fixed byte* unrolled 16 times       : 141,2775 ms
For fixed long*                         : 52,0284 ms
For fixed long* unrolled 16 times       : 25,9794 ms
SIMD Vector16b equals Vector16b.Zero    : 56,9328 ms
SIMD Vector16b also unrolled 16 times   : 32,6358 ms

Выводы:

  • Mono.Simd имеет только ограниченный набор инструкций. Я не нашел инструкций для вычисления скалярной суммы (вектора) или макс (вектор). Однако существует оператор равенства векторов, возвращающий bool.
  • Loop unrolling - мощная техника. Даже самый быстрый код сильно выгоден от его использования.
  • LINQ является нечестивым, потому что он использует делегаты из лямбда-выражений. Если вам нужна ультрасовременная производительность, то ясно, что это не путь.
  • Приведенные методы используют оценку короткого замыкания, что означает, что они заканчиваются, как только они сталкиваются с ненулевым.
  • SIMD-код в итоге был избит. Есть еще вопросы по SO, оспаривающие, действительно ли SIMD делает вещи быстрее.

Отправленный этим кодом в Peer Review, до сих пор обнаружено и исправлено 2 ошибки.

Ответ 2

Скалярная реализация обрабатывает long, которые являются 64-битными (8-байтовыми) за раз, и получает большую часть ускорения благодаря этому параллелизму, который является мощным.

Приведенный выше код SIMD/SSE использует 128-битные инструкции SIMD/SSE (16 байтов). При использовании более новых 256-битных (32-байтовых) инструкций SSE реализация SIMD происходит примерно на 10% быстрее. С инструкциями AVX/AVX2 на 512-битных (64-байтовых) в новейших процессорах реализация SIMD с их использованием должна быть еще быстрее.

    private static bool ZeroDetectSseInner(this byte[] arrayToOr, int l, int r)
    {
        var zeroVector = new Vector<byte>(0);
        int concurrentAmount = 4;
        int sseIndexEnd = l + ((r - l + 1) / (Vector<byte>.Count * concurrentAmount)) * (Vector<byte>.Count * concurrentAmount);
        int i;
        int offset1 = Vector<byte>.Count;
        int offset2 = Vector<byte>.Count * 2;
        int offset3 = Vector<byte>.Count * 3;
        int increment = Vector<byte>.Count * concurrentAmount;
        for (i = l; i < sseIndexEnd; i += increment)
        {
            var inVector  = new Vector<byte>(arrayToOr, i          );
            inVector     |= new Vector<byte>(arrayToOr, i + offset1);
            inVector     |= new Vector<byte>(arrayToOr, i + offset2);
            inVector     |= new Vector<byte>(arrayToOr, i + offset3);
            if (!Vector.EqualsAll(inVector, zeroVector))
                return false;
        }
        byte overallOr = 0;
        for (; i <= r; i++)
            overallOr |= arrayToOr[i];
        return overallOr == 0;
    }

    public static bool ZeroValueDetectSse(this byte[] arrayToDetect)
    {
        return arrayToDetect.ZeroDetectSseInner(0, arrayToDetect.Length - 1);
    }

Улучшенная версия (благодаря предложению Питера) показана в приведенном выше коде, является безопасной и была интегрирована в пакет HPCsharp nuget для ускорения на 20% с использованием 256-битных инструкций SSE.