9个不稳定版本 (3个重大更新)
0.6.0 | 2024年6月3日 |
---|---|
0.3.1 | 2024年4月7日 |
0.3.0 | 2023年11月23日 |
0.2.2 | 2023年11月13日 |
0.1.1 | 2023年9月30日 |
#45 在 #mmap
每月322次下载
在 2 crate 中使用
79KB
931 行
ε-serde
ε-serde 是一个用于 ε-拷贝序列化和反序列化的 Rust 框架。
为什么
大型不可变数据结构使用 serde 方法进行反序列化需要花费时间。针对此问题,一些框架如 Abomonation、rkiv 和 zerovec 提供了零拷贝反序列化,可以直接将序列化数据结构的字节流用作 Rust 结构。特别是,这种方法可以将磁盘上的数据结构映射到内存中,使其立即可用。它还使数据结构可以在具有特定属性的区域中加载,例如 Linux 上的透明大页。即使在标准内存加载时,由于整个结构可以通过单一读取操作加载,反序列化和反序列化也会更快。
ε-serde 与上述零拷贝框架具有相同的目标,但提供了不同的权衡。
如何
由于在这些数据结构中,通常大部分数据由切片或向量的内存大块组成,因此在反序列化时,可以快速构建一个合适的 Rust 结构,其引用的内存却不会复制。我们称这种方法为“ε-拷贝反序列化”,因为通常只有极小部分的序列化数据被复制以构建结构。结果是类似的,但反序列化结构的性能将与标准内存中的 Rust 结构相同,因为引用在反序列化时解析。
我们提供了实现序列化和反序列化方法的程序宏,包括基本(反)序列化原始类型、向量等,基于 mmap_rs 的便利内存映射方法,以及一个将反序列化结构与后端(例如内存切片或内存映射区域)耦合的 MemCase
结构。
作者
Tommaso Fontana 在INRIA工作,在Stefano Zacchiroli的指导下,提出了 ε-serde 的基本想法,即用等效引用替换结构。代码是由Sebastiano Vigna共同开发的,他提出了 MemCase
以及 ZeroCopy
/DeepCopy
逻辑。
局限性
在选择使用 ε-serde 之前,您应该了解以下主要局限性:
-
您的类型不能包含引用。例如,您不能在树结构上使用 ε-serde。
-
虽然我们提供了实现序列化和反序列化的程序宏,但它们要求您的类型以特定的方式进行编写和使用;特别是,您希望 ε-复制的字段必须是实现
DeserializeInner
的泛型参数,与一个 反序列化类型 相关联。例如,我们为Vec<T>
/Box<[T]>
提供了实现,其中T
是零拷贝,或者String
/Box<str>
,它们分别关联的反序列化类型是&[T]
或&str
。非零拷贝类型的向量及其boxed切片将在内存中进行递归反序列化。 -
在反序列化类型
T
之后,您将获得与之关联的反序列化类型DeserType<'_,T>
,它通常引用底层序列化支持(例如,内存映射区域);因此需要生命周期。如果您需要将反序列化结构存储在新结构的字段中,则需要永久地将反序列化结构与它的序列化支持绑定在一起,这可以通过使用方便的方法MemCase
实现,例如Deserialize::load_mem
、Deserialize::load_mmap
和Deserialize::mmap
将其放入。一个MemCase
将解引用到其包含的类型,因此只要涉及字段和方法,就可以透明地使用它,但如果您的原始类型是T
,则新结构的字段必须为MemCase<DeserType<'static, T>>
类型,而不是T
类型。
优点
-
几乎立即的反序列化,分配最少,前提是您按照 ε-serde 指南设计您的类型或使用标准类型。
-
通过反序列化得到的结构与您序列化的结构相同,只是类型参数将被它们的关联反序列化类型所替换(例如,向量将变为切片的引用)。这与 rkiv 的情况不同,它要求您在反序列化类型上重新实现所有方法。
-
您可以从只读支持中进行反序列化,因为所有在反序列化时生成的动态信息都存储在新生成的内存中。这与 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(())
# }
注意我们如何序列化一个向量,但反序列化一个切片的引用;在序列化装箱切片时也会发生同样的事情。引用指向 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
生命周期。例如,
# 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>>
的字段保持相同类型。
示例:带有参数的零复制结构体
对于零复制结构体,情况略有不同,因为即使它们代表了字段类型,类型也不会被替换。因此,所有参数都必须是零复制。例如,
# 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
提供几个使用 ε-serde 的数据结构的 sux-rs
包。
设计
每个使用 ε-serde 可序列化的类型有两个在原则上正交但实践中经常相互影响的特性
关联的反序列化类型没有约束:可以是任何东西。然而,通常来说,人们会尝试使用一种与原始类型有一定兼容性的反序列化类型,即在它们都满足可以为其编写实现的特质的意义上:例如,ε-serde将向量反序列化为切片的引用,因此可以为其编写切片引用的实现,它们将在原始类型和反序列化类型上均能工作。并且,在一般情况下,ZeroCopy
类型的反序列化会进入自身。
成为ZeroCopy
或DeepCopy
类型决定了在序列化和反序列化序列(如数组、切片、boxed切片和向量)时的类型处理方式。零拷贝类型的序列是使用引用反序列化的,而深拷贝类型的序列则是递归地在分配的内存中反序列化为关联的反序列化类型的序列。重要的是要注意,您不能序列化一个元素类型既不是 ZeroCopy
也不是 DeepCopy
的序列(有关更详细的解释,请参阅CopyType
文档)。
从逻辑上讲,零拷贝类型应该反序列化为引用,这确实在大多数情况下发生,并且在派生代码中肯定是这样。然而,原始类型总是完全反序列化。这种非正交选择有两个原因:
- 原始类型占用的空间非常小,将其作为引用反序列化并不高效;
- 如果类型参数
T
是原始类型,为AsRef<T>
编写泛型代码真的很不优雅; - 将原始类型反序列化为引用需要进一步填充以对齐它们。
由于这仅适用于原始类型,当反序列化包含原始类型的一个元组时,将得到一个引用(实际上,如果确实需要将原始类型作为引用反序列化,可以使用此替代方案)。如果您反序列化一个只包含原始类型字段的零拷贝结构体,也会发生同样的情况。
相反,深拷贝类型是递归字段逐个序列化和反序列化的。在ε-serde中的基本思想是:如果字段具有类型参数,则在ε拷贝反序列化期间,该类型将被其反序列化类型所取代。由于反序列化类型是递归定义的,因此替换可以在任何深度级别发生。例如,类型为A = Vec<Vec<Vec<usize>>>
的字段将被反序列化为A = Vec<Vec<&[usize]>>
。
这种方法使得编写对ε-serde感知的结构成为可能,这些结构能够隐藏替换过程。一个很好的例子是来自 sux-rs
的 BitFieldVec
结构,它通过(通常)使用 Vec<usize>
作为后端,暴露了一个固定位宽字段的数组;除了扩展方法之外,BitFieldVec
的所有方法都来自 trait BitFieldSlice
。如果您有一个自己的结构体,其中一个字段是类型 A
,当使用 A
等于 BitFieldVec<Vec<usize>>
时序列化您的结构体,在 ε-copy 反序列化后,您将得到一个版本为您的结构体,其中包含 BitFieldVec<&[usize]>
的版本。所有这些都会在幕后发生,因为 BitFieldVec
是 ε-serde感知的,实际上,如果您使用 trait BitFieldSlice
访问这两个版本,您甚至可能不会注意到任何差异。
MemDbg / MemSize
所有 ε-serde 结构体都实现了 MemDbg
和 MemSize
trait。
派生和手动实现
我们强烈建议使用过程宏 Epserde
来使您的类型可序列化和反序列化。只需在您的结构体上调用该宏,它就会与 ε-serde 完全功能。可以使用属性 #[zero_copy]
来使结构体无拷贝,尽管它必须满足 一些先决条件。
您还可以手动实现 trait CopyType
、MaxSizeOf
、TypeHash
、ReprHash
、SerializeInner
和 DeserializeInner
,但这个过程是错误的,您必须完全了解 ε-serde 的约定。过程宏 TypeInfo
可以用于自动生成至少 MaxSizeOf
、TypeHash
和 ReprHash
。
致谢
本软件部分由欧盟 - NGEU 资助的 NRRP MUR 程序下的 SERICS 项目(PE00000014)和法国国家研究署的 ANR COREGRAPHIE 项目(ANR-20-CE23-0002)资助。
依赖项
~260–710KB
~17K SLoC