#zero-copy #serialization #binary-data #binary-encoding #data-structures #no-alloc

no-std musli-zerocopy

由Müsli带来的清新简单的零拷贝原语

73个版本

0.0.123 2024年7月11日
0.0.122 2024年5月17日
0.0.121 2024年4月28日
0.0.108 2024年3月30日
0.0.86 2023年10月29日

#1039 in 编码

Download history 3378/week @ 2024-04-30 2898/week @ 2024-05-07 3292/week @ 2024-05-14 2671/week @ 2024-05-21 2677/week @ 2024-05-28 3768/week @ 2024-06-04 3244/week @ 2024-06-11 3272/week @ 2024-06-18 2357/week @ 2024-06-25 2775/week @ 2024-07-02 4516/week @ 2024-07-09 4417/week @ 2024-07-16 3887/week @ 2024-07-23 2542/week @ 2024-07-30 2185/week @ 2024-08-06 2846/week @ 2024-08-13

12,270 monthly downloads
用于 musli-tests

MIT/Apache

530KB
9K SLoC

musli-zerocopy

github crates.io docs.rs build status

由Müsli提供的清新简单、速度极快的零拷贝原语。

这提供了一套基本工具来处理在反序列化过程中不需要复制的类型。您定义类型 T,我们提供安全的 &[u8] <-> &T 转换。

读取零拷贝结构具有完整的 #[no_std] 支持。目前构建一个结构需要启用 alloc 功能。

use musli_zerocopy::{buf, Ref, ZeroCopy};

#[derive(ZeroCopy)]
#[repr(C)]
struct Person {
    age: u8,
    name: Ref<str>,
}

let buf = buf::aligned_buf::<Person>(include_bytes!("author.bin"));
let person = Person::from_bytes(&buf[..])?;

assert_eq!(person.age, 35);
// References are incrementally validated.
assert_eq!(buf.load(person.name)?, "John-John");

有关如何工作的详细概述,请参阅 ZeroCopy 及其相应的 ZeroCopy derive。下面还有一份高级指南。

此crate还包括一些您可能感兴趣的高级数据结构

  • phf 基于 phf crate 提供映射和集合,或完美哈希函数。
  • swisshashbrown crate 的移植,它是一个Google SwissTable实现。
  • trie 是前缀字典树的实现,支持高效的字节前缀多值查找。

如果您对 musli-zerocopy 的性能感兴趣,请访问 benchmarks。我将继续扩展此套件以包含更多零拷贝类型,但到目前为止,我们在测试过的用例中已经取得了明显的领先。

这是因为...

  • 零拷贝如果在正确执行的情况下,则不会产生反序列化开销。你从某个地方获取字节,验证它们,并将它们视为目标类型。这种做法的方法有限;
  • 填充已经实现并进行了优化,使其生成的代码大致等同于你手动编写的代码,并且;
  • 增量验证意味着你只需为所访问的内容付费。因此,对于随机访问,我们只需验证正在访问的部分。

概述


为什么我应该考虑musli-zerocopy而不是X?

由于这是每个人都会问的第一个问题,以下是与其他流行库的不同之处

  • zerocopy不支持填充类型[^padded],字节到引用的转换,或者不允许解码类型的转换,除非所有位模式都可以由零填充[^zeroes]。我们仍然希望提供一个更完整的工具包,用于构建和交互像通过phfswiss模块一样复杂的数据结构。这个crate确实可能在某个时候使用zerocopy的特性和方法。
  • rkyv操作于&[u8]类型,并为你生成一个优化的Archived变体。这个库让你可以直接构建相当于Archived变体的东西,并且你与数据模型交互的方式不会产生验证的开销。使用rkyv,我的电脑需要100%的CPU核心,大约半秒钟的时间来加载1200万个字典条目^dictionary,这是一个增量验证所不产生的高昂成本。不验证不是一种选择,因为那将非常不稳定——你的应用程序将容易受到恶意字典文件的影响。

[^padded]: 这在zerocopy的路线图上,但与核心的FromBytes / ToBytes特性对基本不兼容。

[^zeroes]: FromBytes扩展了FromZeroes


指南

本库中的零拷贝指的是与直接位于&[u8]内存中的数据结构交互,而无需先解码它们的行为。

从概念上讲,它的工作方式有点像这样。

假设你想要存储字符串"Hello World!"

use musli_zerocopy::OwnedBuf;

let mut buf = OwnedBuf::new();
let string = buf.store_unsized("Hello World!");
let reference = buf.store(&string);

assert_eq!(reference.offset(), 12);

这将导致以下缓冲区

0000: "Hello World!"
// Might get padded to ensure that the size is aligned by 4 bytes.
0012: offset -> 0000
0016: size -> 12

