На курсах по программированию связные списки преподаются как одна из фундаментальных структур данных, но на самом деле такие списки чаще встречаются на технических собеседованиях, чем в реальных проектах.

В этом посте будет продемонстрирован практический пример, в котором связный список существенно обгоняет Vec. Мы напишем простую библиотеку для валидации данных, которая будет показывать, где именно находится ошибка в невалидном вводе. Здесь будет наглядно показано, как можно использовать связные списки при обходе графа.

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

Мы начнём с азбучной реализации, а потом будем постепенно её оптимизировать и рассматривать, как это отразится на производительности.

От читателя поста ожидается, что он на базовом уровне понимает Rust, обычные структуры данных, а также концептуально представляет, как выделяется память (в стеке и куче).

Дополнение (14.05.2024): Я учёл поступившую обратную связь и подчеркнул, какие идеи объективно плохи, прояснил некоторые отступления и удалил идею о imbl.

Чтобы было проще прослеживать этапы реализации и исследовать код, отсылаю вас к репозиторию, сопровождающему этот пост.

Валидационный API

Наша библиотека минималистична, семантически соответствует схеме JSON:

use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    jsonschema::validate(
      // Образец JSON для валидации
      &json!({
          "name": "John",
          "location": {
               "country": 404
          }
      }),
      // Схема JSON 
      &json!({
          "properties": {
              "name": {
                  "type": "string"
              },
              "location": {
                  "properties": {
                      "country": {
                          "type": "string"
                      }
                  }
              }
          }
      }),
    ).expect_err("Should fail");
    Ok(())
}

В данном примере 404 — не строка, и этот код должен приводить к примерно такой ошибке:

404 is not of type ‘string’ at /location/country
 |                             |               |
 |                              ---------------
 |                                     |
 |                                     Location within the JSON instance
 |
  Failing value

Эта библиотека и изложенные в статье идеи оптимизации выведены на базе работы с моим крейтом jsonschema.

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

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

Не слишком углубляясь в детали реализации Validator и Node, рассмотрим процесс валидации в упрощённом виде, без отслеживания местоположения:

type ValidationResult = Result<(), ValidationError>;

fn validate(instance: &Value, schema: &Value) -> ValidationResult {
    Validator::new(schema)
        .expect("Invalid schema")
        .validate(instance)
}

struct ValidationError {
    message: String,
}

impl Validator {
    /// Проверяем экземпляр JSON при помощи этого валидатора
    fn validate(&self, instance: &Value) -> ValidationResult {
        self.node.validate(instance)
    }
}

trait Node {
    fn validate(&self, instance: &Value) -> ValidationResult;
}

impl Node for Properties {
    fn validate(&self, instance: &Value) -> ValidationResult {
        if let Value::Object(object) = instance {
            // Перебираем свойства и валидируем их, если они имеются.
            for (key, value) in &self.properties {
                if let Some(instance) = object.get(key) {
                    // Делегируем проверку дочернему валидатору.
                    value.validate(instance)?;
                }
            }
        }
        Ok(())
    }
}

impl Node for Type {
    fn validate(&self, instance: &Value) -> ValidationResult {
        // ... 
    }
}

Функция validate на основе предоставленной схемы создаёт новый экземпляр Validator (это тоже граф), а затем вызывает его метод validate внутри экземпляра JSON. Типаж Node определяет метод validate, который должен быть реализован в каждом правиле валидации.

Притом, что в этой версии просто выдаются сообщения об ошибках, а местоположение ошибок не отслеживается, оно служит верхней планкой для последующих оптимизаций в рамках функции validate.

Расстановка бенчмарков

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

В этой схеме содержится 10 уровней вложенности, и из ключевых слов мы целенаправленно употребляем в ней лишь ключевые слова properties и type — их вполне достаточно, чтобы продемонстрировать издержки поведения path-tracking (отслеживания пути). Зато такой подход помогает упростить бенчмарки и сосредоточиться на исследовании производительности, а не на семантике схемы JSON Schema как таковой. Стоит отметить, что и с другими ключевыми словами поведение path-tracking практически не изменится.

{
    "properties":{
        "another":{
            "type":"string"
        },
        "inner":{
            "properties":{
                "another":{
                    "type":"string"
                },
                "inner":{
                    "properties":{
                        // И так далее вплоть до 10 уровня
                    }
                }
            }
        }
    }
}

