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


  1. lebedec
    05.06.2023 13:57
    +3

    Если гонитесь за скоростью, попробуйте ещё убрать аллокацию вектора. На большом количестве вызовов, должно быть ощутимо быстрее.

    let mut b62_str = [b'0'; U128_BASE62_ENCODED_LEN];
    ...
    unsafe { String::from(std::str::from_utf8_unchecked(&b62_str)) }


    1. gpaw
      05.06.2023 13:57

      +1, но немного дополню. можно сделать такое решение - буфер, сочетающий в себе данные на стеке и аллокацию из кучи -

      pub struct TinyBuffer<T: Copy, const S: usize> {
          /// небольшой буфер, используемый по умолчанию, чтобы избежать лишнего обращения к аллокатору
          tiny: [MaybeUninit<T>; S],
          /// количество элементов буфера
          length: usize,
          /// вместимость текущей ячейки
          capacity: usize,
          /// указатель на первый элемент буфера
          pointer: *mut T,
      }
      


    1. rsk Автор
      05.06.2023 13:57

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


  1. Biga
    05.06.2023 13:57

    Компиляторы в некоторых случаях умеют превращать деление на константу в умножение и сдвиг. (https://libdivide.com)


  1. kovserg
    05.06.2023 13:57

    Они содержат только буквы и цифры, поэтому их можно выделять двойным кликом

    Если набирать с экрана то лучше убрать не однозначные символы O0o 1liI B8, если по телефону то независимым от больших и маленьких букв сделать. А вот для выделения курсором можно unicode использовать база резко увеличиться :)


    Посмотрим на скорость алгоритма, результат бенчмарка

    А для чего вам скорость или весь текст состоит из идентификаторов? Или это по тестам оказалось самым узким местом?


    ps: Т.к. используются случайные числа, можно получать случайные слова, которые могут оскорбить случайных пользователей. Были инциденты.


    1. Quartz32
      05.06.2023 13:57
      -1

      Любая белиберда хуже оскорбления, потому что неизвестно, какое оскорбление за ней скрывается.


    1. rsk Автор
      05.06.2023 13:57

      Если набирать с экрана то лучше убрать не однозначные символы O0o 1liI
      B8, если по телефону то независимым от больших и маленьких букв сделать.
      А вот для выделения курсором можно unicode использовать база резко
      увеличиться :)

      да, есть в планах использование двух типов идентификаторов, глобально уникальные как описано в статье, а также локально уникальные, например, 5-ти символьные base32 строки (см. Crockford's Base32)

      А для чего вам скорость или весь текст состоит из идентификаторов? Или это по тестам оказалось самым узким местом?

      ради эстетики, слишком уж медленно было и хорошо поддается оптимизации


  1. gpaw
    05.06.2023 13:57
    +1

    спасибо, схоронил ) немного оффтопик, но - по опыту, раст прямо-таки создан для различного рода оптимизаций. сейчас пишу имплементацию сопоставлений unicode для своей бд, так на нормализации добился до 40% прироста по сравнению с ICU4X.

    приятно увидеть людей со схожими взглядами на код )


  1. pfffffffffffff
    05.06.2023 13:57

    А можно ссылку на приложение заметок?


    1. rsk Автор
      05.06.2023 13:57

      не знаю можно ли здесь оставлять ссылки на свои проекты, но все есть в профиле, называется heaplist.app


  1. Gorthauer87
    05.06.2023 13:57

    А чем не устроил base58?


    1. rsk Автор
      05.06.2023 13:57

      Хм, заставили призадуматься, действительно u128 кодированное в base58 тоже помещается в 22 символа. В свою защиту скажу base62 проще, более однозначный алфавит. Ну а если идти по пути уменьшения алфавита, тогда можно использовать base57, тоже будет 22 символа. Спасибо подумаю над этим.


      1. aiscy
        05.06.2023 13:57

        Рассмотрите еще newbase60.


  1. NooneAtAll3
    05.06.2023 13:57

    Посмотрим на сгенерированный компилятором Rust ассемблер:

    __umodti3
    __udivti3

    а вы оптимизацию точно включали?
    компиляторы давным-давно умеют деление на константу превращать в умножение


    если компилятору не доверяем, то используйте fastdiv/fastdivide/strength_reduce либы (и "заранее расчитайте" нужную константу с помощью const)


    1. rsk Автор
      05.06.2023 13:57

      да, все бенчмарки были в релиз-билде, как описано в статье оптимизация выполена за счет перехода от деления 128-битных чисел к делению 64-битных

      fastdiv еще не пробовал, потестирую, спасибо