Не думай о секундах свысока

Анатолий 'Ин Ши' Попов bio photo By Анатолий 'Ин Ши' Попов Comment

Пообещав выпустить новый релиз коннектора к тарантулу к концу июля, я не представлял себе полный объём работ. Особенно, учитывая новую работу и прочие изменения в жизни. На сегодня могу сказать, что работы над реализацией нового пакета msgpack.spec завершены. Целью было достичь максимально возможной производительности и эффективности использования памяти: в идеале оно должно быть нулевым. В целом, я считаю, что цель достигнута в общем и целом. Дополнительной памяти никакой не выделяется, ну и при простой упаковке медленнее, чем нативный, всего в два раза. Но в фейсбуке я писал, что в 4. Давайте разберёмся, в чём фокус. Бенчмарк простой: упаковать в msgpack массив из 100 достаточно больших целых чисел через общий метод для запаковки целых чисел. Вызываемый метод обязан проверить все границы и только на последнем шаге вызвать конкретную упаковку.

C# код для бенчмарка:

private const ushort length = 100;
private const uint baseInt = 1 << 30;
private readonly byte[] _buffer = ArrayPool<byte>.Shared.Rent(short.MaxValue);
public void MsgPackSpecArrayMinus()
{
    var buffer = _buffer.AsSpan();
    var wroteSize = MsgPackSpec.WriteArray16Header(buffer, length);
    for (var i = 0; i < length; i++)
        wroteSize += MsgPackSpec.WriteInt32(buffer.Slice(wroteSize), baseInt - i);
}

С код для бенчмарка, с использованием msgpuck:

#include <msgpuck.h>
char buf[65535];
extern void serializeIntArrayMinus()
{
    const uint32_t size = 100;
    const int64_t base = 1L << 30;
    char *w = buf;
    w = mp_encode_array(w, size);
    int64_t idx = 0;
    for (; idx < size; ++idx)
        w = mp_encode_uint(w, base-idx);
}

C++ код для бенчмарка, с использованием msgpack-c:

#include <msgpack.hpp>
using namespace msgpack;
extern "C" void serializeIntArrayMinus()
{
    const uint32_t size = 100;
    const int64_t base = 1L << 30;
    sbuffer buffer;
    packer<sbuffer> pk(&buffer);
    pk.pack_array(size);
    int64_t idx = 0;
    for (; idx < size; ++idx)
        pk.pack(base-idx);
}

Результаты бенчмарков ниже. Здесь Empty - это вызов пустого метода для тестирования стоимости PInvoke. То есть нативные бенчмарки выполняются примерно за 82-84нс.

           Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | --------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:| MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
      CArrayMinus |  92.95 ns | 1.1212 ns | 0.9363 ns |  93.77 ns |   0.98 |     0.03 |       0 B |
            Empty |  10.15 ns | 0.2168 ns | 0.2028 ns |  10.33 ns |   0.11 |     0.00 |       0 B |
    CppArrayMinus |  94.17 ns | 2.0049 ns | 3.0009 ns |  95.35 ns |   1.00 |     0.04 |       0 B |

Если честно, я был разочарован. Терять в два раза - ещё ладно, хотя хотелось бы вообще не терять. @EgorBo сказал, что наверно это стоимость Span<T> и метода Slice, которые всё-таки не бесплатные. И, мол, если переписать на пойнтерах в дотнете, то будет также быстро. Ну, хорошо, давайте перепишем на пойнтерах:

[Benchmark]
public unsafe void Pointer()
{
    fixed (byte* pointer = &_buffer[0]) // bounds check, pinning of pointer
    {
        pointer[0] = DataCodes.Array16;
        Unsafe.WriteUnaligned(ref pointer[1], length);
        for (var i = 0u; i < length; i++)
        {
            pointer[3 + 5 * i] = DataCodes.UInt32;
            Unsafe.WriteUnaligned(ref pointer[3 + 5 * i + 1], baseInt);
        }
    }
}

