[Перевод] Уменьшаем базу данных в 2000 раз при помощи Rust (завершение)

Первая часть

Сериализация

По сути, мы получили косвенную базу данных в памяти. Далее нам нужно сериализовать её, чтобы проверить, как interning влияет на её постоянное хранение. Кроме того, сериализация — это важный шаг для проверки реальности заявленной экономии места. И, наконец, это ещё одна возможность для дальнейшего сжатия данных.

В Rust для сериализации стандартно используется крейт serde, который мы уже использовали для импортирования входных данных в JSON. Serde изначально имеет поддержку множества форматов благодаря крейтам расширений, поэтому я решил попробовать несколько из них.

  • CBOR (ciborium),

  • JSON (serde_json),

  • Postcard (postcard),

  • Крейт bincode (спецификация отсутствует).

Пишем собственные (де)сериализаторы с помощью Serde (0,29%)

Обычно достаточно лишь аннотировать каждый тип в схеме #[derive(Serialize, Deserialize)], и Serde предоставит готовые функции (де)сериализации, благодаря чему этот фреймворк и настолько привлекателен. Однако это будет не очень эффективно для таблицы Interner, содержащей одинаковые объекты дважды (в векторе и хэш-таблице). Кроме того, в документации Serde говорится, что несмотря на то, что Rc (де)сериализируем, если выбрать фичу rc, он копирует внутреннее содержимое при каждой ссылке на него, что неоптимально и семантически неэквивалентно.

struct Interner {
    vec: Vec>,
    map: HashMap, u32>,
}

В этом случае мы не хотим сериализировать и вектор объектов, и таблицу поиска индексов: всё состояние интернера можно восстановить из одного вектора. И в самом деле, если мы десериализируем объекты в том же порядке, то получим те же индексы. А Serde как будто упрощает сериализацию последовательности.

Документация Serialize и Serializer быстро приводит нас к решению, а именно к методам serialize_seq() или collect_seq() (первый хорошо работает с итераторами в функциональном стиле).

use serde::{Serialize, Serializer};

impl Serialize for Interner {
    fn serialize(&self, serializer: S) -> Result
    where
        S: Serializer,
    {
        // Приказываем Serde сериализовать последовательность элементов.
        serializer.collect_seq(self.vec.iter().map(|rc| rc.deref()))
    }
}

С десериализацией всё сложнее, и это неудивительно, ведь нужно обрабатывать потенциальные ошибки в сериализованном потоке. В конечном итоге, придётся изучить руководство непосредственно на веб-сайте Serde, так как в обычной документации API подробности не объясняются.

Необходимо не только манипулировать трейтами Deserialize и Deserializer, но и написать Visitor, а также (в этом случае) манипулировать SeqAccess. Бойлерплейта намного больше, но в итоге всё работает.

use serde::de::{SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};

impl<'de, T> Deserialize<'de> for Interner
where
    T: Eq + Hash + Deserialize<'de>,
{
    fn deserialize(deserializer: D) -> Result
    where
        D: Deserializer<'de>,
    {
        // Приказываем Serde десериализовать последовательность элементов при помощи указанного visitor.
        deserializer.deserialize_seq(InternerVisitor::new())
    }
}

// Visitor для создания Interner.
struct InternerVisitor {
    // Этот visitor не хранит состояние, но Rust требует использовать обобщённый параметр T.
    _phantom: PhantomData Interner>,
}

impl InternerVisitor {
    fn new() -> Self {
        Self {
            _phantom: PhantomData,
        }
    }
}

impl<'de, T> Visitor<'de> for InternerVisitor
where
    T: Eq + Hash + Deserialize<'de>,
{
    // Сообщаем Serde, что на выходе будет Interner.
    type Value = Interner;

    // Сообщение об ошибке, отображаемое, если входной поток не содержит последовательности из Ts.
    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
        formatter.write_str("a sequence of values")
    }

    // Обратный вызов, совершаемый Serde с последовательностью элементов.
    fn visit_seq(self, mut seq: A) -> Result
    where
        A: SeqAccess<'de>,
    {
        // Serde может дать нам подсказку о количестве элементов в последовательности.
        let mut interner = match seq.size_hint() {
            None => Interner::default(),
            Some(size_hint) => Interner {
                vec: Vec::with_capacity(size_hint),
                map: HashMap::with_capacity(size_hint),
                references: 0,
            },
        };

        // Cобираем элементы в цикле и возвращаем Interner.
        while let Some(t) = seq.next_element()? {
            interner.push(t);
        }

        Ok(interner)
    }
}

