保持hlist_node内存的紧凑性连续性以提高遍历性能

遍历?

是的,遍历!遍历本来就是饱受诟病的事情,它就是为低效而生的,若要提高性能,采用别的数据结构多好,干嘛要提高遍历的性能?!

我不会没事瞎写文章充数的,一定是发生了什么,当然,这个我放到最后说。

是的,遍历也需要高性能。特别是在某些万不得已的折中环境下,比如hlist本来就是哈希链表了,为了解决必然会遇到的哈希冲突,遍历就是必须的,比如以下的接口都是hlist_head遍历:

hlist_for_each
hlist_for_each_entry
hlist_for_each_entry_safe
hlist_for_each_entry_rcu
...

当然了,平心而论,如果hlist过长,此时首先要考虑的是增加hash桶的数量以及提高hash算法的散列性能,而不是去优化遍历的性能,但是就事论事,在非hash表的情况下,如果单纯考虑遍历本身,我这里确实有些话要说。


首先,先摆出一个事实:

  • 在连续的内存上进行遍历,其性能远超在离散的内存上进行遍历!

这是因为CPU在访问内存地址P时,会把一个cacheline的数据预取到cache,在连续的内存上,随着遍历的进行,链表项的访问和预取将形成一个流水化作业,这个流水线只要不被打断,遍历就好像在cache中进行一样。

现在考虑实际情况,我们的链表项能保证地址连续吗?或者退一步说,能保证其地址尽可能连续吗?

我建议先阅读我前面的一篇文章:
https://blog.csdn.net/dog250/article/details/105544111

kmalloc由于使用全局共享的kmem_cache slab(当前实现是slub,所以这里没有写错)数组,因此很难保证内存对象的连续,那么如果我们自己ceate一个kmem_cache呢?事情会变好吗?

还是看我前面的那篇文章,有个结论:

  • kmem_cache是栈式管理对象的!free操作相当于一个push,alloc操作相当于一个pop!

我们看看这种栈式管理的性质:

  • 栈式管理保证了kmem_cache的slab本身内部碎片的最小化。
  • 栈式管理无法保证连续分配到的对象地址的连续性。
  • 栈式管理的slab对象分配顺序取决于已用对象的释放的顺序。

我们做一个实验以证之,还是那个alloc.stp,稍微修改一下:

%{
#include <linux/module.h>
#include <linux/time.h>

struct stub {
	struct hlist_node hnode;
	unsigned char m[5];
};

#define SIZE	8000

static inline void hlist_add_behind_rcu(struct hlist_node *n,
                                        struct hlist_node *prev)
{
        n->next = prev->next;
        n->pprev = &prev->next;
        rcu_assign_pointer(hlist_next_rcu(prev), n);
        if (n->next)
                n->next->pprev = &n->next;
}

static void *(*_global_cache_flush)(void);
%}

function kmemcache_test()
%{
	struct timeval tstart, tend;
	unsigned long tuses = 0, tuseu = 0;
	struct hlist_head *hashlist;
	struct hlist_node *node;
	struct kmem_cache *memcache;
	struct stub *p, *ent, *ent_prev;
	unsigned long rnd;
	unsigned long addr, addr_pre, addr_next;
	int i, j, retry = 2, sort = 0;

	hashlist = kmalloc(sizeof(struct hlist_head), GFP_KERNEL);
	if (!hashlist) {
		return;
	}
	_global_cache_flush = (void *)kallsyms_lookup_name("global_cache_flush");
	if (!_global_cache_flush) {
		return;
	}

	INIT_HLIST_HEAD(hashlist);

	memcache = kmem_cache_create("test_", sizeof(struct stub), 0, 0, NULL);
	if (!memcache) {
		return;
	}

realloc:
	for (i = 0; i < SIZE; i ++) {
		p = kmem_cache_alloc(memcache, GFP_KERNEL);
		if (p && sort == 0) {
			hlist_add_head_rcu(&p->hnode, hashlist);
		} else if (p && sort == 1) {
			addr = (unsigned long)p;
			if (hlist_empty(hashlist)) {
				hlist_add_head_rcu(&p->hnode, hashlist);
			} else { // 该else分支处理按序插入
				hlist_for_each_entry_safe(ent, node, hashlist, hnode) {
					addr_pre = (unsigned long)ent;
					if (addr < addr_pre) {
						hlist_add_head_rcu(&p->hnode, hashlist);
						break;
					}
					if (node == NULL) {
						hlist_add_behind_rcu(&p->hnode, &ent->hnode);
						break;
					} else {
						struct stub *ns  = hlist_entry(node, struct stub, hnode);
						addr_next = (unsigned long)ns;
					}
					if (addr > addr_pre && addr < addr_next) {
						hlist_add_behind_rcu(&p->hnode, &ent->hnode);
						break;
					}
				}
			}
		}
	}

	i = j = 0;
	/* 排除cache的既有影响:
	 * 大概率已经在cacheline了,释放掉以凸显链表节点的地址对遍历的影响) 
	 */
	_global_cache_flush();
	do_gettimeofday(&tstart);
	hlist_for_each_entry_rcu(ent, hashlist, hnode) {
		/* 将以下的打印注释释放你将看到细节 */
		//STAP_PRINTF("[%d] 0x%p ", i, ent);
		if (!strcmp(ent->m, "abcd")) // 稍微做点事,显得很忙。
			j ++;
		if (i > 0) {
			unsigned long hi = (unsigned long)ent;
			unsigned long lo = (unsigned long)ent_prev;
			signed long delta = hi - lo;
			if (delta < 0)
				delta = lo - hi;
			//STAP_PRINTF("delta [%llx] sort:%d  retry:%d\n", delta, sort, retry);
		} else {
			//STAP_PRINTF("delta [0] sort:%d  retry:%d\n", sort, retry);
		}
		ent_prev = ent;
		i ++;
	}
	do_gettimeofday(&tend);
	tuses = (tend.tv_sec - tstart.tv_sec)*1000000 + tend.tv_usec - tstart.tv_usec;
	tuseu = tuses/1000000;
	tuses = tuses - tuseu*1000000;
	if (retry != 2) {
		STAP_PRINTF("%ld .. %ld   sort:%d  retry:%d   %d\n", tuseu, tuses, sort, retry, j);
	}

begin:
	// 随机释放!当然了,可以安排一个精心的释放序列,干点坏事
	hlist_for_each_entry_safe(ent, node, hashlist, hnode) {
		get_random_bytes(&rnd, sizeof(unsigned long));
		if (rnd % 2 == 0) {
			hlist_del(&ent->hnode);
			kmem_cache_free(memcache, ent);
		}
	}
	if (!hlist_empty(hashlist)) {
		goto begin;
	}
	
	/* 控制流程:
	 * 第1次:刚刚初始化的kmem_cache slab分配SIZE个对象并遍历(内存地址是顺序的)
	 * 第2次:随机顺序将对象全部释放后再重新分配SIZE个对象并遍历(内存地址是散乱的)
	 * 第3次:随机顺序将对象再次全部释放后重新分配SIZE个对象按照内存地址顺序插入hlist并遍历
	 */
	if (retry > 0) {
		if (retry == 1)
			sort = 1;
		retry --;
		goto realloc;
	}
	kmem_cache_destroy(memcache);
%}