Результаты многообещающие, всего на 25% (80 нс - нативный, а у нас 100 нс) хуже, чем нативный код. Что объясняется тем, что у нас есть проверка границ массива на взятии адреса и закрепление массива в памяти, чтобы его GC никуда не двигал при дефрагментации кучи. Не то, чтобы она должна случиться в нашем случае, но так как ГЦ и рантайм ничего не знают о нашем коде в целом, то рантайм закрепит массив в любом случае.

           Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | --------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:| MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
          Pointer |  99.37 ns | 1.1726 ns | 1.0969 ns | 100.32 ns |   1.05 |     0.03 |       0 B |

Тут я заметил, что в коде с указателями мы пишем числа как есть, а процессор у меня Intel, т.е. порядок байт - little endian. А в msgpack - порядок байт big endian. Так может быть разворачивание байт такое медленное в дотнете?

           Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | --------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:| MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
 PointerBigEndian | 103.97 ns | 1.0718 ns | 1.0026 ns | 104.85 ns |   1.13 |     0.03 |       0 B |

Разница незначительная, следовательно переходим к следующей гипотезе - Span<T> дорогой. Берём код с указателями, меняем указатели на спаны и получаем:

           Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | --------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:| MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
    SpanBigEndian | 155.05 ns | 2.3034 ns | 2.1546 ns | 157.00 ns |   1.64 |     0.05 |       0 B |   SpanLengthBigEndian | 153.94 ns | 1.6384 ns | 1.5326 ns | 155.42 ns |   1.63 |     0.05 |       0 B |
 PointerBigEndian | 103.97 ns | 1.0718 ns | 1.0026 ns | 104.85 ns |   1.13 |     0.03 |       0 B |

Ну, безусловно недешёвый. По сравнению с нативным кодом замедление - в два раза. В полтора по сравнению с кодом на указателях. Но всё-таки не в четыре, как в базовом тесте. Указывание длины немного помогает, надо будет поставить потом во все методы в библиотеки. Давайте вернём упаковывание чисел через высокоуровневый BinaryPrimitives класс. Ну, в нашем случае высокоуровневым.

                   Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | ----------------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:|
    MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
      SpanLengthBigEndian | 153.94 ns | 1.6384 ns | 1.5326 ns | 155.42 ns |   1.63 |     0.05 |       0 B |  SpanBigEndianBinaryPrimitive | 162.62 ns | 2.3434 ns | 2.1920 ns | 164.92 ns |   1.72 |     0.06 |       0 B |

Да, высокий уровень и несколько дополнительных проверок стоили нам ещё около 10 нс. Но всё ещё не 200 нс. И тут я заметил то, что в бенчмарке с указателями я сериализую basiInt, а в начальном - baseInt - i. Может ли из-за простого вычитания так просесть перфоманс? Давайте проверим.

           Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | --------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:| MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
 MsgPackSpecArray | 164.12 ns | 3.2278 ns | 3.9640 ns | 166.59 ns |   1.74 |     0.07 |       0 B |

Вуаля. А может вычитание и в нативном коде нам даст такой эффект?

                   Method |      Mean |     Error |    StdDev |        Q3 | Scaled | ScaledSD | Allocated | ----------------------------- |----------:|----------:|----------:|----------:|-------:|---------:|----------:|
    MsgPackSpecArrayMinus | 342.31 ns | 3.2070 ns | 2.8429 ns | 343.53 ns |   3.62 |     0.11 |       0 B |
         MsgPackSpecArray | 164.12 ns | 3.2278 ns | 3.9640 ns | 166.59 ns |   1.74 |     0.07 |       0 B |
                   CArray |  94.60 ns | 1.9332 ns | 2.9523 ns |  96.06 ns |   1.00 |     0.00 |       0 B |
              CArrayMinus |  92.95 ns | 1.1212 ns | 0.9363 ns |  93.77 ns |   0.98 |     0.03 |       0 B |
                 CppArray |  84.76 ns | 1.3228 ns | 1.2374 ns |  85.66 ns |   0.90 |     0.03 |       0 B |
            CppArrayMinus |  94.17 ns | 2.0049 ns | 3.0009 ns |  95.35 ns |   1.00 |     0.04 |       0 B |

Непохоже. В общем, я так и не понял, что происходит и зафайлил баг в .net core. Ну, а без этого - всё прекрасно. Всего лишь в два раза медленнее, чем нативный код. Будем надеяться, что рантайм ускорят, а баг с минусом найдут и поправят :).

comments powered by Disqus