我们在偏移量0016处看到的是8字节的Ref<str>。第一个字段存储了获取字符串的偏移量,第二个字段存储了字符串的长度。

让我们看一下一个 Ref<[u32]> 的例子。

use musli_zerocopy::{Ref, OwnedBuf};

let mut buf = OwnedBuf::new();
let slice: Ref<[u32]> = buf.store_slice(&[1, 2, 3, 4]);
let reference = buf.store(&slice);

assert_eq!(reference.offset(), 16);

这将导致以下缓冲区

0000: u32 -> 1
0004: u32 -> 2
0008: u32 -> 3
0012: u32 -> 4
0016: offset -> 0000
0020: length -> 4

在地址 0016 处我们存储了两个字段,它们对应于一个 Ref<[u32]>

接下来,让我们通过一个使用 Custom 结构的例子来调查一下。

use core::mem::size_of;
use musli_zerocopy::{OwnedBuf, Ref, ZeroCopy};

#[derive(ZeroCopy)]
#[repr(C)]
struct Custom {
    field: u32,
    string: Ref<str>,
}

let mut buf = OwnedBuf::new();

let string = buf.store_unsized("Hello World!");
let custom = buf.store(&Custom { field: 42, string });

// The buffer stores both the unsized string and the Custom element.
assert!(buf.len() >= 24);
// We assert that the produced alignment is smaller or equal to 8
// since we'll be relying on this below.
assert!(buf.requested() <= 8);

这将导致以下缓冲区

0000: "Hello World!"
0012: u32 -> 42
0016: offset -> 0000
0020: size -> 12

我们的结构从地址 0012 开始,首先有一个 u32 字段,紧接着是字符串。


读取数据

稍后当我们想要使用这个类型时,我们将生成的缓冲区包含在其他地方。

我们需要一些数据(我们称之为 DNA)来从原始缓冲区中读取类型

  • 缓冲区的 对齐。您可以通过 requested() 来读取。在接收端,我们需要确保缓冲区遵循此对齐。动态上,可以通过使用 aligned_buf(bytes, align) 来实现。其他技巧包括在对齐的新类型中嵌入静态缓冲区,我们将在下面展示。网络应用程序可能简单地同意使用特定的对齐方式。这种对齐方式必须与被强制转换的类型兼容。
  • 产生缓冲区的机器的 字节序。任何数值元素都将按照本地字节序排列,因此如果它们不同,就必须在读取端进行调整。
  • 正在读取的类型定义,它实现了 ZeroCopy。这是上面的 Custom。通过使用 ZeroCopy derive,我们确保可以安全地将缓冲区强制转换为类型的引用。最坏的情况是数据可能被破坏,但我们永远不会在使用安全 API 时做出不安全的行为。
  • 读取 ZeroCopy 结构的偏移量。为了读取一个结构,我们将指针和类型组合成一个 Ref 实例。

如果目标是同时在同一系统上生成和读取缓冲区,可以做出一些假设。如果这些假设被证明是错误的,那么最坏的结果也只会是错误,只要您使用安全 API 或遵守不安全 API 的安全性文档。

信息 关于通过网络发送数据的一些说明。只要包括缓冲区的对齐和数据结构的大小端,这是完全可以做到的。

# use musli_zerocopy::OwnedBuf;
let buf = OwnedBuf::new();

/* write something */

let is_little_endian = cfg!(target_endian = "little");
let alignment = buf.requested();

以下是从新类型对齐的 &'static [u8] 缓冲区直接读取类型的例子

use core::mem::size_of;
use musli_zerocopy::Buf;

// Helper to force the static buffer to be aligned like `A`.
#[repr(C)]
struct Align<A, T: ?Sized>([A; 0], T);

static BYTES: &Align<u64, [u8]> = &Align([], *include_bytes!("custom.bin"));

let buf = Buf::new(&BYTES.1);

// Construct a pointer into the buffer.
let custom = Ref::<Custom>::new(BYTES.1.len() - size_of::<Custom>());

let custom: &Custom = buf.load(custom)?;
assert_eq!(custom.field, 42);
assert_eq!(buf.load(custom.string)?, "Hello World!");

在零偏移量处写入数据

大多数情况下,您希望将数据写入缓冲区,其中第一个元素是当前正在写入的元素。

这很有用,因为它满足上述最后一个要求,即结构体可以读取的偏移量将是零,并且它所依赖的所有数据都存储在随后的偏移量中。

use musli_zerocopy::OwnedBuf;
use musli_zerocopy::mem::MaybeUninit;

let mut buf = OwnedBuf::new();
let reference: Ref<MaybeUninit<Custom>> = buf.store_uninit::<Custom>();

let string = buf.store_unsized("Hello World!");

buf.load_uninit_mut(reference).write(&Custom { field: 42, string });

