Skip to content

Latest commit

 

History

History
1112 lines (870 loc) · 73.2 KB

benchmark-rig.md

File metadata and controls

1112 lines (870 loc) · 73.2 KB

压测靶场

怎么在本机量出可信的 RPS 与 QUIC 大载荷吞吐,以及为什么这件事比看上去难。

这一页不是读数记录 —— 读数在 benchmark.md。这里是量具本身: 怎么建、怎么跑、什么情况下读数不作数。

为什么要单独开一个靶场

examples/webapp_demo 看上去可以直接当靶场:它有个 --serve 模式,/hello 返回一份 内存里的 JSON,正是 benchmark.md 测的那类动态路由。但它每个请求都写一行访问日志, 而那一行要过全局日志锁(一次请求五次)、两次 vsnprintf,再 fwrite 到 stdout。

实测这个差别很大:同一台机器、同样的负载下,把访问日志关掉,吞吐从 ~26 000 涨到 ~34 500 RPS(+33%)。拿 demo 当靶场,量到的主要是日志系统的价钱。

所以靶场 bench/bench_server.cpp 默认一个中间件都不装,把日志变成一根能单独扳动的 旋钮(--access-log),于是「日志值多少 RPS」本身也成了一个可以量出来的量。

建与跑

靶场默认不建,也不进 CI —— 它是量具,不是功能。

靶场建在 webapp 之上,所以任何已经能建 webapp 的树,加一个开关重新 configure 就能建它:

cmake -S . -B build-webapp -DUVCPP_BUILD_BENCH=ON
cmake --build build-webapp --config Release --target uvcpp_bench_server

(全新工作区也行,只是要先把 webapp 的依赖拉下来 —— llhttp 与 nlohmann/json 走 FetchContent,和建 webapp 是同一套。多配置生成器下 --config Release 不能省, 否则建出来是 Debug,读数就没有意义了。)

UVCPP_BUILD_BENCH=ON 配一个 UVCPP_BUILD_WEBAPP=OFF 的树不会报错,只是不建靶场 (靶场是 webapp 上的一个服务)。

Windows 上 copy_test_dlls 会把 uvcpp.dll / uv.dll / llhttp.dll 一并拷到靶场输出 目录 —— 少了这一步,靶场会以 0xc0000135 静默退出,而静默退出的靶场在压测脚本眼里 和"服务起不来"是同一件事,很容易被误读成服务端的锅。

跑起来:

uvcpp_bench_server --port 8080

启动横幅会把这份读数的全部配置打出来 —— 版本、端口、是否装访问日志、日志等级、 内存池开关、try_write 开关。这不是装饰:一份不带配置的 RPS 不是结论,是传闻。端口传 0 时由内核分配,实际端口只有横幅里有。

开关 作用
--port N 监听端口,0(默认)= 内核分配
--access-log 装上访问日志中间件(用来量它的价钱),默认不装
--log-level L TRACE/DEBUG/INFO/WARN/ERR/FATAL,默认 WARN
--static <dir> 额外把静态根挂到 /assets,默认不挂
--loops N 事件循环数,1..64,默认 1。N == 1 与不传这个开关逐字节相同;N > 1 是「1 条接受者 + N−1 条工作循环」
--help 打用法就退,不启服务

路由有三条:GET /json(内存里预先拼好的 JSON)、GET /text、GET /stats。 /stats 打 {"loops":n,"per_loop":[…],"total":N} 的连接分布,只在 curl 时被访问, 不在 /json 热路径上。它是多循环那条判据的取证口:

分布不是判据,能看见分布才是判据。 只报「RPS 涨了」说明不了「涨是因为几条工作 循环都在干活」—— 「只有一个循环真干活」与完全成功的读数逐字相同。

驱动器:为什么是多个生成器进程

cd bench && npm install         # 依赖声明在 bench/package.json;本仓不 vendor 它
node bench/driver.js --url http://127.0.0.1:8080/json --proc uvcpp_bench_server \
     --nproc 4 --duration 15 --connections 25

bench/package.json 把 autocannon 钉在精确 8.0.0(不写 ^):现有全部读数是 这个版本跑出来的,换版本就换了量具,旧数不能比。package-lock.json 一并入库 (钉传递依赖,对量具比对应用更要紧)。锁里的 resolved 一律指向 registry.npmjs.org, 不带本机 npm 配置里的镜像 —— 否则等于把某一面镜像钉进仓库。装出来的 node_modules/ 已在 .gitignore 里。

单个 autocannon 是单线程 JS,它自己会先到顶。本机实测:单进程时生成器吃满一个核、 服务端也吃满一个核,两边同时到顶 —— 这种读数是没有分辨力的,既说明不了服务端能跑多少, 也说明不了还能不能更快。所以驱动器铺 N 个生成器进程,把生成器这一侧的算力摊到多个核上。

判据是「服务端占了几个核」,不是 RPS

驱动器每次都会报服务端占了多少个核,并且按它判结论:

服务端占用 含义
≥ 0.9 × 工作循环数 服务端到顶 —— 这个 RPS 是服务端的能力
< 0.9 × 工作循环数 服务端没到顶 —— 这个数只是生成器的上限,不能当服务端性能

门槛是「工作循环数」,不是 --loops N。 这两者差一,而且差得很要命:--loops N 是「1 条接受者 + N−1 条工作循环」,而 0 号那条接受者不承载任何连接(只看它转发, /stats 里 per_loop[0] 恒为 0)。所以 --loops 4 的服务端最多只能占 ~3 个核 —— 拿 0.9 × 4 = 3.6 去判,永远判「没到顶」,把真实能力印成「没量到」。N == 1 时 没有接受者/工作之分,那条循环自己干全部活,门槛就是 0.9。

驱动器(bench/driver.js --loops N)按这个规则给门槛。--loops N 的绝对值同样只在 同一台机器上可比,理由与上面那条一样。

量不到服务端 CPU 时驱动器退出 3(没判),而不是当成 0 报绿。退出码沿用本仓的 三值约定:0 判过 / 3 前提不满足。

这一条是整页最要紧的规矩。少了它,"RPS 涨了"和"这次生成器恰好更闲"长得一模一样。

本机不是一台安静的压测机

量之前先看清机器。本机(2026-09-21)是 Intel Core Ultra 5 125H,14 核 / 18 线程, 移动端 P+E 混合,3.6 GHz,而且是一台有人用的工作站:基线负载 ~40%、559 个进程、 远程桌面与浏览器常驻。

这有两个后果。

一、P 核与 E 核差很多。 服务端线程落在哪一类核上,读数能差一倍。所以量之前把服务端 钉在一个核上,别让它中途迁移:

$p = Get-Process uvcpp_bench_server
$p.ProcessorAffinity = 1        # 逻辑 CPU 0,在本机是 P 核
$p.PriorityClass = 'High'

不钉核时同一个配置连跑两轮能差 104%;钉核后落到 ~9%。9% 也只是能用,不是很好 —— 所以下面说的"相对测量"要按多轮最小值判,不要按单轮。

--loops N>1 时钉的不是一个核,是一个核集,而且有一条容易踩空的规矩:每个物理 P 核 只给一个逻辑 CPU。本机的 P 核 HT 对是 {0,1} {10,11} {12,13} {14,15} —— 把 8 个逻辑 核全给出去,HT 兄弟会被算成两个核,占核数虚高,那个比值就没意义了:

$p = Get-Process uvcpp_bench_server
$p.ProcessorAffinity = 0x5401   # {0,10,12,14}:4 个不同物理 P 核,每核一个逻辑 CPU
$p.PriorityClass = 'High'
# 回读!不读回 = 静默失败(本仓撞过:五档数字照打、一次都没绑上)
'{0:x}' -f [int64]$p.ProcessorAffinity

掩码要和臂一起换:单循环臂给 0x1,--loops 4 给 0x5401。生成器一律避开全部 8 个 P 逻辑核(兄弟线程偷的是同一份执行资源),本机给 {2..9,16,17}。读数对不上就先怀疑钉核, 钉核回读不符要退 3,不能当一次测量记下来。

--loops N>1 还有一个只有真跑过才知道的前提:这台机器推不动 4 条循环的服务端到顶。 两种驱动方式各撞一头,形状是稳的(下面是同一时段的扫描,只用来看方向,不是读数):

驱动方式 谁先到顶
流水线 1、10 连接 回环路径:服务端只到 ~3.2 核、生成器 ~79%,两边都不满
流水线 4~16 生成器:99%,服务端掉到 ~170%
连接数 10 → 25 → 50 反而更慢(与上面第 3 条同源:每次读事件的固定开销随连接数放大)

所以多循环的绝对值在本机目前拿不到 —— 这不是库的问题,是装置的两头都够不着。 要拿到得换一台更闲的机器,或把生成器摊到更多物理核上(本机生成器只有 10 个 E/LP-E 核; 把 P 核让给生成器就得先减服务端的循环数,那是另一套臂)。

二、绝对值只在同一台机器上可比。 benchmark.md 那条 75 791 RPS 是在另一台机器 (Windows 10 1903)上量的;本机同口径只有 ~35 000。这不是回归,是硬件不同 —— 两台的数不能相减,也不能相除。

已经量出来的三件事

单事件循环、GET /json、Release、内存池 ON、try_write ON:

靶场 访问日志 生成器 RPS 服务端占核
webapp_demo /hello ON 1 × 10 ~26 000 —
webapp_demo /hello ON 4 × 25 19 827 0.94
bench /json off 1 × 10 34 483 0.93
bench /json off 4 × 25 26 106 0.95
  1. 服务端在每一种配置下都钉在 ~0.94 核 ⇒ 瓶颈在服务端,不在生成器(4 个生成器时 每个只占 ~42%)。
  2. 访问日志值约 25% 的吞吐(26 000 → 34 483)。它每请求过多次全局锁加两次 vsnprintf,中间件在 src/webapp/uvcpp_web_middleware.cpp:92。
  3. 连接越多越慢:10 连接 34 483 → 100 连接 26 106,服务端 CPU 基本没变。 同样的 CPU 干更少的活 ⇒ 存在一个每次读事件的固定开销,且它随连接数放大。 头号嫌疑是接收缓冲:每次读都按 64 KiB 分配,而分配函数整块清零 —— uvcpp_alloc_arry 是 malloc 紧跟一次 memset,见 src/uvcpp/uvcpp_alloc.h:102-105。一次读事件白写 64 KiB,这笔钱与收到多少字节无关。

第 3 条还没有定论(上表是四次独立运行,不是同一轮的对照),要坐实它得先把 「按读事件计价」和「按请求计价」分开量。在那之前,它是嫌疑,不是结论。

QUIC 吞吐量具(bench/bench_quic.cpp)

上面那一套量的是 HTTP/1.1 的 RPS。QUIC 是另一条路(跑在 UDP 上、由 ngtcp2 驱动), 上面的靶场够不着它,于是「大载荷吞吐」这个数只能从库外引一份靶场。那个缺口本身就是 个洞:「改一处到底有没有用」在本仓里无法回答。这个量具补的就是这条能力。

它建在 net 层上,所以 UVCPP_BUILD_WEBAPP=OFF 的树照样建得出来(上面那一套建不出来):

cmake -S . -B build-quic -DUVCPP_BUILD_NET=ON -DUVCPP_ENABLE_OPENSSL=ON \
      -DUVCPP_ENABLE_QUIC=ON -DUVCPP_BUILD_BENCH=ON
cmake --build build-quic --target uvcpp_bench_quic --parallel 2

三种模式:

模式 量什么
--mode=push 纯发送方向:客户端 write_stream(N) 带 FIN,服务端只收不回
--mode=echo 往返:服务端把收到的原样回写(issue #34 那张表就是这个形状)
--mode=udpfloor 地板:裸 uv_udp 对打同尺寸报文 —— 它不是本库的读数。--pkt=N 定每个数据报装多少字节,必须与 QUIC 那一档实际用的数据报一样大,否则两条读数不可比:地板也是按包数付内核税的

两条口径是量具的一部分,不是随手选的:

  • 每个尺寸换一条新连接。 拥塞窗口是连接级的:同一条连接上先跑 2 MB 再跑 8 KB, 第二笔量到的是被第一笔养肥的 cwnd,而不是「8 KB 该有多快」。
  • 丢掉第一轮。 冷启动那轮会把握手尾巴与初始窗口一起量进去,不是稳态吞吐。

本机实测(2026-10-09,Windows 11,MinGW64,--rounds=15,每档 15 轮取最小值)

六档一次跑完,push / echo / 地板三张表同一次运行。跨次运行会漂(这台机器上同一份 二进制、同一个尺寸,换个 session 复量能差 ~6%,见下面 ⚠️),所以引用这些数时连 是哪一次运行一起说。

下面这一版是 ack_thresh = 16 之后的那一次(理由、代价与交错 A/B 见 src/quic/uvcpp_quic_session.cpp 里 kAckThreshold 那段注释)。原始输出: _probe/quicack/rig_after_ack16.log。

尺寸 push 最小 push cyc/B echo 最小 echo cyc/B 裸 UDP 地板(--pkt=1444)
1 KiB 0.016 ms 46.80 0.025 ms 72.24 0.013 ms(74.6 MB/s)
8 KiB 0.052 ms 18.90 0.100 ms 36.60 0.051 ms
64 KiB 0.400 ms 18.27 0.769 ms 35.16 0.438 ms
256 KiB 1.585 ms 18.07 3.158 ms 35.88 1.758 ms
1 MiB 6.611 ms(151.3 MB/s) 18.72 13.472 ms(74.2 MB/s) 37.86 7.176 ms(139.4 MB/s)
2 MiB 13.916 ms(143.7 MB/s) 19.62 27.245 ms(73.4 MB/s) 38.44 14.569 ms(137.3 MB/s)

⚠️ 这一版读数取自"数据报上限放开之后"(见下面 PMTUD 那一节)。地板那一列 也因此换了口径 —— 上限还钉在 1200 时记的 17.759 ms 是 --pkt=1200 量的,而 QUIC 现在发到 1444,拿旧地板当分母会高估库的开销(上一轮里、同一台机器同一次运行, --pkt=1200 的 2 MiB 地板是 17.096 ms,--pkt=1444 是 14.390 ms)。地板自己会动 这一点正是那个缺陷的量:地板也按包数付内核税。

读法:push 那一列已经贴着地板了(2 MiB:13.916 对 14.569 ms)—— 单向大载荷上, 本库的 QUIC 栈相对裸 UDP 没有可测的开销。往返那一列是地板的 1.87 倍,多出来 的那一倍是反向的 ACK/流控反馈,地板没有对端要回话。而地板那 14.569 ms 里全是本机 UDP 回环的税(10.0 µs/包),本库一个字节都没摊上。绝对数只在同一台机器、同一次运行 内可比。

小尺寸那几行说明这个量具量的是什么:push 的 cyc/B 在 1 KiB 是 46.80、8 KiB 是 18.90、2 MiB 是 19.62 —— 非单调,因为小尺寸那端单笔只有 0.016~0.052 ms,几乎 全是建流尾巴与首次发送的固定开销,cyc/B 在那一点上不是吞吐指标。要比"库快不快" 看 1 MiB / 2 MiB 两行。

对照:ack_thresh = ngtcp2 默认的 2(改动前的那一轮)

尺寸 push 最小 push cyc/B echo 最小 echo cyc/B 裸 UDP 地板(--pkt=1444)
1 KiB 0.014 ms 40.36 0.022 ms 64.64 0.012 ms(78.8 MB/s)
8 KiB 0.062 ms 22.81 0.135 ms 49.25 0.051 ms
64 KiB 0.538 ms 24.18 1.170 ms 53.09 0.438 ms
256 KiB 2.320 ms 26.46 4.409 ms 50.11 1.760 ms
1 MiB 9.691 ms(103.2 MB/s) 27.44 17.775 ms(56.3 MB/s) 50.05 7.072 ms(141.4 MB/s)
2 MiB 19.364 ms(103.3 MB/s) 27.58 35.575 ms(56.2 MB/s) 50.40 14.390 ms(139.0 MB/s)

⚠️ 这两轮是不同的 session,而本节开头那句"跨次运行能差 ~6%"正是针对这种比法 —— 所以别把下表之间的小差别当成信号。严格的前后对照是同一 session 里交错跑的两个 二进制(_probe/quicack/ab.sh),那一组给的 2 MiB push 是 1.37×。

两轮的地板几乎没动(14.390 → 14.569,落在漂移里),差的全在 push / echo 两列: 2 MiB push 19.364 → 13.916 ms(−28%,cyc/B 27.58 → 19.62),2 MiB echo 35.575 → 27.245 ms(−23%)。cyc/B 同向掉了近三成,说明省下来的是本线程真少 干的活(每包少一次 sendto),不是墙钟运气。

分块大小是量出来的,不是拍的

发送队列的分块大小(uvcpp_quic_session.h 的 kChunkSize)在三档上量过 (--mode=push --rounds=9 --sizes=2097152,同一台机器):

kChunkSize 2 MiB 最小 cyc/B
16 KiB 25.410 ms 35.95
64 KiB 21.907 ms 30.68
256 KiB 21.913 ms 30.85

⚠️ 这张表量于数据报上限放开之前(那时 QUIC 被自己的常量钉在 1200 字节),所以 64 KiB 那行的 21.907 ms 不等于上面对照表(ack_thresh=2 那一轮)里 2 MiB 那一档 的 19.364 ms —— 两处不可直接比, 它跟的是更早的那一版绝对值。放开之后没有复量过这三档,所以这张表只当"当初为什么 选 64 KiB"的记录读,别当今天的三档读数。

64 KiB 与 256 KiB 差 0.03%(噪声内),16 KiB 慢 16% —— 拐点在 64 KiB,往上加没有 回报,而一块的容量是每条约活的流都要占的,所以取小的那个。三档都由同一个脚本 改一个常量重编得来,原始输出在 _probe/sweep.log。

数据报大小:一条被自己的常量关死的 PMTUD

uvcpp_quic_session.cpp 里那个 settings.max_tx_udp_payload_size 原本逐字写着 NGTCP2_MAX_UDP_PAYLOAD_SIZE(1200) —— 也就是"任何路径都保证能过"的那个下限。 看着最保守,实际是把 ngtcp2 自带的 PMTUD 整条关掉:

  • conn_start_pmtud() 拿 min(对端 max_udp_payload_size, 本端 max_tx_udp_payload_size) 当硬上限;上限 =1200 时,ngtcp2 自带探测表 {1406, 1342, 1232, 1444} 四档全部越界 (ngtcp2_pmtud.c 里那个 for 循环把四档一路 continue 过去);
  • 于是 ngtcp2_pmtud_finished() 立刻为真 → conn_stop_pmtud(),此后每个数据报都被 conn_shape_udp_payload() 钉死在 1200 字节。

症状就是包数:2 MiB 单向要 1748 个数据报,而本机每包约 10 µs 的内核税不打折。 改成 1500(典型以太网 MTU)之后 PMTUD 才真的会跑,路径上限停在探测表最大一档 1444(ngtcp2_rtb.c 只在探测包被确认时抬它)。

A/B:同一个 2 MiB,两组二进制交错跑,各 5 轮取最小值。

数据报上限钉在 1200(对照臂) 放开之后
接收侧 on_read(DATA) 次数 1743 / 1761 / 1766 / 1761 / 1747 1444 / 1407 / 1446 / 1442 / 1392
push 最小 21.603 ms 17.539 ms(−18.8%)
echo 最小 39.866 ms 35.459 ms(−11.1%)

第一行才是这个改动的结构证据:两组读数一次都没有重叠,而它不受墙钟噪声 影响 —— 这台机器上同一份二进制、同一个尺寸,最小值在 20.7~24.9 ms 之间跳, 单看墙钟是分不出结论的,交错 + 取最小值才分得出。cyc/B 同向:2 MiB 单向 33.51 → 25.25,往返 60.59 → 50.14。

全尺寸扫描(1024 B … 2 MiB,push/echo 各 3 轮交错):每一档都变快 (+4.2% ~ +23.8%),没有一档变慢。

⚠️ 同一个常量还决定了对外的 max_udp_payload_size 传输参数,这次也一起从默认的 65527 收到 1500:我们宣布"收得下 65527 字节的数据报",而两只端点的接收缓冲只有 4096 —— 以前撞不上,纯粹因为对端也是本库、也被同一个常量钉着;换一个照 RFC 走的 第三方实现就会在内核那层被截断。它进 Initial 包,是线上一档行为。

两个入口各有一条 static_assert 守着(同文件):把上限钉回 1200、或把落点缓冲 缩到探测表最大一档(1444)以下 —— 编译就过不去。两种情况都会把 PMTUD 静默关掉, 而静默正是它危险的地方(关掉之后一切照常工作,只是慢)。两条都用对照臂验过: 把常量改成 1200,两条断言同时报出各自的理由、构建失败;改回来即通过。

对照组:同一台机器上跑 MsQuic

上面这些读数一直没有对照臂 —— 只能证明本库自己变快了。2026-10-09 补上:装置是 .NET 10 自带的 System.Net.Quic(即 MsQuic),在仓库外的 _probe/quiccmp/,口径与 本量具对齐(2 MiB 单向、15 轮取最小值)。

先钉核再比。 不钉核时 MsQuic 快约 1.7 倍,看着像"它在用更多核";钉到同一个 P 核 (start /affinity 1)之后差距基本不变:

2 MiB push,钉 core 0(P 核) min_ms MB/s
uvcpp QUIC 15.273 131.0
MsQuic QUIC 8.345 239.7
.NET 自己的裸 UDP 地板 16.365 122.2

核数解释不了它。 MsQuic 从 1 个逻辑核到 18 个几乎不缩放(不钉核 8.04 / 钉 P 核 8.345 / 钉 LP-E 核 11.071),而本量具本来就是单线程的 —— 所以「给 QUIC 加多循环」 不是这笔账的解法(多循环是"单核装不下时横向扩展"的账,不是"单核跑得慢"的账)。

贵的是每一个数据报,不是每一个字节。 同样 2 MiB、同样一个核,只把 .NET 裸 UDP 地板的报文尺寸换掉:

pkt 数据报数 min_ms MB/s
1434 1462 16.365 122
8192 256 3.326 601
65507 32 0.930 2148

一个 QUIC 栈(8.345 ms)能比裸 UDP 灌包(16.365 ms)快一倍,只可能是它的每个系统 调用搬的字节数跟别人不一样。在 Windows 上本库是一个数据报一次 syscall(平台差异 见本节末尾的 ⚠️),数据报上限还被 ngtcp2 的 PMTUD 探测表钉在 1444(见上一节)。

但"报文开大"不是 MsQuic 用的那一招。 用 netstat -s -p udp 的累计值取差,数线上 真正走了多少个数据报:

2 MiB push 线上数据报/轮(每方向) min_ms µs/包
uvcpp QUIC 1689 / 1691 13.586 8.04
MsQuic QUIC 1507 / 1508 6.838 4.54

MsQuic 线上的数据报只少 11%(≈1392 B/个,和本库的 1434 基本一样),时间却少一半 ⇒ 不是"它的包更大",是"它每个包更便宜"。

便宜在哪,还是用 .NET 裸 UDP 地板直接验(同一台机、同一个核、pkt=1434、--uso=1):

地板这一档 线上数据报 min_ms µs/包
两个选项都不开(≈ 本库的处境) 1462 16.365 11.19
开 UDP_SEND_MSG_SIZE(=2) 1473 4.368 2.99
再开 UDP_RECV_MAX_COALESCED_SIZE(=3) 1473 3.860 2.64

线上数据报一个没少(1462 → 1473),时间少 3.7 倍 —— 省掉的只能是系统调用:一次 SendTo 交给协议栈 64 KiB,由它按 1434 字节切开送出去。这就是 Windows 的 UDP 分段 卸载(USO),收方向对应的是收合并。MsQuic 的 4.54 µs/包正落在这个区间里,而本库 连它自己的裸 UDP 地板都在 11 µs/包那一档 ⇒ 「一个数据报一次 syscall」就是本库慢的 全部原因,ngtcp2 那层没有可测开销。

⚠️ 代价与边界:

① 上面那张 11.19 → 2.99 µs/包的表是 Windows 的数,别当成"libuv 的固有开销"。 两个平台在这件事上差得很远,而且分水岭在 libuv 里,不在本库里:

  • Linux:libuv 已经自己做了这件事,一行代码都不用改。 src/unix/udp.c 的 uv__udp_sendmsg() 一次从 write_queue 里取最多 20 个待发请求,折成一次 sendmmsg()(uv__udp_sendmsgv() 里 if (count > 1) 那条分支;Linux/FreeBSD/ Solaris 走 sendmmsg,macOS 走 sendmsg_x)。触发条件恰好就是本库 QUIC 的自然形状: 写循环在一次同步调用里连发 K 个数据报 ⇒ 第 1 个走单发 sendmsg,后面 K−1 个 只进队列并起 POLLOUT,由回调按 20 个一批发出去。也就是说 Linux 上这条账已经塌缩了 约 20 倍,不是 1。

  • Windows:libuv 里完全没有这条路。 src/win/udp.c 的 uv__send() 每个请求一次 WSASendTo,uv__udp_try_send2() 就是个 for 循环;而 Windows 根本没有 sendmmsg 这种"一次系统调用发 N 个数据报"的原语。

  • ⚠️ Linux 那半边是读码读出来的,本库没有在 Linux 上量过。 直接判据是 strace -f -c -e trace=sendmmsg,sendmsg <bench>:若 sendmmsg 次数 ≈ 数据报数/20, 批量就真的生效了。在跑出这张表之前,不要对外说"Linux 上没这个问题"。

  • 所以 Windows 上要拿到同样的效果只有 USO 一条路,而它必须绕开 uv_udp_send (uv_fileno() 拿句柄设 UDP_SEND_MSG_SIZE + 把 K 个数据报攒成一次发送)—— 那确实背离了"只用 libuv 的 I/O 原语"这个初衷。于是当时摆在面前的是两条路: 要么不做,要么把这条能力做进 libuv 自己(Linux 那半边上游已经做了, Windows 这半边本该也是上游的事)。

    ⚠️ 这一段是当时的分析结论,不是现在的决定 —— 后来的取舍跟它不一样。 1.5.2 选了第三条路:把发送侧 USO 做进本库,用一个默认派生的开关关着 (UVCPP_ENABLE_UDP_GSO,Windows + QUIC 上默认 ON,非 Windows 整段编掉、宏恒为 0)。 落地后的实测(1.33–1.35× 墙钟,而线上数据报数在噪声内不变)、两层闸门、 以及这里没提到的几个坑(uv_fileno 要 bind() 之后才有 socket; setsockopt 返回成功 ≠ 协议栈真会切)都在 quic-guide.md §1.2。 收方向仍然没做 —— 半开比不开更糟(粘在一起的数据报会被当成一次读),理由见那一节。

    这段原文留着,是故意的:它是"先定根因、再谈做不做"的现场。改成事后叙述, 就看不出当初的取舍了 —— 而那个取舍恰好被推翻了。

② 本库每轮比 MsQuic 多发 ~12% 的数据报(1690 对 1507)—— 那是控制包/ACK 策略的差, 是另一笔、且小得多的账。上面那张「报文开大」的表(1434→65507)则是回环特有的 便宜(回环 MTU 是 4294967295),真实 1500 MTU 的网上两边都只能发 ~1400 字节, 别拿它外推;USO 这一条不受它限制(真网卡支持就有效,不支持则设选项会失败)。

另注:_probe/quiccmp 里那两个常量原本写成 19 / 20,两个都错(正确值是 2 / 3, 见 ws2ipdef.h;20 其实是 UDP_CHECKSUM_COVERAGE)。在改对之前探针一直报"无效参数", 于是"USO 在这台机器上装不上"被误当成了结论 —— 那是装置的错。改对之后全部档位都 OK。

尺寸从 2 MiB 拉到 128 MiB:差距不但不缩小,还满格

两个量具的默认尺寸表都封顶在 2 MiB(bench_quic.cpp 是 1 K/8 K/64 K/256 K/2 M), 所以「只测了 2 MiB,大文件未必输」是个必须量掉的问题。--sizes= 两边都收, 同一张表重跑(--rounds=5,丢第一轮):

单流 push uvcpp min_ms uvcpp MB/s MsQuic min_ms MsQuic MB/s 比
2 MiB 14.895 134.3 8.804 227.2 1.69×
8 MiB 59.117 135.3 30.270 264.3 1.95×
32 MiB 230.093 139.1 119.347 268.1 1.93×
128 MiB 901.212 142.0 445.037 287.6 2.02×

两边都钉到核 0 再跑其中两档(对照臂不钉核时那个 2 MiB 的历史值也是这个量级):

钉核 0 uvcpp min_ms MB/s MsQuic min_ms MB/s 比
32 MiB 238.848 134.0 107.188 298.5 2.23×
128 MiB 1000.126 128.0 424.209 301.7 2.36×

两条独立的结论:

  1. 本库从 2 MiB 起就已经在稳态了。 数据量翻 64 倍,吞吐只从 134.3 涨到 142.0 MB/s (+5.8%);MsQuic 则到 8 MiB 才爬满(227.2 → 287.6,+27%)。所以「2 MiB 那组对本库 不公、它还欠一个爬坡」这个假设不成立 —— 那个 134 MB/s 就是它的稳态,而 MsQuic 在 2 MiB 上量到的反而是被爬坡压过的数。拿 128 MiB 那一格去比才是公平的, 而那一格是 2.02×(钉核 2.36×),比 2 MiB 那一格还难看。
  2. 每数据报成本在 64 倍量程上是个常数,这是"机制就是那个机制"最硬的证据:
    • 本库:recv_calls 那一列 128 MiB 是 92505 次、每次 1452.4 B ⇒ 一次系统调用一个 数据报;901.2 ms ÷ 92505 = 9.74 µs/次(钉核 10.8)。
    • MsQuic:128 MiB ÷ ~1440 B ≈ 93200 个数据报;445.0 ms ÷ 93200 ≈ 4.78 µs/个 (钉核 4.55)。
    • 与 2 MiB 那一档量到的 8.04 / 4.54 µs 是同一个数。常数 ⇒ 不是小尺寸的假象。

还没测的面(要下更大结论得先补):单笔超过 128 MiB。日志在 _probe/quicsize/(仓库外)。

并发流与双向:差距不会缩小,反而拉宽

两个量具原本都只支持单流。给两边都加了 --streams=N(--sizes= 仍写一个数,语义 变成每条流的字节数,所以一轮总量 = streams × size)。

先做对照(新开关必须在 N=1 时回到加它之前的形状,否则装置本身变了)。本库 streams=1 的 32 MiB 有三个独立读数:不传开关 232.953 ms、显式 --streams=1 226.509 ms、扫描里的第一档 228.411 ms —— 全部落在 226.5 ~ 233.0 ms,而改动前 那一档是 230.093 ms,就在这个区间里。recv_calls 分别是 23131 / 23115 / 23094 (±0.16%,比时间还紧 —— 数据报个数才是那个稳的量)。MsQuic 侧 108.3 ms (改动前 119.3)—— 但同一档的会话间漂移就有 9%(这台机器不是安静的压测机), 所以下面的表只比自己会话内的相对值。

固定总量 32 MiB、扫并发流数(同一个会话里逐档交错:先 uvcpp,再 MsQuic):

streams uvcpp min_ms uvcpp MB/s MsQuic min_ms MsQuic MB/s 比
1 225.7 141.8 101.9 314.0 2.21×
2 230.2 139.0 93.7 341.7 2.46×
4 236.0 135.6 89.9 356.1 2.63×
8 236.2 135.5 92.3 346.7 2.56×
16 237.6 134.7 89.5 357.4 2.65×

并发流对两边的作用是反的:本库 −5%(141.8 → 134.7 MB/s),MsQuic +14% (314.0 → 357.4)。所以「我们可能只在单流上吃亏、并发起来就追上了」是错的 —— 差距 从 2.2× 拉宽到 2.6× 并在那里停住。

两边的线上数据报在 1 流和 16 流下几乎一模一样,这是"多流改不了这笔账"的直接证据:

  • 本库:recv_calls 在 1/2/4/8/16 流下是 23105 / 23119 / 23089 / 23165 / 23132, B/call 恒定 ~1450 ⇒ 一次系统调用一个数据报,与流数无关。
  • MsQuic:netstat -s -p udp 增量,1 流每轮 23886 / 23894 个,16 流 24012 / 24020 (+0.5%),而时间从 97.7 降到 89.9 ms ⇒ 数据报没少,只是每个便宜了 8% (4.09 → 3.75 µs/个)。

每数据报成本(32 MiB 档):本库 9.8 ~ 10.3 µs,MsQuic 3.8 ~ 4.1 µs。 这两个数各自 正好贴着自己那一侧的地板(不开 USO 是 11.19、开了是 2.99)⇒ 分水岭仍是"每个数据报几次 系统调用",多流改不了它,因为流的条数不改变数据报的个数。

echo(双向回写),同一个尺寸表、单流:

单流 echo uvcpp min_ms MB/s MsQuic min_ms MB/s 比
2 MiB 28.259 70.8 13.042 153.4 2.17×
8 MiB 111.076 72.0 46.648 171.5 2.38×
32 MiB 437.619 73.1 199.747 160.2 2.19×

双向把两边的吞吐都砍掉约一半(本库 142 → 73 MB/s,MsQuic 288 → 160),而比例几乎不动 (push 2.0× → echo 2.2×)⇒ 每数据报那笔账在两个方向各付一次,比例守恒。

仍未测:echo × --streams=N 的组合。

这个量具是怎么被用上的:一次崩溃的根因

issue #34 报的是「大载荷下崩」。--mode=echo --sizes=2097152 20 次里崩 3 次 (0xC0000005),出错的那次读在 ngtcp2_cpymem ← ngtcp2_pkt_encode_stream_frame 里,目标区域是 MEM_RESERVE —— 也就是读过了某个堆块已提交区的末尾。

根因写在 ngtcp2.h 里:调用方必须把交给 ngtcp2_conn_writev_stream() 的那段数据 原样留着("keep ... in tact"),直到 acked_stream_data_offset 说它被对端确认。 ngtcp2 存的是指针(重传帧链里 ngtcp2_vec_copy 只拷那个指针数组),所以任何 再分配都是 UB。当时那份实现在两个方向上都违反了它:

  1. 缓冲区会增长 —— std::vector 扩容把地址整个搬走;
  2. 缓冲区会前移 —— 丢掉已确认的前缀时把尾巴 memmove 上来,地址没变、字节变了。

先做的是对照:把「地址与字节都不许动」临时焊死(预留够 + 不做前移), 20 次 0 崩,而未修的对照是 3/20。正式修法是分块发送队列 —— 每块出生时 reserve() 一次定下容量、此后只写到这里为止,只在整块都被确认时才丢 —— 于是两条 约束都按构造成立,丢块 O(1) 且一个字节都不搬。

⚠️ "一次定下多少"这条后来(1.7.2)改过: 上面那句原先写的是无条件 reserve(64 KiB),那对 2 MiB 的大块传输是对的,可 HTTP/3 的常态是"每条流只发几百字节"。 现在容量按这次要写多少取(stream_send::kMinChunkSize 是下限、kChunkSize 仍是 上限),所以一条只发几百字节的流只占一块 1 KiB。本机 h3 量具(-c 64 -m 64)上的读数: 分配字节/请求 72 374.8 → 7 507.5、峰值 RSS 91 MiB → 51 MiB(A B B A 两序各一次), 分配次数几乎不动(53.3891 → 53.3875)。下面那张 kChunkSize 的表量的仍是上限, 它的结论不变。

「代价百分之几」这句话在这里量不出来,而且原因本身是个发现。 把 HEAD 那份存储 换回来重编、跑同一个量具(3 次独立构建 × push/echo 各一次,--rounds=15 --sizes=2097152),6/6 次都在 15 轮内报 DATA MISMATCH:

第几次 push 首个不符的流偏移 echo 首个不符的流偏移
1 1 811 177 186 107
2 63 612 63 612
3 63 612 63 605

旧形状在那 6 次里没有一次吐出过完整的 2 MiB:量具连一行耗时都不打印,直接报 「第一个不符在流偏移 N」。所以它不是"比新版慢一点",而是交付的字节是错的, 那个基线在 2 MiB 上根本不存在 —— 拿它当分母算百分比是没有意义的。崩(3/20)只是 这份实现最响的那种症状,静默坏字节才是常态。这也是为什么新用例 (quic_stream_func.cpp 第四阶段)宁可多花时间做整条流的逐字节比对:计数式判据 看这两种症状长得一模一样。

同一次调查还顺手拿掉了发送路径上每包一次的拷贝:两个端点现在先试 uv_udp_try_send()(同步,返回时 data 已死,不必拷),只有不是「已发出」才落回 原来那条异步拷贝入队的路。2 MB 一笔就是约 1700 次 new char[] + memcpy + delete[] 与约 1700 次多余的循环轮转。

HttpArena 的 h3 量具:上游 h2load

榜单 baseline-h3 那一档不是用本仓的量具量的。它的负载是上游 nghttp2 的 h2load (用 --enable-http3 编的,容器里钉 v1.69.0),对端是别人写的客户端;而 bench/bench_quic.cpp(见上一节)的两端都是本库 —— 凡是「两端恰好一致」的假设, 在那个量具上都不成立,而 h3 这一档的读数恰恰取决于对端怎么选 CID、什么时候 ACK、 什么时候重传。要复现它,只能把那个对端建出来。

这套装置建于 2026-10-12,是那之后所有 h3 A/B 的唯一量具。它同时验证了另一件事: 换对端不是可选的严谨,是能不能看见 bug 的分界线 —— 两端同源时,两个实现里 任何「恰好一致」的错误都会互相抵消。

建:三步 + 一个从 deb 里拆出来的 libev

h2load 的 h3 腿要 libngtcp2_crypto_ossl(nghttp2 只认这个名字的 crypto helper):

PFX=/tmp/h3lab/prefix
OSSL=<repo>/_local_deps/openssl-3.5-inst    # 3.5 才有 SSL_set_quic_tls_cbs

# ① nghttp3:只要静态库
<repo>/_local_deps/nghttp3/configure --prefix=$PFX \
  --enable-lib-only --enable-shared=no && make -j && make install

# ② ngtcp2:--with-openssl 指到 3.5,产出 libngtcp2_crypto_ossl.a
<repo>/_local_deps/ngtcp2/configure --prefix=$PFX \
  --enable-lib-only --enable-shared=no \
  PKG_CONFIG_PATH=$OSSL/lib/pkgconfig:$PFX/lib/pkgconfig && make -j && make install

# ③ nghttp2 1.69.0:--enable-app 才会建 h2load
/tmp/h3lab/src/nghttp2-1690/configure --prefix=$PFX \
  --enable-shared=no --enable-app --enable-http3 \
  --disable-examples --disable-python-bindings CXXFLAGS=-std=c++20 \
  LIBEV_CFLAGS=-I$PFX/usr/include \
  LIBEV_LIBS="-L$PFX/usr/lib/x86_64-linux-gnu -lev" && make -j

三处不是照抄就行的地方,每一处都先花掉了一次误判:

  • 上游那份 h2load.cc 直接用 ev_loop/ev_io/ev_timer —— 驱动器是 libev,不是 libuv,也不是「h2load 顺带的依赖」。本机没有 libev 而 sudo 要密码,但 apt-get download libev-dev 不需要 root:落盘后 dpkg-deb -x 到 $PFX 即可。 而且那两个变量是必需的:configure.ac 里那条 AC_CHECK_LIB([ev],[ev_time]) 在 LIBEV_CFLAGS 与 LIBEV_LIBS 都设了的时候会跳过检查 —— 不设它反而更容易过, 设了才是「明确告诉它库在哪」。libev 只属于这个生成器进程,与本库无关。
  • 最后一步会以 cannot find -lcares 收场,而那不是「没装 c-ares」。 libc-ares-dev 给的是 libcares.so 与 libcares_static.a,它链接指向的 libcares.so.2 在运行时包 libc-ares2 里,而那个包没下 —— 前缀里于是躺着一条 悬空符号链接。补 libcares.so.2 的链接即可;去改 CARES_LIBS 是在治症状。
  • nghttp2 1.70 要 GCC 14(新加的 <print>),1.69.0 + -std=c++20 用 GCC 13 能过。 容器里钉的就是 1.69.0,本机跟它对齐 —— 生成器换版本,读数就不可比。

跑:条目裸机起,命令照抄平台

条目本身照 HttpArena 仓里 frameworks/libuvcpp/Dockerfile 的 flags 手工配一遍, 不用 docker:_local_deps 的 OpenSSL 与 pgsql-dev 顶上,八个 FETCHCONTENT_SOURCE_DIR_* 指到 _local_deps,那份 Dockerfile 里的四个硬编码路径 (证书、密钥、数据集、静态根)sed 成本机的实际路径。建完把 libuvcpp.so 装到一个前缀, 条目的 rpath 指过去 —— 之后改库不用重编条目,A/B 因此是同一个二进制在跑。

平台那条命令(scripts/lib/tools/h2load-h3.sh;profiles.sh 给 baseline-h3 的 连接数是 64,H3THREADS 默认 64):

h2load --alpn-list=h3 "https://localhost:8443/baseline2?a=1&b=1" -c 64 -m 64 -t 64 -D 5s

--alpn-list=h3 不是修饰,它同时是唯一的判别开关。 不加它,h2load 会在 8443 上 按 h2 over TLS 谈,读数照样一片漂亮而与 h3 毫无关系 —— 本仓为此误判过一次 (把一个 266 975 的数字当成了 h3 的结果)。

读数要看两个数:req/s,以及 requests: 那行除以 -c —— 也就是每连接出了几个 响应。只报 rps,会把「停滞没被触及」与「瓶颈换了地方」混成同一种东西:这两种情况的 rps 可以一模一样。

2026-10-12 的基线(本机 18 核,Intel Core Ultra 5 125H)

改前(1.6.4 那份实现):

命令 requests 2xx req/s 每连接响应数
-c 64 -m 64 -t 64 -D 5s ×3 315 / 255 / 150 315 / 255 / 150 63.0 / 51.0 / 30.0 4.9 / 4.0 / 2.3
-c 1 -m 1 -t 1 -D 5s 1 0 0.20 —
-c 1 -m 64 -t 1 -D 5s 14 12 2.80 —

平台在它自己那台机器上量到的是 58 rps / 294 个 2xx(baseline-h3-64)—— 正落在这三格 的区间里。这是这套量具保真的证据:本机的绝对值不必等于平台,但同一条命令下病理的 形状必须一致。

第三行是分水岭那一格:单连接、单路流水,改前 5 秒只出 1 个请求,而且它失败了。 「64 条连接一起卡」与「一条连接也卡」是两种病,这一格把它们分开 —— 它说明病在 每一条连接里面,扇出(多循环)救不了它,必须先修连接本身。

同一天、同一个量具、改后:

命令 requests 2xx req/s
-c 64 -m 64 -t 64 -D 5s ×3 429 553 / 412 692 / 399 236 全 2xx,0 个 5xx 85 910.6 / 82 538.4 / 79 847.2
-c 1 -m 1 -t 1 -D 5s 4 386 4 385 877.2
-c 1 -m 64 -t 1 -D 5s 480 131 480 129 96 026.2

每连接响应数从 ~4.9 变成 ~6 700。改法与判据写在那一条提交与 CHANGELOG.md 里; 这一页只留量具,读数归 benchmark.md。

扇出 A/B:同一条目,只换 set_loops()

同一台机器、同一个 server 二进制、同一份条目与同一条命令行(-c 64 -m 64 -t 4 -D 5s), 只把 h3.set_loops(n) 的 n 换掉(条目里那个 H3_LOOPS 环境变量就是它;h1/h2 三条腿各自 set_loops(worker_loops()),两轮里一个字没动)。每一格 2–3 次取整段:

h3.set_loops(n) req/s 每连接响应数 服务端 CPU
1(= 不调用,改动前的形状) 88 738 / 89 708 / 91 433 6 933 / 7 008 / 7 143 0.89 核(5.02 CPU·s ÷ 5.67 s)
4 267 124 / 273 215 20 869 / 21 345 —
8 388 024 / 389 482 / 437 385 30 314 / 30 428 / 34 171 5.62 核(37.56 CPU·s ÷ 6.68 s)

CPU 那两格是另跑的一对(同一命令行,读 /proc/<pid>/stat 的 utime+stime 差分; pidstat 本机没装),rps 分别是 84 092 与 430 899。三件事从这张表里读得出来, 第三件是必须写清楚否则这张表会被误读的那一件:

  1. n = 1 与改动前的形状一致。 上一节那张「改后」表是 79 847–85 910 req/s、 每连接 ~6 700;这里是 88 738–91 433、~7 000。同一格命令、同一天、同一台机器上 的两次测量,落在彼此的 run-to-run 离散里。
  2. 单循环那一格是被服务端自己顶住的:0.89 核 / 84 k req/s ⇒ 一条循环就是一个 核的上限;n = 8 时单核产出(431 k ÷ 5.62 ≈ 77 k)也在同一档。所以这条线的 天花板是核数,不是别的东西。
  3. 它不到 8×,而且不该拿它当"还差多少":8 条循环只拿到 4.3–4.9×。原因是 两端在同一台 18 核机器上,h2load 自己也在抢核 —— 服务端固定 n = 8、 只扫生成器的线程数:-t 1 → 203 281、-t 2 → 355 583、-t 8 → 333 474 (-t 4 那三格就是上表的 388 024–437 385)。光换生成器的线程数,读数就从 203 k 跳到 356 k,说明它在这条线上已经是共同瓶颈。要报"扇出到底能到几倍", 得把生成器搬到另一台机器上 —— 在那之前,这一页只报"随循环数上升",不报倍数上限。

h1/h2 那三档的 A/B:同一份条目 + 一个环境变量

h3 那两节的形状("同一份二进制、只换一个旋钮")在 h1/h2 上要难一点:上面那条 h3 的 A/B 换的是条目里的 set_loops(n),而库里绝大多数改动没有这样一个现成的开关。做法 是把条目复制成一份 server_ab,改动点读到哪个臂由环境变量决定(第一个用到它的 是预压缩孪生文件那一步的 UVCPP_ABL_PRECOMPRESSED=0|1,后来缓存校验跑哪条线程那一步 用了同一个办法:UVCPP_ABL_LOOPSTAT=0|1(h1/h2 那条腿,读数见 doc/benchmark.md §4.7); h3 那条腿走的是另一份静态实现,于是同一个旋钮在条目里是另一个名字 UVCPP_ABL_H3_LOOPSTAT=0|1(读数见 §4.8),其余一个字 不动;库装到一个前缀、条目的 rpath 指过去,于是改库不用重编条目,两臂跑的是同一 份二进制。

驱动器是 wrk 那一路形状的小脚本:固定 -t 8 条连接、-D 5s、按 URI 列表轮流发, 报 2xx 数、rps、每请求微秒。服务端 taskset -c 0-7、驱动器 taskset -c 8-15 (照上一节那条教训:两端同机时生成器自己也会成为共同瓶颈,先在核上把它们分开), 两臂交替起停(0 1 1 0)把时间漂移摊到两边,每格两次取整段。

起停之间必须断言端口上只有一个监听地址、一个持有进程。 这一条不是洁癖,是这一轮 真的误判过一次:条目里每个 app 都 set_loops(n),于是同一个端口上有 n 个 SO_REUSEPORT 监听套接字,而内核按 4 元组哈希在"进程"之间也分流 —— 上一轮遗留的 一个 server_fastfs 还绑着 8443 时,驱动器每次新连接被随机分给两个进程中的任一个, 而它们一个开了预压缩孪生、一个没开,看上去就是"孪生文件时好时坏"。查了半天库,最后 ss -ltnp 'sport = :8443' 一次就露出了两个 pid。库里没有那个缺陷。 别名要按 ss 报出的 pid 反查(不能按 /proc/*/exe 匹配二进制名 —— 那要穷举所有名字),杀完 再断言端口真的空了;set_loops(n) 的 n 个套接字是 C 臂的合法形状,所以断言写的是 "恰好一个地址、恰好一个持有进程",不是"恰好一个套接字"。

读数(同一天、同一台机器、同一份条目):

形状 臂 0 臂 1 差
一轮 static-h2 的 20 个 URI 实际下发字节 434 605 325 461 −25.1%
icon-sprite.svg(-c 64,在 MIME 排除表里 ⇒ 改前根本没压过) 19 764 rps / 384.3 µs 64 473 rps / 110.7 µs +226.2%
vendor.js(-c 64) 17 099 / 445.0 µs 21 639 / 349.3 µs +26.6%
整轮 static-h2(-c 256) 41 899 46 645 +11.3%
整轮 static-h2(-c 1024) 37 108 39 779 +7.2%

rps 那四行是两臂各 4 次的均值,同臂内的极值:static-h2-c256 36 039–48 054 对 39 579–52 057、static-h2-c1024 32 873–43 745 对 37 219–41 735、svgbig 18 221–21 138 对 61 837–68 539、vendor 16 769–17 590 对 20 289–22 208。svgbig 与 vendor 两行的 两臂区间不重叠,另外两行只是"方向一致、量级对得上"(相邻格本来就差 15%)。字节数那 一行是精确值不是采样 —— 它读的是每个响应的 content-length,同一份资源每次请求都一样。

icon-sprite.svg 那一格是这几行里唯一能把归因钉死的:它在 MIME 排除表里 (image/svg+xml),改前压根没被压过,两臂唯一的差别就是线上 71 680 → 11 739 字节 ⇒ 那一格量到的是纯带宽。其余几格的差值里同时含带宽与"请求线程上那次 zlib 压缩"。

预压缩孪生:两条静态腿各自的探针

1.6.15-dev 给 uvcpp_web_app::serve_static 那条腿加了 precompressed(见上一节), 1.6.18-dev 给它补到 uvcpp_static_server 上 —— 而那是另一份实现,挂在 uvcpp_http_server::on_request() 上,条目里只有 h3 那条腿用它(uvcpp_web_app 没有 uvcpp_http_server 那样的裸监听器,h3 要挂静态文件就只能用 uvcpp_static_server)。 所以这一轮的量具不是"条目的一个环境变量",而是一支探针(probe.cpp,在仓外): probe <srv|app> <root> <precompressed 0|1> <loops> 一次只起一条腿、只起一个 监听器,srv 走 uvcpp_http_server + uvcpp_static_server,app 走 uvcpp_web_app + serve_static。语料就是条目那份 data/static(20 个文件 + 30 个孪生 = 50 个,原件合计 1 271 603 B),驱动器是 h2load -p http/1.1 打一份把 20 个 URI 全 列上的清单。

坑一:语料挂在哪个文件系统上会直接决定结论。 本机那份 data/static 的原路径 (/home/zwy/mintSet/...)是一个 ntfs-3g(FUSE)挂载(findmnt 报 /dev/nvme0n1p4 fuseblk),在那里一次 stat 要 45.9 µs;同一批 50 个文件 cp -a 到 ext4 上之后是 1.35 µs —— 34 倍:

/home/zwy/mintSet/public/HttpArena/data/static   5000 stats in 229.8 ms = 45.95 us/stat
/tmp/arena-run/pc/withtwin                        5000 stats in   6.8 ms =  1.35 us/stat

两条静态腿都每请求 stat 一次(那是 mtime 实时更新那条设计),而开了孪生之后还要 再多 stat 一次候选结尾名。于是同一份代码、同一批字节,只在原路径上量会得到反的 结论:app 腿 pc=1 看上去比 pc=0 慢 2.4 倍(23 982 → 9 806 rps),而那正是"多出来 的那次 stat 乘 46 µs"本身 —— 那条腿当时已经整个卡在 stat 上(关掉它也只跑 24 k, 关/开只差一次 stat 的量)。此后凡是量静态腿,语料一律先 cp -a 到 ext4,下面这张 表就是在那份拷贝上量的。平台是把数据集挂进容器的,落在普通文件系统上,所以 ext4 那组 才是能外推的一组。

坑二:h2load -p http/1.1 的 -m 是「每连接在途请求数」(流水线深度),不是 并发流数。 uvcpp_web_app 有一条自带的闸(cfg_.max_pipelined_requests,默认 8, uvcpp_web_app.cpp),越界时回 503 "too many pipelined requests" 并关连接。于是 -m 32 打 app 腿会得到这样一行:

requests: 20000 total, 156082 started, 35696 done, 31528 succeeded, 168472 failed,
          164304 errored, 0 timeout
status codes: 31566 2xx, 0 3xx, 0 4xx, 4168 5xx

—— 看着像服务端崩了,其实是驱动器的参数越了闸。-m 2 才稳(8000 个里 0 个 503), -m 4 已经偶尔越(40 ~ 660 个)。两条腿在同一个 -m 下表现还不一样: uvcpp_static_server 挂在 uvcpp_http_server::on_request() 上,那一层没有这道闸, 同一个 -m 32 它 40 000 个请求全 2xx。所以"5xx 数"在两条腿之间不可比,它量的是驱动器。

坑三:-c 256 那一档是双峰的,别拿它当判据。 上面那些越界的 503 每个都会带走一条 连接,h2load 要重连,于是同一格读数能落在大相径庭的两个值上(pc=1 一次 38 299 rps、 重跑 219 398 rps)。-c 64 -m 2 下三次重复的散布在 5% 以内,所以下表取那一档; -c 256 只用来量字节数(那一栏与并发无关)。

读数(同一份 ext4 拷贝、8 条循环、h2load -p http/1.1 -c 64 -m 2 -n 8000、 Accept-Encoding: br;q=1, gzip;q=0.8,20 个文件轮转)。下表是 2026-10-14 在 1.6.44-dev 上重跑的,每一臂都是同一次会话内交替跑 3 次取中位数:

腿 precompressed=0 precompressed=1 差 8000 请求送出字节
uvcpp_static_server(h3 那条腿) 66 554.63 rps(120.20 ms) 89 791.80(89.10 ms) +34.9% 173 843 446 → 131 194 904(−24.5%)
uvcpp_web_app::serve_static 68 464.43(三次中位) 92 262.63(三次中位) +34.8% 173 843 446 → 131 194 904(−24.5%)
uvcpp_static_server,连 cache_tag 也不填(1.6.44-dev 之前的形状) 977.00(8.19 s) — — 173 843 446

三次原始读数:srv pc=0 为 67 303.24 / 66 554.63 / 64 449.08、pc=1 为 89 791.80 / 90 780.14 / 84 193.69;app pc=0 为 62 364.55 / 68 464.43 / 71 858.44、 pc=1 为 92 262.63 / 92 065.14 / 92 707.40;第三行为 941.33 / 979.10 / 977.00。 字节那一栏是精确值(读每个响应的 content-length)。

第三行是消融臂,这张表全靠它才读得动:同一份源码,只把 fill_cached_response() 里填 tag 的那一段关掉、单独链一份 libuvcpp.so,其余一个字节不改。它复刻出旧版记的 933.15 rps 那个量级(每请求在循环线程上把整份文件 deflate 一遍:compress_enabled_ 默认就是 true,而变体缓存按 resp.cache_tag 做键、本类从前不填 ⇒ 缓存对它不生效; 20 个文件平均 63.6 KB、最大的 app.js 204 800 B ⇒ 每请求 2 ~ 7 ms 的 zlib)。补上 cache_tag 让这一臂跳到 66 554.63(68.1×),"发盘上孪生"这个选项自己只剩 66 554.63 → 89 791.80(+34.9%)。

旧版这张表(srv 933.15 → 82 451.28 的 88×、app 110 773 → 185 280 的 +67%)不要 再引用,理由是两条:一是 1.6.44-dev 补上了 cache_tag,"关"那一臂的起点整个抬上去了 (88× 里 68.1× 是补 tag 的功劳,不是这个选项的);二是同一台机器今天整体慢得多 —— 把 1.6.18 那份发布包挂上同一装置跑同一条孪生命令(那条路与今天逐字节同策), srv pc=1 只得 78 174 ~ 79 411、app pc=1 得 74 663 ~ 83 365,而当时那一臂记的是 82 451.28 / 185 280。跨会话的绝对值在这套装置上不可比,只有同一次会话内的比值可比。

顺带把每请求 CPU 也量了(cpr.sh 的 us_per_req,同一装置、同一个孪生臂,用 -n 80000 让 10 ms 的 tick 精度够用,两臂交替各 3 次):uvcpp_static_server 从 1.6.18 发布包的 19.125 µs/req 掉到本树的 17.375 µs/req(−1.75 µs,−9.2%)。这一臂 每请求只送 ~16 KB(发的是孪生),所以这一跳量的就是"命中时把这份字节整块拷一遍"那一次 memcpy —— 它由 1.6.27-dev 的 8ddd6b1(shared_ptr + body_share)做掉, 1.6.44-dev(补 cache_tag 与表的上界)在这条臂上量不出差别:孪生命中时本来就不设 tag。别把这个数读成"1.6.44-dev 值 10 µs":计划里那条 ≥10 µs 是照平台 static-h3 的形状(218 µs/req,h2/QUIC + TLS + 64 连接)定的,而在这台机器上一条 h1 请求总共才 17 µs —— 同一处改动在两种形状里能占的份额本来就差一个量级。

另有一条口径要声明:precompressed=0 那一臂发出去的不是原文件,是实时 deflate 出来 的 gzip。它每圈 20 个 URI 送 431 160 B(= 172 463 836 ÷ 400 圈),比盘上那套 .gz 孪生 (含 5 个无孪生文件共 363 783 B)还大 —— 实时压缩器是按延迟配的,不是按体积配的。 所以 −24.5% 那一栏里同时含"省掉一次白压"和"换成一份压得更狠的字节"两件事;纯带宽那一份 是 br 与 gz 之间的 10.5%(15 个有孪生的文件上是 14.6%)。

这个量具什么时候不作数

  • 它量的是对端的脸色。 同一份服务端换一个 h3 客户端(nghttpx --http3 也行), 读数就变。这不是缺陷,是这一档的性质 —— 所以这里的数不能当「本库的 h3 吞吐」引用。
  • 别拿它跟 bench_quic.cpp 的数比。 后者两端同源、载荷可控;这里两端不同源, 路径上还多一层 TLS 与 ALPN 协商。
  • 回环上的数不外推(理由同上一节)。

平台自己的读数从哪读

本仓每一节的读数都要自己造量具;平台不用 —— 它自己就是量具,而且把原始读数公开了。 要引用平台的数(例如 benchmark.md §4.5),从这四个地方取:

要什么 在哪
每档读数(rps / 延迟 / 2xx / 5xx / CPU / RSS / reconnects) 推送条目的那条分支上的 site/data/results/<条目>.json
该档的原始服务端输出 同仓 site/static/logs/<档>/<连接数>/<条目>.log
家族分与名次 https://www.http-arena.com/badge/index.json
参考机、核绑定、内核调参 site/content/docs/hardware/_index.md、scripts/lib/system.sh

四个坑:

  • 读的是 bot 的提交,不是 PR 评论。 平台跑完会把结果提交回推送条目的那条分支 (Benchmark results: <条目> [skip ci]),所以 git show <sha>:site/data/results/<条目>.json 就是权威原文。PR 评论里那份是摘要,而且发出去还能改 —— 引用它等于引用一份会变的副本。
  • badge/index.json 里同一个家族并列着两份名次:default(tuned=0 且 type ∈ {flagship, emerging})与 withTuned。同一份数据、同一个分、名次不同, 引用时不说清是哪一份就没有意义。
  • 家族分是同家族各档之和 ⇒ 没订阅的档等于整档 0 分,不是"没参加"。所以 results/<条目>.json 里少一个键要读成「这一档还没订阅」,而不是「这一档跑挂了」。
  • rps 只把 2xx 算进去。 一档只要还有 5xx,rps × duration 与 2xx + 5xx 就对不上 —— 而那正是"负载没变、只是拒了一批"与"负载真的涨了"的分界线。引用任何一档之前先做这 一次除法,benchmark.md §4.5 里 static-h2 那条闭合的归因就是这么读出来的。

每请求指令数的量具(callgrind)

上面那几节的读数都是 rps / 微秒,它们有一个共同的毛病:这台机器的 CPU 频率会漂。 /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 是 powersave,实测 scaling_cur_freq 在 2.36 GHz 上下浮动,而量程是 400–4500 MHz。同一份二进制、同一条 命令行、同一个下午,µs/请求 能摆到 35%(/proc/<pid>/stat 差分那一路尤其明显)—— 于是"改前 15.5、改后 17.1"这种对比什么都没说明,除非两臂交替跑(A B B A), 而且只认同轮次内的差。这一节补的量具绕开了这条:每请求指令数。

valgrind --tool=callgrind 数的是指令条数,与频率、与缓存命中无关,同一份二进制上是 确定性的。实测同一版本连跑三次:11 285.8 / 11 283.3 / 11 291.8(散度 0.08%)—— 比 rps 那一路稳两个数量级。代价是慢(本机约 50×),所以它只配在小窗口上用。

A/B 怎么摆:只换 LD_LIBRARY_PATH 指的那份 .so

被测服务端(HttpArena 条目的裸机版 server_ab)是动态链 libuvcpp.so 的,所以 "改前/改后"两版可以共存:各留一份 .so 在两个目录里,跑的时候把 LD_LIBRARY_PATH 指到其中一个。比 git stash + 重编可靠,也不会把工作树搅乱 —— 两臂跑的是同一份 二进制,唯一的变量就是库里那几个字节。驱动器 cg-diff.sh <so 目录> <标签> <连接数> <URI> 一次只跑一个臂,外面套一层交替顺序的小脚本。

窗口取 -c 2000 -t 8 -d 5s。每次都要记下这一次的 2xx 数,因为下面的分母是它。

两次 dump、part: 是增量不是快照

跑法是:起 valgrind(在 taskset -c 0 上),等端口起来、sleep 3 让初始化落定, callgrind_control -d <vgpid> 打第一份;然后才是那 5 秒负载;负载跑完再 -d 打第二份。 --combine-dumps=yes 让两次 dump 追加进同一个文件、共用一份压缩符号表,文件里按 part: 1、part: 2 切开。于是 part 2 就是这 5 秒窗口里的增量,把它除以这一次的 2xx 才是"每请求"。

这一路上踩过的坑,按会不会让你得出错的数排序:

  1. totals: 行不是代价行。 每个 part 末尾有一行 totals: <N>,它的首字符是 t 所以不会撞上"首字符是数字才当代价行"的判据 —— 但一旦解析器写松了(比如按 "两个字段、第二个是数字"来判),这一行的 N 会被加到 part 的最后一个函数头上, 总量直接翻几倍。这个坑真的吃到过:曾经算出 31 876.9 条/请求,而真值是 11 285。
  2. 分组键必须归一。 callgrind 记对象名的方式在不同 dump 里可以不一样(一会儿是 /tmp/.../so-before/libuvcpp.so、一会儿是 libuvcpp.so)。做逐函数 A/B 时如果不拿 os.path.basename(ob) 归一,同一个函数会以「−X / +X」成对出现,看上去像"每个 函数都被改到了"—— 而实际上那是同一个函数的两条键各算了一次自己。
  3. part 分隔符是 part:,带冒号。 写成 part (空格)就一条边都抽不出来,而脚本 静默返回 0,看上去像"这个函数没人调"。
  4. cfn= / fn= 的名字可能就写在同一行(cfn=(664) __strlen_avx2),此时去查 (664) -> name 的定义表是查不到的 —— 表面现象是"调 strlen 的边合计 0 条"。
  5. $! 必须是 valgrind 自己的 pid。 拿它去 callgrind_control -d;而 kill -9 直接杀 valgrind 会丢掉符号表(那个文件此后解析出来全是地址)。所以先 -d 再杀。
  6. 一次 dump 的 part 1 是"起完到开跑"那段(初始化、建监听、3 秒空闲)。它是 几十倍于 part 2 的十分之一量级(本机 21.8 M 对 319 M),不要拿它算每请求。

同时量到的两条旁证

  • 系统调用:读 /proc/<pid>/io 的 syscr / syscw 差分除以 2xx。h1 baseline 是 write/req = 1.0040、read/req = 1.0047 —— 恰好一条请求一次 read 一次 write, 没有可省的系统调用;pipelined(-p 16)则降到 0.126 / 0.064。
  • 服务端 CPU:读 /proc/<pid>/stat 的 utime+stime 差分除以 2xx(HZ 由 getconf CLK_TCK 给)。这条只能同轮次比(理由见本节开头那个 35%)。

这一路上被证伪的三个假设

写下来是因为它们看上去都很像"大的那个杠杆",而三个都不是:

  • 「uv_write 每条响应多一次 epoll_ctl」是错的。 读 _local_deps/libuv/src/unix/stream.c 可以确认:uv_write2 在写队列为空时直接调 uv__write(stream) 并且不挂 POLLOUT; uv__io_start(POLLOUT) 只在部分写 / UV_EAGAIN 那条"没写完"的支路上才走到。一条 装得下的响应一次 epoll_ctl 都不多花。
  • 「每请求不止一次 write」也是错的。 见上面 syscr/syscw 那两个 1.004 —— 就是各一次。
  • 「result += "字面量" 每次都要 strlen」是错的 —— -O3 下 GCC 已经把它常量折叠 掉了。这条值得单独记:__strlen_avx2 在 h1 baseline 上是 16.10 次/请求,看上去正好 对得上序列化里那十来处字面量落笔,于是"改成 append(ptr, n) 就能省掉它们"是极自然的 推断。实测是 0:把 to_string_into 里九处 result += "…" 全换成显式长度之后, 同量具两轮夹逼 10 927.7 / 10 920.2 → 10 927.0,落在散布里 —— 改动已整体还原。 原因是 char_traits<char>::length(const char*) 在 libstdc++ 里就是 __builtin_strlen, 实参是字面量时编译期就折叠了;objdump 上两个二进制在 to_string_into 里都是 17 处 _M_append(char const*, unsigned long)、一处 strlen@plt,一模一样。 那 16.10 次的真身在别处:把 strlen 的 PLT 桩(一个匿名地址,所以按名字找调用者永远 是 ?)反查回去,落在 uvcpp_http_response::set_header(const char*, const char*) 上 的只有 2.01/req(http_header{std::string(name, n), value} 里 value 那一下); 其余的调用者是 libstdc++ 自己(std::string(const char*) 那一族),在库这一侧 根本看不见 —— 想再往下走就得先数清每请求 11 次 malloc 是谁要的,而不是盯着 strlen。

h3 那条腿怎么套进同一套装置,以及一条必须先做的标定

4.9 用的就是这一节的装置,只换了两处:客户端换成 h2load --alpn-list=h3(那条腿的 服务端是另一个类 uvcpp_static_server),端口等的是 UDP 8443(ss -lun,不是 -ltn)。 其余照旧:两臂各留一份 .so、只换 LD_LIBRARY_PATH、两次 dump、取 part 2 除以客户端 自报的 2xx。

连接数别照抄平台形状:-c 64 -m 64 -t 8 -D 5s 在 callgrind 下只跑出 136 个 2xx, 而握手的 RSA(rsaz_1024_sqr_avx2 + rsaz_1024_mul_avx2)占了 47.6% 的指令 —— 64 条连接各握一次手,除以 136 就是每请求几百万条。压到 -c 16 并把窗口拉到 -D 20s 之后,两臂的 RSA 绝对值逐条相同,它就从干扰项变成了对照组。先把握手摊薄,再看表。

rep movsb 是按字节计费的 —— 这条一定要先标定,否则会把"字节数"读成"时间"。 callgrind 下 glibc 的 memmove-vec-unaligned-erms 一次 16 483 B 的搬运记成 16 515 条 Ir (= 字节数 + 32),而真机上同一段只要 73 ~ 80 ns(215 GB/s,源在 L2)。所以 "__memcpy_avx_unaligned_erms 占了 19.6%"这句话的真实含义是"搬运了 41 KB/请求", 不是"花掉 19.6% 的时间"。读到一条 memcpy 占比很高的画像时,先把它的字节数除以实测 带宽,再决定要不要动它。标定代码 20 行、两次跑完(裸跑 + 同一个二进制过一遍 callgrind), 这条比值是后面所有"整块拷贝值不值得消"的判断的地基。

用户态拷贝的量具(memspy)

4.10 用的装置。它回答的问题是 callgrind 与采样器都答不了的那个:每一字节是被哪一行代码拷的。

为什么不能靠采样器:__nss_database_lookup 是个假名字

上一轮那个定时抓 PC 的采样器(按 /proc/<pid>/maps 折成「模块 + 偏移」,再取该模块里 最近的前一个符号)在 h2 两条腿上排第一的都是 libc.so.6:__nss_database_lookup@GLIBC_2.2.5 (34.16% / 34.10%)。它其实是 memcpy/memmove 的真实现:

读数(读 /proc/self/maps 自己算、不走 dladdr)
memcpy / memmove 真实现 libc+0x188a80(同一个地址)
前面最近的一个导出符号 __nss_database_lookup libc+0x188040(相隔 2 624 B)
后面下一个导出符号 _thread_db_dtv_slotinfo_list_slotinfo libc+0x1c5d58

⇒ 中间是 247 KiB 没有任何导出符号的 .dynsym 空洞,glibc 把 AVX/ERMS 那几份 memcpy/memmove/memset 实现都放在里面(.dynsym 只留 memcpy@GLIBC_2.14 一类 IFUNC 入口)。 任何"最近前一个符号"的解析都会把这一簇记到 __nss_database_lookup 头上 —— 数字是真的,名字是假的。

两个坑记下来:dlsym(h, "memcpy") 会直接穿到真实现,所以这条路拿到的是地址不是名字; *(void**)dlsym(...) 想读 GOT 的话读到的是指令字节(0x48f88948fa1e0ff3,endbr64 开头)。 唯一可靠的做法是自己读 maps 算 libc 基址再减。

装置本身:LD_PRELOAD + 精确计费(不是采样)

  • 拦 memcpy / memmove / memset / __memcpy_chk;
  • 分组键是 __builtin_return_address(0),即调用点而不是被调函数; 16 384 槽开放寻址表,无锁 CAS 插入(多线程下不丢样本);
  • 每组记调用次数 / 总字节 / 四个尺寸档(<64 / <1K / <16K / ≥16K);
  • SIGUSR2 或析构时 dump,前附一份带 MSPY_MAP 前缀的 /proc/self/maps,离线 resolve.py 折符号。

三个必须记住的坑:

  1. MSPY_MAP 前缀不能省。第一版把 maps 直接 fputs 到 stderr,解析器一行都没认出来 (grep -c MSPY_MAP = 0),于是全部模块显示 (未知),看起来像"libc 里没有拷贝"。
  2. 编译要带 -fno-builtin-memcpy -fno-builtin-memmove -fno-builtin-memset, 否则拦截器自己的实现会被编译器改成对自己 always_inline 的递归。 构造函数还没跑到时 real_memcpy 是空的 ⇒ 留一条逐字节的兜底,不然在 memcpy 早于 构造函数的路径上会崩。
  3. 它有一个已知盲区:GCC 对编译期长度已知的小 memcpy 会内联成几条 mov, 根本不经过 memcpy —— -O0 也一样。自测:1000 次 16 字节常量长度的拷贝 一次都没拦到,而同样 16 字节、长度取自运行时的循环全拦到了。 ⇒ 这张表的大块(16 KB 级)是准的,小碎块会系统性漏;读的时候只用 ≥16K 那一档。

A/B 怎么摆:只换 .so,负对照要一起读

  • 两臂各留一份 .so(libuvcpp.t0.so / libuvcpp.t1.so),运行时 LD_LIBRARY_PATH 里 第一段换掉;.so 之间只差一个编译单元,所以任何差异只能来自那一处改动。
  • 负对照必须同时跑:改动只该在 h2+TLS 那条腿上生效,所以 h1 明文与 h2 明文两条腿 就是现成的对照。4.10 里 h1 那条腿两臂逐字节相同(942 303 166 B),h2 是 +0.75%。
  • 判据取每请求字节数,不是总量:两臂的 2xx 数天然不同(同样的 5 秒窗口), 总量会跟着分母变。t1 两轮分别 143 461 B/请求与 143 029 B/请求 —— 差 0.3%, 比任何 µs 读数都稳。
  • µs 腿一律交错:非交错跑(t0 全跑完再跑 t1)在这台机器上会读出 +1.2% 的假倒退, 因为同一轮里两条负对照也跟着涨了 +1.6% / +1.0%。三条腿一起动 = 机器在动。 改成交错(t0,t1,t0,t1,t0,t1)再取中位数,才拿到 82.1 → 76.0 µs(−7.4%,3–0)。

变异臂:把一次改动拆成两半

4.10 那次改动其实是两件事(reserve + 共享视图)。为了不把两件事混成一个数, 多建了第三个 .so:只把 reserve 去掉、视图保留。

臂 改了哪半 每请求拷贝字节
t0 — 184 141 B
t2(变异) 只要视图,不要 reserve 167 641 B
t1 两半都要 143 461 B

t0 − t2 = 视图那半,t2 − t1 = reserve 那半;两个数加起来正好等于 t0 − t1。 再配上两条独立旁证(t1 里 tls_flush_out 调用点整个消失;reserve 治的那两个 std::string 站点 ≥16K 档次数在 t2 里原样回来),这条归因才算钉死。

JSON 写出器的量具(仓外探针)

json-* 那几档要的是「每请求从活数据现算现序列化」,所以想量「序列化本身值多少」,就得 把服务端剥到只剩这一件事:不起事件循环、不发包,一根只读语料的探针。

  • 语料:HttpArena 仓的 data/dataset.json(50 项),与条目 main.cc 读的是同一份 (平台侧由 DATASET_PATH 注入)。
  • 形状:{"items":[...],"count":N},每项 8 个字段 + 一个现算的 "total",整份 8 397 B(对应平台的 json-50 档)。
  • 四个臂:A 每项 nlohmann::json::dump() 后拼;W 走 uvcpp_json_writer 一趟直写; F 手写一趟(栈上两位一取的整数表、光标直推);E 直接拼预先算好的每项字符串 —— E 只是下界诊断,预序列化缓存在 json 档的两种 type 上都不合规。
  • 两处必须做对,否则读数是假的:
    1. 两臂都返回 std::string,不要一臂复用外部缓冲。 第一版让 A 臂复用一个缓冲、 W 臂每轮新开一个串,量到的就成了「开缓冲」而不是序列化。条目里 build_json 本来就是 返回一个 std::string,两臂都得照这个形状。
    2. taskset -c 2-3 绑核。 这台机器同时是桌面机(见上面「本机不是一台安静的压测机」), 不绑核时读数被同机负载污染,两版代码的差会被埋掉。
  • append_raw 计数:另编一份「把库里那个计数器打开」的副本(在 append_raw 开头 ++g_appends —— 注意它落在 namespace uvcpp 里,探针侧要写 namespace uvcpp { extern size_t g_appends; },否则链接期报未定义,而报错信息里 只出现裸名字 g_appends,看不出是名字空间不对)。量到 4 073 → 1 536 次/响应,这是 「一个键 4~5 次追加 → 1 次」的直接证据。
  • A/B 怎么摆:两臂从同一份源码树编,只有 uvcpp_json_writer.cpp 一个文件不同 —— 「改前」那份用 git show HEAD:src/uvcpp/uvcpp_json_writer.cpp 取。
  • 别用 Python 文本模式拷这个 .cpp:它在 src/ 下且带 BOM,文本模式读写会静默 把 CRLF 与 BOM 一起吃掉(本仓撞过,症状只是 Linux 的 config-contract 一条红)。A/B 一律 走 git show 那种二进制安全的取法。

读数在 benchmark.md §4.6。

这一页不是什么

  • 不是读数记录 —— 读数属于跑它的那套硬件加配置,写在 benchmark.md。
  • 不是回归门禁 —— 靶场不进 CI,本机的噪声也不支持一个绝对阈值。
  • 不是「本库比 X 快」的证据 —— 对照臂(MsQuic)只说明差距落哪一类成本上:每个 数据报的系统调用,不是核数;而且那是回环上的数,别外推(见「对照组」那一节)。

出处

  • 靶场与驱动器:bench/bench_server.cpp / bench/driver.js / bench/gen.js。
  • QUIC 吞吐量具:bench/bench_quic.cpp(--mode=push|echo|udpfloor)。
  • HttpArena 的 h3 量具:上游 nghttp2 的 h2load(--enable-http3),钉 v1.69.0。
  • 每请求指令数的量具:valgrind --tool=callgrind + callgrind_control -d; A/B 靠 LD_LIBRARY_PATH 换 libuvcpp.so,解析与逐函数对比是一次性脚本(不随库发布)。
  • JSON 写出器的量具:一根不起服务的仓外探针(语料是 HttpArena 的 data/dataset.json), 四个臂 A / W / F / E,加一份把 append_raw 计数器打开的副本;同样不随库发布。
  • 平台自己的读数:HttpArena 仓 site/data/results/<条目>.json(bot 提交回推送条目的分支)、 site/static/logs/、site/content/docs/hardware/_index.md,以及 https://www.http-arena.com/badge/index.json。位置与四个坑见上面「平台自己的读数从哪读」。
  • 口径与历史读数:benchmark.md。