由于项目工作的需要,我们团队阅读了清华在文件系统方面的一个比较新颖的工作:Octopus。Octopus是一个基于持久内存 NVM 和远程直接内存访问 RDMA 技术的分布式内存文件系统。清华的陆游游老师现已将代码开源,可 点击此处 阅读。
这一工作中的是 ATC-17 (CCF A类),可 点击此处 阅读论文。
我们团队希望通过学习清华的这个优秀的同行工作,来进一步开展自己对于分布式持久内存文件系统的研究。关于论文的分析,虽然有做PPT给同伴们介绍过,但具体的博客分析可能会晚些才放上来。这一系列的内容主要是分析Octopus的源码设计(少许会结合论文内容来讲,希望有兴趣的同学可以自己先读一读),总结Octopus的框架、结构、设计组件及代码创新点等。
系列分析总共包括 个部分。第一部分是 论文摘要,相当于Octopus系统的一个简介;第二部分是 设计框架,在这一部分我们会宏观地介绍Octopus的组成体系及各部分的功能及相互间的联系;第三部分是 代码分析,也是本博客的重中之重。在这一部分我们首先介绍头文件体系(在include文件夹中),了解Octopus的存储结构,表结构,主要数据结构,内存池划分等等。接下来我们介绍方法实现代码(在src文件夹中),我们会通过比对头文件内的函数名称来看每一个方法是采用何种方式实现,好处是什么,取舍考虑是什么。进一步地,我们会通过代码文件间的依赖关系,函数依赖关系去深入探讨Octopus的创新性、局限性并留出进一步讨论的空间。
(内容请见系列上一篇博客)
(内容请见系列上一篇博客)
(其他模块内容请见系列上一篇博客)
我需要强调的是,Octopus这部分的代码并没有完全兑现其论文中所描述的技术。在这一部分中我们来看几个主要函数的实现,它们分别是 FlushData
(从cache刷出数据到持久内存),TxLocalBegin
(本地事务开始),TxWriteData
(事务记录),TxLocalCommit
(本地事务提交),TxDistributedBegin
(分布式事务开始),TxDistributedPrepare
(分布式事务准备),TxDistributedCommit
(分布式事务提交)。
至于TxManager的构造函数以及getTxWriteDataAddress
,都是比较简单直观的,在此不再赘述。
首先我们来看这个关键的数据刷出函数:FlushData
,代码如下所示:
void TxManager::FlushData(uint64_t address, uint64_t size) {
uint32_t i;
size = size + ((unsigned long)(address) & (CACHELINE_SIZE - 1));
for (i = 0; i < size; i += CACHELINE_SIZE) {
_mm_clflush((void *)(address + i));
}
}
我们知道,由于CPU及cache对于程序数据执行的优化,会导致指令及数据输出乱序(不严格按照程序执行顺序)执行。因此,允许按序刷出数据的原语便提供给编程人员使用,以满足特殊的数据顺序要求。因特尔在最新的指令集中支持如下五种刷出原语:
命令 | 解释 |
---|---|
CLFLUSH | 刷出单个cacheline,指令按序,无并发保障 |
CLFLUSHSHOPT(后接SFENCE) | 刷出单个cacheline,可不按序,支持并发 |
CLWB(后接SFENCE) | 与CLFLUSHSHOPT相似,区别是刷出单个cacheline时该数据仍可保留在cache中 |
NT STores(后接SFENCE) | 非即时刷出,“写聚合”,旁路cache |
WBINVD | 仅内核态刷出,同时无效化所有该CPU上的cache line |
具体内容可以阅读 Intel官方文档。
Octopus在该代码里使用的是 clflush
机制(但是缺少mfence
的配合)。
注意 _mm_clflush(addr)
的宏定义是:
#define _mm_clflush(addr)\
asm volatile("clflush %0" : "+m" (*(volatile char *)(addr)))
FlushData
到底做了一件什么事呢?从代码中,我们了解到,它是从指定地址address开始,以CACHELINE_SIZE(64字节)为单位,循环刷出指定大小size内的数据。我对此抱有的担忧是,现代操作系统仅支持最多8字节的原子写,如果在64字节数据刷出的过程中,系统或程序崩溃了,数据不一致该怎么办?显然,TxManager 并没有很好地考虑这一方面的问题。
接下来我们看 TxLocalBegin
的实现,代码如下所示:
uint64_t TxManager::TxLocalBegin() {
lock_guard lck (LocalMutex);
LocalLogEntry *log = (LocalLogEntry *)LocalLogAddress;
log[LocalLogIndex].TxID = LocalLogIndex;
log[LocalLogIndex].begin = true;
FlushData((uint64_t)&log[LocalLogIndex], CACHELINE_SIZE);
LocalLogIndex += 1;
return (LocalLogIndex - 1);
}
一个事务是一次原子性的操作集合,这些操作要么全都完成,要么全都没做(对软件/用户不可见)。事务的状态包括 开始、中止 和 提交。
从代码里可以看出,为了处理并发事务的操作,这里用到了锁机制。然而,每次事务的开始都需要对本地事务管理器加锁,这会严重影响性能。可以优化的点是将锁的粒度放得更细一些——比如访问某块数据时对该块数据上锁,其余块仍可继续并发开始事务。当然这样做的代价是锁的空间开销。
这一步将第 LocalLogIndex
项日志的 begin
状态标记为 true
,然后将整个日志 CACHE_LINE
刷出到持久内存。
接着我们再看 TxWriteData
的实现,代码如下所示:
void TxManager::TxWriteData(uint64_t TxID, uint64_t buffer, uint64_t size) {
LocalLogEntry *log = (LocalLogEntry *)LocalLogAddress;
memcpy((void *)log[TxID].logData, (void *)buffer, size);
FlushData((uint64_t)log[TxID].logData, size);
}
memcpy
是将缓存中的指定大小数据(待更新数据)拷贝到日志结构中,然后将该日志数据刷出到持久内存中。
而提交操作(代码如下所示)则进一步设置 commit
标签为提交(完成=1)或者中止(=0),并刷出到持久内存中。
void TxManager::TxLocalCommit(uint64_t TxID, bool action) {
LocalLogEntry *log = (LocalLogEntry *)LocalLogAddress;
log[TxID].commit = action;
FlushData((uint64_t)&log[TxID].commit, CACHELINE_SIZE);
}
当事务完成以后,对于原始(文件)数据的更新就可以真正反映在持久内存中了,关于预写式日志技术,如有疑问可以 点击这里 查看维基百科。
至于分布式的开始和提交,与本地开始、提交相似,我们此处略过不谈,但 TxDistributedPrepare
值得看看,代码如下所示:
void TxManager::TxDistributedPrepare(uint64_t TxID, bool action) {
DistributedLogEntry *log = (DistributedLogEntry *)DistributedLogAddress;
log[TxID].prepare = action;
FlushData((uint64_t)&log[TxID].prepare, CACHELINE_SIZE);
}
在论文里,作者说准备阶段是要求所有的 Participant 将必要数据信息交给 Coordinator 来统一处理,以计算资源为代价减少通信资源开销,从而得到一个优化的性能结果。
然而,此处代码显得确实太过精简了,只是做了一个 prepare
标签的确认,而没有给出具体的分布式日志间的通信技术。这是我们希望可以继续攻坚的研究点——也是一个非常有趣的研究点。
除了两阶段提交协议,我们还能想出什么协议来协调分布式数据一致性呢?怎么能够平衡好 compute, memory 和 communicate 三者间的 tradeoff 换取某一场景下的最优呢?
(内容请见系列下一篇博客)