#序列化 #零拷贝 #反序列化 #mmap #固定大小 #数据结构 #内存区域

无需 std epserde

ε-serde 是一个 ε-copy(即几乎零拷贝)序列化和反序列化框架

16 个版本 (5 个重大变更)

0.6.1 2024年6月3日
0.5.1 2024年4月7日
0.5.0 2024年3月18日
0.3.0 2023年11月23日

#195 in 解析器实现

Download history 160/week @ 2024-04-26 275/week @ 2024-05-03 105/week @ 2024-05-10 131/week @ 2024-05-17 160/week @ 2024-05-24 474/week @ 2024-05-31 125/week @ 2024-06-07 113/week @ 2024-06-14 91/week @ 2024-06-21 50/week @ 2024-06-28 85/week @ 2024-07-05 43/week @ 2024-07-12 74/week @ 2024-07-19 138/week @ 2024-07-26 82/week @ 2024-08-02 74/week @ 2024-08-09

每月376次下载
6 包中使用(4 个直接使用)

Apache-2.0 OR LGPL-2.1-or-later

135KB
2K SLoC

ε-serde

downloads dependents GitHub CI license Latest version Documentation

ε-serde 是一个 Rust 框架,用于 ε-copy 序列化和反序列化。

为什么

大型不可变数据结构使用 serde 方法反序列化需要时间。解决这个问题的可能方案包括如 serdeAbomonationrkivzerovec 这样的框架,它们提供 零拷贝 反序列化:序列化数据结构的字节流可以直接用作 Rust 结构。特别是,这种方法使得将磁盘上的数据结构映射到内存中成为可能,使其立即可用。它还使得在具有特定属性(如 Linux 上的透明大页)的内存区域中加载数据结构成为可能。即使使用标准内存加载,反序列化也更快,因为整个结构可以通过单个读取操作加载。

ε-serde 与上述零拷贝框架具有相同的目标,但提供了不同的权衡。

如何

由于在这些数据结构中,通常大部分数据都是由切片或向量的形式的大块内存组成的,因此在反序列化时,可以快速构建一个适当的 Rust 结构,其引用的内存则不进行复制。我们称这种方法为 ε-copy 反序列化,因为通常只有极小部分序列化数据被复制来构建结构。结果是类似于上述框架的,但反序列化结构的性能将与标准内存中的 Rust 结构相同,因为引用在反序列化时得到解析。

我们提供实现序列化和反序列化方法的进程宏,包括基本(反)序列化原语类型、向量等,基于 mmap_rs 的便捷内存映射方法,以及一个将反序列化结构与其后端(例如内存切片或内存映射区域)耦合的 MemCase 结构。

Tommaso Fontana 在 INRIA 在 Stefano Zacchiroli 的指导下工作期间,提出了 ε-serde 的基本想法,即用等效引用替换结构。代码是与 Sebastiano Vigna 共同开发的,他提出了 MemCaseZeroCopy/DeepCopy 逻辑。

缺点

在选择使用 ε-serde 之前,您应该了解以下主要限制

  • 您的类型不能包含引用。例如,您不能在树结构上使用 ε-serde。

  • 虽然我们提供了实现序列化和反序列化的进程宏,但它们要求您的类型以特定的方式编写和使用;特别是,您想要 ε-复制的字段必须实现 DeserializeInner,它关联一个 反序列化类型。例如,我们为 Vec<T>/Box<[T]> 提供了实现,其中 T 是零复制,或 String/Box<str>,分别具有关联的反序列化类型 &[T]&str。非零复制类型的向量及其boxed切片将在内存中进行递归反序列化。

  • 在反序列化类型 T 之后,您将获得与之关联的反序列化类型 DeserType<'_,T>,它通常引用底层序列化支持(例如,内存映射区域);因此需要生命周期。如果您需要将反序列化的结构存储在新结构的字段中,则需要永久性地将反序列化结构与它的序列化支持绑定在一起,这可以通过使用便利方法 Deserialize::load_memDeserialize::load_mmapDeserialize::mmap 将它放入 MemCase 来实现。一个 MemCase 将解引用其包含的类型,因此在字段和方法方面可以透明地使用,但如果你原始的类型是 T,则新结构的字段必须为 MemCase<DeserType<'static, T>> 类型,而不是 T 类型。

