2 个版本

0.1.1 2023年4月17日
0.1.0 2023年4月17日

#445嵌入式开发

每月下载量:23

Apache-2.0

33KB
405

tinydyn

轻量级动态调度,适用于嵌入式。

Ref<dyn Trait>RefMut<dyn Trait> 包装了一个指针和调用特质方法所需的元数据,并通过 Deref 转换为一个实现了 Traittinydyn 特质对象

特质必须通过添加 #[tinydyn] 注释来选择加入。这定义了一个替代的、更轻量的 vtable,如果特质只有一个方法,它将完全通过将函数指针内联来消除它。这不会影响特质的标准行为,并且仍然可以将其转换为 dyn Trait。然而,这将是一种浪费。

示例

use tinydyn::{tinydyn, Ref};

#[tinydyn]
trait Foo {
    fn blah(&self) -> i32;
    fn blue(&self) -> i32 { 10 }
}
impl Foo for i32 {
    fn blah(&self) -> i32 { *self + 1 }
}

// Like upcasting to `&dyn Foo`, but with a lighter weight vtable.
let x: Ref<dyn Foo> = Ref::new(&15);
assert_eq!(x.blah(), 16);
assert_eq!(x.blue(), 10);

节省空间

待办:一个真实大型嵌入式项目中的数字

对于每个将向上转换为该特质的特质和具体类型,Rust 都会创建一个新的 vtable。每个 vtable 包含 3 个额外的指针大小的布局和销毁信息。这些不是必需的,因此 tinydyn 的自定义 vtable 不包含它们。

此外,如果 tinydyn 的 Ref[Mut] 只有一个方法,它将 vtable 内联放置在其中。这既节省了虚拟调用的解引用操作,也消除了分配静态 vtable 的需要 - 真正实现了动态调度的零成本!

设计

背景

Rust 中的特质对象不是完全零成本的。为了使一组代码能够处理大小、对齐和行为各不相同的多种类型,Rust 必须包含额外的元数据与擦除类型一起,以便能够与之一起工作。

假设我们有一个具有两个方法的特质 Doggo

trait Doggo {
    fn wag(&self);
    fn bark(&self);
}

具体类型可以通过定义必要的方法来实现该特质。每个这些方法都知道编译时具体的类型。

struct Pupper {
    age: u32,
    name: &'static str,
}

impl Doggo for Pupper {
    fn wag(&self) { /* wag when self.name heard */ }
    fn bark(&self) { /* yip based on self.age */ }
}

struct Woofer {
    woof_freq: u16,
}

impl Doggo for Woofer {
    fn wag(&self) { /* big woofer wagging */ }
    fn bark(&self) { /* release a woof at self.woof_freq */ }
}

要使用相同的代码处理实现相同 Doggo 接口的不同类型,可以使用泛型。一个 fn take_doggo(x: &impl Doggo) 会为传入的每个具体类型创建 take_doggo 的副本,并在编译时,这个副本知道类型在内存中的布局,如何调用所需的 Doggo,以及如何最佳内联。

当只有一个副本或代码很简当时,这种 单态化 是最好的,因为编译器有最多的信息可用。

如果你需要一份代码来处理多种类型,我们可以 擦除 一些编译时信息。一个 fn take_doggo(x: &dyn Doggo) 与一个 trait 对象 的引用一起工作,这是一个持有 Rust 需要处理它的所需信息的 动态大小类型。这个函数通过仅需要一个 take_doggo 的副本,以牺牲一些间接引用和更复杂的内联来交换。

假设我们有一个 bluey: Pupperbluey 是一个类型为 Pupper 的值)。当我们将 &bluey 升级为 &dyn Doggo 时,我们通过一个 无大小强制转换 擦除了它的类型。这种无大小强制转换在 虚表 上附加了一个额外的指针,这是一个定义了特定于特质的运行时访问类型信息的表,最值得注意的是方法地址。这创建了一个宽指针,就像 &[T] 带有指针和长度一样。

同一具体类型的多个 &dyn Doggo 可以共享相同的虚表。

动机

有三个指针大小的值始终包含在内,但不是用于动态分派,而是用于其他擦除类型操作

  • size,它被 mem::size_of_val 用于释放,以及在自定义 DST 内部的类型布局。
  • align,它被 mem::align_of_val 用于释放,以及在自定义 DST 内部的类型布局。
  • 释放粘合剂,它是可选的,基本上是具体类型的 drop_in_place。仅当需要动态释放类型时才需要,例如 Box<dyn Trait>

