Как оптимизировать код С++/C для большого числа целых чисел

Я написал приведенный ниже код. Код проверяет первый бит каждого байта. Если первый бит каждого байта равен 0, он объединяет это значение с предыдущим байтом и сохраняет его в другой переменной var1. Здесь pos указывает на байты целого числа. Целое число в моей реализации - uint64_t и может занимать до 8 байтов.

uint64_t func(char* data)
{
    uint64_t var1 = 0; int i=0;
    while ((data[i] >> 7) == 0) 
    {
        variable = (variable << 7) | (data[i]);
        i++;
    }   
   return variable; 
}

Так как я многократно вызываю func() триллион раз для триллионов целых чисел. Поэтому он работает медленно, есть ли способ, с помощью которого я могу оптимизировать этот код?

EDIT: Благодаря Джо Z... действительно является формой распаковки uleb128.

Ответ 1

Я только проверил это минимально; Я счастлив исправить ошибки. С современными процессорами вы хотите сильно смещать свой код в сторону легко прогнозируемых ветвей. И если вы можете спокойно прочитать следующие 10 байтов ввода, ничего не нужно сохранять, защищая их чтения условными ветвями. Это приводит меня к следующему коду:

// fast uleb128 decode
// assumes you can read all 10 bytes at *data safely.
// assumes standard uleb128 format, with LSB first, and 
// ... bit 7 indicating "more data in next byte"

uint64_t unpack( const uint8_t *const data )
{
    uint64_t value = ((data[0] & 0x7F   ) <<  0)
                   | ((data[1] & 0x7F   ) <<  7)
                   | ((data[2] & 0x7F   ) << 14)
                   | ((data[3] & 0x7F   ) << 21)
                   | ((data[4] & 0x7Full) << 28)
                   | ((data[5] & 0x7Full) << 35)
                   | ((data[6] & 0x7Full) << 42)
                   | ((data[7] & 0x7Full) << 49)
                   | ((data[8] & 0x7Full) << 56)
                   | ((data[9] & 0x7Full) << 63);

    if ((data[0] & 0x80) == 0) value &= 0x000000000000007Full; else
    if ((data[1] & 0x80) == 0) value &= 0x0000000000003FFFull; else
    if ((data[2] & 0x80) == 0) value &= 0x00000000001FFFFFull; else
    if ((data[3] & 0x80) == 0) value &= 0x000000000FFFFFFFull; else
    if ((data[4] & 0x80) == 0) value &= 0x00000007FFFFFFFFull; else
    if ((data[5] & 0x80) == 0) value &= 0x000003FFFFFFFFFFull; else
    if ((data[6] & 0x80) == 0) value &= 0x0001FFFFFFFFFFFFull; else
    if ((data[7] & 0x80) == 0) value &= 0x00FFFFFFFFFFFFFFull; else
    if ((data[8] & 0x80) == 0) value &= 0x7FFFFFFFFFFFFFFFull;

    return value;
}

Основная идея заключается в том, что небольшие значения являются общими (и поэтому большинство из if-операторов не будут достигнуты), но сборка 64-битного значения, которое нужно замаскировать, - это то, что может быть эффективно конвейерным. С хорошим предсказателем ветки, я думаю, что вышеприведенный код должен работать очень хорошо. Вы также можете попробовать удалить ключевые слова else (без изменения чего-либо еще), чтобы узнать, имеет ли это значение. Отраслевые предсказатели - это тонкие звери, и точный характер ваших данных также имеет значение. Если ничего другого, вы должны уметь видеть, что ключевые слова else являются необязательными с логической точки зрения и существуют только для руководства генерации кода компилятора и обеспечения возможности для оптимизации поведения предсказателя ветвления оборудования.

В конечном счете, независимо от того, эффективен этот подход, зависит от распределения вашего набора данных. Если вы попробуете эту функцию, мне будет интересно узнать, как это получается. Эта конкретная функция фокусируется на стандарте uleb128, где значение сначала отправляется LSB, а бит 7 == 1 означает, что данные продолжаются.

Существуют подходы SIMD, но ни один из них не поддается 7-разрядным данным.

Кроме того, если вы можете пометить этот inline в заголовке, это также может помочь. Все зависит от того, сколько мест вызывается, и находятся ли эти места в другом исходном файле. В целом, однако, настоятельно рекомендуется использовать inline, если это возможно.

Ответ 2

Ваш код проблематичен

uint64_t func(const unsigned char* pos)
{
    uint64_t var1 = 0; int i=0;
    while ((pos[i] >> 7) == 0) 
    {
        var1 = (var1 << 7) | (pos[i]);
        i++;
    }
    return var1;    
}

Сначала незначительная вещь: i должна быть без знака.

Во-вторых: вы не утверждаете, что не читаете за пределами pos. Например. если все значения вашего массива pos равны 0, то вы достигнете pos[size], где size - это размер массива, поэтому вы вызываете поведение undefined. Вы должны передать размер вашего массива функции и проверить, что i меньше этого размера.

В-третьих: если pos[i] имеет самый старший бит, равный нулю для i=0,..,k с k>10, тогда предыдущая работа будет отброшена (когда вы вытащите старое значение из var1).

Третий момент действительно помогает нам:

uint64_t func(const unsigned char* pos, size_t size)
{
    size_t i(0);
    while ( i < size && (pos[i] >> 7) == 0 )
    {
       ++i;
    }
    // At this point, i is either equal to size or
    // i is the index of the first pos value you don't want to use.
    // Therefore we want to use the values
    // pos[i-10], pos[i-9], ..., pos[i-1]
    // if i is less than 10, we obviously need to ignore some of the values
    const size_t start = (i >= 10) ? (i - 10) : 0;
    uint64_t var1 = 0;
    for ( size_t j(start); j < i; ++j )
    {
       var1 <<= 7;
       var1 += pos[j];
    }
    return var1; 
}