// Для полноты создаём новую функцию push(), немного более эффективную, чем intern().
impl Interner {
    /// Безусловно записываем значение без валидации того, что оно уже интернировано.
    fn push(&mut self, value: T) -> u32 {
        let id = self.vec.len();
        assert!(id <= u32::MAX as usize);
        let id = id as u32;

        let rc: Rc = Rc::new(value);
        self.vec.push(Rc::clone(&rc));
        self.map.insert(rc, id);

        id
    }
}

Вот сравнение форматов сериализации на этом этапе, отсортированное по размеру закодированных данных. Явным победителем оказался Postcard с 3,2 МБ, что намного меньше, чем JSON с 65 МБ в красивом виде или 26 МБ в минифицированном виде (коммит b5013e6).

Формат

Байты

Относительный размер

Время кодирования

Время декодирования

Postcard

3275869

0.29%

17 ms

16 ms

Bincode

5437330

0.48%

16 ms

24 ms

CBOR

17484567

1.54%

56 ms

128 ms

JSON

26485131

2.33%

74 ms

118 ms

JSON (pretty)

65026281

5.72%

152 ms

135 ms

Интересно также сравнить время кодирования и декодирования, так как более компактные форматы обычно и быстрее обрабатываются. Впрочем, результаты стоит воспринимать с долей скепсиса, потому что это не бенчмарк, а единственный пример данных на моей машине и без использования оптимизированных библиотек наподобие simd_json.

Сжатие и борьба с Linux pipes (0,05%)

После сериализации базы данных в байты можно применить к ним стандартные операции сжатия. Даже после interning стоит ожидать какой-то доли избыточности: например, сообщения о нарушениях движения составляются по шаблонам, в которых варьируются только некоторые детали.

Мне не особо хотелось изучать различные библиотеки сжатия Rust, потому что я думал, что в этом эксперименте будет проще породить команду. И в самом деле, тип Command в стандартной библиотеке Rust позволяет запускать другие программы, безопасным образом передавая им аргументы и входные данные, а также получать вывод. В принципе, следует конфигурировать Stdio::piped() для взаимодействия с stdin/stdout/stderr дочернего процесса, и на этом всё… По крайней мере, так я думал.

// Наивная реализация, которая не заработала (межпроцессная взаимная блокировка).
fn gzip_compress(input: &[u8]) -> Result, Box> {
    let compress = Command::new("gzip")
        .arg("-c")  // Сжатие в stdout.
        .arg("-6")  // Уровень сжатия.
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()?;

    compress.stdin.as_mut().expect("Failed to open stdin").write_all(input)?;

    let output = compress.wait_with_output()?;
    if !output.status.success() {
        /* Возвращает ошибку */
    }
    Ok(output.stdout)
}

Эта наивная реализация работала с простыми примерами с короткими входными данными, но, похоже, приводила к взаимной блокировке при попытке сжатия более объёмных данных!

Разгадка кроется в руководстве Linux по конвейерам. Когда родительский и дочерний процессы общаются через stdin/stdout/stderr, это происходит через так называемый pipe (конвейер). При этом ядро предоставляет буфер, в который один процесс может выполнять запись, а другой процесс может читать.

Однако чтобы не исчерпывать ресурсы системы, буфер имеет ограниченный размер. В частности, /proc/sys/fs/pipe-max-size задаёт максимальный размер (в байтах) одного конвейера, который в моей системе равен 1 МиБ. Кроме того, в /proc/sys/fs/pipe-user-pages-hard и /proc/sys/fs/pipe-user-pages-soft задаётся максимальное количество страниц, которое может создавать один пользователь конвейера (среди всех процессов). На моей машине это значение равно 64 МиБ.

