追求懒惰

Manish Goregaokar的博客

非轭状物质(零拷贝#1)

Manish Goregaokar于2022年8月3日发布程序设计,

这是一个由三部分组成的系列文章的第1部分,该系列文章介绍了我在过去一年中一直致力于的零拷贝反序列化的有趣抽象。这一部分是关于使零拷贝反序列化更易于使用。第2部分是关于使其适用于更多类型,可以找到在这里; 而第3部分是关于完全消除反序列化步骤的,可以找到在这里。这些帖子可以按任何顺序阅读,尽管这篇帖子解释了什么是零拷贝反序列化是。

背景

在过去的一年半里,我一直在全职工作重症监护病房4X作为各公司之间的合作,Unicode Consortium正在Rust建立一个新的国际化库。

关于ICU4X,我可以说很多,但要专注于一个核心价值主张:我们希望它成为模块化的在数据和代码方面。我们希望ICU4X能够在内存昂贵的嵌入式平台上使用。我们希望受下载大小限制的应用程序能够支持所有语言,而不是选择几个流行的语言,因为它们无法捆绑所有这些数据。作为其中的一部分,我们希望加载数据快速的并且是可插拔的。用户应该能够为其个人用例设计自己的数据加载策略。

要实现正确的国际化,关键是数据。不同的地区1以不同的方式做事,所有关于这方面的信息都需要放在某个地方,最好不要放在代码中。您需要有关特定区域设置日期格式的数据2,复数在特定语言中是如何工作的,或者如何准确分割像泰语这样通常不使用空格书写的语言,以便可以在适当的位置插入换行符。

鉴于对数据的关注非常对我们来说,有吸引力的选择是零拷贝反序列化。在尝试做好零拷贝反序列化的过程中,我们构建了一些很酷的新库,本文就是其中之一。

加里·拉尔森,“奶牛工具”,远侧1982年10月

零拷贝反序列化:基础

如果您已经熟悉Rust中的零拷贝反序列化,可以跳过本节

反序列化通常涉及两项协同完成的任务:验证数据,以及构建可通过编程访问的内存表示;即,最终反序列化值。

根据格式的不同,前者通常相当快,但后者可能非常慢,通常围绕着任何需要新分配且通常需要大拷贝的可变大小数据。

#[导出(序列化, 反序列化)]
结构  {
    //这个场地几乎可以自由建造
    年龄以下为: u8型,
    //构建它需要少量分配和复制
    名称以下为: 字符串,
    //这可能需要一段时间
    已写入的生锈文件以下为: Vec公司<字符串>,
}

典型的二进制数据格式可能会将其存储为年龄的字节,后跟长度名称,后跟的字节名称,后跟向量的另一个长度,然后是每个向量的长度和字符串数据字符串价值。反序列化u8型age只需要读取它,但其他两个字段需要分配足够的内存并复制每个字节,此外还需要对类型进行验证。

此场景中的一种常见技术是跳过分配并简单地复制验证字节并存储参考到原始数据。这只能用于序列化格式,其中数据在序列化文件和反序列化值中以相同的方式表示。

使用时塞尔维亚在Rust中,这通常通过使用奶牛<'a,T>具有#[serde(借用)]以下为:

#[派生(序列化, 反序列化)]
结构 <“a> {
    年龄以下为: u8型,
    #[塞德(借用)]
    名称以下为: 奶牛<“a, 字符串>,
}

现在,什么时候名称正在进行反序列化,反序列化程序只需要验证它实际上是有效的UTF-8字符串,以及的最终值名称将引用从自身反序列化的原始数据。

&“一个字符串也可以代替奶牛,但这使反序列化impl不太通用,因为格式将字符串存储为与其在内存中的表示相同的值(例如,包含转义的字符串的JSON)将无法回退到拥有的值。因此,拥有或借贷奶牛<'a,T>在编写参与零拷贝反序列化的Rust代码时,它通常是良好设计的基石。

你可能会注意到已写入的生锈文件在此新结构中找不到。这是因为塞尔维亚,现成的,无法处理零拷贝反序列化,除了字符串【u8】,原因很充分。其他框架,如rkyv公司可以,但我们也成功地做到了这一点塞尔维亚。我将在第2部分
角色Confused pion的语音气泡
这里不是还有副本吗年龄现场?

是的,“零拷贝”有点用词不当,它真正的意思是“零分配”,或者,“零大拷贝”。这样看:数据年龄确实会被复制,但没有,例如,分配一个向量人<'a>,在单独反序列化时,您只会看到该副本发生几次人<'a>或反序列化包含人物<'a>几次。要进行大型复制没有涉及到分配,您的类型首先必须是堆栈上那么大的类型,人们通常会避免这种情况,因为这意味着每次移动值时都会有一个大副本,即使您没有反序列化。

当生活给你生命的时候…。

Rust中的零拷贝反序列化有一个非常讨厌的缺点:生存期。突然,所有反序列化类型上都有生存期。他们当然会;它们不再是自包含的,而是包含对最初反序列化的数据的引用!

这也不是Rust独有的问题,零拷贝反序列化总是会在类型之间引入更复杂的依赖关系,不同的框架对此的处理方式不同;从将生命周期的管理留给用户,到使用引用计数或GC来确保数据保持不变。如果愿意,Rust序列化库也可以这样做。在这种情况下,塞尔维亚以一种非常粗鲁的方式,希望图书馆用户能够控制这里的精确内存管理,并将这个问题视为终身问题。

不幸的是,像这样的生活往往会影响到一切。现在,每个保留反序列化类型的类型都需要一个生存期,这可能也会成为用户的问题。

此外,Rust生命周期是一个纯编译时构造。如果你的值是一个有生命周期的类型,你需要在编译时知道它什么时候肯定不再使用,并且你需要保留它的源数据直到那时。Rust的设计意味着您无需担心会遇到这种情况错误的,因为编译器会捕获您,但您仍然需要去做吧

所有这些都不适合于您希望在运行时管理生命周期的情况,例如,如果您的数据正在从较大的文件反序列化,并且您希望缓存加载的文件,只要从该文件反序列化的数据仍然存在。

通常在这种情况下,您可以使用Rc<T>,它实际上是的“运行时而不是编译时”版本&“一个T是安全的共享引用,但这仅适用于共享同质类型的情况,而在本例中,我们试图共享从一个blob数据反序列化的不同类型,而blob本身是不同类型的。

ICU4X希望用户能够根据需要使用缓存和其他数据管理策略,因此这根本不行。有一段时间,ICU4X没有,但无处不在的生命周期贯穿了它的大多数类型:它既令人困惑,又不符合我们的目标。

…让生活回到过去

这里的很多设计都可以在设计文件

之后一连串的讨论在这方面,主要是与谢恩,我设计,一个试图提供寿命擦除在Rust中通过自引用类型。

角色Confused pion的语音气泡
等待,一生擦除?

喜欢类型擦除!“类型擦除”(生锈,使用dyn特性)允许您采用编译时概念(值的类型),并将其移动到可以在运行时决定的内容中。类似地是接受带有编译时生命周期概念的类型,并允许您在运行时决定它们。

角色Confused pion的语音气泡
没有Rc<T>已经让您将生命周期作为一个运行时决策了吗?

有点,Rc<T>它本身就让你避免编译时生存期,而适用于已经存在一个生存期(例如,由于零拷贝反序列化)的情况,您希望覆盖该生存期。

角色Confused pion的语音气泡
酷!那看起来像什么?

一般的想法是,可以采用零拷贝可反序列化类型,如奶牛<'a,str>(或更复杂的东西)并将其“约束”为反序列化的值,我们称之为“cart”。

角色Negative pion的语音气泡
*呻吟*没有另一个用双关语命名的板条箱,Manish。

我永远不会停止。

不管怎样,这就是它的样子。

//为了清楚起见,明确提到了一些类型

//加载文件
 文件以下为: 卢比<[u8型]> = 英尺::阅读(“数据。明信片”)?.到();

//通过克隆文件数据来创建对该文件数据的新Rc引用,
//然后把它当作一辆轭车
 以下为: <奶牛<'静态, 字符串>, 卢比<[u8型]>> = ::附加到购物车(文件.clone(克隆)(), |目录| {
    //从文件反序列化
     奶牛以下为: 奶牛<字符串> =  明信片::来自字节(&目录);
    奶牛
})

