一个Netfilter nf_conntrack流表查找的优化-为conntrack添加一个per cpu cache
对Linux协议栈多次perf的结果,我无法忍受conntrack的性能,然而它的功能是如此强大,以至于我无法对其割舍,我想自己实现一个高速流表。可是我不得不抛弃依赖于conntrack的诸多功能。比方state match。Linux NAT等,诚然。我尽管对NAT也是抱怨太多,但无论如何。不是还有非常多人在用它吗。
以前,我针对conntrack查找做过一个基于离线统计的优化,其思路非常easy,就使用动态的计算模式取代统一的hash算法。我事先会对经过该BOX的全部五元组进行採样记录,然后离线分析这些数据,比方将五元组拼接成一个32源IP地址+32位目标IP地址+8位协议号+16位源port+16位目标port的104位的长串(在我的实现中,我忽略了源port。由于它是一个易变量,值得被我任性地忽略)。然后依据hash桶的大小,比方说是N,以logN位为一个窗体大小在104位的串上滑动,找出相异数量最大的区间,以此区间为模区间,这样就能够将数据流均匀分布在各个hash桶中,假设数据流过多导致冲突链表过长。能够建立多维嵌套hash,把这个hash表倒过来看,它是多么像一棵平衡N叉树啊,N叉Trie树不就是这回事吗?这里的hash函数就是“取某些位”,这重新展示了Trie与hash的统一。
以上的优化尽管优美,可是却还是复杂了,这个优化思路是我从硬件cache的设计思路中借鉴的。可是和硬件cache比方CPU cache相比,软件的相似方式效果大打折扣,原因在于软件处理hash冲突的时候仅仅能遍历或者查找,而硬件却能够同一时候进行。请学校里面的不要觉得是算法不够优越。这是物理本质决定的。
硬件使用的是门电路,流动的是电流。而电流是像水流一样并行连通的,软件使用的逻辑。流动的是步骤,这就是算法。算法就是一系列的逻辑步骤的组合,当然,也有非常多复杂的所谓并行算法,可是据我所知。非常多效果并不好,复杂带来了很多其它的复杂,终于经不起继续复杂,仅仅好作罢,另外,这么简单个事儿。搞复杂算法有点大炮打苍蝇了。
nf_conntrack的简单优化-添加一个cache
假设什么东西和兴许的处理速率不匹配,成为了瓶颈。那么就添加一个cache来平滑这样的差异。CPU cache就是利用了这个思路。对于nf_conntrack的效率问题,我们也应该使用相同的思路。可是详细怎么做。还是须要起码的一些哪怕是定性的分析。假设你用tcpdump抓包。就会发现。结果差点儿总是一连串连续被抓取的数据包属于同一个五元组数据流,可是也不绝对。有时会有一个数据包插入到一个流中。一个非常合理的抓包结果可能是以下这个样子:
数据流a 正方向
数据流a 正方向
数据流a 反方向
数据流a 正方向
数据流c 正方向
数据流a 反方向
数据流a 发方向
数据流b 反方向
数据流b 正方向
数据流 正方向
....
看出规律了吗?数据包到达BOX遵循非严格意义上的时间局部性,也就是属于一个流的数据包会持续到达。至于空间局部性,非常多人都说不明显,可是假设你细致分析数据流a,b,c。d...的源/目标IP元组,你会发现它们的空间局部性,这是TCAM硬件转发表设计的根本原则。TCAM中“取某些位”中“某些位”说明这些位是空间上最分散的局部。这是一种对空间局部性的逆向运用,比方核心传输网上,你会发现大量的IP都是去往北美或者北欧的。
我本希望在本文中用数学和统计学来阐述这一规律,可是这个行为实在不适合在一篇大众博客中进行,当有人面试我的时候问到我这个问题,我也仅仅能匆匆几句话带过,然后假设须要,我会用电邮的方式来深入解析,可是对于一篇博客。这样的方式显得卖弄了,并且会失去非常多读者,自然也就没有人为我提意见了。
博客中最重要的就是高速给出结果,也就是该怎么做。言归正传。
假设说上述基于“空间局部性逆向利用”的“取某些位hash”的优化是原自“效率来自规则”这个定律的话,那么规则的代价就是复杂化。这个复杂化让我无法继续。
另一个比这个定律更加普适的原则就是“效率来自简单”,我喜欢简单的东西和简单的人,这次,我再次证明了我的正确。
在继续之前,我会先简单描写叙述一下nf_conntrack的瓶颈究竟在哪。
1.nf_conntrack的正反向tuple使用一个hash表,插入,删除。改动操作须要全局的lock进行保护。这会赞成大量的串行化操作。
2.nf_conntrack的hash表使用了jhash算法。这样的算法操作步骤太多,假设conntrack数量少,hash操作将会消耗巨大的性能。
[Tips:假设你了解password学中的DES/AES等对称加密算法,就会明确。替换。倒置。异或操作可数据完毕最佳混淆,使得输出与输出无关。从而达到最佳散列,然而这效果的代价就是操作复杂化了,加解密效率问题多在此。这样的操作是如此规则(各种盒)以至于全然能够用硬件电路实现,可是假设没有这样的硬件使用CPU的话。这样的操作是极其消耗CPU的,jhash也是如此,尽管不非常。]
3.nf_conntrack表在多个CPU间是全局的,这会涉及到数据同步的问题。尽管能够通过RCU最大限度缓解,但万一有人写它们呢。
鉴于以上。逐步击破,解决方式就有了。
2.cache尽可能小。保存最有可能命中的数据流项。同一时候保证cache缺失的代价不至于过大。
3.建立一个合理的cache替换自适应原则,保证在位者谋其职,不思进取者自退位的原则
可是这样就完美了吗?远不!
考虑到CPU cache的设计,我发现conntrack cache全然不同,对于CPU。由于虚拟内存机制。cache里面保存的肯定来自同一个进程的地址空间(不考虑更复杂的CPU cache原理...),因此除非发生分支跳转或者函数调用,时间局部性是一定的。可是对于网络数据包,全然是排队论统计决定的,全部的数据包的命名空间就是全世界的IP地址集合。指不定哪一会儿就会有随意流的数据包插入进来。最常见的一种情况就是数据流切换,比方数据流a和数据流b的发送速率,经过的网络带宽实力相当。它们非常有可能交替到达。或者间隔两三个数据包交替到达,这样的情况下。你要照应谁呢?这就是第三个原则:效率来自公平。
因此。我的终于设计是以下的样子:
cache链表太短:流项频繁在conntrack hash表和cache中跳动被替换。
cache链表太长:对待无法命中cache的流项。cache缺失代价太高。
胜者原则:胜者通吃。凡有的,还要加给他叫他多余。没有的,连他全部的也要夺过来。(《马太福音》)均衡原则1-针对胜者:遍历cache链表的时间不能比标准hash计算+遍历冲突链表的时间更长(平均情况)。
均衡原则2-针对败者:假设遍历了链表没有命中,尽管损失了些不该损失的时间,可是把这样的损失维持在一个能够接受的范围内。
效果:数据流到达速率越快就越easy以极低的代价命中cache。数据流达到速率越慢越不easy命中cache,然而也不用付出高昂的代价。
仅仅有连续的数据包到达时间间隔小于某个动态计算好的值的时候。才会运行cache替换。
我的中间步骤測试代码例如以下:
//改动net/netfilter/nf_conntrack_core.c //Email:marywangran@126.com //1.定义 #define A #ifdef A /* * MAX_CACHE动态计算原则: * cache链表长度 = 平均冲突链表长度/3, 当中: * 平均冲突链表长度 = net.nf_conntrack_max/net.netfilter.nf_conntrack_buckets * 3 = 经验值 * */ #define MAX_CACHE 4 struct conntrack_cache { struct nf_conntrack_tuple_hash *caches[MAX_CACHE]; }; DEFINE_PER_CPU(struct conntrack_cache, conntrack_cache); #endif //2.改动resolve_normal_ct static inline struct nf_conn * resolve_normal_ct(struct net *net, struct sk_buff *skb, unsigned int dataoff, u_int16_t l3num, u_int8_t protonum, struct nf_conntrack_l3proto *l3proto, struct nf_conntrack_l4proto *l4proto, int *set_reply, enum ip_conntrack_info *ctinfo) { struct nf_conntrack_tuple tuple; struct nf_conntrack_tuple_hash *h; struct nf_conn *ct; #ifdef A int i; struct conntrack_cache *cache; #endif if (!nf_ct_get_tuple(skb, skb_network_offset(skb), dataoff, l3num, protonum, &tuple, l3proto, l4proto)) { pr_debug("resolve_normal_ct: Can't get tuple\n"); return NULL; } #ifdef A cache = &__get_cpu_var(conntrack_cache); rcu_read_lock(); if (0 /* 优化3 */) { goto slowpath; } for (i = 0; i < MAX_CACHE; i++) { struct nf_conntrack_tuple_hash *ch = cache->caches[i]; struct nf_conntrack_tuple_hash *ch0 = cache->caches[0]; if (ch && nf_ct_tuple_equal(&tuple, &ch->tuple)) { ct = nf_ct_tuplehash_to_ctrack(ch); if (unlikely(nf_ct_is_dying(ct) || !atomic_inc_not_zero(&ct->ct_general.use))) { h = NULL; goto slowpath; } else { if (unlikely(!nf_ct_tuple_equal(&tuple, &ch->tuple))) { nf_ct_put(ct); h = NULL; goto slowpath; } } /*************************************** 优化1简单介绍 *****************************************/ /* 并不是直接提升到第一个。而是依据两次cache命中的间隔酌情提升,提升的步数与时间间隔成反比 */ /* 这就避免了cache队列本身的剧烈抖动。其实。命中的时间间隔假设能加权历史间隔值。效果更好 */ /*******************************************************************************************/ /* * 基于时间局部性提升命中项的优先级 */ if (i > 0 /* && 优化1 */) { cache->caches[0] = ch; cache->caches[i] = ch0; } h = ch; } } ct = NULL; slowpath: rcu_read_unlock(); if (!h) #endif /* look for tuple match */ h = nf_conntrack_find_get(net, &tuple); if (!h) { h = init_conntrack(net, &tuple, l3proto, l4proto, skb, dataoff); if (!h) return NULL; if (IS_ERR(h)) return (void *)h; } #ifdef A else { int j; struct nf_conn *ctp; struct nf_conntrack_tuple_hash *chp; /*********************** 优化2简单介绍 **************************/ /* 仅仅有连续两个数据包到达的时间间隔小于n时才会运行cache替换 */ /* 这是为了避免诸如ICMP之类的慢速流导致的cache抖动 */ /************************************************************/ if (0 /* 优化2 */) { goto skip; } /************************** 优化3简单介绍 *****************************/ /* 仅仅有在总的conntrack数量大于hash bucket数量的4倍时才启用cache */ /* 由于conntrack数量小的话,经过一次hash运算就能够一次定位。 */ /* 或者经过遍历非常短的冲突链表就可以定位,使用cache反而减少了性能 */ /******************************************************************/ if (0 /* 优化3 */) { goto skip; } ct = nf_ct_tuplehash_to_ctrack(h); nf_conntrack_get(&ct->ct_general); chp = cache->caches[MAX_CACHE-1]; for (j = MAX_CACHE-1; j > 0; j--) { cache->caches[j] = cache->caches[j-1]; } cache->caches[0] = h; if (chp) { ctp = nf_ct_tuplehash_to_ctrack(chp); nf_conntrack_put(&ctp->ct_general); } } skip: if (!ct) { ct = nf_ct_tuplehash_to_ctrack(h); } #else ct = nf_ct_tuplehash_to_ctrack(h); #endif /* It exists; we have (non-exclusive) reference. */ if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) { *ctinfo = IP_CT_ESTABLISHED + IP_CT_IS_REPLY; /* Please set reply bit if this packet OK */ *set_reply = 1; } else { /* Once we've had two way comms, always ESTABLISHED. */ if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) { pr_debug("nf_conntrack_in: normal packet for %p\n", ct); *ctinfo = IP_CT_ESTABLISHED; } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) { pr_debug("nf_conntrack_in: related packet for %p\n", ct); *ctinfo = IP_CT_RELATED; } else { pr_debug("nf_conntrack_in: new packet for %p\n", ct); *ctinfo = IP_CT_NEW; } *set_reply = 0; } skb->nfct = &ct->ct_general; skb->nfctinfo = *ctinfo; return ct; } //2.改动nf_conntrack_init int nf_conntrack_init(struct net *net) { int ret; #ifdef A int i; #endif if (net_eq(net, &init_net)) { ret = nf_conntrack_init_init_net(); if (ret < 0) goto out_init_net; } ret = nf_conntrack_init_net(net); if (ret < 0) goto out_net; if (net_eq(net, &init_net)) { /* For use by REJECT target */ rcu_assign_pointer(ip_ct_attach, nf_conntrack_attach); rcu_assign_pointer(nf_ct_destroy, destroy_conntrack); /* Howto get NAT offsets */ rcu_assign_pointer(nf_ct_nat_offset, NULL); } #ifdef A /* 初始化每CPU的conntrack cache队列 */ for_each_possible_cpu(i) { int j; struct conntrack_cache *cache; cache = &per_cpu(conntrack_cache, i); for (j = 0; j < MAX_CACHE; j++) { cache->caches[j] = NULL; } } #endif return 0; out_net: if (net_eq(net, &init_net)) nf_conntrack_cleanup_init_net(); out_init_net: return ret; }
希望看到的人有机会測试一下。
效果和疑问能够直接发送到代码凝视中所看到的的邮箱。