Оптимизируем кодирование 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *