☰
FPGA卸载LSM引擎compaction:软硬件协同设计详解
2026/10/15 15:46:38 网站建设 项目流程

读这篇 FPGA-Accelerated Compactions for LSM-based Key-Value Store 的过程中,我一直在想一个问题:为什么这类系统论文读完之后,给人的印象往往比论文里那一串性能数字更持久?我的答案是,它把一个长期被后台任务掩盖的“成本中心”摆到了台面上,并且给出了一条工程上可执行的路——用 FPGA 卸载 LSM-based Key-Value Store 里最吃 CPU 的 compaction 过程。这篇博文就按我自己的理解,把论文的背景、核心设计、关键细节、性能收益和适用边界完整拆一遍,中间会夹带一些我在存储系统上做 profiling 和软硬件协同设计时踩过的坑,希望能给同行一些参考。

1. 为什么这篇论文值得读:compaction 是 LSM 引擎的隐形耗电大户

1.1 从一次普通写入说起:LSM 树的日常工作

先拉齐一下基础概念,不然后面讲卸载方案容易飘。一个典型的 LSM-based Key-Value Store,写入路径大概是这样的:用户写入先落到内存里的 memtable(一个内存中的可排序结构,比如跳表或者有序哈希),同时为了故障恢复,还会写一份 WAL 日志;threshold 到达之后,memtable 被冻结并转储成不可变的有序文件,落到第 L0 层;L0 之下的每一层都有容量上限,后台线程定期的把上层选出的文件,与下一层 key 范围有重叠的文件,做一次多路归并,生成新的下层文件后删除旧文件。整个过程就是 compaction。

一句话概括 compaction:它本质上就是文件级别的 k 路归并排序,外加数据筛选和旧版本回收。用个生活化的类比:memtable 是前台收银台临时攒的小票盒子,盒子满了就得搬到仓库,仓库里的货都是按编号排好序的箱子;但不同批次搬进来的箱子之间并不保证全局有序,于是定期需要在仓库里“并箱”——把几个箱子按编号合并成更大的箱子,让仓库更规整。这个“并箱”动作就是 compaction。

为什么要反复做这件事?因为 LSM 树的核心规律是:写的快、查的慢。为了让读路径尽量少扫描文件,它必须保证每一层的 key 范围相对有序、文件数量可控,同时通过层级合并把旧版本、被删除的记录真正从磁盘上清走。也就是说,compaction 不是可有可无的优化,而是维持查询效率、回收存储空间、控制放大效应的刚需。任何 LSM 引擎,只要持续写入,compaction 就停不下来。

1.2 compaction 那张 CPU 账单到底有多厚

很多人第一次看 LSM 引擎的 CPU profiling 时都会意外:前台读写请求消耗的 CPU 通常不大,真正烧核的往往是那些后台 compaction 线程。我见过线上服务器一个写密集实例里,4 到 8 个核长期被 compaction 占满,主业务请求还在挤剩下的核,延迟波动一下子就上来了。

这里有几个关键词值得一提:

放大类型含义典型代价
写放大一次逻辑写入最终触发多少实际磁盘写入LSM 引擎通常在 10~30 之间
读放大一次查询需要读多少个文件/块与层数、文件重叠度相关
空间放大旧版本和被删数据占用的额外空间依赖 compaction 的回收效率

写放大 20 意味着写入 1GB 逻辑数据,SSD 上实际落盘可能超过 20GB,这 20GB 里大部分都是 compaction 反复重写出来的中间结果。每一次重写都要执行这样的动作:从源文件里读数据块,在内存里做多路归并,逐 key 比较、决定保留还是丢弃、按目标 key 范围重新组织,然后写入新文件。这几步里,逐 key 的比较和内存搬移是纯 CPU 开销,数据块越大、参与归并的文件越多,CPU 账单就越厚。

还有一个容易被忽略的点:多路归并的访问模式对 CPU 缓存特别不友好。每个输入文件都维护一个游标,需要从多个块里各取一小段数据参与比较,这种“多个流交替消费”的模式,会让缓存换入换出很频繁,有效带宽远低于理论内存带宽。结果就是即使核数够多,单核的归并吞吐也很难拉上去,多核并行又会引发内存带宽竞争和锁开销。所以用通用 CPU 去硬扛 compaction,本质是在用一套“万能但每步都有额外损耗”的工具,去执行一份“完全可以定制成流水线”的固定工作。