优点

  • 几乎即时的反序列化,前提是你设计的类型遵循 ε-serde 指南或使用标准类型。

  • 反序列化得到的结构与你序列化的结构相同,只是类型参数将被其关联的反序列化类型替换(例如,向量将变为切片的引用)。这与 rkiv 不同,rkiv 要求你重新实现反序列化类型的所有方法。

  • 反序列化得到的结构具有与序列化结构完全相同的性能。这与 zerovecrkiv 不同。

  • 您可以从只读支持中进行反序列化,因为反序列化时生成的所有动态信息都存储在新分配的内存中。这与 Abomonation 不同。

示例:标准类型的零拷贝

让我们从一个最简单的情况开始:可以零拷贝反序列化的数据。在这种情况下,我们序列化一个包含一千个零的数组,并返回一个对这种数组的引用

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;

let s = [0_usize; 1000];

// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized0");
s.serialize(&mut std::fs::File::create(&file)?)?;
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t will be inferred--it is shown here only for clarity
let t: &[usize; 1000] =
    <[usize; 1000]>::deserialize_eps(b.as_ref())?;

assert_eq!(s, *t);

// You can derive the deserialization type, with a lifetime depending on b
let t: DeserType<'_, [usize; 1000]> =
    <[usize; 1000]>::deserialize_eps(b.as_ref())?;

assert_eq!(s, *t);

// This is a traditional deserialization instead
let t: [usize; 1000] = 
    <[usize; 1000]>::deserialize_full(
        &mut std::fs::File::open(&file)?
    )?;
assert_eq!(s, t);

// In this case we map the data structure into memory
let u: MemCase<&[usize; 1000]> = 
    <[usize; 1000]>::mmap(&file, Flags::empty())?;

assert_eq!(s, **u);

// When using a MemCase, the lifetime of the derived deserialization type is 'static
let u: MemCase<DeserType<'static, [usize; 1000]>> = 
    <[usize; 1000]>::mmap(&file, Flags::empty())?;

assert_eq!(s, **u);
#     Ok(())
# }

注意我们序列化了一个数组,但我们反序列化了一个引用。这个引用指向 b 的内部,因此没有进行拷贝。对 deserialize_full 的调用创建了一个新的数组。第三个调用将数据结构映射到内存中,并返回一个可以透明地用作数组引用的 MemCase;此外,MemCase 可以传递给其他函数或存储在结构字段中,因为它包含结构和支持它的内存映射区域。

类型别名 DeserType 可以用来派生与类型关联的反序列化类型。它包含一个生命周期,即包含序列化数据的内存区域的生命周期。然而,当反序列化为 MemCase 时,生命周期是 'static,因为 MemCase 是一个所有者类型。

示例:标准结构的 ε-copy

零拷贝反序列化并不那么有趣,因为它只能应用于内存布局和大小在编译时已知且固定的数据。这次,让我们序列化一个包含一千个零的 Vec:ε-serde 将反序列化其关联的反序列化类型,这是一个切片的引用。

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;

let s = vec![0; 1000];

// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized1");
s.serialize(&mut std::fs::File::create(&file)?)?;
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t will be inferred--it is shown here only for clarity
let t: DeserType<'_, Vec<usize>> =
    <Vec<usize>>::deserialize_eps(b.as_ref())?;

assert_eq!(s, *t);

// This is a traditional deserialization instead
let t: Vec<usize> = 
    <Vec<usize>>::load_full(&file)?;
assert_eq!(s, t);

// In this case we map the data structure into memory
let u: MemCase<DeserType<'static, Vec<usize>>> = 
    <Vec<usize>>::mmap(&file, Flags::empty())?;