$ cat /proc/sys/fs/pipe-max-size
1048576
$ cat /proc/sys/fs/pipe-user-pages-hard
0
$ cat /proc/sys/fs/pipe-user-pages-soft
16384

Вернёмся к вызову gzip: проблема в том, что если входные данные слишком велики, то вызов write_all(input) будет заблокирован, пока дочерний процесс (gzip) считывает данные из конвейера. Процесс gzip действительно начинает чтение из него, но также стремится максимально скоро записывать в свой стандартный вывод сжатый вывод (чтобы ограничить используемую память). Однако при наивной реализации мой родительский процесс не читает этот вывод, пока не завершится вся запись входных данных. Поэтому сжатый вывод будет тоже заполнять этот конвейер.

В какой-то момент мы оказываемся в ситуации, когда мой родительский процесс оказывается заблокирован на записи новых входных данных в конвейер stdin (ожидая, пока их считает gzip), а дочерний процесс gzip заблокирован на записи нового сжатого вывода в конвейер stdout (ожидая, пока его считает родительский процесс). Иными словами, возникает межпроцессная взаимная блокировка (deadlock)!

d0f5ff4e7462511fb881fb66de5acfcc.png

С этим ограничением нужно быть очень аккуратным: даже если вы думаете, что вам не понадобится записывать больше 1 МиБ (или того значения, которое указано в /proc/sys/fs/pipe-max-size вашей системы), общий лимит на одного пользователя может ограничить конвейеры только одной страницей (после достижения /proc/sys/fs/pipe-user-pages-soft), которая обычно равна 4 КиБ, то есть намного меньше, чем стандартный 1 МиБ.

Чтобы решить эту проблему, надо сделать так, чтобы несжатые входные данные и сжатый вывод записывались одновременно, то есть использовать потоки. Мы всё равно сможем пользоваться удобными функциями write_all() и read_to_end(), если они будут находиться в параллельных потоках.

В конечном итоге я создал вспомогательную функцию для автоматизации этого паттерна записи всего среза входных данных и чтения всего вывода. Обратите внимание на использование std::thread::scope(), позволяющего перехватывать входные срезы с нестатическими сроками жизни.

#![feature(exit_status_error)]

fn io_command(mut child: Child, input: &[u8]) -> Result, Box> {
    std::thread::scope(|s| -> Result, Box> {
        let mut stdin = child.stdin.take().expect("Failed to open stdin");
        let mut stdout = child.stdout.take().expect("Failed to open stdout");

        let input_thread = s.spawn(move || -> std::io::Result<()> {
            eprintln!("Writing {} bytes...", input.len());
            stdin.write_all(input)?;
            drop(stdin);
            Ok(())
        });

        let output_thread = s.spawn(move || -> std::io::Result> {
            eprintln!("Reading bytes...");
            let mut output = Vec::new();
            stdout.read_to_end(&mut output)?;
            Ok(output)
        });

        eprintln!("Waiting for child...");
        child.wait()?.exit_ok()?;

        input_thread.join().expect("Failed to join input thread")?;
        let output = output_thread
            .join()
            .expect("Failed to join output thread")?;
        Ok(output)
    })
}

Вернёмся к нашей теме: я выполнил бенчмарки нескольких популярных алгоритмов сжатия. Они показали. что разница размеров между форматами обычно становится меньше после сжатия. На текущий момент мы достигли порога 2000-кратного уменьшения размера для самых компактных форатов: с 1,1 ГБ до 530 КБ для данных за май 2024 года (коммит b7281f5).

5031376510467a83618d980b9b6264cf.png

Формат

Сериализация

gzip -6

brotli -6

xz -6

Postcard

3275869

0.29%

861917

0.08%

721120

0.06%

539200

0.05%

Bincode

5437330

0.48%

893271

0.08%

700194

0.06%

529996

0.05%

CBOR

17484567

1.54%

1187575

0.10%

826739

0.07%

615124

0.05%

JSON

26485131

2.33%

1253219

0.11%

916683

0.08%

736036

0.06%

JSON (красивый)

65026281

5.72%

1563418

0.14%

1006035

0.09%

958624

0.08%

Однако схожие размеры после сжатия не означают что можно просто использовать JSON и забыть об этом: скорости (де)сериализации и (де)компрессии выше при более оптимизированных форматах наподобие Postcard!

