В процессе работы над своим приложением для заметок, когда я дошел до сохранения данных в базу данных я стал использовать для идентификации записей 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%
Комментарии (15)
Biga
05.06.2023 13:57Компиляторы в некоторых случаях умеют превращать деление на константу в умножение и сдвиг. (https://libdivide.com)
kovserg
05.06.2023 13:57Они содержат только буквы и цифры, поэтому их можно выделять двойным кликом
Если набирать с экрана то лучше убрать не однозначные символы O0o 1liI B8, если по телефону то независимым от больших и маленьких букв сделать. А вот для выделения курсором можно unicode использовать база резко увеличиться :)
Посмотрим на скорость алгоритма, результат бенчмарка
А для чего вам скорость или весь текст состоит из идентификаторов? Или это по тестам оказалось самым узким местом?
ps: Т.к. используются случайные числа, можно получать случайные слова, которые могут оскорбить случайных пользователей. Были инциденты.
Quartz32
05.06.2023 13:57-1Любая белиберда хуже оскорбления, потому что неизвестно, какое оскорбление за ней скрывается.
rsk Автор
05.06.2023 13:57Если набирать с экрана то лучше убрать не однозначные символы O0o 1liI
B8, если по телефону то независимым от больших и маленьких букв сделать.
А вот для выделения курсором можно unicode использовать база резко
увеличиться :)да, есть в планах использование двух типов идентификаторов, глобально уникальные как описано в статье, а также локально уникальные, например, 5-ти символьные base32 строки (см. Crockford's Base32)
А для чего вам скорость или весь текст состоит из идентификаторов? Или это по тестам оказалось самым узким местом?
ради эстетики, слишком уж медленно было и хорошо поддается оптимизации
gpaw
05.06.2023 13:57+1спасибо, схоронил ) немного оффтопик, но - по опыту, раст прямо-таки создан для различного рода оптимизаций. сейчас пишу имплементацию сопоставлений unicode для своей бд, так на нормализации добился до 40% прироста по сравнению с ICU4X.
приятно увидеть людей со схожими взглядами на код )
pfffffffffffff
05.06.2023 13:57А можно ссылку на приложение заметок?
rsk Автор
05.06.2023 13:57не знаю можно ли здесь оставлять ссылки на свои проекты, но все есть в профиле, называется heaplist.app
Gorthauer87
05.06.2023 13:57А чем не устроил base58?
rsk Автор
05.06.2023 13:57Хм, заставили призадуматься, действительно u128 кодированное в base58 тоже помещается в 22 символа. В свою защиту скажу base62 проще, более однозначный алфавит. Ну а если идти по пути уменьшения алфавита, тогда можно использовать base57, тоже будет 22 символа. Спасибо подумаю над этим.
NooneAtAll3
05.06.2023 13:57Посмотрим на сгенерированный компилятором Rust ассемблер:
__umodti3 __udivti3
а вы оптимизацию точно включали?
компиляторы давным-давно умеют деление на константу превращать в умножениеесли компилятору не доверяем, то используйте
fastdiv
/fastdivide
/strength_reduce
либы (и "заранее расчитайте" нужную константу с помощьюconst
)rsk Автор
05.06.2023 13:57да, все бенчмарки были в релиз-билде, как описано в статье оптимизация выполена за счет перехода от деления 128-битных чисел к делению 64-битных
fastdiv еще не пробовал, потестирую, спасибо
lebedec
Если гонитесь за скоростью, попробуйте ещё убрать аллокацию вектора. На большом количестве вызовов, должно быть ощутимо быстрее.
gpaw
+1, но немного дополню. можно сделать такое решение - буфер, сочетающий в себе данные на стеке и аллокацию из кучи -
rsk Автор
не совсем понял насчет "убрать аллокацию", можно убрать инициализацию вектора это да, будет немного быстрее, если вы имеете ввиду передавать в функцию буфер для заполнения, то можно, но не подойдет для моего случая