//字符串仍然可以用`.get()访问`
打印!("{}", .获取())

();
//只有现在,文件上的引用计数才会减少
由于当前的编译器错误,这里的一些API可能无法正常工作。在这篇博客文章中,为了便于说明,我使用了这些API的理想版本,但值得查看Yoke文档,看看您是否需要使用其他变通方法API。大多数自Rust 1.61起,共修复了个错误。
角色Positive pion的语音气泡
上述示例使用明信片以下为:明信片真的很整洁塞尔维亚-兼容的二进制序列化格式,设计用于资源受限的环境。它速度很快,代码大小很低,快来看看吧!

类型轭架<Cow<'static,str>,Rc<[u8]>>是“终身擦除”奶牛<str>“绑定”到支持数据存储“购物车”Rc<[u8]>”. 这意味着Cow包含对购物车数据的引用,但是将保留购物车类型直到完成,这确保了来自奶牛不再摇晃。

中对数据的大多数操作通过操作.get(),在这种情况下,它将返回奶牛<'a,str>,其中“a是借款的期限.get()。这保证了事情的安全:a奶牛<'static,str>在这种情况下分发并不安全,因为奶牛实际上并不是从静态数据中借用;然而,只要我们在访问期间将生存期转换为更短的时间,就可以了。

结果是'静态在中找到类型实际上是一个谎言!Rust实际上不允许您处理带有借用内容的类型一些生命周期,这里我们想解除编译器管理生命周期和自己管理生命周期的职责,所以我们需要提供它某物这样我们就可以命名类型,并且'静态是Rust中唯一预先存在的命名生存期。