В заключение: Мы отделили логику и избавились от всех отброшенных записей. Ускорение зависит от фактических данных, которые у вас есть. Если количество записей отбрасывается, вы сохраняете много записей в var1 с помощью этого подхода.

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

Имейте в виду, что если вы действительно используете 10 значений, первое значение заканчивается сокращением.

64 бит означает, что имеется 9 значений с их полными 7 битами информации, оставляя ровно один бит слева против десятого. Возможно, вы захотите переключиться на uint128_t.

Ответ 3

Небольшая оптимизация будет:

while ((pos[i] & 0x80) == 0) 

Побитовое и, как правило, быстрее, чем сдвиг. Это, конечно, зависит от платформы, и также возможно, что компилятор сам выполнит эту оптимизацию.

Ответ 4

Можете ли вы изменить кодировку?

Google столкнулся с одной и той же проблемой, и Джефф Дин описывает действительно классное решение на слайде 55 своей презентации:

Основная идея заключается в том, что чтение первого бита нескольких байтов плохо поддерживается на современных архитектурах. Вместо этого возьмите 8 из этих бит и упакуйте их как один байт, предшествующий данным. Затем мы используем префиксный байт для индексации в таблицу поиска по 256 элементов, которая содержит маски, описывающие, как извлекать числа из остальной части данных.

Я считаю, что протокольные буферы в настоящее время закодированы.

Ответ 5

Можете ли вы изменить кодировку? Как вы обнаружили, используя бит в каждом байте, чтобы указать, действительно ли другой байт действительно отстой для эффективности обработки.

Лучший способ сделать это - это модель UTF-8, которая кодирует длину полного int в первый байт:

0xxxxxxx // one byte with 7 bits of data
10xxxxxx 10xxxxxx // two bytes with 12 bits of data
110xxxxx 10xxxxxx 10xxxxxx // three bytes with 16 bits of data
1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx // four bytes with 22 bits of data
// etc.

Но UTF-8 обладает специальными свойствами, чтобы было легче отличить от ASCII. Это раздувает данные, и вы не заботитесь о ASCII, поэтому вы должны изменить его, чтобы выглядеть так:

0xxxxxxx // one byte with 7 bits of data
10xxxxxx xxxxxxxx // two bytes with 14 bits of data.
110xxxxx xxxxxxxx xxxxxxxx // three bytes with 21 bits of data
1110xxxx xxxxxxxx xxxxxxxx xxxxxxxx // four bytes with 28 bits of data
// etc.

У этого уровня сжатия тот же уровень сжатия, что и ваш метод (до 64 бит = 9 байтов), но значительно проще для процессора.

Из этого вы можете построить таблицу поиска для первого байта, которая дает вам маску и длину:

// byte_counts[255] contains the number of additional
// bytes if the first byte has a value of 255.
uint8_t const byte_counts[256]; // a global constant.

// byte_masks[255] contains a mask for the useful bits in
// the first byte, if the first byte has a value of 255.
uint8_t const byte_masks[256]; // a global constant.

И затем для декодирования:

// the resulting value.
uint64_t v = 0;

// mask off the data bits in the first byte.
v = *data & byte_masks[*data];

// read in the rest.
switch(byte_counts[*data])
{
    case 3: v = v << 8 | *++data;
    case 2: v = v << 8 | *++data;
    case 1: v = v << 8 | *++data;
    case 0: return v;
    default:
        // If you're on VC++, this'll make it take one less branch.
        // Better make sure you've got all the valid inputs covered, though!
        __assume(0);
}

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

Ответ 6

Во-первых, вместо переключения вы можете выполнить побитовое тестирование на соответствующий бит. Во-вторых, вы можете использовать указатель, а не индексирование (но компилятор должен сам выполнить эту оптимизацию. Таким образом:

uint64_t
readUnsignedVarLength( unsigned char const* pos )
{
    uint64_t results = 0;
    while ( (*pos & 0x80) == 0 ) {
        results = (results << 7) | *pos;
        ++ pos;
    }
    return results;
}

По крайней мере, это соответствует тому, что делает ваш код. Для переменных длина кодирования целых чисел без знака, это неверно, поскольку 1) кодировки с переменной длиной слова являются немногочисленными, и ваш код big endian, и 2) ваш код не имеет байта высокого порядка. Наконец, страница Wiki предполагает, что у вас есть тест обратное развитие. (Я знаю этот формат в основном из кодирования BER и Буферы протокола Google, оба из которых устанавливают бит 7 для указания что последует следующий байт.

Подпрограмма, которую я использую:

uint64_t
readUnsignedVarLen( unsigned char const* source )
{
    int shift = 0;
    uint64_t results = 0;
    uint8_t tmp = *source ++;
    while ( ( tmp & 0x80 ) != 0 ) {
        *value |= ( tmp & 0x7F ) << shift;
        shift += 7;
        tmp = *source ++;
    }
    return results | (tmp << shift);
}

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

uint64_t
readUnsignedVarLen( unsigned char const* source )
{
    unsigned char buffer[10];
    unsigned char* p = std::begin( buffer );
    while ( p != std::end( buffer ) && (*source & 0x80) != 0 ) {
        *p = *source & 0x7F;
        ++ p;
    }
    assert( p != std::end( buffer ) );
    *p = *source;
    ++ p;
    uint64_t results = 0;
    while ( p != std::begin( buffer ) ) {
        -- p;
        results = (results << 7) + *p;
    }
    return results;
}

Необходимость проверки переполнения буфера, скорее всего, сделает это немного медленнее, но на некоторых архитектурах, константа значительно быстрее, чем смещение переменной, так что это может быть быстрее на них.

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