ea0374ab4a44aadf6e0e96fd877dc7e3.png

Формат

Сериализация

gzip -6

brotli -6

xz -6

encode

decode

encode

decode

encode

decode

encode

decode

Postcard

22 ms

27 ms

88 ms

21 ms

144 ms

18 ms

583 ms

37 ms

Bincode

9 ms

24 ms

166 ms

28 ms

191 ms

19 ms

1111 ms

39 ms

CBOR

72 ms

131 ms

189 ms

63 ms

230 ms

36 ms

3255 ms

51 ms

JSON

72 ms

100 ms

233 ms

96 ms

449 ms

44 ms

3314 ms

60 ms

JSON (красивый)

137 ms

129 ms

376 ms

180 ms

441 ms

74 ms

2934 ms

81 ms

Ещё один аспект, не охваченный моим экспериментом — это влияние на размер кода: я ожидаю, что более простые форматы скомпилируются в более компактный код. Учитывайте это, особенно если вы развёртываете код для встроенных систем или в WebAssembly для веб-сайта.

Кодирование в кортежи

При более внимательном изучении сериализованного вывода я заметил в выводе JSON и CBOR множество строк _phantom.

{"id": 11, "_phantom": null}

Что-то наподобие Interned, который по моим ожиданиям должен быть сериализоваться как простой integer, занимал слишком много места.

struct Interned {
    id: u32,
    _phantom: PhantomData T>,
}

По умолчанию Serde сериализует каждую struct как map, что вызывает две проблемы:

  1. имена полей сериализуются как ключи в map,

  2. типы нулевого размера наподобие PhantomData сериализуются, хотя они и не содержат никакой информации и их можно тривиально воссоздать.

Это поведение по умолчанию может быть полезно, если ваши типы эволюционируют со временем (поля добавляются, удаляются или переименовываются) и вы хотите сохранять некий уровень «стандартной» совместимости, но это уже проектное решение1. Однако в моём случае абстракция Interned использовалась настолько часто, что обеспечивала большую избыточность.

1. На мой взгляд, такой подход, ставший популярным благодаря форматам наподобие Protocol Buffers, может, к сожалению, создать иллюзию версионности «из коробки». Если вы меняете семантику системы, то вам почти всегда приходится писать дополнительный код, чтобы обеспечить совместимость между версиями. Версионность «из коробки» может предотвратить явные сбои в кратковременной перспективе, но в то же время способствует тому, что вы можете забыть написать нужный код совместимости, а также тому, что появятся более тонкие различия, вызванные игнорированием неизвестных, но важных полей. В долговременной перспективе может вызывать ошибки, повреждение данных, нестабильности или даже прочие сбои.

Изучив вопрос, я нашёл пару issue, а также обсуждение, начатое мейнтейнером Serde, которое в итоге свелось к тому, что лучше сериализовать struct как кортеж, а не как map. Учитывая то, что у кортежей нет имён полей, это должно решить проблему. Этот запрос фичи так и не был реализован в самом крейте serde, однако в отдельном крейте serde_tuple есть макросы derive Serialize_tuple и Deserialize_tuple.

use serde_tuple::{Deserialize_tuple, Serialize_tuple};

#[derive(Serialize_tuple, Deserialize_tuple)]
struct Interned {
    id: u32,
    _phantom: PhantomData T>,
}

Это решает первую проблему (коммиты a9924e9 и cec5359). Однако PhantomData всё равно продолжали встречаться в сериализованном выводе как значения null.

[11, null]

Учитывая частоту использования типа Interned, я в конечном итоге снова написал особый сериализатор.

impl Serialize for Interned {
    fn serialize(&self, serializer: S) -> Result
    where
        S: Serializer,
    {
        serializer.serialize_u32(self.id)
    }
}

Для соответствующих форматов (CBOR и JSON) эти оптимизации уменьшили закодированный размер на 72–75%). После сжатия снижение размера оказывалось более скромным, в пределах 11–33% (коммит 356bc0a).

a02fd54d0a14862bf55faf1b83fc1820.png

Формат

Сериализация

gzip -6

brotli -6

xz -6