probe begin
{
	kmemcache_test();
	exit(); // oneshot模式
}

以下是几次的运行结果:

 0 .. 218   sort:0  retry:1 i:0
 0 .. 213   sort:1  retry:0 i:0

 0 .. 222   sort:0  retry:1 i:0
 0 .. 208   sort:1  retry:0 i:0

 0 .. 219   sort:0  retry:1 i:0
 0 .. 211   sort:1  retry:0 i:0

 0 .. 227   sort:0  retry:1 i:0
 0 .. 209   sort:1  retry:0 i:0

 0 .. 237   sort:0  retry:1 i:0
 0 .. 207   sort:1  retry:0 i:0

 0 .. 240   sort:0  retry:1 i:0
 0 .. 206   sort:1  retry:0 i:0

足以见得按照内存排序对遍历性能的影响。

你可能想知道栈式管理的slab在随机释放对象后再分配时发生了什么,我画一幅图就明白了,为简单起见,我用顺序的自然数编号内存地址:
在这里插入图片描述
我们可以得出一些trick式的结论,在确定hash桶无法增加的情况下,为了让hlist桶冲突链表的遍历性能更高,我们需要:

  • 尽量保持hlist_node的内存地址的紧凑且单调递增。
  • 条件允许的情况下,每个桶一个kmem_cache,单独维护一个slab池子。

想做到保持内存的紧凑,内核已经提供了API:

void hlist_add_after_rcu(struct hlist_node *prev, struct hlist_node *n);
// 低版本内核没有add behind接口,需要自己copy
void hlist_add_behind_rcu(struct hlist_node *n, struct hlist_node *prev);

插入的代价仅仅是执行一次冒泡,如果插入操作是非频操作,建议这么做!

说说我为什么写这篇文章。

昨天发现了使用kmalloc导致的性能问题后,我换成了自行维护的kmem_cache来管理对象的内存,性能相比之前减少了20%的pps损耗,好事啊!

然而,经过频繁的alloc,free操作后,我发现了偶然的性能抖动,难道我自己维护的slab还有什么幺蛾子问题吗?

通过crash工具的kmem -S以及slabinfo,slabtop,我发现我自己的slab也变得零散了…在解决这个问题之前,我首先想到了一个 坏主意:

  • 我什么都不用做,只要在一个slab里不断的分配内存,然后按照精心布局的顺序释放掉它们,就能把这个slab打的全是空洞…

然后就又是左右手互搏了。如果我自己碰到了我这样的坏人,我该怎么应对!

旁边的同事建议我自己维护一个预分配好的大池子,自己管理自己的内存,不要使用栈式的slab。当然,我也是蠢蠢欲动地想实现一个 基于位图的连续内存分配算法

  • 学习buddy系统(管理全局内存)的样子做一个大小固定的buddy系统。

大小不固定和大小固定的内存对象管理算法,说到底就是一个是解决内碎片的问题,一个是解决外碎片的问题,类似于段式(大小不固定)和页式(大小固定)的内存管理方法。

可是我不会编程啊!

说到底还是因为懒,最后发现方法虽然不是很精确,但是足够简单!大多数情况够用了,就是我上面说的 按照node的内存地址重排 的方案。结果测试下来,pps性能照之前的优化又提高了将近一倍!损耗由%4进一步降低到了2%左右!

皮鞋啊皮鞋,买皮鞋👞去咯!

我们知道,机械磁盘的磁头运动是一个重体力劳动,保持顺序读写方能高效,实际上,内存的非机械读写也同样。这一切背后的原则就是数据的局部性!


浙江温州皮鞋湿,下雨进水不会胖!

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值