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

6c6cab30755c1cc7a22da8a1d9707592

В процессе работы над своим приложением для заметок, когда я дошел до сохранения данных в базу данных я стал использовать для идентификации записей 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%

© Habrahabr.ru