В разных экземплярах 0, 5 или 10 уровней вложенности, в соответствии со структурой схемы. В валидных экземплярах на самом глубоком уровне присутствует строковое значение для свойства "another", а в невалидных экземплярах — целое число.

// Валидный - 5 уровней
{
    "inner":{
        "inner":{
            "inner":{
                "inner":{
                    "another":"hello"
                }
            }
        }
    }
}
// Невалидный - 5 уровней
{
    "inner":{
        "inner":{
            "inner":{
                "inner":{
                    "another":1
                }
            }
        }
    }
}

Чтобы сосредоточиться на производительности процесса валидации как такового, мы жёстко закодируем схему в Validator::new, а Validator будем собирать однократно, а затем переиспользовать.

Давайте измерим производительность при помощи criterion, для этого выполним следующую процедуру валидации:

const NUMBER_OF_ITERATIONS: usize = 10000;

fn benchmarks(c: &mut Criterion) {
    // сниппет ... 
    for instance in &benchmark.instances {
        c.bench_with_input(
            BenchmarkId::new(instance.kind, &instance.name),
            &instance.value,
            |b: &mut Bencher, value| {
                b.iter(|| {
                    for _ in 0..NUMBER_OF_ITERATIONS {
                        let _ = validator.validate(value);
                    }
                });
            },
        );
    }
}

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

Плохая идея № 1: клонировать Vec

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

fn validate(&self, instance: &Value) -> ValidationResult {
    // Начнём с пустого вектора
    self.node.validate(instance, vec![])
}

trait Node {
    // Добавим параметр `path`, по которому будем отслеживать, где сейчас находимся в экземпляре JSON 
    fn validate(&self, instance: &Value, path: Vec<&str>) -> ValidationResult;
}

impl Node for Properties {
    fn validate(&self, instance: &Value, path: Vec<&str>) -> ValidationResult {
        if let Value::Object(object) = instance {
            for (key, value) in &self.properties {
                if let Some((key, instance)) = object.get_key_value(key) {
                    // КЛОНИРУЕМ!
                    let mut path = path.clone();
                    path.push(key);
                    value.validate(instance, path)?;
                }
            }
        }
        Ok(())
    }
}

impl Node for Type {
    fn validate(&self, instance: &Value, path: Vec<&str>) -> ValidationResult {
        match (self, instance) {
            // ... Сравним тип `instance` с ожидаемым типом
            _ => Err(ValidationError::new(
                format!("{instance} is not of type '{self}'"),
                // Преобразуем путь в итератор
                path.into_iter(),
            )),
        }
    }
}

Теперь этот путь хранится в структуре ValidationError:

struct ValidationError {
    message: String,
    /// Где именно находится ошибка в экземпляре, полученном на вход.
    location: Vec<String>,
}

impl ValidationError {
    /// Создаём новую ошибку валидации.
    fn new(
        message: impl Into<String>,
        // Принимаем итератор и преобразуем его в `Vec<String>`
        location: impl Iterator<Item = impl Into<String>>,
    ) -> Self {
        Self {
            message: message.into(),
            location: location.map(Into::into).collect(),
        }
    }
}

Если вы уже имеете опыт программирования на Rust, то, вероятно, узнаёте вызов clone() как типичное «решение» проблем с временами жизни и мутабельностью. Притом, что в некоторых ситуациях такое решение приемлемо (в зависимости от того, какие у вас ограничения по производительности и требования к поддержке продукта), зачастую такой вызов сигнализирует, что в коде есть место для оптимизации. Давайте воспользуемся здесь такой возможностью.

Примечание: в таблице та идея, которую мы рассматриваем сейчас, сравнивается с более ранней.

Клонирование — в принципе плохая идея, но на практике такой подход встречается часто, когда от разработчика требуется решить задачу «дёшево и сердито», либо когда о других вариантах он просто не знает. Я и сам неоднократно так делал.

Давайте визуализируем самый медленный «валидный» бенчмарк при помощиcargo flamegraph, чтобы было понятнее, что происходит. Как и ожидалось «перевыделение памяти» отражается на диаграмме:

Такие диаграммы, называемые «флеймграфами», отлично подходят для трассировки стека, подробнее см. здесь.

Нормальная идея №2: переиспользовать выделенную память

Правда, как вы уже могли заметить, совсем не обязательно клонировать данные. Что если менять один и тот же вектор, заталкивая в него сегменты и выталкивая их из него по мере обхода?

