论文笔记:Sherman: A Write-Optimized Distributed B+Tree Index on Disaggregated Memory(SIGMOD 2022)

Abstract:本文提出了Sherman,一种在存算分离架构下的写优化的分布式B+树索引。Sherman结合了RDMA硬件特性和RDMA友好的软件技术,从三个角度提高了索引写性能。

  • 为了减少往返,Sherman通过利用RDMA的按顺序交付特性来合并依赖的RDMA命令。
  • 为了加速并发访问,Sherman引入了利用rdma网卡的HOCL锁。
  • 为了减轻写放大,Sherman对B+Tree的数据结构布局进行了两级版本机制的裁剪。

实验结果表明,与现有技术相比,在典型的写密集型工作负载上,Sherman的吞吐量和99%的延迟都要快一个数量级。

Contributions

  • 对存算分离架构上现有树索引的分析,说明了单边方法中写入操作的低效率源于过多的往返、缓慢的同步原语和写入放大。
  • Sherman的设计和实现,一种在存算分离架构上进行写优化的B+树索引,通过结合RDMA硬件功能和RDMA友好的软件技术来提高写性能。
  • 实验评估显示Sherman在不同工作负载下的高性能

1 Introduction

Memory Disaggregation

传统的数据中心将CPU和Memory集成在一起,这样导致了内存的利用率非常低。为了解决这一问题,研究者们探索出了存算分离架构。如下图所示,这种架构将CPU和Memory在物理上分离成了两个独立的组件:compute severs(CSs) 和 memory servers(MSs)。CSs拥有大量的CPU内核(10s - 100s),同时为了减少从CSs到MSs的远程访问,CSs总是配备一小块内存作为本地缓存(1 - 10gb)。而MSs拥有大容量内存(100s - 1000s GB),但是拥有一小组很小的CPU核(1 - 2),以支持轻量级的管理任务,但是计算能力几乎为零。

在这里插入图片描述

RDMA

CSs中的CPU可以通过高速RDMA网络直接访问MSs中的分解内存。RDMA是一种高性能网络通信技术,允许计算机系统在网络上直接访问另一台计算机系统的内存,而无需涉及操作系统的干预。

RDMA提供了两种verbs:

  • two-sided verbs(双边):RDMA_SEND和RDMA_RECV 用于发送和接收消息。
  • one-sided verbs(单边):RDMA_WRITE、RDMA_READ和RDMA_ATOMIC(RDMA_FAA和RDMA_CAS) 直接在远程存储器上操作,而不涉及接收器的CPU。

Motivation

在这种存算分离架构中,

  • Excessive Round Trips:由于单侧动词的有限语义,修改索引节点(例如,B+树中的树节点)总是需要多次往返

  • Slow Synchronization Primitives:解决写-写冲突锁速度较慢,甚至在高争用场景下会可能出现性能崩溃。有以下三种原因:

    • NIC采用昂贵的并发控制来确保RDMA原子命令之间的原子性,其中每个命令需要两个PCIe事务,延长了冲突命令的队列时间
    • 不必要的重试:获取锁失败时,客户端线程重试,此类重试需要远程网络访问,浪费有限资源
    • 缺乏公平性:现有锁没有考虑冲突锁之间获取的公平性,使得客户端请求饥饿
  • Write Amplification:写放大问题。RDMA更偏向于更小的I/O。

    • B+树的树节点需要保证一定的顺序,但是每一次的节点移动都会造成写放大
    • 粗粒度一致性检查:由于粗粒度的一致性检查机制,任何对部分节点区域的修改都需要回写整个节点,从而导致严重的写放大

Overview

下图为Sherman的总体架构。