实际签名.get()有点奇怪因为它需要是泛型的,但如果我们借用的类型是富(Foo),然后签名.get()是这样的:

执行 <<'静态>> {
    fn公司 得到<“a>(&“a 自己) -> &“a <“a> {
        ...
    }
}

对于允许在轭<Y,C>,它必须实现轭式<'a>。手动实现此特征是不安全的,在大多数情况下,您应该使用#[导出(轭式)]以下为:

#[导出(轭式, 序列化, 反序列化)]
结构 <’a> {
    年龄以下为: u8型,
    #[serde(借用)]
    名称以下为: 奶牛<“a, 字符串>,
}

 以下为: <<'静态>, 卢比<[u8型]> = ::附加到购物车(文件.clone(克隆)(), |目录| {
    明信片::来自字节(&目录)
});

不像大多数#[导出]第页,可轭式即使字段尚未实现,也可以派生可轭式,具有生存期的字段也具有其他泛型参数的情况除外。在这种情况下,用标记类型就足够了#[轭(证明_偏差_手动)]并确保所有具有生命周期的字段也实现可偏转的

还有很多你可以做的例如,您可以“投影”一个轭,以使用在初始轭中找到的数据子集获得一个新轭:

 以下为: <<'静态>, 卢比<[u8型]>> = ...

 人员_名称以下为: <奶牛<'静态, 字符串>> = .项目(|第页, _| 第页.name(名称));

这允许您混合来自不同Yoke的数据。

也许令人惊讶的是,可变的也!毕竟,它们主要用于copy-on-write数据,所以如果没有额外的借用的数据潜入:

 多用途终端 以下为: <<'静态>, 卢比<[u8型]>> = ...

//让这个名字听起来更好听
.带_mut(|| {
    //这会把“奶牛”变成自己的
    .name(名称).to_最小值().推动(“,先生”)
})

总体是一个非常强大的抽象,对于涉及零拷贝反序列化的许多情况以及涉及大量借用的其他情况都很有用。在ICU4X中,我们用来加载数据的抽象总是使用s、 允许混合使用各种数据加载策略(包括缓存)

它是如何工作的

角色Positive pion的语音气泡
Manish即将说出“协变”一词,所以我会在他前面说:如果你在理解这一节和下一节时有困难,不要担心!他的板条箱的内部工作依赖于多个利基概念,大多数Rustaceans从来都不需要关心这些概念,即使是那些处理其他高级代码的概念。

依靠协变寿命. The可轭式特征如下:

酒吧 不安全的 特质 可轭式<“a>以下为: '静态 {
    类型 输出以下为: “a
    //方法已省略
}

典型的实现方式如下所示:

不安全的 执行<“a> 可轭式<“a> 对于 奶牛<'静态, 字符串> {
    类型 输出以下为: “a = 奶牛<“a, 字符串>
    // ...
}

此特征的实现将在'静态具有生存期的类型的版本(我将调用它自我<'static>并将类型映射到具有生存期的版本(自我<'a>). 它只能在生存期为“a协变的也就是说,在哪里治疗是安全的自我<'a>具有自我<'b>什么时候“b”寿命更短。大多数有寿命的类型都属于这一类4尤其是在零拷贝反序列化空间。

角色Positive pion的语音气泡
您可以在诺米孔

对于任何可轭式类型Foo(静态),可以获得具有生存期的该类型的版本“a具有<Foo作为Yokeable::输出. The可轭式trait公开了一些方法,这些方法允许用户安全地执行在具有协变生存期的类型上允许的各种转换。

#[导出(轭式)]在大多数情况下,依赖编译器的能力来确定生存期是否是协变的,并且实际上不会生成太多代码!在大多数情况下可轭式是纯安全代码,如下所示:

执行<“a> 可轭式 对于 <'静态> {
    类型 输出以下为: “a = <“a>
    fn公司 转型(&自己) -> &自我::输出 {
        自己
    }
    fn公司 transform_owned公司(自己) -> 自我::输出 {
        自己
    }
    fn公司 变压器_mut<F类>(&“a 多用途终端 自己, (f)以下为: F类)
    哪里
        F类以下为: '静态 + 对于<“b”> FnOnce公司(&“b” 多用途终端 自我::输出) {
        (f)(自己)
    }
    //fn make()被省略,因为它不太相关
}

