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)机制在处理当前条目时提前加载下一个条目,显著减少内存访问开销,提高整体性能。

键相关数据的存储方式:

每个键关联的数据可以通过以下两种方式进行管理:

  1. 由用户自己维护一个独立的数据表,该表的条目数量和位置与哈希表一一对应(这种方式在 Flow Classification 场景中使用)。
  2. 直接存储在哈希表内部。

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)

查找一个键时,做法如下:

  1. 根据键算出哈希值,得到两个可能的桶。
  2. 先看主桶里有没有和输入的签名一样的条目。
    • 有的话,再比对真正的键是否完全相同。
    • 签名只是预筛选,真正判断靠“完整键”。
  3. 如果主桶没有,再去次桶找。
  4. 两边都没有?那就说明这个键不存在。

➕ 插入过程(add)

插入一个键的时候:

  1. 算出主桶和次桶的位置。
  2. 如果主桶里有空位,直接插入,更新签名、键值表和索引。
  3. 如果主桶满了怎么办?
    • 把主桶里某个现有条目“踢出去”,挪到它自己的备用桶去(就像布谷鸟把别人的蛋踢走)。
    • 然后把当前要插入的新键放进去。
    • 如果备用桶也满了?再踢一个出去……
    • 这个“踢来踢去”的过程会持续,直到有空位为止。

最终如果实在找不到空位(虽然概率非常低),就会插入失败——除非开启了“可扩展桶功能”。


➖ 删除过程(delete)

  1. 像查找一样,先找到这个键在主桶或次桶的位置。
  2. 找到后,把这个条目标记为“空”。
  3. 但!如果哈希表是无锁并发模式,或者开启了“删除不释放”,那它并不会真的释放内存。
    • 是你(开发者)来负责判断什么时候没人用了,再释放内存。
    • 通常用 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):

  1. 和普通模式一样,计算出 primary 和 secondary bucket。
  2. 依次执行以下步骤:
    • 检查 primary bucket → 签名匹配 → 完整 key 匹配
    • 若未匹配,再检查 secondary bucket
    • 若仍未匹配 → 查找链表形式的 extendable extra buckets
      • 逐个遍历链表中的条目
      • 签名+key 完整比较
  3. 都未命中则说明该 key 不存在。

插入流程(Addition):

  1. 尝试插入到 primary → 若满,进行 displacement 踢出流程。
  2. 如果 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

结论分析

  1. Primary 是主要插入目标:
    • 在哈希表较空时,几乎所有 key 都能插入 primary bucket。
    • 这意味着查找速度快,CPU 缓存命中率高。
  2. 使用率越高,Secondary bucket 被使用得越多:
    • 一旦超过 80% 使用率,超过 15% 的 key 会出现在 secondary bucket
    • 到 90% 使用率,将近四分之一的 key 会被踢到 secondary
  3. 性能影响:
    • 查找操作平均需要更多 bucket 访问 → 延迟略有上升
    • 虽然仍保持 Cuckoo Hash 的常数级查找,但 cache 行和 memory access 次数增加
  4. 最大利用率约为 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)→ 插入失败
  1. 删除流(Delete Flow)
  • 在哈希表中删除 Flow Key
  • 若返回有效位置 pos
    • 标记 flow_table[pos] 为无效或清除其内容
  1. 🧹 释放流位置(Free Flow)
  • 如果配置了 RTE_HASH_EXTRA_FLAGS_NO_FREE_ON_DELlock-free read/write concurrency
    • 删除只是标记,不能立即释放资源
    • 需要使用 RCU 机制(如 rte_rcu_qsbr)确认所有 reader 都已停止引用这个位置后,才能真正释放
    • 否则可能发生读写冲突或数据一致性问题
  1. 查找流(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 管理策略或定期回收机制
posted @ 2025-04-25 22:44  Tohomson  阅读(140)  评论(0)    收藏  举报