在这里插入图片描述

  • B+Tree Structure:Sherman是一个B+树,其中的值存储在叶节点中。同时为每个叶节点和内部节点记录一个同级指针,在节点拆分/合并的情况下,客户端线程总是可以通过遵循这些同级指针来到达目标节点,从而有效地支持并发操作。Sherman中的每个指针(即子/兄弟指针)都是64位的,包括两部分:16位的MS唯一标识符和对应MS中的48位内存地址

  • Concurrency Control

    • 写-写冲突:Sherman使用节点粒度的独占锁来解决写冲突,在修改树节点之前,客户端线程必须获取关联的独占锁。
    • 读-写冲突:Sherman支持无锁搜索,通过一致性检查机制,来检测由于写入引起的不一致数据。
  • Cache Mechanism:为了减少树遍历中的远程访问,Sherman采用了缓存机制。cache只存储两种类型的数据节点copies:

    1. 叶节点的上一层节点(level 1 in Figure 5)
    2. 包括根节点在内的最高两层节点

    客户端线程首先查找类型2的节点,当缓存命中时,直接从MS获取目标叶节点;否则,他将搜索类型1的节点,通过RPC遍历所有节点。类型1所有节点都在缓存中。类型2的节点被构造成无锁skiplist,通过power-of-two-choices进行驱逐。随机选择两个缓存节点,驱逐最近使用最少的节点。

  • Memory Management.

    • 申请内存时,客户端线程使用两阶段内存分配方案从MS获取内存。客户端线程首先以轮询方式选择MS,并向MS请求空闲块。然后,它为块中的本地树节点分配内存空间。这样的两阶段方案有两个优点。首先,它避免了大多数分配操作的网络通信。其次,它显著降低了内存线程的处理开销
    • 释放内存时,客户端线程只需要在释放树节点之前清除该节点中的一个空闲位;稍后在获取垃圾节点的请求时将意识到该节点已被释放。

Hierarchical On-Chip Lock

On-chip lock table

Sherman将锁从树节点中分离出来,并将锁存储到MS侧的on-chip中;每个树节点与保护它的锁在同一个MS中,每个MS中的这些锁被构造为一个数组,即全局锁表(GLT)。获取树节点的锁时,客户端线程首先将树节点的地址散列为相应GLT中的索引号,然后向锁发出RDMA_CAS命令。对于锁释放,客户端线程通过RDMA_WRITE命令清除锁。

Hierarchical structure

Sherman在每个CS中设计了一个本地锁表(LLT),以协调同一CS中的冲突锁请求。LLT为所有GLT中的每个锁存储一个本地锁。当线程需要锁定树节点时,它首先在LLT中获取关联的本地锁,然后在GLT中获取相关的锁;因此,来自相同CS的冲突锁定请求在CS的LLT上排队,避免了不必要的远程重试。对于锁释放,线程首先释放GLT中的锁,然后释放LLT中的本地锁。

Handover mechanism

HOCL的层次结构实现了一种切换机制。释放锁时,如果线程发现了本地锁的等待队列不是空的,它将把锁移交给等待队列的第一个线程。为了避免其他CS的线程饥饿,Sherman将连续切换的最大数量限制为4。移交锁的线程不再需要远程访问来获取锁,从而至少节省了一次往返。

HOCL伪代码如下所示:

MAX_LOCK_PRE_MS = 131072 # the number of locks in each MS
MAX_DEPTH = 4 # maximum number of consecutive handovers
def HOCL_Lock(addr):
    idx = calculate_hash(addr) % MAX_LOCK_PRE_MS
    l = LLT[addr.ms_id][idx]
    if lock(l) == False: # get locks in LLT first
        handover = False
        # wait queue ensures first-come-first-served fairness
        l.wait_queue.push(&handover)
        while &handover != l.wait_queue.head():
            continue
        lock(l) # get locks in LLT first
        l.wait_queue.pop()
        if handover == True: # be handed over a lock
            return
    # get locks in GLT finally: RDMA_CAS(addr, compare, swap)
    while RDMA_CAS(&GTL[addr.ms_id][idx], 0, cs_id) == False:
        continue
        
        
def HOCL_Unlock(addr, combine_list):
    idx = calculate_hash(addr) % MAX_LOCK_PRE_MS
    l = LLT[addr.ms_id][idx]
    handover = l.wait_queue.head()
    if handover != None && (++l.handover_depth) <= MAX_DEPTH:
        *handover = true # hand over lock to another thread
    else:
        l.handover_depth = 0
        # collect an RDMA_WRITE command for lock (in GLT) release
        # command's details: write 0 into 16-bit GLT[addr.ms_id][idx]
        combine_list.push{(0, &GLT[addr.ms_id][idx], 16bit)}
  	unlock(l) # release locks in LLT
    RDMA_WRITE combine_list # posting combined commands in list

Two-Level Version

为了能够以无锁的方式读取树节点,就需要在读取数据之前检测当前节点是否正在进行写入。有两种主要的一致性检查机制。

  • 第一种机制是图中(a),每个节点都包括一个覆盖整个节点区域的校验和(校验和本身除外);校验和在修改相应节点时被重新计算,并且在读取节点时被验证
  • 另一种机制是图中(b),在每个节点的开始和结束处存储一个version;当通过RDMA_WRITE修改节点时,相应的两个version递增;只有当两个version相同时,通过RDMA_READ获得的内容才是一致的。

在这里插入图片描述