trait Node {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: &mut Vec<&'a str>,
    ) -> ValidationResult;
}

impl Node for Properties {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: &mut Vec<&'a str>,
    ) -> ValidationResult {
        // ...
                    path.push(key);
                    value.validate(instance, path)?;
                    path.pop();
        // ...
    }
}

impl Node for Type {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: &mut Vec<&'a str>,
    ) -> ValidationResult {
        match (self, instance) {
            // ... Compare `instance` type with expected type
            _ => Err(ValidationError::new(
                format!("{instance} is not of type '{self}'"),
                path.iter().copied(),
            )),
        }
    }

Аннотации времени жизни ('a) требуются здесь потому, что параметр пути — это изменяемая ссылка на Vec, содержащий ссылки на параметр  instance. Время жизни 'a гарантирует, что ссылки в path не переживут тот instance, на который указывают.

Действительно, пользуясь &mut Vec , можно значительно повысить производительность по сравнению с упрощённым подходом. В данном случае мы многократно используем один и тот же участок памяти, выделенный из кучи, а не создаём множество клонов. Правда, этот подход немного сложнее с точки зрения учёта данных, а также требует дополнительно аннотировать время жизни.

Именно эту идею возьмём за основу при сравнении. Она подобна той, которую я исходно зафиксировал в моём крейте jsonschema.

Идея №3, получше: связный список

Правда, а есть ли вообще необходимость выделять память кучи под вектор при обходе? Подумайте: для каждого узла во входном значении, который требуется обойти, делается соответствующий вызов функции validate со своим собственным стековым кадром. Поэтому фрагменты пути могут храниться в этих стековых кадрах, и полностью отпадает необходимость выделять память из кучи.

Сделаем шаг назад и рассмотрим эту ситуацию под немного иным углом — так станут нагляднее некоторые идеи, которые не слишком очевидны на низком уровне.

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

/// Узел в связном списке, представляющий собой указатель JSON.
struct JsonPointerNode<'a, 'b> {
    segment: Option<&'a str>,
    parent: Option<&'b JsonPointerNode<'a, 'b>>,
}
impl<'a, 'b> JsonPointerNode<'a, 'b> {
    /// Создать корневой узел указателя JSON 
    const fn new() -> Self {
        JsonPointerNode {
            segment: None,
            parent: None,
        }
    }
    /// Добавить новый сегмент к указателю JSON.
    fn push(&'a self, segment: &'a str) -> JsonPointerNode<'a, 'b> {
        JsonPointerNode {
            segment: Some(segment),
            parent: Some(self),
        }
    }
    /// Преобразовать узел указателя JSON в вектор, содержащий элементы пути.
   fn to_vec(&'a self) -> Vec<&'a str> {
        // Собрать сегменты по направлению от головы к хвосту 
        let mut buffer = Vec::new();
        let mut head = self;
        if let Some(segment) = &head.segment {
            buffer.push(*segment);
        }
        while let Some(next) = head.parent {
            head = next;
            if let Some(segment) = &head.segment {
                buffer.push(*segment);
            }
        }
        // Обратить порядок буфера, чтобы поставить сегменты в правильном порядке 
        buffer.reverse();
        buffer
    }
}

Теперь можно заменить &mut Vec:

fn validate(&self, instance: &Value) -> ValidationResult {
    self.node.validate(instance, JsonPointerNode::new())
}

trait Node {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: JsonPointerNode<'a, '_>,
    ) -> ValidationResult;
}

impl Node for Properties {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: JsonPointerNode<'a, '_>,
    ) -> ValidationResult {
        // ...
                    value.validate(instance, path.push(key.as_str()))?;
        // ...
}

impl Node for Type {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: JsonPointerNode<'a, '_>,
    ) -> ValidationResult {
        match (self, instance) {
            // ... Сравнить тип `instance` с ожидаемым типом
            _ => Err(ValidationError::new(
                format!("{instance} is not of type '{self}'"),
                path.to_vec().into_iter(),
            )),
        }
    }
}

Во время обхода никакая память из кучи не выделяется! Помогло ли это?

Ух! При работе с валидным вводом так получается значительно лучше! С невалидными образцами всё примерно как и раньше, но мы пока не оптимизировали реализацию связного списка.

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

Обратите внимание: связные списки уступают векторам по показателю локальности кэша, и в некоторых сценариях из-за этого может замедляться производительность.

