1 MiB 发送后,业务尾延时为什么升高?
主要证据指向:发送期间换核,预热的业务数据不在新核的 L1 中,随后的随机链式访问需要等待缓存数据。少数这样的窗口足以拉高 p99、p999。
2026-10-12 · Apple M3 / macOS 14.4.1 · Clang 18.1.8 -O3 · 高 QoS,未硬绑核。本页是原因诊断,不替换完整性能报告。
测试条件
消息 32 B每次发送 1 MiB第一层 16 KiB第二层 256 MiB业务数据 48 KiB
第一层为 4 × 4 KiB;第二层为 65,536 × 4 KiB。普通 write、publishMAX、不扫描 current、保留原来的写后 volatile 首字节读取。各队列回调均执行 64 次循环并校验全部消息、全部字节和 FIFO。
业务每次查询访问并修改 8 个随机链接节点。发送前执行 512 次预热;发送后立即测 64 次查询,分成 4 组,每组 16 次。每类每轮 2,500 个发送窗口,共 10,000 个计时组,运行 3 轮。每轮正式消息数 81,920,000 条;队列与消费线程持续工作,积压目标约 80%。
p50 / p99 / p999 是 16 次查询的平均 ns/次的分位数,先按每轮 nearest-rank 计算,再取三轮均值;不是单次查询、也不是消息从写入到回调的延时。
第一条证据:慢样本集中在换核之后
在预热结束和发送结束分别读取 CPU 编号。这一步约 1.2 ns,放在计时组之外;没有在业务计时组内读取硬件计数器。
| 三轮全部样本 | 换核样本 | 总数 | 占比 |
|---|---|---|---|
| 发送窗口 | 503 | 7,500 | 6.7% |
| 最慢 1% 的计时组 | 270 | 300 | 90% |
| 最慢 0.1% 的计时组 | 24 | 30 | 80% |
6.7% 的换核窗口占了最慢 1% 样本的 90%。这是关联证据;下面还用缓存事件和相同机器码的对照验证机制。CPU 首尾编号相同不能排除中途换出再换回。
查看每轮全体分位数与同核子样本
| 轮次 | 全体 p50 | 全体 p99 | 全体 p999 | 同核子样本 p99 |
|---|---|---|---|---|
| 1 | 15.6 | 119.8 | 721.4 | 44.3 |
| 2 | 15.6 | 151.0 | 885.4 | 49.5 |
| 3 | 15.6 | 109.4 | 625.0 | 36.5 |
同核子样本只用于诊断,不能拿去替代全部样本的正式 p99;没有删除任何慢样本。
第二条证据:用户态 L1 未命中与等待周期明显增加
使用 kperf/kperfdata 直接读取当前写线程的硬件事件,窗口仅覆盖发送后的 64 次业务查询。缓存、流水线分两批,各 3 轮。可配置事件设为仅用户态,排除采集系统调用自身的内核缓存访问。
以下比较“中间 50% 窗口”和“最慢 1% 窗口”:每轮先取事件中位数,表中是三轮中位数的平均。它们是 64 次查询窗口,不是前面 16 次一组的同一批分位数样本。
| 实际硬件事件 / 64 次查询窗口 | 普通窗口 | 最慢 1% 窗口 |
|---|---|---|
| L1 读未命中(已退休 load) | 25.7 | 982.7 |
| L1 写未命中(已退休 store) | 14.3 | 492.3 |
| L1 脏行向共享 L2 回写 | 7.7 | 209.0 |
| L1 数据 TLB 未命中 | 4.7 | 11.7 |
| Map Unit 因 Dispatch 背压停顿周期 | 3171.7 | 33903.7 |
| 已退休 store 引发内存顺序违规 | 0.0 | 0.0 |
这些计数来自硬件,不是用耗时或吞吐换算得到。它们支持“缓存数据等待增加”的解释;事件计数本身不能精确分摊每种原因占了多少纳秒。
另一轮更细的 16 查询组诊断中,普通组和最慢 1% 组的指令计数中位数均为 3,007,周期却从约 1,437 增至 6,074;内核时间中位数均为约 83 ns。这里固定指令/周期计数包含采集边界的内核执行,不能把 3,007 叫作业务函数本身的指令数。
PMU 一次读取中位开销约 0.67–0.83 μs,会扰动微秒级窗口。PMU 数据只解释机制;改善方案的性能表使用未在业务窗口内读计数器的 wall 模式。
第三条证据:相同线程、相同机器码,仅改变预热数据归属
不使用队列。两个测量分支都由同一个 worker 执行相同 Business::step。一个分支由 worker 自己先预热并修改全部业务数据;另一个分支由另一核上的线程预热并修改,再交给 worker。预热和握手不计入查询耗时。
| 预热方式 · 三轮 wall 均值 | p50 ns/次 | p99 ns/次 | p999 ns/次 |
|---|---|---|---|
| 执行查询的线程自己预热 | 31.2 | 48.6 | 60.8 |
| 另一核上的线程预热 | 92.0 | 133.7 | 210.9 |
另一次带固定计数器的三轮对照,两支的指令计数中位数均为 7,638 / 64 次查询窗口;周期中位数的三轮均值约 7977.0 → 22946.7。所有执行结果校验和一致。
原测试、重试变体、跨线程对照、同 worker 对照的 Business::step 共 39 条静态指令,机器码 SHA-256 完全相同。跨核共享对象的控制验证了缓存归属的成本;它不是对真实操作系统迁移的一次一条指令跟踪,也不能直接当成固定核性能预测。
为什么几条访存指令能让尾延时差这么多?
add x9, x12, x9, lsl #6 // 根据当前节点编号形成地址
ldr x13, [x9, #8] // 读取节点 value
eor / ror / add // 更新业务状态
str x8, [x9, #8] // 修改 value,产生脏数据
ldr w9, [x9] // 读取 next,决定下一个节点地址
... 下一轮 add / ldr当 next 的 load 未命中时,下个随机地址还不知道,处理器很难提前发出后续整条访问链。代码和指令数量不变,但等待周期变多。这里没有在计时业务中调用 write;复制和回调仍在其他线程继续执行。
“L1 脏数据回写”指修改过的缓存行从 L1 向共享 L2 写回,不能等同于每次都写到了 DRAM。另一个核访问数据还涉及缓存一致性和读写权限;未命中也可能由其他缓存层满足,不能把所有 L1 未命中都称为“访问内存”。
本机这次取得的是窗口硬件事件 + 精确汇编 + 相同机器码控制实验;没有取得带地址的逐条指令延时轨迹,因此不能把某一条 ldr 的单次延时直接标成固定的多少 ns。
已测试的改善方案
第一层仍为 16 KiB,其他负载不变。每个方案每轮 10,000 组,3 轮取平均。以下仅为本次 1 MiB 诊断的同批对照;没有替换原完整性能报告。
| 队列 / 重试策略 | p50 ns/次 | p99 ns/次 | p999 ns/次 | 发送中换核窗口 / 2,500 |
|---|---|---|---|---|
| TwoBufQueue · 整轮累计 1024 次重试后让出 | 15.6 | 92.9 | 435.8 | 85.3 |
| TwoBufQueue · 单条消息连续 1024 次重试才让出 | 15.6 | 91.1 | 323.8 | 83.3 |
| TwoBufQueue · retry_yield_every=0 | 15.6 | 73.8 | 206.6 | 58.7 |
| ReaderWriterQueue | 23.4 | 86.8 | 462.7 | 175.7 |
| rigtorp::SPSCQueue | 13.0 | 153.6 | 715.3 | 310.0 |
| atomic_queue | 39.1 | 88.5 | 335.1 | 110.0 |
retry_yield_every=0 保留 cpu_relax,自旋等待,但不主动调用 std::this_thread::yield。本批 p50 相同,p99 均值约降低 21%,p999 均值约降低 53%;长时间没有可用块时会持续占用 CPU。
三轮单独结果:改善并不保证每一轮
| 轮次 / 策略 | p99 ns/次 | p999 ns/次 |
|---|---|---|
| 第 1 轮 · TwoBufQueue · 整轮累计 1024 次重试后让出 | 106.8 | 791.7 |
| 第 2 轮 · TwoBufQueue · 整轮累计 1024 次重试后让出 | 91.1 | 354.2 |
| 第 3 轮 · TwoBufQueue · 整轮累计 1024 次重试后让出 | 80.7 | 161.5 |
| 第 1 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出 | 78.1 | 171.9 |
| 第 2 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出 | 91.1 | 408.9 |
| 第 3 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出 | 104.2 | 390.6 |
| 第 1 轮 · TwoBufQueue · retry_yield_every=0 | 67.7 | 169.3 |
| 第 2 轮 · TwoBufQueue · retry_yield_every=0 | 88.5 | 242.2 |
| 第 3 轮 · TwoBufQueue · retry_yield_every=0 | 65.1 | 208.3 |
不主动让出在第 3 轮的 p999 仍高于原策略;“连续重试才让出”未获得稳定改善。不能把三轮均值改善当成尾延时上界,也不能据此承诺本库在任何环境都比其他队列更低。
针对迁移这一原因,更直接的办法是在支持的平台把整条热路径线程(业务 + write)固定到同一物理核,并避开复制、回调和其他重任务。现有 producer_cpu 可供支持的平台使用;本机 macOS 不支持按 CPU 编号硬绑核,实测 THREAD_AFFINITY_POLICY 返回 46(service not supported),高 QoS 也不能保证不迁移。这里没有拿其他平台的绑定效果充当已实测结果。
硬绑核可以消除换核这一部分原因,仍不能消除中断、抢占和内核执行。在最慢的少量 p999 样本中,也观察到指令数、内核时间增加;例如一组指令从通常 3,007 升到 10,780,内核时间从 83 ns 升到约 2,417 ns。仅优化队列内存占用无法保证消除这部分。
数据与复核
结构化证据汇总 · 原始数据索引与 SHA-256 · 原测试的业务函数汇编 · 四种程序的业务机器码一致性
原始 JSON 用 gzip 无损压缩;未过滤慢样本。诊断源码与采集脚本一并保留。生产 Writer / Pool / Reader 实现未修改;“连续重试”仅在隔离源码中作实验。完整 1–16 MiB 报告也未覆盖。
在项目中可用现有 benchmarks/burst/run_burst_tail_1m.py 重新运行普通性能对照,默认包含 yield1024、yield0、yield65536 三种策略、三轮、三种开源组件;指定一个新的 output 目录可重新测量。硬件事件采集需额外管理员权限,属于本页独立诊断。
计数器接口说明:Apple thread_selfcounts 定义;Apple Recount 计数范围;kperf / kperfdata 接口及用户态配置标志。缓存事件说明取自本机 kperfdata 事件数据库。