Знай, что ты измеряешь

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

В предыдущей серии я описал как я замерял скорость работы своей библиотеки и заметил странную разницу при работе с константами и вычитанием, которой не было в нативном коде. Я даже открыл тикет на эту тему. И вот там-то в ходе обсуждения мне @mikedn напомнил интересную тему про микробенчмарки: знай, что ты тестируешь. Чем больше кода используется в бенчмарке и чем больше условий, тем больше шансов ошибиться и измерить что-нибудь не то. Например, в этом тикете про скорость коннектора, люди измеряют не столько скорость работы коннектора, сколько скорость работы сборщика мусора в .net.

Я наткнулся на похожую историю. Ниже результаты правильного бенчмарка:

Method Mean Error StdDev Q3 Scaled ScaledSD Allocated
Span 378.8 ns 6.778 ns 6.340 ns 385.2 ns 2.01 0.04 0 B
SpanConst 198.7 ns 1.255 ns 1.174 ns 199.5 ns 1.06 0.02 0 B
Pointer 235.5 ns 4.583 ns 4.501 ns 237.6 ns 1.25 0.03 0 B
C 188.1 ns 2.714 ns 2.538 ns 190.3 ns 1.00 0.00 0 B
Cpp 189.5 ns 2.896 ns 2.709 ns 191.5 ns 1.01 0.02 0 B

Здесь Span и SpanConst бенчмарки выполняют сериализацию, используя Span<T>, только второй сериализует константу, а первый - нет. Остальные называются аналогично предыдущему посту.

Давайте пока отложим SpanConst в сторону. Как мы видим, Span результат ухудшился незначительно, а вот все остальные - значительно, ровно в два раза. Почему так? Предыдущий бенчмарк сериализовал либо одно большое число, либо серию больших чисел, но все они лежат в отрезке num ∈ [1<<30 - 100, 1<<30]. А новый берёт число 99000 и на каждой итерации вычитает одну тысячу, пока не достигнет нуля. Посмотрим на код метода mp_encode_uint в msgpuck (библиотечные методы в других библиотеках выглядят аналогично):

MP_IMPL char *
mp_encode_uint(char *data, uint64_t num)
{
    if (num <= 0x7f) {
        return mp_store_u8(data, num);
    } else if (num <= UINT8_MAX) {
        data = mp_store_u8(data, 0xcc);
        return mp_store_u8(data, num);
    } else if (num <= UINT16_MAX) {
        data = mp_store_u8(data, 0xcd);
        return mp_store_u16(data, num);
    } else if (num <= UINT32_MAX) {
        data = mp_store_u8(data, 0xce);
        return mp_store_u32(data, num);
    } else {
        data = mp_store_u8(data, 0xcf);
        return mp_store_u64(data, num);
    }
}

В старом бенчмарке GCC 6.0 смог догадаться, что num ∈ [1<<30 - 100, 1<<30] даже для не-константы и выпилил все ветки, кроме одной. В .net core JIT смог это сделать только для случая с константой. В остальных случаях jit генерировал полный код для прохода всех веток. Поэтому “замедление” было на самом деле честной работой кода. А вот быстрая работа - это была аномалия, связанная с тем, что иногда компилятор выкинет лишний код, если будет уверен в его ненужности и ваша программа станет быстрее. Это следует не забывать и учитывать при проектировании бенчмарка. Например, в данном бенчмарке присутствует SpanConst метод. Его задача продемонстрировать истинность гипотезы о том, что jit и gcc выкидывают код.

Вывод из всего этого можно сделать простой: бенчмарки - это сложно, надо знать, что ты замеряешь и мерить, в том числе, и граничные условия.

comments powered by Disqus