1.3 FPGA 为什么在这种场景里能插一脚

FPGA 不是万能硬件,但它正好擅长这一件事:按数据通路的形状定制执行逻辑。CPU 是通用流水线,每条指令都要经过取指、译码、执行、访存这些固定步骤;FPGA 则可以把比较器、缓冲、归并逻辑直接用硬件描述语言部署在可编程逻辑里,数据从 DMA 进来之后流式经过归并单元,不需要每字节都过一次通用 CPU 流水线。

如果用一句话总结论文的动机,就是:compaction 的归并过程非常规整,k 路输入、逐 key 比较、顺序输出,这是一个可以被流水线化的流式任务,非常适合从 CPU 卸载到 FPGA。相比之下,memtable 的操作太轻量,卸载收益小;flush 只是一次简单排序,硬件加速空间有限;前台读路径延迟敏感,不适合引入 DMA 和中断等不确定性。所以论文选择 compaction 作为加速点,非常聚焦,也足够有分量。

2. 论文核心思路:软硬件分工,让 FPGA 接管多路归并

2.1 总体架构:一张卡、一条 PCIe 链路、一个任务队列

论文描述的系统,从顶层看是典型的软硬件协同架构。主机侧运行的是常规的 LSM 引擎,可以把它想象成一个 LevelDB/rocksdb 类的开源引擎,保持原有的读写接口、WAL、memtable 和文件格式。硬件侧是一块 FPGA 加速卡,通过 PCIe 与主机相连,卡上至少有三个核心部分:DMA 引擎负责与主机内存交换数据,归并核心负责执行真正的 k 路归并,片上缓冲(BRAM 或者板载存储)用来平滑数据流的速率波动。

关键是两者之间的接口。论文里最核心的抽象,是在任务描述符(task descriptor)层面定义了一个“compaction 卸载协议”。软件侧把一次 compaction 要做的所有事情打包成一个描述结构,丢给设备;设备执行完,把结果写回主机内存并通知软件。对软件来说,这类似于把一次“函数调用”变成了“远程过程调用”,只不过远端不是另一台机器,而是同一台服务器里的一块 FPGA 卡。

我从论文里读出的第一原则是“硬件不替做决策,只替做苦力”。策略层面,比如哪些文件参与合并、产生的目标文件的 key 范围、快照序号是多少、旧版本能回收多少——这些仍然由软件侧决定。FPGA 只负责把决策变成实际的数据搬运、比较、归并和写出。这种分工降低了系统的耦合度,也是这套方案能被工程接受的重要原因。

2.2 软件侧设计:尽量不惊动原有引擎

任何想落地的卸载方案,最忌让软件侧伤筋动骨。论文的做法是在软件侧引入一个“compaction offload 层”,把原本由后台线程执行的归并逻辑,替换为“打包任务描述符 + 提交任务队列 + 等待完成通知 + 回收旧文件”这几步。

描述符大致长这样(我根据自己的工程经验还原的一个示意结构,不代表论文原样):