Postcard

3275869

861917

721120

539200

Bincode

5437330

893271

700194

529996

CBOR

4475821 (-74%)

955541 (-20%)

731878 (-11%)

535068 (-13%)

JSON

6560658 (-75%)

1005689 (-20%)

777507 (-15%)

545832 (-26%)

JSON (pretty)

18168838 (-72%)

1162250 (-26%)

890102 (-12%)

639072 (-33%)

Стоит отметить, что такие форматы, как Postcard и Bincode, и так по умолчанию выполняют эту оптимизацию, используя то, что они не самоописываемые. Благодаря использованию крейта serde_tuple можно отказаться от гарантий самоописания и других форматов наподобие JSON, так как для обмена данными без их повреждения отправитель и получатель должны согласовать схему (то есть конкретный порядок полей в каждой struct).

Возвращаемся к оптимизации множеств

В предыдущей части поста я говорил, что множества объектов можно лучше унифицировать, отсортировав их. Наивное решение (используемое по умолчанию) заключается в их сериализации как списка интернированных ID. Однако на практике эти ID часто идут последовательно, потому что объекты были созданы примерно в одно и то же время. Например, все нарушения, происходившие в один день, будут встречаться в таблице Interned рядом друг с другом.

[0, 70, 72, 73, 74, 75, 77, 78, 79, 80, 81]

Поэтому мы можем не сериализовать ID напрямую, а использовать дельта-кодирование: сериализовать каждый ID как разность с предыдущим ID.

[0, 70, 2, 1, 1, 1, 2, 1, 1, 1, 1]

Это выгодно по двум причинам:

  • в популярных форматах сериализации используется кодирование переменной длины, то есть малые числа кодируются в меньшее количество байтов, чем большие,

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

Дельта-кодирование можно достаточно просто реализовать в каждом множестве при помощи модифицированного сериализатора Serde.

struct InternedSet {
    set: Box<[Interned]>,
}

impl Serialize for InternedSet {
    fn serialize(&self, serializer: S) -> Result
    where
        S: Serializer,
    {
        let mut prev = 0;
        serializer.collect_seq(self.set.iter().map(|x| {
            let id = x.id();
            let diff = id - prev;
            prev = id;
            diff
        }))
    }
}

Хотя дифференциальное кодирование не влияет на формат Bincode (который кодирует целые числа с фиксированным размером), для остальных форматов сериализованный размер уменьшился на 9–19%). После сжатия размер в случае gzip и brotli уменьшился на 8–16%, а для xz — на 4–8% (коммит 4ea388f).

6b71cbc77e883457f54e0ee42188d61c.png

Формат

Сериализация

gzip -6

brotli -6

xz -6

Postcard

2966573 (-9%)

746478 (-13%)

624801 (-13%)

499524 (-7%)

Bincode

5437330 (=)

821573 (-8%)

631655 (-10%)

506956 (-4%)

CBOR

3688285 (-18%)

814306 (-15%)

634705 (-13%)

493064 (-8%)

JSON

5317810 (-19%)

841046 (-16%)

708651 (-9%)

504196 (-7%)

JSON (pretty)

15018136 (-17%)

988869 (-15%)

820774 (-8%)

590380 (-7%)

На практике, мы можем совершить ещё один шаг: дельты не просто были малыми, но и часто имели значение 1 при длинных последовательностях идущих по порядку элементов. Поэтому я решил добавить ещё кодирование длин серий. Стоит отметить, что поскольку ID отсортированы, дельты гарантированно будут неотрицательными, благодаря чему становится возможным сдвоенное дельта-кодирование/RLE: интерпретируем отрицательное число n как серию из −n последовательных элементов, а неотрицательное число — как дельту от предыдущего элемента.