然而,如果你的代码不需要这些呢?如果你只需要通过借用进行动态分派,并且不需要准确布局信息,这些值就是不必要的膨胀,堆积在嵌入式系统中。

tinydyn 虚表

tinydyn通过在特性行为上使用#[tinydyn]宏定义了更轻量级的动态调度对象。这定义了一个替代的虚函数表格式和调用这些方法的引用包装器。这个包装器不能查询具体类型的运行时布局信息,也不能丢弃它。然而,它可以调用特性行为的方法。

内联虚函数表

tinydyn针对只有一个方法的特性行为进一步优化:它将那个方法的函数指针与擦除类型一起包含,而不是使用静态虚函数表。这是动态调度方案可能的最便宜的方式,也是如何在C中实现它的方式。

双重指针

出于安全原因,Ref/ RefMut解引用到的未指定大小特性行为对象是一个指向特性行为对象的指针,创建了对象的指针的双重引用。所以,虽然你可以将它们转换为 &(impl Trait + ?Sized),但这如果不是优化的话,将会略微增大代码大小。

为什么不能对dyn Trait进行优化以减小其大小?

从理论上讲,rustc可以确定特性行为对象的大小、对齐和丢弃粘合剂在整个程序中从未被访问,并将它们从虚函数表中移除,甚至可能像tinydyn那样内联虚函数表。然而,rustc倾向于全局分析,更喜欢将此留给LLVM;而LLVM不知道特性行为虚函数表的格式。

这些是tinydyn不需要遵守的要求。它没有Box

不知道其大小的特性行为对象

由于tinydyn特性行为对象不知道它们指向的内容的大小或对齐方式,因此在类型擦除期间不能创建对具体类型的引用。

因此,为了使tinydyn特性行为对象实现特性行为,实现者本身必须具有擦除指针类型。然而,如果该指针类型是已知的,这会带来它自己的一系列问题。你可以交换两个Sized引用,并且标记为特性行为不安全的函数现在可以调用,即使没有可能的实现。

tinydyn特性行为对象以特定的设计方式这样做

  • 它们主要通过RefRefMut类型进行引用,这些类型持有调用特性行为方法所需的数据指针和元数据,而没有开销。
  • 这些没有实现特性行为,但解引用到一个实现了特性行为的!Sized包装对象,称为动态包装器
  • 动态包装器持有与Ref[Mut]相同的指针,因此解引用创建了一个双重引用以避免直接引用目标。
  • 不鼓励像特性行为对象通常那样通过引用使用解引用包装对象。这不仅具有不准确的size_of_valalign_of_val,它是一个双重指针,直接使用它更昂贵。
  • 调用虚表(vtable)的函数被标记为 #[inline(always)],这样在调用特质方法时创建的双指针就会被检测为不必要的,并由 LLVM 优化掉。

这种伪造布局和双重间接引用,最终是为了稳健性而做出的必要设计决策。

函数指针 transmute

为了防止生成重复函数或相同方法的多个地址,这个库对一个函数项进行 unsafe 转换,将其转换为 *const () 并将其转换为 "&self-erased" fn 指针。这是它执行的最有问题的操作。它对不安全 Rust 做出以下断言

  • &'a T(其中 T: Sized)和 *const () 之间没有布局差异。这些指针可以在 'a 生存期内安全地进行转换。同样适用于 &mut*mut ()
  • 生命周期对于函数调用 ABI 是 完全 透明的。
  • 如果所有参数具有相同的布局,则可以将转换为 *const () 的函数项安全地转换为函数指针
    • 所有参数具有相同的布局。
    • 所有指针参数具有相同的可变性。
    • 在此平台上,函数指针的大小与 *const () 相同(由 transmute 检查)。

贡献

有关详细信息,请参阅 CONTRIBUTING.md

许可协议

Apache 2.0;有关详细信息,请参阅 LICENSE

免责声明

此项目不是官方的 Google 项目。它不受 Google 支持,Google 明确声明对此项目的质量、可销售性或特定用途的适用性不承担任何保证。

依赖关系

~275–730KB
~17K SLoC