Хорошая идея №4: прицельное выделение памяти

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

Очевидно, проблема заключается в повторном выделении памяти внутри JsonPointerNode::to_vec. Этого можно избежать, выделив ровно столько памяти, сколько нужно. Для этого потребуется лишний раз обойти связный список, чтобы вычислить мощность, но выигрыш в производительности, который приобретается благодаря избавлению от перевыделений, с верхом компенсирует издержки на этот дополнительный обход:

impl<'a, 'b> JsonPointerNode<'a, 'b> {
    pub(crate) fn to_vec(&'a self) -> Vec<&'a str> {
        // Обходим связный список для расчёта мощности
        let mut capacity = 0;
        let mut head = self;
        while let Some(next) = head.parent {
            head = next;
            capacity += 1;
        }
        // Собрать сегменты по направлению от головы к хвосту
        let mut buffer = Vec::with_capacity(capacity);
        let mut head = self;
        if let Some(segment) = &head.segment {
            buffer.push(*segment);
        }
        while let Some(next) = head.parent {
            head = next;
            if let Some(segment) = &head.segment {
                buffer.push(*segment);
            }
        }
        // Обратить порядок буфера, чтобы поставить сегменты в правильном порядке
        buffer.reverse();
        buffer
    }
}

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

Хорошая идея №5: избегать временных Vec

На данном этапе временные Vec — наиболее серьёзный фактор, замедляющий работу с невалидным вводом. Если подробнее рассмотреть реализацию ValidationError, заметим в ней вызов collect. Он свидетельствует, что сначала мы собираем Vec<&str> в JsonPointerNode::to_vec, а потом почти сразу же собираем из него  Vec<String>, то есть выделяем Vec дважды. Почему бы нам не начать с Vec<String>:

impl ValidationError {
    fn new(message: impl Into<String>, location: Vec<String>) -> Self {
        Self {
            message: message.into(),
            location,
        }
    }
}

impl Node for Type {
    fn validate<'a>(
        &self,
        instance: &'a Value,
        path: JsonPointerNode<'a, '_>,
    ) -> ValidationResult {
        match (self, instance) {
            // ... Сравнить тип `instance` с ожидаемым
            _ => Err(ValidationError::new(
                format!("{instance} is not of type '{self}'"),
                path.to_vec(),
            )),
        }
    }
}

impl<'a, 'b> JsonPointerNode<'a, 'b> {
    pub(crate) fn to_vec(&self) -> Vec<String> {
        // ...
        if let Some(segment) = &head.segment {
            buffer.push((*segment).to_string());
        }
        while let Some(next) = head.parent {
            head = next;
            if let Some(segment) = &head.segment {
                buffer.push((*segment).to_string());
            }
        }
        // ...
    }
}

Такая оптимизация позволяет заметно улучшить производительность при обработке невалидных случаев:

Потенциально хорошая идея №6: оптимизировать размер структуры

Иногда стоит попытаться уменьшить размер структур, в особенности, если структура часто передаётся по значению. Если структуры небольшие, то память на них расходуется  меньше, а функции вызываются быстрее, причём приходится копировать в стек меньше данных.

Если бы мы попытались отслеживать не только ключи в объектах JSON, но и индексы в массивах, то это можно было бы сделать при помощи перечисления, вот так:

enum Segment<'a> {
    /// Имя свойства внутри объекта JSON.
    Property(&'a str),
    /// Индекс внутри массива JSON.
    Index(usize),
}

struct JsonPointerNode<'a, 'b> {
    segment: Option<Segment<'a>>,
    parent: Option<&'b JsonPointerNode<'a, 'b>>,
}

В таком случае структура JsonPointerNode будет занимать 32 байта:

_eq!(std::mem::size_of::<JsonPointerNode>(), 32);

Однако, избегая Option в поле segment, можно сократить размер структуры до 24 байт. Идея в том, чтобы положить в корневой узел какое-нибудь дешёвое значение и никогда его не читать:

struct JsonPointerNode<'a, 'b> {
    segment: Segment<'a>,
    parent: Option<&'b JsonPointerNode<'a, 'b>>,
}
impl<'a, 'b> JsonPointerNode<'a, 'b> {
    /// Создать корневой узел указателя JSON 
    const fn new() -> Self {
        JsonPointerNode {
            // Данное значение несущественно, оно никогда использоваться не будет
            segment: Segment::Index(0),
            parent: None,
        }
    }
   fn push(&'a self, segment: Segment<'a>) -> JsonPointerNode<'a, 'b> {
        JsonPointerNode {
            segment,
            parent: Some(self),
        }
    }
    /// Преобразуем указатель JSON в вектор элементов пути.
    pub(crate) fn to_vec(&self) -> Vec<String> {
        // ...
        if head.parent.is_some() {
            buffer.push(head.segment.to_string())
        }
while let Some(next) = head.parent {
            head = next;
            if head.parent.is_some() {
                buffer.push(head.segment.to_string());
            }
        }
        // ...
    }
}

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

Другие идеи, не доведённые до ума

Думаю, также можно было бы немного нарастить производительность, избавившись от вызова reverse в JsonPointerNode::to_vec, поскольку он тоже перекидывает данные. Есть, например, такой способ: присваивать сегменты, начиная с задней части вектора, заполненного значениями, задаваемыми по умолчанию. Но для такой записи в обратном порядке потребуется вести дополнительный учёт, который может нивелировать наши приобретения. Поэтому важно профилировать любые изменения и расставлять бенчмарки — так гарантируется, что по каждому практическому случаю можно будет достичь измеримой выгоды.

Ещё одна идея — хранить ссылки на сегменты пути внутри ValidationError, а не клонировать строки:

struct ValidationError<'a> {
    message: String,
    location: Vec<&'a str>,
}

Таким образом, можно обойтись без клонирования сегментов пути, а вместо этого хранить ссылки на них. Так можно было бы повысить производительность, особенно, если приходится иметь дело с длинными путями или большим количеством ошибок. Однако, при таком подходе  ValidationError станет менее гибкой, поскольку будет привязана к времени жизни входных данных JSON.

Отличная идея № 7: может быть, связный список вам не нужен?

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

impl ValidationError {
    pub(crate) fn push_segment(&mut self, segment: String) {
        self.location.push(segment);
    }
    pub(crate) fn finish(mut self) -> ValidationError {
        self.location.reverse();
        self
    }
}
fn validate(&self, instance: &Value) -> ValidationResult {
    if let Err(error) = self.node.validate(instance, 0) {
        // Обратить сегменты пути в методе `finish` 
        Err(error.finish())
    } else {
        Ok(())
    }
}

impl Node for Properties {
    fn validate(&self, instance: &Value, level: u32) -> ValidationResult {
        // ... 
            for (key, value) in &self.properties {
                if let Some(instance) = object.get(key) {
                    if let Err(mut error) = value.validate(instance, level + 1) {
                        error.push_segment(key.to_string());
                        return Err(error);
        // ...
    }
}

impl Node for Type {
    fn validate(&self, instance: &'a Value, level: u32) -> ValidationResult {
        // ...
            _ => Err(ValidationError::new(
                format!("{instance} is not of type '{self}'"),
                Vec::with_capacity(level as usize)
        // ...
    }
}

Правда, в этом примере мне пока не удалось добиться стабильного улучшения бенчмарков :(

Ещё один плюс может быть связан с тем, что мы больше не используем оператор ?. Ведь при обращении с ним выполняется преобразование From/Into, но у нас предусмотрен всего один тип ошибок, и нам не требуется его преобразовывать. Именно поэтому в serde есть собственный макрос  tri!, используемый вместо ?;.

Заключение

В конечном счёте, нам удалось повысить производительность приблизительно в 1,9/1,3 раза (по сравнению с исходной реализацией) – соответственно, в сценариях с валидным и невалидным вводом. В целом эта фича даёт выигрыш в 18% / 95% сверх версии, в которой не используется путь!

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

Выводы

  1. Упрощённый подход может быть вполне хорош

  2. Проблему нужно рассматривать в разных масштабах

  3. Нужно подыскивать такую структуру данных, которая решает стоящую перед вами проблему

  4. Выделяйте ровно столько памяти, сколько нужно, и тогда, когда это необходимо

  5. Если какие‑то значения приходится часто передавать — постарайтесь уменьшить их размер

  6. Если пишете посты — избегайте употреблять в заголовке слово «сверхскоростные»

Спасибо за внимание!

© 2021-2024 Dmitry Dygalo

Комментарии (1)


  1. domix32
    06.06.2024 12:31
    +2

    Непонятно зачем ему в принципе String если он их никак не мутирует. В теории вообще можно было хранить только слайсы на куски исходной json строки.