оригинал: [0, 70, 72, 73, 74, 75, 77, 78, 79, 80, 81]
оптимизированный: [0, 70, 2, -3, 2, -4]
impl Serialize for InternedSet {
    fn serialize(&self, serializer: S) -> Result
    where
        S: Serializer,
    {
        // Скомбинировали дельта-кодирование + RLE.
        let mut rle_encoded = Vec::with_capacity(self.set.len());
        let mut prev: Option = None;
        let mut streak: i32 = 0;

        for x in &self.set {
            let id = x.id();
            let diff = id - prev.unwrap_or(0);
            if prev.is_some() && diff == 1 {
                streak += 1;
            } else {
                if streak != 0 {
                    rle_encoded.push(-streak);
                    streak = 0;
                }
                rle_encoded.push(diff as i32);
            }
            prev = Some(id);
        }
        if streak != 0 {
            rle_encoded.push(-streak);
        }

        serializer.collect_seq(rle_encoded)
    }
}

Результаты последней оптимизации были неоднозначными: хотя закодированный размер снизился на 4–11%, сжатый размер остался почти неизменным — в пределах ±2% (коммит d098356).

78d6f916628706a19b29127ffac50495.png

Формат

Сериализация

gzip -6

brotli -6

xz -6

Postcard

2832574 (-5%)

751517 (+1%)

632425 (+1%)

504388 (+1%)

Bincode

4855254 (-11%)

825177 (+0%)

643860 (+2%)

516736 (+2%)

CBOR

3539237 (-4%)

813895 (-0%)

637836 (+0%)

495860 (+1%)

JSON

5103171 (-4%)

846290 (+1%)

714886 (+1%)

510584 (+1%)

JSON (pretty)

13361128 (-11%)

983777 (-1%)

823720 (+0%)

587044 (-1%)

Окончательный результат: легковесная база данных только для добавления

Чтобы воссоздать эти результаты, можно найти мой код на GitHub. Я использовал данные RATP Status из коммита ef028cc (файлы в папке datas/json/).

git init
git remote add origin https://github.com/wincelau/ratpstatus
git fetch --depth 1 origin ef028cce567b6ce9183a185e699206e0f483b99d
git checkout FETCH_HEAD

Концептуально, interning — это простая методика, однако мы уже увидели, что она может привести ко множеству вариантов и оптимизаций. Обычно она применяется к строкам, но на практике мы выиграли от interning всевозможных структур данных.

К концу этой статьи мы опосредованно создали одну из самых простых архитектур реляционных баз данных (без части, относящейся к запросам/SQL). Она обладает некоторыми ограничениями, приемлемыми при работе с временными последовательностями:

  • только добавление данных: мы не можем ни изменять, ни удалять имеющиеся объекты,

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

  • один писатель: для выполнения инкремента при интернировании нового объекта без синхронизации.

Также относительно легко должно оказаться добавление инкрементных обновлений: учитывая, что таблицы interning предназначены только для добавления данных с инкрементными индексами, можно с лёгкостью создать снэпшот базы данных, а позже создать diff, содержащий только новые объекты, добавленные после снэпшота. Тогда поверх можно реализовать множественные узлы-читатели, и таким образом мы получим базу данных с дублированием: писатель время от времени будет вещать читателям инкрементные обновления.

А следует ли на практике использовать один из бесчисленного количества крейтов для interning, или их следует писать самостоятельно? Ответ зависит от того, что вы хотите сделать!

Вам важна очень оптимизированная структура в памяти для строк? Возможно, вам идеально подойдёт показанное мной наивное представление Rc. Вас устроит единственный глобальный интернер или вы хотите манипулировать локальными аренами интернеров, как показано в этом посте? Вам нужно сериализовать данные, и если да, то должна ли сериализация быть компактной?

Вы можете сами придумать паттерн для interning, даже не зная его названия и того, что его реализует множество библиотек: это тоже абсолютно приемлемо! Надеюсь, этот пост поможет вам оценить существующие библиотеки и принять осознанное решение о том, нужно ли заново изобретать велосипед.

Приложение: оптимизируем функцию interning

Пользователи Reddit предложили мне два способа улучшения реализации interning.

  • watsonborn порекомендовал использовать HashMap::entry() API, чтобы не искать значение дважды при его вставке в интернер.

  • angelicosphosphoros порекомендовал использовать для манипуляций со строками Rc вместо Rc.

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

Непосредственное применение HashMap::entry() не будет оптимальным, потому что он требует передачи полного ключа, то есть всегда понадобится создавать Rc , даже если мы просто выполняем поиск (при котором будет достаточно передачи &T).