assert_eq!(s, **u);
#     Ok(())
# }

注意我们如何序列化一个向量,但反序列化一个切片的引用;序列化boxed切片时也会发生同样的事情。引用指向 b 内部,因此执行的拷贝非常少(实际上,只是一个包含切片长度的字段)。所有这些都是因为 usize 是一个零拷贝类型。还要注意我们使用了便利方法 Deserialize::load_full

然而,如果您的代码必须同时与原始版本和反序列化版本一起工作,则它必须编写为实现由这两种类型实现的特质的代码,例如 AsRef<[usize]>

示例:零拷贝结构

您可以将您的类型定义为零拷贝,在这种情况下,它们将像前例中的 usize 一样工作。这要求结构由零拷贝字段组成,并且需要使用 #[zero_copy]#[repr(C)] 注释(这意味着您将失去编译器重新排序字段以优化内存使用的可能性)

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq, Copy, Clone)]
#[repr(C)]
#[zero_copy]
struct Data {
    foo: usize,
    bar: usize,
}

let s = vec![Data { foo: 0, bar: 0 }; 1000];

// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized2");
s.serialize(&mut std::fs::File::create(&file)?)?;
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t will be inferred--it is shown here only for clarity
let t: DeserType<'_, Vec<Data>> =
    <Vec<Data>>::deserialize_eps(b.as_ref())?;

assert_eq!(s, *t);

// This is a traditional deserialization instead
let t: Vec<Data> = 
    <Vec<Data>>::load_full(&file)?;
assert_eq!(s, t);

// In this case we map the data structure into memory
let u: MemCase<DeserType<'static, Vec<Data>>> = 
    <Vec<Data>>::mmap(&file, Flags::empty())?;
assert_eq!(s, **u);
#     Ok(())
# }

如果一个结构不是零拷贝,那么结构体的向量总是会被反序列化为向量。

示例:带参数的结构

通过定义字段类型由参数定义的结构,可以获得更多的灵活性。在这种情况下,ε-serde 将用关联的反序列化类型替换结构体的类型参数来反序列化结构。

让我们设计一个将包含一个整数(将被拷贝)和一个我们想要 ε-copy 的整数向量的结构

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq)]
struct MyStruct<A> {
    id: isize,
    data: A,
}

// Create a structure where A is a Vec<isize>
let s: MyStruct<Vec<isize>> = MyStruct { id: 0, data: vec![0, 1, 2, 3] };
// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized3");
s.store(&file);
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t will be inferred--it is shown here only for clarity
let t: DeserType<'_, MyStruct<Vec<isize>>> = 
    <MyStruct<Vec<isize>>>::deserialize_eps(b.as_ref())?;

assert_eq!(s.id, t.id);
assert_eq!(s.data, Vec::from(t.data));

// This is a traditional deserialization instead
let t: MyStruct<Vec<isize>> = 
    <MyStruct::<Vec<isize>>>::load_full(&file)?;
assert_eq!(s, t);

// In this case we map the data structure into memory
let u: MemCase<DeserType<'static, MyStruct<Vec<isize>>>> = 
    <MyStruct::<Vec<isize>>>::mmap(&file, Flags::empty())?;
assert_eq!(s.id, u.id);
assert_eq!(s.data, u.data.as_ref());
#     Ok(())
# }

注意原本包含 Vec<isize> 字段的现在包含了一个 &[isize](这种替换是自动生成的)。引用点在 b 内部,因此无需复制该字段。尽管如此,反序列化会创建一个新的结构体 MyStruct,并复制原始数据。第二次调用则创建了一个完整的副本。我们可以为我们的结构体编写方法,使其适用于 ε-复制的版本:我们只需确保它们是以可以同时在原始类型参数及其相关反序列化类型上工作的方式定义的;我们还可以使用 type 来减少冗余

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq)]
struct MyStructParam<A> {
    id: isize,
    data: A,
}

