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,放在计时组之外;没有在业务计时组内读取硬件计数器。

三轮全部样本换核样本总数占比
发送窗口5037,5006.7%
最慢 1% 的计时组27030090%
最慢 0.1% 的计时组243080%

6.7% 的换核窗口占了最慢 1% 样本的 90%。这是关联证据;下面还用缓存事件和相同机器码的对照验证机制。CPU 首尾编号相同不能排除中途换出再换回。

查看每轮全体分位数与同核子样本
轮次全体 p50全体 p99全体 p999同核子样本 p99
115.6119.8721.444.3
215.6151.0885.449.5
315.6109.4625.036.5

同核子样本只用于诊断,不能拿去替代全部样本的正式 p99;没有删除任何慢样本。

第二条证据:用户态 L1 未命中与等待周期明显增加

使用 kperf/kperfdata 直接读取当前写线程的硬件事件,窗口仅覆盖发送后的 64 次业务查询。缓存、流水线分两批,各 3 轮。可配置事件设为仅用户态,排除采集系统调用自身的内核缓存访问。

以下比较“中间 50% 窗口”和“最慢 1% 窗口”:每轮先取事件中位数,表中是三轮中位数的平均。它们是 64 次查询窗口,不是前面 16 次一组的同一批分位数样本。

实际硬件事件 / 64 次查询窗口普通窗口最慢 1% 窗口
L1 读未命中(已退休 load)25.7982.7
L1 写未命中(已退休 store)14.3492.3
L1 脏行向共享 L2 回写7.7209.0
L1 数据 TLB 未命中4.711.7
Map Unit 因 Dispatch 背压停顿周期3171.733903.7
已退休 store 引发内存顺序违规0.00.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.248.660.8
另一核上的线程预热92.0133.7210.9

另一次带固定计数器的三轮对照,两支的指令计数中位数均为 7,638 / 64 次查询窗口;周期中位数的三轮均值约 7977.0 → 22946.7。所有执行结果校验和一致。

原测试、重试变体、跨线程对照、同 worker 对照的 Business::step 共 39 条静态指令,机器码 SHA-256 完全相同。跨核共享对象的控制验证了缓存归属的成本;它不是对真实操作系统迁移的一次一条指令跟踪,也不能直接当成固定核性能预测。

为什么几条访存指令能让尾延时差这么多?

CPU A 预热业务数据进入 A 的 L1,修改后的数据由缓存一致性协议维护。
→
发送期间换核线程转到 CPU B;A 的私有 L1 不会随线程一起搬过去。
→
业务恢复执行B 上的链式 load 等待数据;下一节点地址还依赖前一节点的 next。
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.692.9435.885.3
TwoBufQueue · 单条消息连续 1024 次重试才让出15.691.1323.883.3
TwoBufQueue · retry_yield_every=015.673.8206.658.7
ReaderWriterQueue23.486.8462.7175.7
rigtorp::SPSCQueue13.0153.6715.3310.0
atomic_queue39.188.5335.1110.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.8791.7
第 2 轮 · TwoBufQueue · 整轮累计 1024 次重试后让出91.1354.2
第 3 轮 · TwoBufQueue · 整轮累计 1024 次重试后让出80.7161.5
第 1 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出78.1171.9
第 2 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出91.1408.9
第 3 轮 · TwoBufQueue · 单条消息连续 1024 次重试才让出104.2390.6
第 1 轮 · TwoBufQueue · retry_yield_every=067.7169.3
第 2 轮 · TwoBufQueue · retry_yield_every=088.5242.2
第 3 轮 · TwoBufQueue · retry_yield_every=065.1208.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 事件数据库。