impl Interner {
    fn intern(&mut self, value: T) -> u32 {
        // Функция entry() требует создания Rc.
        let rc: Rc = Rc::new(value);
        *self.map.entry(Rc::clone(&rc)).or_insert_with(|| {
            let id = self.vec.len();
            assert!(id <= u32::MAX as usize);
            self.vec.push(rc);
            id as u32
        })
    }
}

Однако существует более эффективный HashMap::raw_entry_mut() API, доступный в nightly-фиче hash_raw_entry (или в крейте hashbrown стабильного Rust). Этот API позволяет начала поискать элемент с тем, что заимствует ключ (в нашем случае ссылка &T), и позже развернуть полный ключ Rc только в случае, если элемент пуст. Это максимальная оптимизация, доступная для map API в Rust.

#![feature(hash_raw_entry)]

impl Interner {
    fn intern(&mut self, value: T) -> u32 {
        let (_, id) = self
            .map
            .raw_entry_mut()
            // Здесь мы передаём &T для поиска.
            .from_key(&value)
            .or_insert_with(|| {
                let id = self.vec.len();
                assert!(id <= u32::MAX as usize);

                let rc: Rc = Rc::new(value);
                self.vec.push(Rc::clone(&rc));
                // Здесь мы передаём Rc для вставки.
                (rc, id as u32)
            });
        *id
    }
}

Что касается второй рекомендации, то с ней не всё так просто: хотя Rc занимает меньше места в памяти и избегает уровня разыменования указателя, преобразование из String в Rc требует клонирования всего содержимого строки. Однако затраты на это клонирование возникают только при вставке новых строк, поэтому можно надеяться, что они будут амортизированы с запасом в случае многократного повторения одинаковых строк (в чём и заключается смысл interning).

Или же можно использовать что-то типа крейта CompactStr (выполняющего оптимизацию малых строк) для экономии места в памяти.

Но что потребуется для поддержки Interner? Сначала нам нужно добавить повсюду явное ограничение T: ?Sized, так как срез строки str — это динамический тип. Во-вторых, мы не можем вызывать функцию с обычным параметром T = str, потому что он динамический. Вместо этого, например, можно использовать трейты преобразования Borrow и Into, чтобы создать более гибкий обобщённый API.

Если объединить две рекомендации, то функция interning принимает показанный ниже вид. Вместо получения T она принимает любой параметр, который (1) можно заимствовать как &T (чтобы искать его в map) и (2) можно преобразовать в Rc (чтобы вставлять его в map).

impl Interner {
    fn intern(&mut self, value: impl Borrow + Into>) -> u32 {
        let (_, id) = self
            .map
            .raw_entry_mut()
            // Поиск принимает любой тип, который можно заимствовать как &T.
            .from_key(value.borrow())
            .or_insert_with(|| {
                let id = self.vec.len();
                assert!(id <= u32::MAX as usize);
                let id = id as u32;

                // Вставка принимает любой тип, который можно преобразовать в Rc.
                let rc: Rc = value.into();
                self.vec.push(Rc::clone(&rc));
                (rc, id)
            });
        *id
    }
}

Это позволяет передавать Interner широкий спектр типов: заимствованный срез строки &str, принадлежащую строку String, boxed-срез строки Box, срез строки с копированием при записи Cow<'_, str>, и Rc и так далее. Однако стоит учитывать, что преобразование большинства этих типов в Rc (для пути вставки) вызовет клонирование содержимого строки.

Это изменение также потребовало изменений в логике оценки размера и десериализации, подробности приведены в коммите 279e2cb.

  1. And no, RATP status isn«t implemented in Rust. A PHP website can be blazingly fast too!  ↩

  2. In my opinion, this approach, made popular by formats like Protocol Buffers, may unfortunately give a false sense of «out-of-the-box» versioning. If you«re changing the semantics of your system, you almost always need to write additional code to ensure compatibility between the various versions. Out-of-the-box versioning can prevent outright outages in the short term, but also makes it easier to forget writing the needed compatibility code and to introduce more subtle differences due to ignoring unknown but important fields. This can cause errors, data corruption, instabilities or even other outages in the longer term. ↩

© Habrahabr.ru