这期周报分享一个 Apache OpenDAL 的低垂果实:通过去除额外的复制开销将 OpenDAL KV 性能提升 1000%!

背景

OpenDAL 在很久之前增加了 Kv Adapter:通过将 Key-Value 存储后端的常用 GET/SET 操作抽象出来以大幅度简化对接一个 kv service 的成本。维护者需要简单的实现 GET/SET/DELETE 等函数就能够对接形如 Redis/HashMap 这样的存储后端:

#[async_trait]
pub trait Adapter: Send + Sync + Debug + Unpin + 'static {
    async fn get(&self, path: &str) -> Result<Option<Vec<u8>>>;

    async fn set(&self, path: &str, value: &[u8]) -> Result<()>;

    async fn delete(&self, path: &str) -> Result<()>;
}

但是对于纯内存的数据结构来说,Kv Adapter 并不是零开销的:

  • 写入的时候需要对数据进行额外的多次复制
  • 读取时需要将所有的数据都复制出来

不仅如此,现在的 Kv Adapter 只能存储字节流,无法存储额外的 metadata,使得其使用场景受到了不应该的限制。

改进

Issue kv: Use Bytes as value to avoid copy between read/write 提出可以在 map 中存储 Bytes 而不是 Vec<u8> 来避免额外的复制,而 Alternative implementation for memory backend 则更进一步的指出我们可以在 map 中存储一个自定义的数据结构,里面可以附加上的 metadata。

结合以上两个思路,我提出了新的 Typed Kv Adapter

#[derive(Debug, Clone)]
pub struct Value {
    pub metadata: Metadata,
    pub value: Bytes,
}

#[async_trait]
pub trait Adapter: Send + Sync + Debug + Unpin + 'static {
    async fn get(&self, path: &str) -> Result<Option<Value>>;

    async fn set(&self, path: &str, value: Value) -> Result<()>;

    async fn delete(&self, path: &str) -> Result<()>;
}

在数据结构中引入自行设计的 Value 结构体,存储支持零开销复制的 Bytes 而不是 Vec<u8>,使得我们的 Adapter 不需要额外的复制就能够读写数据。

迁移 moka

将一个 service 从 kv::Adapter 迁移至 typed_kv::Adapter 非常简单,我们只需要将其定义声明修改即可:

- inner: SegmentedCache<String, Vec<u8>>,
+ inner: SegmentedCache<String, typed_kv::Value>,

然后修改对应 Adapter 的接口,比如:

- async fn get(&self, path: &str) -> Result<Option<Vec<u8>>>
+ async fn get(&self, path: &str) -> Result<Option<typed_kv::Value>>

效果

在演示 PR 中,我将 moka 从 kv::Adapter 迁移到了 typed_kv::Adapter。在所有的基准测试中,读写性能都有了显著提高。其中,最低的 read 提升了 10%,最高的提升达到了 1519.8%;write 部分受益于零开销实现的 Bytes 类型,在 service_moka_write_once/16.0 MiB 上取得了 166323% 的性能提升。

下一步计划

接下来,OpenDAL 将把所有的内存后端迁移到 typed_kv::Adapter,并增加更多的 kv 后端支持。然后可以进行一次横向的 bytes 读写性能比较,看看在 OpenDAL 的标准使用场景下,看看哪个内存后端性能更强~