let reference = reference.assume_init();
assert_eq!(reference.offset(), 0);

可移植性

默认情况下,归档将使用本机 ByteOrder。为了构建和加载可移植归档,必须显式指定使用的字节顺序。

这通过指定缓冲区构建过程中使用的字节顺序,并显式设置接收它的类型中的 E 参数来实现,例如 Ref<T, E, O>

我们可以从定义一个完全 Portable 归档结构开始,该结构接收大小和 ByteOrder。请注意,也可以明确指定所需的字节顺序,但这样做可以使其作为一个例子具有最大的灵活性

use musli_zerocopy::{Size, ByteOrder, Ref, Endian, ZeroCopy};

#[derive(ZeroCopy)]
#[repr(C)]
struct Archive<E, O>
where
    E: ByteOrder,
    O: Size
{
    string: Ref<str, E, O>,
    number: Endian<u32, E>,
}

从结构体中构建缓冲区相当直接,OwnedBuf 有一个 with_byte_order::<E>() 方法,允许我们指定在构建过程中与之交互的类型中使用的“粘性” ByteOrder

use musli_zerocopy::{endian, Endian, OwnedBuf};
let mut buf = OwnedBuf::new()
    .with_byte_order::<endian::Little>();

let first = buf.store(&Endian::le(42u16));
let portable = Archive {
    string: buf.store_unsized("Hello World!"),
    number: Endian::new(10),
};
let portable = buf.store(&portable);

assert_eq!(&buf[..], &[
    42, 0, // 42u16
    72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, // "Hello World!"
    0, 0, // padding
    2, 0, 0, 0, 12, 0, 0, 0, // Ref<str>
    10, 0, 0, 0 // 10u32
]);

let portable = buf.load(portable)?;

let mut buf = OwnedBuf::new()
    .with_byte_order::<endian::Big>();

let first = buf.store(&Endian::be(42u16));
let portable = Archive {
    string: buf.store_unsized("Hello World!"),
    number: Endian::new(10),
};
let portable = buf.store(&portable);

assert_eq!(&buf[..], &[
    0, 42, // 42u16
    72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, // "Hello World!"
    0, 0, // padding
    0, 0, 0, 2, 0, 0, 0, 12, // Ref<str>
    0, 0, 0, 10 // 10u32
]);

let portable = buf.load(portable)?;

限制

偏移量、无大小值的尺寸和切片长度都限制在32位。您所使用的系统必须有至少32位宽的 usize 类型。这是默认情况下节省空间的做法。

系统上的指针宽度在编译时进行检查,尝试使用大于 2^32 的偏移量或大小将导致panic。

大于 2^32 的地址使用示例导致panic

Ref::<Custom>::new(1usize << 32);

使用长度大于 2^32Ref<\[T\]> 导致panic的示例

Ref::<[Custom]>::with_metadata(0, 1usize << 32);

使用大小大于 2^32Ref<str> 值导致panic的示例

Ref::<str>::with_metadata(0, 1usize << 32);

如果您想访问超出此限制的数据,建议将数据集划分为32位地址可寻址的块。

如果您确实想更改此限制,可以通过设置各种 Size 依赖类型的默认 O 参数来修改它

可用的 Size 实现包括

  • u32 用于32位大小的指针(默认)。
  • usize 用于目标相关大小的指针。
// These no longer panic:
let reference = Ref::<Custom, Native, usize>::new(1usize << 32);
let slice = Ref::<[Custom], Native, usize>::with_metadata(0, 1usize << 32);
let unsize = Ref::<str, Native, usize>::with_metadata(0, 1usize << 32);

要使用自定义的 Size 初始化 OwnedBuf,您可以使用 OwnedBuf::with_size

use musli_zerocopy::OwnedBuf;
use musli_zerocopy::buf::DefaultAlignment;

let mut buf = OwnedBuf::with_capacity_and_alignment::<DefaultAlignment>(0)
    .with_size::<usize>();

OwnedBuf 的构建过程中指定的 Size 将传递到它返回的任何指针中

use musli_zerocopy::{DefaultAlignment, OwnedBuf, Ref, ZeroCopy};
use musli_zerocopy::endian::Native;

#[derive(ZeroCopy)]
#[repr(C)]
struct Custom {
    reference: Ref<u32, Native, usize>,
    slice: Ref::<[u32], Native, usize>,
    unsize: Ref::<str, Native, usize>,
}

let mut buf = OwnedBuf::with_capacity(0)
    .with_size::<usize>();

let reference = buf.store(&42u32);
let slice = buf.store_slice(&[1, 2, 3, 4]);
let unsize = buf.store_unsized("Hello World");

buf.store(&Custom { reference, slice, unsize });

依赖关系

~0.5-1MB
~23K SLoC