struct compaction_desc { uint64_t src_segment_id[8]; // 参与合并的源文件列表 uint32_t src_segment_cnt; // 源文件数量 uint64_t dst_segment_id; // 目标文件标识 uint64_t snapshot_seq; // 快照序号,决定哪些旧版本可被回收 uint64_t key_range_low; // 本任务负责的 key 范围下界 uint64_t key_range_high; // 本任务负责的 key 范围上界 uint32_t flags; // 压缩、校验、同步标志 };

有了这个描述符,原有引擎的 compaction 调度器不再需要关心归并的细节,它只关心:上层选好了哪些文件、目标 key 范围是什么、做完之后怎么切换元数据。而 FPBA 侧做完归并之后,软件侧的收尾动作跟原来的软件 compaction 完全一致:把旧文件从层级结构中移除,把新文件登记到对应层,然后释放空间。这保证了卸载方案对外行为的一致性,也让测试和验证变得相对简单。

值得强调的是,软件侧不能把所有任务一股脑塞给设备就算完。论文里虽然没有详细展开,但在工程上必须配套一个“任务队列水位管理”:设备侧缓冲有容量上限,软件要能够感知队列拥堵,在设备满时停止下发新任务,避免数据堆积导致任务超时或者 DMA 失败。这个水位管理是整个系统的隐形骨架,也是我在后面第 3 章会重点展开的部分。

2.3 硬件侧归并单元:一个为字节流而生的比较阵列

FPGA 内部的归并单元,是论文里最吸引我的部分。它的核心是一个 k 路归并器:从 k 个输入流中各取当前记录,比较器阵列同时对比这些记录的 key,每个周期选出其中最小的那条记录输出。因为有多路输入同时参与比较,FPGA 的归并吞吐理论上可以做到每周期输出一条记录,这比 CPU 上一条条循环比较要快得多。

有一个细节需要处理:LSM 的 key 是变长的。FPGA 寄存器天生适合做定宽比较,面对变长 key 就比较麻烦。常见的做法是,在进入比较器阵列之前,先经过一个 key 解析模块,把变长 key 的长度信息提取出来,然后按“长度对齐 + 内容逐段比较”的方式设计状态机;如果再做细一点,还可以把 key 的哈希结果放在高比特位做一个快速过滤,减少逐字节比较的次数。

归并单元的输出侧还要考虑压缩。很多 LSM 引擎默认会对数据块做压缩存储,如果 FPGA 直接处理压缩后的数据,比较之前必须先解压,输出之后还得重新压缩。论文层面,我理解第一版大概率只针对未压缩或者简单编码方式做了加速,压缩算法下沉到 FPGA 属于后续演进方向,因为这个工作量非常大。能想清楚“第一刀切到哪里”很重要,这点我会在最后一章的工程判断里再讲。

3. 论文关键细节:调度、流水线与一致性设计

3.1 任务调度:多并发场景下的优先级与背压

真实系统里,compaction 不是孤零零的。L0 层达到阈值时可能有两个文件合并进 L1,L1 的某个 key 区间又同时需要和 L2 合并,还有新的 memtable flush 正在路上。与此同时,用户读写请求还在共享磁盘带宽。这意味着 FPGA 必须能同时服务多个归并任务,任务之间还要有优先级,不能只按 FCFS 傻跑。

从系统设计的角度,设备侧会有一个小型的任务调度器,决定当前哪几个归并任务占用归并阵列。调度策略通常要考虑几点:L0 往 L1 的 compaction 往往是写路径卡顿的罪魁祸首,优先级最高;同一层不同任务之间,key 范围重叠度高的优先合并,这样能更快降低读放大;与此同时,用户 IO 请求的带宽需要被显式保留,不能让后台任务把磁盘带宽全部抢走。

优先级调度一旦引入,背压(backpressure)就不可避免。硬件缓冲满了怎么办?软件还在不断下发任务怎么办?我的经验是,一定要在任务队列上做水位线:软件端维护一个“在途任务上限”,达到上限就暂停提交,等设备侧的完成队列反馈后再继续。宁可让软件侧暂时等一等,也不能让硬件侧的缓冲溢出。这个道理跟网络交换机里的流控完全一样,本质都是“上游减速,换取系统稳定”。

3.2 三阶段流水线:读、合并、写要互相掩护

compaction 全链路大致是三段:Source Read -> Merge -> Sink Write。如果只把注意力放在 FPGA 的归并速度上,很容易踩坑——因为最终吞吐往往不是由最快的归并单元决定,而是由最慢的那一段决定。

我见过不少软硬件协同方案,第一版都犯同一个毛病:Merge 在 FPGA 上跑得飞快,但软件侧的 DMA 缓冲区只给了一个,Source Read 在读下一批数据时,Merge 必须停下来等,整个流水线退化成串行执行。真正的做法是要给每个阶段配上双缓冲(或者多缓冲),让 Source Read 在填缓冲 A 的时候,Merge 消费缓冲 B 的内容,同时 Sink Write 把上一轮输出写走:

流水线阶段硬件/软件承担者缓冲要求
Source Read软件侧发起读,设备侧 DMA 接收至少双缓冲,避免读等合并
MergeFPGA 归并单元输入输出缓冲独立,容量足够容纳多个数据块
Sink WriteFPGA/软件发起写,DMA 写回目标位置输出缓冲可排空,不能阻塞归并

用接力搬砖来类比更直白:如果仓库门口只留一块砖的位置,第一个人放完砖之前,第二个人只能站着等;只要多留几块砖的位置,两个人就可以一直在动。论文能跑出好看的性能数字,我认为很大一部分功劳不是归并单元本身,而是它把三段工序合理流水线化了,IO 时间被 Merge 时间成功隐藏掉。

这个思路同样适用于软件侧线程池设计。即使在纯软件环境里,把 compaction 拆成“预读线程 + 归并线程 + 写回线程”,用队列连接阶段,也能比单线程边读边合并快不少。先学会这一层,再去看 FPGA 方案,会觉得豁然开朗。

3.3 变长 key、压缩和失败恢复这几个硬骨头

变长 key 比较前面已经提到了,补充一点:如果比较器设计得不好,地址对齐和长度判断本身就会成为新的吞吐瓶颈,所以硬件设计里通常会把 key 按定长前缀拆开,比较完成后再进内容比较。这跟 CPU 上做字符串比较时先比长度、再比内容的思路一脉相承。

压缩和校验也值得一提。如果源文件是压缩存储,归并前必须解压;如果目标文件要求压缩,归并后还得重新压。很多引擎还会按块计算 checksum,写入时校验。这些环节如果全部留在 CPU 上,那么即便归并单元加速得再快,CPU 仍然要承担大量数据搬运和计算,收益会被明显稀释。论文的完整方案理想状态下应该把解压/压缩也一并卸载,但这确实是不小的工作量。以我的判断,第一版很可能只对特定编码方式、或者只对纯归并过程做加速,其余交给软件,优先打通全链路收益。

失败恢复是另一个必须提前设计、否则上线就翻车的环节。compaction 最怕做到一半出故障:旧文件删了、新文件没写完,系统就进入不一致状态。规范做法是,输出文件先写到一个临时位置,完整落盘后才通过元数据切换让新文件可见,旧文件才能删除。放在 FPGA 卸载场景里,硬件侧还要额外支持“任务断点状态记录”,比如当前任务已经写到了第几个块、写出的 key 范围到哪里,这样系统重启后可以决策是重做还是续跑,而不是从零再来。

4. 性能收益、成本与适用边界

4.1 论文报告了哪些收益,又该怎么理解

论文里报告的实验,按我读到的内容来看,覆盖了典型的写密集和读写混合 workload。整体结论是正向的:持续写入场景下吞吐有提升,CPU 占用大幅下降,compaction 延迟收敛得更快,用户请求的长尾延迟也有改善。

这里我想特别解释一个很多读者容易误解的地方:写吞吐提升并不是因为单次写入变快了,而是因为后台 compaction 不再侵占 CPU 核,前台请求获得了更稳定的资源。举个例子,64 核服务器上如果原来有 4~8 个核被后台 compaction 烧满,那么这些核被卸载到 FPGA 之后,相当于把一批计算资源“还给”了业务路径。p99 延迟的改善同理,它主要来自资源争用的消除,而不是硬件本身的速度奇迹。

所以读这类论文,别只盯着“FPGA 比 CPU 快了多少倍”,要看到端到端的系统收益:CPU 占用下降、IO 路径更平稳、调度更可预期。这些指标对在线系统的重要性,往往超过峰值吞吐那一两个百分点。

4.2 硬件成本和功耗:不算便宜,但算得过来账

FPGA 加速板卡的成本不能忽略,一块中高端板卡的价格比一块 CPU 还贵,功耗也通常在三五十瓦以上。但如果从整体来看这笔账,需要换一个角度:不是“一块卡替代一颗 CPU”,而是“一块卡卸载掉了原本需要 4~8 个核持续运转的后台任务”,对应的功耗节省是整个服务器 CPU 功耗的下降,同时业务侧还获得了更稳的延迟。

工程上还有一个容易被低估的成本:开发。FPGA 的设计调试周期比纯软件长得多。软硬件接口调试、DMA 描述符错误、中断丢失、边界情况下缓冲溢出,每一项都可能耗掉以周计的时间。所以在评估这套方案的性价比时,一定要把“团队有没有 FPGA 开发能力”算进去。如果没有,那论文里的思路初期阶段更适合先在软件里做流水线化模拟,而不是直接买卡。

4.3 什么负载适合,什么负载别硬上

我把适用场景整理成一张判断清单,个人经验挂在后面:

适合加速的负载特征不适合加速的负载特征
写多读少,compaction 占总 CPU 比例高读多写少,compaction 很少触发
任务粒度大,文件级别合并小任务高频,DMA/中断开销摊不掉
多个 compaction 可以并发复用硬件单任务极其短小,硬件加速比小于 1
后台任务导致延迟抖动明显CPU 资源本身过剩

一个实用的预判方法:先做 profiling,统计 compaction 相关线程的 CPU 占用比例。如果长期低于 10%~20%,卸载价值不大;如果超过 30%,那这套方案就值得认真考虑。别在没画 profiling 之前就决定架构,这是我在多个项目里吃过亏之后总结出来的原则。

5. 局限、改进方向与我的工程判断

5.1 工程落地的几个硬门槛

第一道门槛是开发门槛,前面已经说过;第二道是中断与轮询的取舍。设备任务完成通知如果全部走中断,在高频小任务场景下 CPU 会被干扰得很难受,一次中断响应本身的代价可能是几千周期。工程上通常会用完成队列轮询 + 中断合并的方式:大部分时间 CPU 主动轮询完成队列,长时间空闲时才靠中断唤醒。这个细节对实际加速上限影响非常大,但论文图表里一般看不到。

第三道门槛的一致性要求,我在 3.3 已经提到,这里不重复了。第四道门槛其实是部署形态:很多系统并没有独立 FPGA 卡的位置,供电、散热、机箱空间都要考虑。现在很多服务器已经转向 DPU/智能网卡形态,如果把同样的卸载逻辑放到 DPU 上,部署可行性会好很多。论文的硬件形态是 FPGA 卡,但它的任务抽象和调度思想完全可以直接迁移到 DPU。

5.2 从这篇论文延伸出去的方向

说几个我觉得有价值的后续演进方向。软硬件接口层面,可以把任务描述符标准化成一种“后台存储操作卸载协议”。数据块引用、快照序号、key 范围这些信息统一描述之后,软件引擎和硬件加速器可以松耦合,甚至多个引擎共享同一块硬件资源。

功能层面,compaction 的归并只是第一步,布隆过滤器更新、范围删除(Delete Range)的落盘编码、校验计算、更高效的压缩算法,都是可以继续下沉到硬件的工作。越往后面走,CPU 在后台任务上需要做的事情就越少,系统留给前台请求的空间就越充足。

还有一条路线值得关注:用软件做“卸载模拟器”。在没有 FPGA 板卡的情况下,把 compaction 抽象成描述符队列,用独立的线程池消费描述符,先验证调度策略、背压设计和流水线拆分是否合理,等真正拿到硬件再替换执行引擎。这条路径成本低、收获稳定,我认为对许多团队来说是最值得先做的一步。

5.3 几点个人体会

我没有参与这个系统的实现,但读完之后最大的感受是:软硬件协同的成败,往往不在于归并单元设计得多精巧,而在于系统建模有没有做对。把一次 compaction 从“一个函数”变成“一条流水线”,给每个阶段标上缓冲深度、期望带宽、允许时延,然后再去谈卸载策略,很多问题会自己浮出水面。反过来,如果连软件的流水线都没理清楚,就直接上 FPGA,大概率会把软件里的串行瓶颈原封不动搬进硬件。

另一个体会是,读论文不要只读主结果,一定要问一句:这个收益到底是从哪一段省出来的。如果 write offload 只是把归并计算变快了,但数据搬运还是老路径,收益就不会大;只有读-并-写三段真正重叠起来,才能解释论文里那些好看的整体指标。理解了这一点,后续无论做软件优化还是硬件卸载,思路都会清楚很多。

最后再分享一个小技巧:写这类系统的代码前,先把“谁等谁”画清楚。比如软件等硬件完成,硬件等软件释放缓冲,两边都有可能互等,一旦互等就是死锁。我在实际项目里调试过这种问题,最后往往是靠超时机制和任务级状态机解决的,而不是靠现场翻代码。这个教训,我觉得比任何性能数字都更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询