Сверхскоростные связные списки

от автора

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

В этом посте будет продемонстрирован практический пример, в котором связный список существенно обгоняет 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


ссылка на оригинал статьи https://habr.com/ru/articles/819693/


Комментарии

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

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