DPDK CPU包处理 - Hash Library
DPDK 提供了一个哈希库,用于创建哈希表以实现快速查找。哈希表是一种数据结构,经过优化,可以快速地在一组条目中进行搜索,每个条目都有一个唯一的键来标识它。为了提高性能,DPDK 的哈希要求所有的键具有相同的字节数,这个字节数是在哈希创建时设置的。
2.1 哈希 API 概览
哈希表的主要配置参数包括:
- 哈希表中总的条目数量
指定哈希表最多可以容纳多少个键值对,这影响内存分配和性能。 - 键的字节大小(Key size in bytes)
所有键的长度必须相同,且在创建哈希表时就确定好。 - 额外标志位(flags)用于设置额外参数,
例如是否启用多线程模式、是否开启可扩展桶功能(extendable bucket),后续会有更详细说明。
哈希表还允许配置一些底层实现相关的参数,例如:
- 哈希函数(hash function),
负责将键映射成一个哈希值,这是哈希表快速定位键值对的核心机制。
高级功能 API:
除了上述基础操作外,Hash API 还提供了一些高级功能:
- 使用键和预计算的哈希值进行添加/查找/删除
如果调用者已经提前计算好哈希值,可以连同键一起传入,这样可以节省重复计算哈希的时间,提高性能。 - 带数据的数据插入/查找(key + data)
添加操作可以附带一个数据(data),这个数据可以是一个 8 字节的整数,也可以是一个指针(用于更大数据的外部存储)。 - 组合功能(key + precomputed hash + data)
即一次性提供键、预计算的哈希值以及附加数据,提高灵活性与效率。 - 删除时不释放位置的功能
对于多线程应用场景非常实用。即使条目被删除,仍然保留其位置,供其他线程在查找时读取,避免并发冲突。 - 批量查找(batch lookup)功能
API 提供了批量查找接口,相比一个个查找,这种方式通过预取(prefetch)机制在处理当前条目时提前加载下一个条目,显著减少内存访问开销,提高整体性能。
键相关数据的存储方式:
每个键关联的数据可以通过以下两种方式进行管理:
- 由用户自己维护一个独立的数据表,该表的条目数量和位置与哈希表一一对应(这种方式在 Flow Classification 场景中使用)。
- 直接存储在哈希表内部。
2.2 多进程支持
哈希库可以在多进程环境中使用。唯一只能在单进程模式下使用的函数是 rte_hash_set_cmp_func()
,该函数用于设置自定义的比较函数,并通过函数指针进行赋值(因此在多进程模式下不受支持)。
补充说明:
DPDK 支持 多进程 的运行模式,允许多个进程共享数据结构(比如哈希表)以实现更高的并发处理能力。哈希库在这种环境下也是可用的,前提是初始化和访问方式遵循共享内存的正确模式。
但由于多进程之间不能共享函数指针(即便是同一个程序,不同进程的地址空间是隔离的),rte_hash_set_cmp_func()
设置的函数指针在子进程中是无效的,因此该功能只能在单进程中使用。
什么时候会用 rte_hash_set_cmp_func()
?
这个函数允许用户指定一个“自定义比较函数”,用于决定两个键是否相等。默认情况下,DPDK 使用字节级比较(memcmp()
),但有时候用户希望自己定义比较逻辑,比如只比较某几个字段或者使用掩码(mask)比较。
2.3 多线程支持
1. 多写线程支持(RTE_HASH_EXTRA_FLAGS_MULTI_WRITER_ADD)
如果设置了 RTE_HASH_EXTRA_FLAGS_MULTI_WRITER_ADD
这个标志,允许多个线程并发地向哈希表写入数据(包括添加、删除、重置)。
- 写入操作之间是相互保护的(即写操作是线程安全的)。
- 但读线程不会被保护,也就是说在进行写操作时读线程仍然可能访问不一致的数据,这可能导致查找出错或读取到旧数据。
- 适用于没有严格读写一致性要求的场景。
2. 读写并发模式(RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY)
如果设置了 RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY
,那么:
- 读写线程都可以同时访问哈希表,即查找与写入是线程安全的;
- DPDK 内部使用了 读写锁(reader-writer lock) 实现并发控制;
- 这意味着你不需要在写操作期间手动阻塞其他读线程,可以放心在多线程环境中操作哈希表;
- 适用于读写都频繁的典型多线程程序。
3. 事务内存加速(RTE_HASH_EXTRA_FLAGS_TRANS_MEM_SUPPORT)
如果设置了 RTE_HASH_EXTRA_FLAGS_TRANS_MEM_SUPPORT
,则:
- 如果平台支持硬件事务内存(例如 Intel® TSX),哈希库会用事务内存来实现读写锁;
- 这可以大大提高并发性能,因为硬件事务比软件锁的开销更小;
- 如果平台不支持,仍会回退使用软件锁;
- 推荐在支持 Intel® TSX 的平台上开启这个选项。
4. 无锁并发读写(RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY_LF)
如果设置了 RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY_LF
,则:
- 提供无锁的读写并发能力;
- 不会使用读写锁机制,从而避免锁带来的性能瓶颈;
- 特别适用于不支持事务内存的架构(例如当前一些 ARM 平台);
- 这个模式下,会自动启用 “删除时不释放位置” 的机制(下一个会讲)。
5. 删除时不释放位置(RTE_HASH_EXTRA_FLAGS_NO_FREE_ON_DEL)
如果设置了 RTE_HASH_EXTRA_FLAGS_NO_FREE_ON_DEL
:
- 删除一个条目时,其在哈希表中的位置不会立即释放;
- 这样做是为了避免某些读线程在条目删除后还引用它的位置,导致崩溃或错误读取;
- 适用于高并发读写环境下的“读线程与写线程之间存在延迟同步”的场景;
- 开启无锁读写模式时默认启用此标志;
- 需要应用程序自己在确认“所有读线程不再访问该位置”后手动释放;
- DPDK 提供了 RCU(Read-Copy-Update)机制 来帮助实现这个功能:
- 比如使用
rte_hash_rcu_qsbr_add()
注册 RCU 线程; - 使用
QSBR(Quiescent State Based Reclamation)
来判断何时安全释放内存; - 更多内容可参考 DPDK 的 RCU 资源回收框架文档。
- 比如使用
总结:
应用场景 | 推荐标志位 |
---|---|
多个线程只进行查找 | 无需设置额外标志 |
多个线程同时添加/删除 | RTE_HASH_EXTRA_FLAGS_MULTI_WRITER_ADD |
多个线程查找 + 写入(需要一致性) | RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY |
高并发查找+写入(硬件支持TSX) | 再加 RTE_HASH_EXTRA_FLAGS_TRANS_MEM_SUPPORT |
高并发查找+写入(不支持TSX) | 使用 RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY_LF (默认带 NO_FREE_ON_DEL) |
2.4. 可扩展桶功能支持
使用一个额外的标志位来启用此功能(该标志默认未设置)。当设置了 RTE_HASH_EXTRA_FLAGS_EXT_TABLE
标志时,在极少数情况下由于哈希冲突严重导致键插入失败时,哈希表的桶会通过链表的方式进行扩展,以插入这些失败的键。该功能对于某些工作负载(例如电信类工作负载)非常重要,这些工作负载需要插入接近或达到哈希表容量上限(接近 100%)的键,并且不能容忍任何键插入失败(即使只有极少数)。
请注意,当启用了“无锁读/写并发”标志时,用户需要调用 rte_hash_free_key_with_position
API,或配置集成的 RCU QSBR,或使用外部 RCU 机制,以释放空桶和已删除的键,以维持哈希表 100% 容量可用的保证。
2.5. 实现细节(非可扩展桶情况)
哈希表主要由两个表组成:
- 第一个表是一个桶(bucket)数组,每个桶包含多个条目(entry)。每个条目包含给定键的签名(signature,见下文说明)以及指向第二个表的索引。
- 第二个表是一个存储所有键及其关联数据的数组。
哈希库使用 Cuckoo Hash(布谷鸟哈希) 算法来解决冲突。对于任意输入的键,在哈希表中有两个可能的桶(主桶和备用/次桶)可用于存储该键,因此在查找键时,只需检查这两个桶中的条目即可。哈希库使用一个可配置的哈希函数,将输入键转换为一个 4 字节的哈希值。然后使用 部分键哈希(partial-key hashing) 方法从该哈希值中提取桶索引和一个 2 字节的签名。
一旦确定了桶的位置,插入、删除和查找操作的作用范围就被限定在这些桶的条目中(条目通常位于主桶中)。
为了加速桶内的查找逻辑,每个哈希条目除了存储完整键,还存储该键的 2 字节签名。对于较大的键来说,直接对比完整键的开销较大,因此会先比较签名,只有签名匹配时才进行完整键的比较。完整键比较仍然是必须的,因为在同一个桶中,不同的输入键可能具有相同的签名(尽管概率较低,尤其当哈希函数提供良好的分布时)。
查找示例:
首先确定主桶的位置,条目很可能存储在主桶中。如果在主桶中找到了签名,就将对应的键与输入键进行比较,若匹配,则返回该键的存储位置以及关联数据。如果主桶中没有找到,则继续查找次桶,进行相同的匹配过程。如果次桶也未找到,则说明该键不在哈希表中,返回一个负值表示查找失败。
插入示例:
与查找类似,首先确定主桶和次桶的位置。如果主桶中存在空条目,则将签名存入该条目,键和数据(若有)加入到第二个表中,同时将第二个表中的索引存入第一张表的条目中。
如果主桶已满,则会将主桶中的一个条目“驱逐”(push)到其备用桶,然后在该位置插入新键。备用桶的位置通过 partial-key hashing 确定。如果备用桶中有空位,则将被驱逐的条目插入其中;若没有空位,则重复上述过程(再次驱逐其中一个条目),直到找到空位为止。
需要注意的是,虽然第一张表中的条目可能会发生多次移动,但第二张表不会被修改,这对于性能优化非常关键。
在极少数情况下,如果在若干次驱逐后仍未找到空位,则该键被认为无法插入(除非设置了扩展桶标志,此时会扩展桶来插入该键,具体见下一节)。使用随机键时,该方法通常能实现超过 90% 的表使用率,而无需丢弃任何已存储条目(例如使用 LRU 替换策略)或分配更多内存(如扩展桶或重新哈希)。
删除示例:
删除过程与查找类似,会在主桶和次桶中查找该键。如果找到,则将对应条目标记为空。若哈希表配置为 “删除时不释放” 或启用了 “无锁读/写并发”,则不会立即释放该键在第二表中的位置。
此时,需要用户在确认没有读者再引用该位置后,主动释放该位置。用户可以配置集成的 RCU QSBR(Read-Copy-Update + Quiescent State Based Reclamation),或使用外部的 RCU 机制来安全地释放该位置。
📦 哈希表的结构(两个“表”)
可以想象一个哈希表分成两部分:
1. 桶表(bucket table)
- 是一个数组,每个格子叫做“桶”(bucket),每个桶里可以放好几个“条目”(entry)。
- 每个条目不是直接放键和值,而是放:
- 一个 签名(signature),是键经过哈希之后的一个小的识别码(2 字节)。
- 一个 索引,这个索引指向下一个“表”里的位置。
2. 键值表(key/value table)
- 真正存储所有的“键”和“数据”,是另一个数组。
- 也就是说,桶表只是个“目录”,告诉我们去哪个位置找真正的键和数据。
解释:
哈希算法:Cuckoo Hashing(布谷鸟哈希)
- 给一个键做哈希之后,系统能算出它可能放在两个桶中的其中一个(主桶和次桶)。
- 查找时,我们只需要检查这两个桶里的条目。
- 这是为了减少查找范围,提高效率。
查找过程(lookup)
查找一个键时,做法如下:
- 根据键算出哈希值,得到两个可能的桶。
- 先看主桶里有没有和输入的签名一样的条目。
- 有的话,再比对真正的键是否完全相同。
- 签名只是预筛选,真正判断靠“完整键”。
- 如果主桶没有,再去次桶找。
- 两边都没有?那就说明这个键不存在。
➕ 插入过程(add)
插入一个键的时候:
- 算出主桶和次桶的位置。
- 如果主桶里有空位,直接插入,更新签名、键值表和索引。
- 如果主桶满了怎么办?
- 把主桶里某个现有条目“踢出去”,挪到它自己的备用桶去(就像布谷鸟把别人的蛋踢走)。
- 然后把当前要插入的新键放进去。
- 如果备用桶也满了?再踢一个出去……
- 这个“踢来踢去”的过程会持续,直到有空位为止。
最终如果实在找不到空位(虽然概率非常低),就会插入失败——除非开启了“可扩展桶功能”。
➖ 删除过程(delete)
- 像查找一样,先找到这个键在主桶或次桶的位置。
- 找到后,把这个条目标记为“空”。
- 但!如果哈希表是无锁并发模式,或者开启了“删除不释放”,那它并不会真的释放内存。
- 是你(开发者)来负责判断什么时候没人用了,再释放内存。
- 通常用 RCU(Read-Copy-Update)机制 来保证安全释放。
为什么要这么设计?
- 性能:大部分操作只需要查两个桶里的几个条目,速度快。
- 空间利用率高:可以填满 90% 以上还不出问题。
- 安全性:使用签名筛选 + 完整键确认,既快又保证不误判。
2.6 启用扩展桶(Extendable Bucket)模式实现细节
核心变化:
在设置了 RTE_HASH_EXTRA_FLAGS_EXT_TABLE
标志后,哈希表 仍然使用 Cuckoo Hash 算法,但是引入了一个关键增强点:
💡 如果在一定数量的 Cuckoo displacement(逐出)后仍找不到插入位置,会为 secondary bucket 分配一个链表结构的扩展桶(extra buckets),把 key 插入到这个链表中。
查找流程(Lookup):
- 和普通模式一样,计算出 primary 和 secondary bucket。
- 依次执行以下步骤:
- 检查 primary bucket → 签名匹配 → 完整 key 匹配
- 若未匹配,再检查 secondary bucket
- 若仍未匹配 → 查找链表形式的 extendable extra buckets
- 逐个遍历链表中的条目
- 签名+key 完整比较
- 都未命中则说明该 key 不存在。
插入流程(Addition):
- 尝试插入到 primary → 若满,进行 displacement 踢出流程。
- 如果 displacement 达到最大次数仍无法插入:
- 原本情况下插入失败
- 现在则进入扩展桶逻辑:
- 在该 key 对应的 secondary bucket 下,附加一个新的 bucket 到其链表尾部
- 将该 key 放入链表的 bucket 中
注意:扩展桶只用于处理极端冲突情况,因此实际链表不会太长
删除流程(Deletion):
与未开启扩展桶时的逻辑基本一致,有一个特别之处:
如果从 primary/secondary bucket 中删除了一个 key 并产生了空位,那么:
- 会尝试从 关联的扩展桶链表中取出最后一个 entry
- 将其移动到这个空位中,以 缩短链表长度,提升未来查找性能
总结对比
特性 | 非扩展桶模式 | 扩展桶模式(EXT_TABLE) |
---|---|---|
插入失败时 | 插入失败返回错误 | 启动扩展桶链表机制继续插入 |
查找路径 | primary → secondary | primary → secondary → extra buckets |
删除后回收 | 删除 entry 位置置空 | 同时尝试回收 extra bucket 的 tail |
内存利用率 | 约 90%,再高需重建或丢弃旧项 | 可突破 90%,无需丢弃 key |
2.7 哈希表中的条目分布(Entry Distribution)
基本逻辑回顾:
- Cuckoo Hash 使用两个位置(primary 和 secondary)来插入 key。
- 默认插入到 primary bucket。
- 如果 primary 满,则会将已有 entry 逐出(kick out)到它的 alternative bucket,以便插入新 entry。
- 随着哈希表的填满,primary 中的空间减少,secondary 中的 entry 比例逐渐上升。
- secondary bucket 命中率变高,会导致 查找延迟略微上升。
表格分析:Primary vs. Secondary 分布趋势
Table 2.1 — 小型表(1024 个随机 key)
表使用率 (%) | Primary 分布 (%) | Secondary 分布 (%) |
---|---|---|
25 | 100.0 | 0.0 |
50 | 96.1 | 3.9 |
75 | 88.2 | 11.8 |
80 | 86.3 | 13.7 |
85 | 83.1 | 16.9 |
90 | 77.3 | 22.7 |
95.8 | 64.5 | 35.5 |
Table 2.2 — 大型表(100 万随机 key)
表使用率 (%) | Primary 分布 (%) | Secondary 分布 (%) |
---|---|---|
50 | 96.0 | 4.0 |
75 | 86.9 | 13.1 |
80 | 83.9 | 16.1 |
85 | 80.1 | 19.9 |
90 | 74.8 | 25.2 |
94.5 | 67.4 | 32.6 |
结论分析
- Primary 是主要插入目标:
- 在哈希表较空时,几乎所有 key 都能插入 primary bucket。
- 这意味着查找速度快,CPU 缓存命中率高。
- 使用率越高,Secondary bucket 被使用得越多:
- 一旦超过 80% 使用率,超过 15% 的 key 会出现在 secondary bucket
- 到 90% 使用率,将近四分之一的 key 会被踢到 secondary
- 性能影响:
- 查找操作平均需要更多 bucket 访问 → 延迟略有上升
- 虽然仍保持 Cuckoo Hash 的常数级查找,但 cache 行和 memory access 次数增加
- 最大利用率约为 95% 左右(在不使用扩展桶的前提下)
2.8 用例说明:流分类(Flow Classification)
在网络处理应用中,每个数据包都属于某个连接/会话/流(flow)。
为了正确处理包,系统必须知道这个包是哪个连接的成员,因此需要进行 流分类:
“根据包头中的某些字段,把包映射到对应的 flow entry 上。”
例如,每个 TCP 连接的流量都应交由同一个 handler 处理,以便进行状态管理、限速、统计、DPI 分析等。
核心结构
组件 | 描述 |
---|---|
Flow Key | 标识一个连接的关键字段集合(例如5元组) |
Flow Table | 存储每个 flow 的状态,常为数组结构 |
Hash Table | 用于快速查询 flow key → flow table 中的位置索引 |
常用的 Flow Key(五元组)。
应用侧操作逻辑
1.添加流(Add Flow)
- 将 Flow Key 插入哈希表
- 如果成功返回一个位置
pos
- 则
flow_table[pos]
可用于添加/更新该流的状态信息
- 则
- 如果返回错误(如无可用 entry)→ 插入失败
- 删除流(Delete Flow)
- 在哈希表中删除 Flow Key
- 若返回有效位置
pos
:- 标记
flow_table[pos]
为无效或清除其内容
- 标记
- 🧹 释放流位置(Free Flow)
- 如果配置了
RTE_HASH_EXTRA_FLAGS_NO_FREE_ON_DEL
或lock-free read/write concurrency
:- 删除只是标记,不能立即释放资源
- 需要使用 RCU 机制(如
rte_rcu_qsbr
)确认所有 reader 都已停止引用这个位置后,才能真正释放 - 否则可能发生读写冲突或数据一致性问题
- 查找流(Lookup Flow)
- 使用 key 在哈希表中查找
- 若找到返回有效位置
pos
:- 使用
flow_table[pos]
获取流状态并执行后续处理
- 使用
- 若找不到,说明是一个新流
框架:
[ Packet ]
↓
[ Extract 5-Tuple ]
↓
[ Hash(Flow Key) ] ──────┐
↓ │
[ Lookup Position ] │ (Add/Delete/Lookup)
↓ │
[ Flow Table[pos] ] ◀────┘
注意事项
项 | 描述 |
---|---|
flow_table[] |
是用户自己定义的结构数组,记录 per-flow 状态 |
哈希对象 (rte_hash ) |
用于 flow key → index 的快速映射 |
Key 大小 (key_len ) |
需根据 flow key 的字段总和进行设置,如 5-tuple 大约 13~16 字节 |
并发读写 | 高并发环境需结合 RCU 机制使用,防止 use-after-free |
实际使用中的建议
- Flow 表数量建议设置为 网络连接数上限的1~2倍
- 哈希表可开启:
- lock-free concurrency(高性能并发查找)
- extendable bucket(避免插入失败)
- 对于长生命周期流,可配合 LRU/LFU 管理策略或定期回收机制