/// This method can be called on both the original and the ε-copied structure
impl<A: AsRef<[isize]>> MyStructParam<A> {
    fn sum(&self) -> isize {
        self.data.as_ref().iter().sum()
    }
}

type MyStruct = MyStructParam<Vec<isize>>;

// Create a structure where A is a Vec<isize>
let s = MyStruct { id: 0, data: vec![0, 1, 2, 3] };
// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized4");
s.store(&file);
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;
let t = MyStruct::deserialize_eps(b.as_ref())?;
// We can call the method on both structures
assert_eq!(s.sum(), t.sum());

let t = <MyStruct>::mmap(&file, Flags::empty())?;

// t works transparently as a MyStructParam<&[isize]>
assert_eq!(s.id, t.id);
assert_eq!(s.data, t.data.as_ref());
assert_eq!(s.sum(), t.sum());
#     Ok(())
# }

示例:带有内部参数的深拷贝结构体

内部参数,即那些用于字段类型但并不表示字段类型的参数,保持不变。然而,为了可序列化,它们必须被分类为深拷贝或零拷贝,并且在第一种情况下,它们必须具有 'static 生命周期。这个规则的唯一例外是 PhantomData 内部的类型,这些类型甚至不需要可序列化。例如,

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq)]
struct MyStruct<A: DeepCopy + 'static>(Vec<A>);

// Create a structure where A is a Vec<isize>
let s: MyStruct<Vec<isize>> = MyStruct(vec![vec![0, 1, 2, 3]]);
// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized4");
s.store(&file);
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t is unchanged
let t: MyStruct<Vec<isize>> = 
    <MyStruct<Vec<isize>>>::deserialize_eps(b.as_ref())?;
#     Ok(())
# }

注意原本类型为 Vec<Vec<isize>> 的字段保持相同类型。

示例:带有参数的零拷贝结构体

对于零拷贝结构体,事情略有不同,因为即使它们代表字段类型,类型也不会被替换。所以所有参数都必须是零拷贝。即使在 PhantomData 内部的类型也必须遵守这一规定。例如,

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq, Clone, Copy)]
#[repr(C)]
#[zero_copy]
struct MyStruct<A: ZeroCopy> {
    data: A,
}

// Create a structure where A is a Vec<isize>
let s: MyStruct<i32> = MyStruct { data: 0 };
// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized5");
s.store(&file);
// Load the serialized form in a buffer
let b = std::fs::read(&file)?;

// The type of t is unchanged
let t: &MyStruct<i32> = 
    <MyStruct<i32>>::deserialize_eps(b.as_ref())?;
#     Ok(())
# }

注意原本类型为 i32 的字段保持相同类型。

示例:枚举

枚举受到支持,但有两点需要注意:首先,如果您希望它们是零拷贝,则它们必须是 repr(C),因此您将失去编译器优化它们内存表示的可能性;其次,如果您有未在所有变体中使用的类型参数,您在序列化和反序列化时必须小心地指定相同的类型参数。对于非枚举类型来说这是显而易见的,但对于具有默认类型参数的枚举类型来说可能会变得复杂。例如,

# fn main() -> Result<(), Box<dyn std::error::Error>> {
use epserde::prelude::*;
use epserde_derive::*;

#[derive(Epserde, Debug, PartialEq, Clone, Copy)]
enum Enum<T=Vec<usize>> {
    A,
    B(T),
}

// This enum has T=Vec<i32> by type inference
let e = Enum::B(vec![0, 1, 2, 3]);
// Serialize it
let mut file = std::env::temp_dir();
file.push("serialized6");
e.store(&file);
// Deserializing using just Enum will fail, as the type parameter 
// by default is Vec<usize>
assert!(<Enum>::load_full(&file).is_err());
#     Ok(())
# }

示例:sux-rs

sux-rs 包提供了几个使用 ε-serde 的数据结构。

设计