编译器知道这些是安全的,因为它知道类型是协变的,而可轭式trait允许我们讨论这些操作安全的类型,一般地

角色Positive pion的语音气泡
换句话说,编译器知道关于生存期“弹性”的某个有用属性,我们可以通过生成代码来检查该属性是否适用于类型,如果该属性不适用,编译器将拒绝编译该代码。

利用这个特性,然后通过存储工作自我<'static>并在将其分发给任何消费者之前,使用可轭式以各种方式。知道寿命是协变的,才可以安全地进行这种寿命“挤压”。这个'静态是一个谎言,但只要值不是用'静态我们会非常小心地确保它不会泄漏。

更好的转换:ZeroFrom

与此匹配良好的板条箱是从零开始,主要由设计和编写谢恩。它随附从零开始特质:

酒吧 特质 从零开始<“zf”, C类以下为: ?大小>以下为: “zf” {
    fn公司 从零开始(其他以下为: &“zf” C类) -> 自我
}

这个特性的思想是能够通用地处理可转换为(通常为零拷贝)借用类型的类型。

例如,奶牛<'zf,str>实现两者零从<'zf,str>零从<'zf,字符串>,以及ZeroFrom<'zf,Cow<'a,str>>。它类似于作为参考trait,但它允许在发生的借用类型上有更大的灵活性,并且实现者应该在这种转换过程中最小化复制量。例如,当从零开始-构造奶牛<'zf,str>来自其他人奶牛<'a,str>,它会的总是构造奶牛::借来的即使是原件奶牛<'a,str>被拥有。

有一个方便的构造函数轭::attach_to_zero_copy_cart()可以创建轭<Y,C>车外类型C类如果是的实施零从<'zf,C>终身适用'zf。这对于希望执行基本自引用类型但不执行任何奇特的零拷贝反序列化的情况非常有用。

…让生活后悔它认为可以给你一生

有了这个板条箱的生活并不尽如人意。我们,呃…不幸地 发现 高耸入云地 大的 一堆 属于 粗糙的 编译器 漏洞。这其中的许多根源在于轭式<'a>在大多数情况下是通过适用于Yokeable(“轭式<'a>所有可能的生命周期“a”). 这个对于<'a>是一个利基特性,被称为更高等级的生存期或特征界限(通常称为“HRTB”),虽然Rust的类型系统在某种程度上总是有必要推理函数指针,但它也总是有相当大的缺陷,通常不鼓励使用这种用法。

我们使用它是为了从一般意义上讨论类型的生存期。幸运的是,有一个正在积极开发的语言功能更适合于此:通用关联类型

此功能尚不稳定,但幸运的是我们,涉及的大多数编译器错误对于<'a> 因此,我们一直受益于《服务贸易总协定》的工作,我们的许多缺陷报告有助于支持《服务贸易总协定》的准则。大声呼喊杰克·休伊修复了很多这些错误,以及埃迪布帮助调试过程。

截至Rust 1.61,许多主要错误都已修复,但仍有一些特征边界上的错误板条箱有一些工作区助手。根据我们的经验,这里的大多数编译器错误都不是限制性的当谈到如何使用板条箱时,它们最终可能会生成看起来不太理想的代码。总的来说,我们仍然认为这是值得的,我们能够以一种外部方便的方式(即使一些内部代码很混乱)做一些真正整洁的零拷贝的东西,并且我们并不是到处都有生命周期。

试试看!

虽然我不认为板条箱“完成”了,它已经在ICU4X中使用了一年,我认为它已经足够成熟,可以推荐给其他人。试试看!让我知道你的想法!

多亏了芬奇,、和谢恩用于审阅此帖子的草稿

  1. A类区域设置通常是一种语言和位置,尽管它可能包含诸如书写系统甚至使用的日历系统之类的附加信息。 

  2. 记住,这不仅仅是选择MM-DD-YYYY这样的格式的问题!美国英语中的日期看起来就像4/10/224/10/20222022年4月10日,或2022年4月10日星期日。,或2022年4月10日,星期日,这并非没有考虑周数、季度或时间!这很快就为每个语言环境添加了相当数量的数据。 

  3. 这不是真正的Rust语法;自从自我总是公正的自我,但我们需要能够参考自我在这个场景中,作为更高种类的类型

  4. 不是的类型是涉及可变性的类型(&多用途终端或内部可变性),以及涉及函数指针和特性对象的。