Files
dpdk-ws-client/blog/03-performance-optimization-microsecond-dataplane.md
T

195 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微秒级性能优化实战:自研 TCP 栈如何反超内核 52%
> 五轮迭代(P0-P4),把 WS echo RTT 从"落后内核"打到 **1.5ms vs 3.1ms,快 52%**。
> 本文记录每一轮优化的测量方法、改动内容和量化收益——包括一次 19 倍的分配性能提升。
---
## 一、方法论:先测量,再动手
优化最忌讳"我觉得这里慢"。本项目建立了一套三层测量体系:
| 手段 | 测什么 | 工具 |
|------|--------|------|
| `rdtsc` 打点 | 数据路径各环节的 CPU 周期级延迟 | 代码内埋点,`rte_get_timer_cycles()` |
| tcpdump 抓包 | 协议行为正确性(重传、乱序、窗口) | `tcpdump -i eno1 -nn -e 'arp or tcp port 8080'` |
| 对照基准 | 同一链路内核 TCP 的表现 | `test/kern_c_test.py`(10 轮连接+握手+echo) |
基线数据先摸清楚。首版跑通后,跨核数据路径上发现了第一个大问题:
```
rx_enq → pkt_deq (跨核 ring) 0.2 μs ✓ 达标
pkt_deq → rcvbuf (协议处理) 0.4 μs ✓ 达标
rcvbuf → nrecv_seen (通知) 0.1 μs ✓ 达标
pkt_deq → fragment alloc 128 μs ✗✗✗ 超标 20 倍!
```
**热路径上的 `rte_malloc` 一次 128 μs**——这是全部延迟的地板,也是 P0 的靶子。
## 二、P0:Fragment Mempool——消除热路径 malloc(19× 提升)
### 问题
每个 TCP 段到达时,都要 `rte_malloc` 一个 `ng_tcp_fragment` 结构来装数据。通用分配器的开销(锁、元数据、碎片管理)在万兆流量下被放大成主要瓶颈。
### 改造
把结构体里的 `*data` 指针改成 **flexible array `data[]`**,让"结构体 + 负载"成为一块连续内存,然后用 `rte_mempool` 预分配池管理:
```
改造前: 改造后:
struct ng_tcp_fragment { struct ng_tcp_fragment {
... ...
unsigned char *data; ← 移除 int read_offset; ← 新增
→ rte_malloc(sizeof+len) unsigned char data[]; ← 内嵌
→ rte_mempool_get() 一次搞定
```
```c
/* src/tcp_core.h — mempool 配置 */
#define FRAG_POOL_SIZE 4096 /* 在途 fragment 上限 */
#define OFO_POOL_SIZE 256 /* 乱序段上限 */
#define FRAG_ELT_SIZE (sizeof(struct ng_tcp_fragment) + TCP_MSS_DEFAULT + 1)
/* O(1) 无锁分配 */
static inline struct ng_tcp_fragment *frag_alloc(int data_len) {
struct ng_tcp_fragment *f = NULL;
if (rte_mempool_get(g_frag_pool, (void **)&f) < 0) return NULL;
...
}
```
`rte_mempool_get` 走 per-lcore 缓存,**无锁、O(1)、缓存友好**。
### 收益
```
fragment 分配: 128 μs → 6.6 μs (19 倍)
```
一次改动把数据路径地板从 128μs 打到 6.6μs,端到端延迟立刻反超内核。
## 三、P1:WS 帧动态栈分配——64KB 栈占用降到 ~100 字节
### 问题
首版 `ws_recv_frame` 在栈上固定开了一个 64KB 的接收缓冲。应用线程栈占用暴涨,也增加了 cache 污染。
### 改造
改为按需 `alloca()` 动态分配——小帧只占几十字节,大帧才多占:
```
改造前: uint8_t buf[WS_MAX_FRAME_SIZE]; // 恒 64KB
改造后: 按帧头解析出的 payload_len alloca() // 20B ~ 1500B(典型行情消息)
```
**收益**:典型消息的栈占用 **64KB → 20~1500 字节**。
## 四、P2:read_offset——消灭 memmove
### 问题
`nrecv(buf, n)` 要求读 n 字节,但一个 fragment 里可能有 1500 字节。应用只取 200 字节时,首版把剩余 1300 字节 `memmove` 到头部,等待下次读取——**每次部分读取都平移一次数据**。
### 改造
加一个 `read_offset` 字段记录"已经读到哪里",读取时直接偏移,零平移:
```c
// 优化前: // 优化后:
rte_memcpy(buf, f->data, n); rte_memcpy(buf, f->data + f->read_offset, n);
memmove(f->data, f->data + n, f->length - n); f->read_offset += n;
f->length -= n; f->length -= n;
```
**收益**:部分读取场景下数据平移 **1 次 → 0 次**。WS 行情消息常常是"先读 2 字节帧头、再读 4 字节掩码、再读负载"的多次小读模式,这一改动直接命中了高频路径。
## 五、P3:nclose 忙轮询——关闭路径零阻塞
### 问题
首版 `nclose()` 用 `pthread_cond_timedwait` 等待 FIN 确认,关闭路径引入了不可控的唤醒延迟。
### 改造
改为 `rte_pause()` 忙轮询:关闭是短时操作(毫秒级),自旋等待的代价远小于上下文切换:
```c
while (state != CLOSED) rte_pause(); // PAUSE 指令,低功耗自旋
```
**收益**:关闭操作无阻塞抖动,配合 P0 让连接生命周期全程低延迟。
## 六、P4:删除死代码——少 80 行 O(n) 扫描
### 问题
旧版 `ng_tcp_out()` 每轮迭代**扫描全部 256 个哈希桶**找待发送的 TCB。后来引入了 `active_head` 活跃连接链表,旧路径成了纯粹的死代码。
### 改造
删掉旧扫描路径(-80 行),`pkt_process` 定时器与发送统一走 `active_head` 链表遍历——只碰活跃连接。
**收益**:空闲连接再多,每轮迭代的开销也恒定;代码更简单,`make` 后行为不变(这是删除代码的最高境界:删除而不改变行为)。
## 七、五轮优化全景
```mermaid
flowchart LR
subgraph 基线["基线: WS echo RTT > 内核"]
A["rte_malloc 128μs<br/>64KB 栈帧<br/>部分读 memmove<br/>condvar 关闭<br/>256 桶扫描"]
end
subgraph 优化["五轮迭代"]
P0["P0 mempool<br/>128→6.6μs (19×)"]
P1["P1 动态栈<br/>64KB→~100B"]
P2["P2 read_offset<br/>memmove → 0"]
P3["P3 忙轮询关闭"]
P4["P4 删死代码<br/>-80 行"]
end
subgraph 结果["最终: 反超内核 52%"]
Z["WS echo RTT 1.5ms<br/>跨核 0.2μs<br/>分配 6.6μs"]
end
A --> P0 --> P1 --> P2 --> P3 --> P4 --> Z
```
最终成绩单(同一物理链路实测):
| 指标 | DPDK(优化后) | 内核 TCP | 优势 |
|------|---------------|----------|------|
| TCP 连接 | **1.5 ms** | 2.5 ms | 快 40% |
| WS 握手 | **3.0 ms** | 5.4 ms | 快 45% |
| WS echo RTT | **1.5 ms** | 3.1 ms | **快 52%** |
| 跨核传递 | **0.2 μs** | N/A | 硬件级 |
| Fragment 分配 | **6.6 μs** | N/A | 19× 于 malloc |
CPU 数据面微基准(无网卡依赖,i7-12700 / DDR5 / GCC -O2):
| 测试项 | 性能 |
|--------|------|
| 哈希表查找(1000 条目) | **2.1 ns/次** → 4.81 亿次/秒 |
| Ring Buffer SP/SC(1M fragments) | **26.87 Gbps** → 230 万 pps |
| WS 帧编解码(50 万次) | **18.00 Gbps** |
rdtsc 延迟追踪(最新实测):
| 路径 | 延迟 | 说明 |
|------|------|------|
| rx_enq → pkt_deq | **0.2 μs** | rte_ring 无锁跨核传递 |
| pkt_deq → rcvbuf | **0.4 μs** | TCP 11 状态机处理 |
| rcvbuf → nrecv_seen | **0.1 μs** | cond 唤醒 |
| fragment 分配 | **6.6 μs** | mempool O(1) |
## 八、复盘:这五轮优化教给我的事
1. **热路径上的通用设施是最大的敌人**。`rte_malloc` 是"正确"的,但它在每包路径上就是慢。P0 的 19 倍提升来自一次"专用化":把通用分配器换成专用 mempool。
2. **优化顺序 = 数据说话**。没有 rdtsc 打点,谁也不会先想到 128μs 的 malloc;有了数据,P0 是唯一的答案。
3. **小改动大收益往往藏在"看似无害"的地方**。P2 的 read_offset 只加了一个字段,消灭的却是每次部分读取的内存平移。
4. **删除代码也是优化**。P4 没有让任何操作变快,但让每一轮迭代都变短、让系统更可理解——为后续 feature 扫清道路。
下一篇:[从实验代码到产品:DPDK 行情接入网关的产品化实践](./04-DPDK行情接入网关产品化.md)
---
*相关源码:`src/tcp_core.h`(mempool 与 fragment)、`src/tcp_socket.c`(P2/P3)、`src/ws_client.c`(P1)、`src/tcp_core.c`(P4)、`test/bench_tcp.c`(微基准)、`PERF_REPORT.md`(完整数据)*