由于上述两种机制的粒度是树节点,因此对部分节点区域的任何修改都需要写回整个节点(包括元数据,例如checksum和version),从而导致严重的写放大。因此为了解决写放大问题,herman采用了Two-Level Version机制。

  • 首先,Sherman使B+树节点内部保持无序,这样就可以避免在插入/删除时的位移操作。但是可能会造成写操作的复杂度增加。

    • 在查找键时,客户端线程需要遍历整个目标叶节点
    • 在拆分叶节点之前,客户端线程必须对其中的条目进行排序
  • 其次,Sherman引入了entry-level versions,实现细粒度一致性检查。在叶节点中,每个entry存储一对4位entry-level version(即FEV和REV)。此外,在每个叶节点的开始和结束处存储一对4位node-level version(即FNV和RNV),以保护节点粒度的一致性。当只涉及entry 级别的修改,相关联的entry-levels versions递增,并只需要将已经修改的entry通RDMA_WRITE写回,可以有效的避免写放大。当分割叶节点时,客户端线程递增相关联的FNV和RNV,并通过RDMA_WRITE写回整个节点。

在这里插入图片描述

Command Combination

为了减少往返,Sherman引入了一种命令组合技术。RDMA在硬件级别提供了一个强的顺序属性,在一个可靠的连接(RC)队列对中,RDMA_WRITE命令是按照它们被发布的顺序发送的,并且接收端NIC也按照这个顺序来执行这些命令。该技术让客户端可以将依赖的命令组合在一起同时发出(例如,写回和锁定释放)。

命令的组合有以下两种情况:

  • 将树节点的回写和锁释放结合起来,这样可以节省一个往返行程,缩短关键路径
  • 当一个节点A拆分时,我们检查新分配的兄弟节点是否与该节点A属于相同的MS;如果是,三个RDMA_WRITE命令可以组合在一起:➀写回同级节点,➁写回A,➂释放A的锁

Sherman的增删改查操作

插入操作伪代码

NODE_SIZE # size of a tree node
ENTRY_SIZE # size of an entry

def Sherman_insert(key, value):
    leaf_addr = index_cache.find(key) # search index cache
    
    HOCL_Lock(leaf_addr) # lock the targeted leaf node
    node = RDMA_READ(leaf_addr, NODE_SIZE) # RDMA_READ(remote_addr, size);
    combine_list = {} # RDMA_WRITE task list: (buffer, addr, size)
    
    if ∃i,node.entry[i].k == key || node.entry[i].k == None: #update/insert
        # entry-level node modification
        node.entry[i].{k, v} = {key, value}
        node.entry[i].front_entry_ver++
        node.entry[i].rear_entry_ver++
        
        # 个人感觉应该这样写
        # node.entry[i].front_entry_ver++
        # node.entry[i].{k, v} = {key, value}
        # node.entry[i].rear_entry_ver++
        
        # collect a combined command for write-back of the entry in leaf node
        combine_list.push({node, leaf_addr+offset(entry[i]), ENTRY_SIZE})
    else# split
    	sibling = malloc(NODE_SIZE) # local buffer
    	sibling_addr = remote_alloc(NODE_SIZE) # allocate memory at MSs
    	node.sort().move_half_entries_to(sibling) # sort and move
    	sibling.{level, free} = {0, 0}
    	link(node, sibling) # link sibling and list: ... node 	→ sibling ...
    	update_fence_keys(node, sibling)
    	# node-level node modification
    	node.add(key, value)
    	node.front_node_ver++
    	node.rear_node_ver++
    	if leaf_addr.ms_id == sibling_addr.ms_id:
        	# collect a combined command for write-back of the sibling node
       		combine_list.push({sibling, sibling_addr, NODE_SIZE})
    	else:
        	RDMA_WRITE(sibling, sibling_addr, NODE_SIZE)
    	# collect a combined command for write-back of the leaf node
    	combine_list.push({node, leaf_addr, NODE_SIZE})
    
	HOCL_Unlock(leaf_addr, combine_list) # unlock the targeted leaf
	if sibling != None: # update internal nodes
        insert_internal(sibling.entry[0].k, sibling_addr)

查找操作伪代码

def Sherman_lookup(key):
	leaf_addr = index_cache.find(key)
	retry:
        node = RDMA_READ(leaf_addr, sizeof(node))
        # node-level check
        if node.front_node_ver != node.rear_node_ver:
       	 	goto retry
        if ∃e, e in node && e.k == key: # find targeted entry
   	 	# entry-level check
    	if e.front_entry_ver != e.rear_entry_ver:
    		goto retry
    return e.v
return None

Sherman的github链接:https://github.com/thustorage/Sherman

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值