Оптимизируем кодирование u128 в base62

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

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

32dca18531a1435480461f99837a5b1d

По некоторым причинам использовать uuid мне не очень нравилось:

  • это довольно длинная строка из 32 символов, а мне надо будет иногда показывать ее пользователям

  • 6 бит в uuid4 не используются, это константы, расточительно

константные биты в uuid4:

uuid.uuid4().bytes[6] & 0b11110000 #   == 64
uuid.uuid4().bytes[8] & 0b11000000 #   == 128

Я решил изучить другие варианты. Так определенной популярностью пользуются ulid, ksuid. Но они мне тоже не подошли, в основном из-за того что включают в себя временную метку.

Моим вариантом стали полностью случайные 128-битные идентификаторы, которые я кодирую в base62 строку. Почему base62? Они содержат только буквы и цифры, поэтому их можно выделять двойным кликом (a, например, base64 - нет). Идентификаторы стали короче, теперь это 22-символьные строки, например:

4xT8QKx8f3BwZP06VKSEMy

Но есть проблема! Кодирование в систему счисления, которая не является степенью двойки это довольно медленный процесс, из-за того, что включает операцию деления. Для декодирования (base62 в u128) такой проблемы нет, так как там не будет деления, только умножение и сложение.

Итак, сначала попробуем очевидный вариант кодировщика, написанный на Rust:

const BASE62_N: usize = 62;
const BASE62_DIGITS: &[u8; BASE62_N] =
    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// сколько понадобится чисел в base62 для хранения u128?
// вычислить это число можно так, python: math.ceil(math.log(2**128, 62))
const U128_BASE62_ENCODED_LEN: usize = 22;

pub fn u128_to_base62_naive(mut n: u128) -> String {
    // заполнение происходит от меньших разрядов к старшим, незаполненные старшие разряды останутся нулями
    let mut b62_str = vec![b'0'; U128_BASE62_ENCODED_LEN];
    let mut i = U128_BASE62_ENCODED_LEN;
    while n > 0 {
        i -= 1;
        let digit_index = (n % BASE62_N as u128) as usize;
        b62_str[i] = BASE62_DIGITS[digit_index];
        n /= BASE62_N as u128;
    }
    // переменная гарантированно содержит корректную utf-8 строку, незачем дополнительно ее проверять
    unsafe { String::from_utf8_unchecked(b62_str) }
}

По сути это конвертация из десятичной системы счисления в систему счисления с базой 62. Для этого мы делим изначальное число на новое основание, в которое переводим, остаток от деления является следующим разрядом в новой системе счисления, частное от деление снова делим и так далее в цикле.

Посмотрим на скорость алгоритма, результат бенчмарка (i5-4690 3.50GHz): bench_u128_to_base62_naive 1000000: 32.86Mib/s, 2153250.28/s

Грустные цифры, не правда ли? Для сравнения, base64 кодировщик работает со скоростью 309.35Mib/s, hex кодировщик 645.34Mib/s.

Почему так? Так как 62 не является степенью двойки, для конвертации нам приходится на каждом цикле повторно делить все 128-битное число, каждый бит исходного числа влияет на каждый следующий разряд результата. Посмотрим на сгенерированный компилятором Rust ассемблер godbolt, здесь можно увидеть, что для деления используются две функции:

__umodti3
__udivti3

Это функции реализованные в рантайме (llvm) для деления 128-битных чисел (обозначения "u" - unsigned, "ti" - 128 bit). И конечно они будут относительно медленными.

Надо облегчить работу процессору. Как вам известно любое число в базе BASE можно представить в виде: HIGH * BASE**R + LOW, например десятичное 1111222 = 1111 * 10**3 + 222, что это нам дает? Разделим обе части на 10**3 целочисленным делением, получаем 1111 = 1111, для остатка от деления будет 222 = 222, таким образом это представление позволяет нам при преобразовании между базами делить не все число а только его часть. Как вы уже, наверное, догадались мы будет замещать деление 128-битных чисел, делением 64-битных чисел.

Итак для начала нам надо выбрать степень, в которую будем возводить 62, оптимальным будет R, при котором выполняется условие 62**R <= 2**64 < 62**(R+1), вычислить его можно по формуле:

math.floor(math.log(2**64, 62)) #   == 10

Теперь напишем новую реализацию на Rust, выглядит она следующим образом:

const BASE62_N: usize = 62;
const BASE62_DIGITS: &[u8; BASE62_N] =
    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const U128_BASE62_ENCODED_LEN: usize = 22;
const BASE62_POW_10: u128 = (BASE62_N as u128).pow(10);

pub fn u128_to_base62(mut n: u128) -> String {
    let mut b62_str = vec![b'0'; U128_BASE62_ENCODED_LEN];
    // `nlow` будет формировать нижние 10 разрядов в base62
    let mut nlow = (n % BASE62_POW_10) as u64;
    let mut i = U128_BASE62_ENCODED_LEN;
    // преобразуем число в три шага:
    //   0000000000001111111111
    // + 0022222222220000000000
    // + 3300000000000000000000
    // = 3322222222221111111111
    for _ in 0..2 {
        // здесь тот же алгоритм для преобразования числа в новую базу, но теперь с использованием 64-битного деления
        for _ in 0..10 {
            i -= 1;
            let digit_index = (nlow % BASE62_N as u64) as usize;
            b62_str[i] = BASE62_DIGITS[digit_index];
            nlow /= BASE62_N as u64;
        }
        // переходим к следующему блоку разрядов
        n /= BASE62_POW_10;
        nlow = (n % BASE62_POW_10) as u64;
    }
    // завершаем преобразовние 2 оставшихся разрядов
    for _ in 0..2 {
        i -= 1;
        let digit_index = (nlow % BASE62_N as u64) as usize;
        b62_str[i] = BASE62_DIGITS[digit_index];
        nlow /= BASE62_N as u64;
    }
    unsafe { String::from_utf8_unchecked(b62_str) }
}

Смотрим, что в результирующем ассемблере godbolt, теперь, там для 64-битного деления используется инструкция div.

В итоге было __umodti3 x 22, __udivti3 x 22, стало __umodti3 x 3, __udivti3 x 2, div x 44. Вот что показывает бенчмарк: bench_u128_to_base62 1000000: 112.18Mib/s, 7351959.26/s. Ускорение в 3.5 раза!

Готово.

и да я пробовал заменить деление u64, делением u32, значительного прибавления производительности не было, прирост около 2%

Источник: https://habr.com/ru/articles/739936/


Интересные статьи

Интересные статьи

Когда дело доходит до оптимизации производительности, чаще всего особое внимание уделяется скорости и активности использования ЦП. Гораздо реже кто-либо задумывается о потреблении памяти, конечно, пок...
Вместе со Стивом Сьюэллом, CEO Builder.io, разбираемся, почему с точки зрения оптимизации производительности изображения лучше загружать через HTML, а не через CSS. 
Можете представить, сколько времени уйдёт на генерацию списка VM среди сотен подписок Azure? Целая вечность. Известно, что портал Azure выводит только первые 1000 подписок, что усложняет запрос ресу...
Давайте поговорим о побитовых операциях.С ними привычно иметь дело embedded-разработчикам и тем, кто занимается криптографией. Также побитовые операции можно встретить в системном программировании, ко...
Сентябрь 2010. Причина, по которой стартапы бывало использовали конвертируемые векселя (convertible notes) во время во время привлечения бизнес-ангельских инвестиций заключается в том...