设为首页收藏本站

LUPA开源社区

 找回密码
 注册
文章 帖子 博客
LUPA开源社区 首页 业界资讯 技术文摘 查看内容

Nah Lock:一个无锁的内存分配器

2014-12-22 11:28| 发布者: joejoe0332| 查看: 3767| 评论: 0|原作者: gones945, daxiang, C梦, 无若, shines77|来自: oschina

摘要: 我实现了两个完全无锁的内存分配器:_nalloc 和 nalloc。 我用benchmark工具对它们进行了一组综合性测试,并比较了它们的指标值。与libc(glibc malloc)相比,第一个分配器测试结果很差,但是我从中学到了很多东西 ...


nalloc

介绍

我设计nalloc是为了低频率的,以一种更局部化的方式访问内存.

想法是使用同样的无锁包装器,但是使用"slab"分配来代替在arena间的合并访问.每个页/"slab"只提供单一大小的块.好处是,你无需存储头部,也无需检查邻近的块来分配或释放.你无需块的双链表,因为你不会"从中间删除".常见的分配是两次加载和一次存储,用来弹出有锁栈.缺点是,你会浪费空间.

算法:

  • 每个线程为每个大小的类保留"slab"的一个私有的有锁栈.

  • 自然对齐页大小的slab来自一个页的全局LF栈.

  • 每个slab包含一个线程私有的相同大小块的有锁栈.

  • malloc()从头slab检查,从slab的块的栈弹出.

    • 如果slab为空,则slab从slab栈弹出,不放入任何数据结构."唯一"引用它的是从它分配而来的块.

  • free()利用自然对齐得到块的slab,并将块压入slab的块的栈之中.

    • 如果slab的块的栈为空,则slab会根据它的大小添加回slab的栈.

  • 每个slab包含一个"善变的"块的LF栈.

    • 如果malloc()耗尽了一个slab,它将弹出slab的"善变的"块,并将它们压入slab的空闲块的栈.

    • free()会压入"善变的"块的栈,如果它检测到另一个线程拥有这个slab.如果它已经释放slab里面的最后一个块,同时在slab里面的所有块都是"善变的"块,则它将释放这个slab或为这个调用线程"窃取"它.

如果你不在栈里为cmpxchg做循环计数,则时间复杂度为O(1).


  最大的挑战是处理"善变的"块,如何以一种高效的方式,避免在_nalloc中的竞争或者其它竞争.考虑一些选择项(或者不考虑:可略过的),当线程M为slab S的所有者,同时线程F正在释放一个"善变的"块到S:

  • 假设F只是压入善变的块的栈.问题便是,M将free()当作一个信号,即S为非空.如果每个线程压入善变的块的栈,直到S的栈满,那么将不会再收到信号,同时S产生泄露.

  • 假设F尝试着以某种方式积极的发出信号,或者只是移动S回M的slab栈(让它们变成无锁状态).M可能退出,这样就会出现_nalloc面临的同样的问题.

    • 你可以将slab栈存储在一个比M存活期长的管理块上,同时,对有slab引用的块的数目进行引用计数.我实现了这里面的大部分内容--不像在合并访问,利用可以在slab中计算已分配的块的数目的特点,你可以避免在每个malloc和free上更新引用计数.但是,只是简单的使用互斥体去决断,会很复杂,也很困难.

  • M可以"搜索信号"而不只是等待.搜索时间复杂度为O(N).

  关键在于,M并非真正需要记录空slab.这样,在我上面描述的算法中,M有意的删除所有到S的引用,除了那些从S分配的块隐含的引用.如果F的插入会填满只包含善变的块的slab,那么M或者其它的任何线程都不会保留到S的引用,同时F可以对S任意操控.否则,F不能通知M,但M不会产生泄露,因为某个地方依旧保留着指向它的一个引用.

  开心的是,"窃取"是我从开始就想支持的内容.假设你有一个生产者-消费者模型,消费者也需要内存.如果消费者已经将所有内存加载到缓存了,那么将所有的内存返还给生产者真的有意义吗?同时,即使"窃取"对于工作负载来说是不合适的,如果最后一个要加载slab到缓存的线程,就是释放它的线程,那么这样的效果会更好.

  这个方案的代价是,你要么忍受冲突产生的丢失,要么为每个slab保留64B,用来将"善变的"块的栈从块的栈放入到不同的高速缓存线.对于大的slab,64B不算是太大的开销.

  为了避免竞争,当尝试着查明slab是否为空,或者它是否只包含"善变的"块,我需要直接在我的无锁栈中存储一个大小值.我从标签字段获取了32位来存储.

  当_nalloc中的arena链表没有工作的时候,slab栈会工作,因为每个slab可以保证满足一个特定大小的分配.它拥有我之前提到过的理论上的好处:在分配器以及使用内存的程序中的局部性,在分配器中的序列访问(查看下文),以及低碎片化(其它slab有更好的机会填充和释放).这是slab分配的一个优势,当我进行初始设计的时候我完全错过了--除了尝试和失败,我可能在任何情况下都不会发现它.

  不像_nalloc中清除"善变的"栈的时间复杂度O(N),当stack_popall()交换时,弹出"善变的"块的时间复杂度为O(1).关键在于知道slab块的栈为空,这样,你就可以简单的移动"善变的"块的栈的栈顶节点到它上面,而无需断开链表.

  Intel的x86优化指南(和原因)指出,预取实际上会检测序列访问.所以slab也保留了起始于slab基底部的连续块段的大小.它们将更倾向于从段而不是从栈分配和释放(实际上,它们应该更倾向于从栈分配--我在后面实现了).这具备额外的效果,即让slab初始化的时间复杂度为O(1).因为在slab初始化后,栈的遍历字段不需要写入每个块.

  未来的提升点可能在于使用两个栈--一个使用以前的顺序;一个严格按照降序,直到遇到连续段并添加。如果&second_stack->top处于同样的高速缓存线,那将花费很小的开销.(这听起来像更通用的算法的一个特殊情况.)

  对于非少量分配的情况,这个算法缺乏一些合理性.对于每个位于0B和(FOO * 4096)B之间的16B,你不会真正拥有一个slab栈,但是你也不希望用1024B的块来满足513B的请求.另外,大的块由于头部而产生大的开销,所以,在那种情况下,你希望有大的slab--但是,如果没有完全使用,或者过量使用处于关闭状态,那么大的slab会浪费更多的内存.未来的提升点可能是,当它了解到是否需要为了速度而交换空间,以及是否浪费的空间会成为总使用空间的重大碎片时,弹性的放开块大小或过量使用开关(或者,为slab"善变的"块的栈,触发保留一个高速缓存线).

  同时,通过一些修改,我认为,为了满足大的分配,使用空间有效的_nalloc可能变得合情合理.一个程序可能不会进行快速的大块分配而耗尽分配器,因为它会受制于用实际数据填充分配空间的时间(假设程序不会经常为大量事情一次性预分配空间).

  我在完成设计后的唯一的优化是,用算术方法计算分配的大小来替代大小类查找表.这消除了5%的运行时.回想一下,这可能是因为全局LF栈的错误共享--我用__aligned__(64)来标记栈,因为担心这点,但是我并未标记表.

结果

结果根据上述准则生成。

nalloc在100%并行情况下,最终表现如下。



  性能报告如下:

 Performance counter stats for './natest -t 6 -o 10000':

       519,955,974 L1-dcache-loads
        22,066,220 L1-dcache-misses
         #    4.24% of all L1-dcache hits   [26.59%]
       355,314,274 L1-dcache-stores
         5,272,972 L1-dcache-misses
         4,815,201 L1-dcache-prefetches
           396,406 L1-dcache-misses
         7,920,682 LLC-loads
         1,391,780 LLC-misses
               #   17.57% of all LL-cache hits    [22.11%]
        12,972,675 LLC-stores
         1,386,302 LLC-misses
            14,266 LLC-prefetches
             2,807 LLC-misses
               384 page-faults                                                 
     1,712,655,726 cpu-cycles                #    0.000 GHz
       692,724,604 stalled-cycles-frontend   #   40.45% frontend cycles idle
       492,748,671 stalled-cycles-backend    #   28.77% backend  cycles idle
         7,614,825 branch-misses
         2,800,160 cache-misses              #   23.218 % of all cache refs
        12,060,173 cache-references
     2,413,914,436 instructions              #    1.41  insns per cycle
                                             #    0.29  stalled cycles per insn [27.19%]
       0.105481932 seconds time elapsed

它仍然有内存限制,但不是很严重。 相比于前面 tcmalloc 的表现,nalloc 更少的加载和存储。

重复的,上面表明 一个完美并行度的固定负载测试,线程随机分配、读写私有池,且空闲块 <=1024。线程间没有块移动。


结果看起来很好。在不到500LOC(提供的超过2000行复杂代码),nalloc与jemalloc和tcmalloc相比,它们各自有10000和20000 LOC。主要的一点是,nalloc的目标仅仅是一个测试和一个标准,剩下的是为生产准备的。许多知识都有差异和类似之处。但是,我认为这可能就是nalloc被设计用于覆盖不同地情况的原因。

在这些测试中llalloc是Lockless公司的Lockless分配器,这并不是100%无锁编程(它有一个锁在全局堆中)。运行时使用相同的测试,它比nalloc在串行下块两倍,但是这只是说其在64核实现的25x加速度。当以两倍长度运行时,它实现了相对于nalloc的约40x的加速度。在我的基准中,假设一次每线程(once-per-thread)的开销必须被控制,在那个阶段(大约用42ms时间来完成(time-to-completion)),但是我没有研究。


  然而,我确信其它的分配器不会受到这的影响.但是,当问题大小增长的时候,它们表现的会更糟糕.同时,共享缓存可能会再次成为议题.

  真正的无锁分配器,NBMalloc,Streamflow,以及Maged Michael实现的alloc,在ALADDIN上面都编译失败了(在我的机器上有两个编译失败了).我觉得,现在,我不应该尝试去解决这个问题.



酷毙

雷人

鲜花

鸡蛋

漂亮
  • 快毕业了,没工作经验,
    找份工作好难啊?
    赶紧去人才芯片公司磨练吧!!

最新评论

关于LUPA|人才芯片工程|人才招聘|LUPA认证|LUPA教育|LUPA开源社区 ( 浙B2-20090187 浙公网安备 33010602006705号   

返回顶部