ToplingDB Zero Copy

  1. 背景ToplingDB 是 topling 开发的 KV 存储引擎,fork 自 RocksDB,进行了很多改造,其中最重要的部件是 ToplingZipTable, 是 BlockBasedTable 的代替品,性能更高而内存占用更低。ToplingZipTable 使用 CO-Index 与 PA-Zip 实现索引和数据的存储。CO-Index 指 Compressed Ordered Index, 是一类内存压缩的索引,无需解压,在压缩的形态下可以对索引直接搜索,并且搜索速度极快。从 String 类型的 Key,搜索出一个密实 的 整数 ID。PA-Zip 指 Point Accessible Zip, 无需 BlockCache,可以非常快速地 按 ID 定点访问单条数据。PA-Zip 也有非压缩的实现,例如 MyTopling(MySQL) 中长度固定且单条数据较短的表,用一个定长数组就可以非常高效地实现。在这种情况下,是可以实现 zero copy 的,但 RocksDB 中有一些问题导致无法 zero copy。ToplingZipTable 直接使用 topling-zip 中的 BlobStore 类体系来实现 PA-Zip。2. RocksDB 为什么不能 zero copy2.1. SuperVersion 保活传统上,一般通过引用计数来标记对象的存活,但是多线程引用计数会导致 CPU 核间的频繁通讯,导致多核不 Scale。所以 RocksDB 在DB::Get 中,使用了一些技巧,避免了频繁的增减引用计数:对象实例级的 ThreadLocalPtr,每个线程引用一个 SuperVersion 对象,长久保持引用计数对于一个 DB 的只读操作:在操作开始处,取出该线程 TLS 的 SuperVersion 指针并将 TLS 指针设为 InUse,取出的 TLS 指针放到该函数的局部变量中,其它线程就观察不到这个 SuperVersion 了在操作结束处,将保存在函数局部变量中的 SuperVersion 指针放回线程 TLS,其它线程就可以观察到这个 SuperVersion 了任何线程创建新的 SuperVersion 对象时(例如Flush/Compact结束时),查看所有线程的 TLS,回收不在 InUse 状态的 SuperVersion,回收后相应 TLS 设为 null,这样当那个 TLS 所属的线程使用它时,发现是 null,就从全局获取最新的 SuperVersion对 TLS 的操作均使用 atomic以这样的方式,SuperVersion 的保活就不需要每个操作都增减引用计数了,从而实现多核线性 Scale。2.2. PinnableSlice最高效的DB::Get 重载版本是 value 为 PinnableSlice 的那个,对于 BlockBasedTable,PinnableSlice 会 Pin 住 BlockCache 中相应的 Block,避免了从 BlockCache 中把 value memcpy 到 std::string 中。PinnableSlice 支持可插拔的 Cleanable 接口,BlockCache 内存的回收就是通过这个 Cleanable 接口实现的。BlockCache 的生存期和 SuperVersion 是相互独立的,所以 PinnableSlice 在这里可以正常工作。2.3. mmap对于使用 mmap 的 SST,因为有 PinnableSlice,所以理论上,我们可以把 mmap 中存储 value 的内存直接通过指针返回给用户代码。然而问题就出在这里,SST 是(间接)绑定到 SuperVersion 的,要让用户代码访问 SST 的 mmap,那在用户代码访问 value 期间就必须保活相应的 SuperVersion。从 DB::Get 返回时,SuperVersion 就放回 TLS 了,从而可能被其它线程回收,用户代码就不能安全地访问从属于那个 SuperVersion 的 SST 的 mmapRocksDB 自身的 PlainTable 就使用了 mmap,面对这个问题,PlainTable 偷了个懒,只有在 immortal 的情况下,才把 mmap 内存直接返回给用户代码。什么情况下是 immortal 呢?使用了 DB::OpenForReadOnly 的情况下。这个限制就过于苛刻,实践中很难满足!3. ToplingDB 的解决方案首先,我们绝不能为了 Zero Copy 而破坏 API 兼容性,必须利用现有的 API 实现 Zero Copy。我们在 ReadOptions 上面做文章,给 ReadOptions 增加两个方法 StartPin/FinishPin,pin 的对象是 super version:如果想要获取 zero copy 的收益,就在调用 Get 前 StartPin,使用完 Get 回来的 value 之后 FinishPin如果不想改代码,就只是没有 zero copy,一切跟从前一样用户需要关心的改变:diff --git a/include/rocksdb/options.h b/include/rocksdb/options.h
    --- a/include/rocksdb/options.h
    +++ b/include/rocksdb/options.h
    @@ -1735,6 +1735,13 @@ struct ReadOptions {
  2. std::shared_ptr pinning_tls = nullptr;
    +
  3. // pin SuperVersion to enable zero copy on mmap SST
  4. void StartPin();
  5. void FinishPin();
    +
  6. ~ReadOptions();
    ReadOptions();
    ReadOptions(bool cksum, bool cache);
    };
    实现细节都在 ReadOptionsTLS 中,求知欲强的用户可以看一下具体实现。当然,相应地,SST 的实现部分也要为此做一点适配。我们给 PlainTable 做了相应的适配,修改少到不可思议。pinner 设为一个默认构造的 Cleanable,就是指不需要对 value 深拷贝, value 指向的内存也不需要做任何清理工作;pinner 设为 null 时,会对 value 做深拷贝这个修改还带来一个额外收益:当要逐个 Get 多条数据时,只需要一次 StartPin/FinishPin,而 StartPin 时会将 SuperVersion 指针放到 ReadOptionsTLS 中,通过 pinning_tls 获取 SuperVersion 避免了相对昂贵的 ThreadLocalPtr 访问,每次 Get 节省了大约 30 纳秒。4. db_bench 适配我们给 db_bench 增加了一个选项 -enable_zero_copy,开启这个选项,Get 就会使用 StartPin/FinishPin 以使用 zero copy。4.1. ToplingZipTable Zero Copy在阿里云 Xeon 8369HB 的云主机上,我们测出来这样的性能(-key_size=8 -value_size=20):readrandom:
    0.234 micros/op 4279978 ops/sec 23.365 seconds 100000000 operations;
    114.3 MB/s (100000000 of 100000000 found)Compact 之后,单个 Get 操作 234 纳秒,这其中,DB::Get 占了 83%,约合 194 纳秒,db_bench 驱动代码占 17%,真正干活的 ToplingZipTable::Get 只占了 18%,合 42 纳秒:
    图片
    仅 Flush 之后,不 Compact,单个 Get 操作 254 纳秒,比 234 稍微慢一点readrandom:
    0.254 micros/op 3939416 ops/sec 25.384 seconds 100000000 operations;
    105.2 MB/s (100000000 of 100000000 found)测试过程参考 这里,记得添加命令行参数 -enable_zero_copy=true ,同时,对 db_bench_enterprise.yaml 做小幅修改。--- a/sample-conf/db_bench_enterprise.yaml
    +++ b/sample-conf/db_bench_enterprise.yaml
    @@ -119,16 +119,16 @@ CFOptions:
    default:
    max_write_buffer_number: 4
    memtable_factory: "${cspp}"
  7. write_buffer_size: 8M
  8. write_buffer_size: 128M
    # set target_file_size_base as small as 512K is to make many SST files,
    # thus key prefix cache can present efficiency
    # 把 target_file_size_base 设得很小是为了产生很多文件,从而体现 key prefix cache 的效果
  9. target_file_size_base: 512K
  10. target_file_size_base: 64M
    target_file_size_multiplier: 1
    table_factory: dispatch
    compaction_options_level:

    L1_score_boost: 1
  11. max_bytes_for_level_base: 4M
  12. max_bytes_for_level_base: 400M
    max_bytes_for_level_multiplier: 4
    #level_compaction_dynamic_level_bytes: true
    level0_slowdown_writes_trigger: 20
    @@ -144,7 +144,7 @@ DBOptions:
    max_level1_subcompactions: 7
    inplace_update_support: false
    WAL_size_limit_MB: 0
  13. statistics: "${stat}"
  14. statistics: "${stat}"

    allow_mmap_reads: true
    databases:
    db_bench_enterprise:
    4.2. 小插曲这中间有段小插曲,开始在用 db_bench 测试验证时,发现在 Version::Get 中有意想不到的 ReadOptions 析构函数调用,占比还挺高:
    图片
    对照了一下代码,原来在 Version::Get 中,有一行代码:BlobFetcher blob_fetcher(this, read_options);
    ReadOptions 的拷贝就在 BlobFetcher 中,这个很好修,Version::Get 中 read_options 的生存期覆盖了 blob_fetcher, 把拷贝改成引用即可,但是万一 BlobFetcher 在其它地方的生存期没有被 read_options 覆盖,不就出问题了,所以 grep 一下代码,还真找到了这样的地方,一起修掉。4.3. Read 采样RocksDB 会对 Get 操作进行采样,采样过程中需要计算随机数,随机数发生器是个 Thread Local,这个过程的耗时占比本来很小,但是 Zero Copy 之后,整体耗时也就 200 纳秒,它的相对占比就比较大了,所以我们对此增加了一个环境变量配置:TOPLINGDB_GetContext_sampling,可配置为 {kAlways,kNone,kRandom},其中 kRandom 是默认行为,与上游 RocksDB 保持一致。为了降低采样对耗时的扰动,测试中我们设置环境变量 TOPLINGDB_GetContext_sampling=kNone4.4. BlockBasedTable, Cache 管够,但无 Zero Copy换用 BlockBasedTable 进行相同的测试(Compact 之后):readrandom:
    2.652 micros/op 377083 ops/sec 265.194 seconds 100000000 operations;
    10.1 MB/s (100000000 of 100000000 found)虽然 BlockBasedTable 没有 Zero Copy,但前面我们提到,它会用 PinnableSlice pin 住 value 引用的 Block,也不需要 memcpy;但是即便如此,性能仍然差 10 倍,当然,必须再次强调 测试过程 中的说明:该测试条件均是双方的最优条件。ToplingZipTable 使用自己的通用索引 NestLoudsTrie 时,搜索 Key 的速度会慢一些,启用压缩时,获取 Value 的速度会慢一些。外加一条:ToplingZipTable 启用压缩时就没有 zero copy 了。

你可能感兴趣的:(ToplingDB Zero Copy)