每个可以与 ε-serde 序列化的类型都有两个特性,从原则上讲它们是正交的,但在实践中常常相互影响:

  • 该类型有一个相关的反序列化类型,这是您在反序列化时获得的数据类型;
  • 该类型可以是 ZeroCopyDeepCopy,或者两者都不是。

与关联的反序列化类型没有约束:它可以完全是任何类型。然而,通常情况下,人们尝试使用一种与原始类型在一定程度上兼容的反序列化类型,即它们都满足可以实现其实现的特质:例如,ε-serde将向量反序列化为切片的引用,因此可以编写切片引用的实现,它将在原始和反序列化类型上正常工作。此外,通常情况下,ZeroCopy类型将反序列化为自身。

成为ZeroCopyDeepCopy类型决定了序列(如数组、切片、boxed切片和向量)在序列化和反序列化时的处理方式。零拷贝类型的序列是使用引用进行反序列化的,而深拷贝类型的序列则递归地使用分配的内存进行反序列化(以关联的反序列化类型的序列)。重要的是要注意,您不能序列化一个元素类型既不是 ZeroCopy 也不是 DeepCopy的序列(有关更详细的解释,请参阅CopyType文档)。

从逻辑上讲,零拷贝类型应该反序列化为引用,这在大多数情况下确实发生了,尤其是在派生代码中:然而,原始类型始终会完全反序列化。这种非正交选择有两个原因

  • 原始类型占用的空间非常小,将其作为引用反序列化并不高效;
  • 如果类型参数T是原始类型,编写针对AsRef<T>的泛型代码并不优美;
  • 将原始类型反序列化为引用需要进一步的填充以进行对齐。

由于这仅适用于原始类型,在反序列化包含原始类型的单元素元组时,将获得一个引用(并且如果确实需要将原始类型作为引用进行反序列化,则可以使用此解决方案)。如果您反序列化包含单个原始类型字段的零拷贝结构体,也会发生相同的情况。

相反,深拷贝类型是按字段递归地序列化和反序列化的。ε-serde的基本思想是,如果字段具有参数类型,则在ε拷贝反序列化期间,类型将被替换为其反序列化类型。由于反序列化类型是递归定义的,替换可以在任何深度级别发生。例如,类型为A = Vec<Vec<Vec<usize>>>的字段将反序列化为A = Vec<Vec<&[usize]>>

这种方法使得能够编写ε-serde感知的结构,隐藏对用户的替换。一个很好的例子是来自sux-rsBitFieldVec结构,该结构通过(通常是)将Vec<usize>作为后端来暴露具有固定位宽的字段数组;除了扩展方法外,BitFieldVec的所有方法都来自特质BitFieldSlice。如果你有一个自己的结构,其中一个字段是类型A,当使用A等于BitFieldVec<Vec<usize>>序列化你的结构时,在ε复制反序列化后,你将得到一个包含BitFieldVec<&[usize]>的你的结构的版本。所有这些都将在幕后发生,因为BitFieldVec是ε-serde感知的,而且实际上,如果你使用特质BitFieldSlice访问这两个版本,你可能甚至不会注意到差异。

MemDbg / MemSize

所有ε-serde结构都实现了MemDbgMemSize特质。

派生和手动实现

我们强烈建议使用过程宏Epserde使你的类型可序列化和反序列化。只需在你的结构上调用宏,它就会使它完全与ε-serde兼容。可以使用属性#[zero_copy]使结构成为零拷贝,尽管它必须满足一些前提条件

你还可以手动实现特质CopyTypeMaxSizeOfTypeHashReprHashSerializeInnerDeserializeInner,但这个过程容易出错,你必须完全了解ε-serde的约定。过程宏TypeInfo可以用来自动生成至少MaxSizeOfTypeHashReprHash

致谢

本软件部分得到欧盟资助的NRRP MUR项目SERICS(PE00000014)的支持,该项目由欧盟-NGEU资助,以及法国国家研究署的项目ANR COREGRAPHIE,拨款ANR-20-CE23-0002。

依赖关系

~3–32MB
~436K SLoC