Repository navigation
perf(web): 压缩变体表按句柄存/取(热的压缩响应不再拷回) - #21
Merged
Merged
Conversation
sercebr
force-pushed
the
perf/variant-handle
branch
from
September 20, 2026 07:18
efad491 to
0d94f2e
Compare
Antruly#17 第 ④ 步。表里原来自己持有一份压缩产物的拷贝(`uvcpp_buf data`), 于是热路径每次命中都要再拷一份回来。换成 `shared_ptr<const std::string>` 之后,压出来的那份字节**只存在一份**:存表时存的是同一个句柄,命中时把 句柄递出去 —— 两边都不拷。 - 命中:`clear()` + `clone(v.data)` → `resp.body.share(v.data)` (`share()` 顺手把静态层借来的那份放掉,不物化) - 压完:产出落进一个共享串,响应与表**共用**它。这一份拷贝仍在 (deflate 产出落进共享串,冷路径一次性),④ 去掉的是另外两份 - 存表:存同一个句柄(以前 clone 一份,理由是"响应自己那份还要发出去") - 淘汰/上限:按新的 `bytes()` 记字节 —— 忘了换的话字节账恒为 0,字节那条 淘汰腿会**静默**瘸掉(条数那条还在,所以"能淘汰"这件事不会立刻露馅) 报文的字节**逐字节不变**(实测两臂 md5 相同);表这边是 `shared_ptr<const std::string>`,拿不到可写指针,所以"共享出去之后被人改了" 这件事由 `uvcpp_buf` 的静默物化兜着 —— 谁写谁自己拿到一份私有的。
新用例 `buf_share_handle_func`(G1~G5,纯缓冲语义,不起连接): 1. 命中那条路上响应体与表里那条**指针相同**(不是"内容相等" —— 后者对 "到底拷没拷"零信息量); 2. 谁写响应体都**不许动到表里那份**,分三种写法各自记该记的账: 直接写/追加 = 一次真物化(`share_discard_count()` +1), `clear()` 之后再写 = 一个字节都没白拷,所以**不记账**; 3. **谁先走都不影响另一份**:表里那条被淘汰时响应还没发完(④ 之前不可能 出问题,那时两边各持一份拷贝),反过来也一样; 4. 空句柄/空手柄的退化与 `clear()` 一致。 `web_app_static_func` 里那条 `s.bytes > 0` 换成**算死**的账:表内字节数 必须正好等于那三次响应的 Content-Length 之和(换句柄之后账还得对)。
`test_compress_variant` 把字节账算死了,但它只有三条 entry、一次淘汰都没有
—— "淘汰时忘了减"这条腿仍然没有判据。而这条腿瘸掉是**静默**的:
`compress_variants_bytes_` 只涨不减,条数那条腿还在照常淘汰,所以"能淘汰"
这件事照样是绿的,只有大文件会因为字节上限永远触发不了而悄悄不再命中。
新增的用例把**条数上限**跑满(存 1100 条,键不同、体完全一样,于是每条压缩
后都是同一个长度 C),淘汰开始之后:
* 条数腿:表里稳定在 1024 条;
* 字节腿:账必须 == 剩余条数 × C。
变异验证(删掉 `compress_variant_evict()` 里那句
`compress_variants_bytes_ -= victim->second.bytes();`):
bytes = 47300 = 1100 × 43 → 字节腿红
entries = 1024 → 条数腿**仍然绿**
test_compress_variant 的 bytes = 124 不变 → 旧判据也仍然绿
正是"旁路判据把主判据的失效盖住"那个形状。判据钉的是那个上限**存在**,
不是一个恰好相等的数。
---
## 1100 条怎么打得又快又不出岔子
这条用例要真打 1100 条请求,而 CI 那条 `--timeout 30` 是硬线,所以形状是
量出来的、不是随手写的:
* **一条请求一条连接**:跑得对,但 Windows 上每条连接约 4 ms,1100 条 =
4.5 秒。本文件其余用例本来就要十几秒 ⇒ 顶穿 30s(**macOS CI 上已经真的
超时过一次**,这个 PR 的第一版就是这么挂的)。
* **一口气灌进一条连接**(HTTP 流水线):0.2 秒,但**会丢请求** —— 服务端读
缓冲是 64 KiB(`internal_alloc_cb` 用 libuv 建议的尺寸),**跨在 65536 上的
那条请求会被丢掉**。本机 100% 复现:1100 条里固定丢第 745 条,其字节区间
65450..65538 正好盖住 65536;给每条加 19 字节填充,丢的就变成新的跨界那条
(586),而且那之后整段都不回。
这是**另一个**问题(已单独报给作者),不该混进这条判据。
* **每批 20 条走一条连接**(批内最后一条带 `Connection: close`):0.37 秒,
每条请求都回、每条都真的存进表。每批只有两 KB 左右,服务端从空缓冲开始
读,碰不到那个 64 KiB 边界。
sercebr
force-pushed
the
perf/variant-handle
branch
from
September 20, 2026 07:36
0d94f2e to
c851ec2
Compare
`.github/workflows/ci.yml` 里 8 处都是 `--timeout 30`,而这条文件在 CI 上的实测是 ubuntu 16.5 s / macOS 19.1 s / Windows `web` 21.4 s —— 本来就是最重的一条,余量只剩 9 s。它的量级由被测面本身决定(每个请求一条连接、串行走完整条响应链),不是哪条用例 写慢了,想给它减负只能改被测代码。 余量薄到这个程度,表现是**时红时绿**:内容更少的那一版(`efad491`)在 macOS runner 上 卡 30.05 s 红掉,内容更多的 `0d94f2e` 反而 19/19 全过。这比稳定红更难查 —— 它会训练人 「重跑一遍当没事」。 set_tests_properties(test_web_app_static_func PROPERTIES TIMEOUT 90) 只给这一条:命令行那个 30 s 是**默认值**,被属性盖过,所以全局一点没松。这比把整个 job 的 `--timeout` 抬到 60 诚实 —— 那是把每一条用例的挂死检出时间一起抬了。 机制两头都实测过(本机,不是查文档): * `ctest -C Release --timeout 1 -R test_web_app_static_func` ⇒ 跑满 16.89 s **通过**; * 同一命令、把属性暂时写成 `TIMEOUT 2` ⇒ 当场 `52 - test_web_app_static_func (Timeout)`。 `if(TARGET ...)` 是必须的、不是装饰:`UVCPP_BUILD_WEBAPP` 默认 OFF,那种配置下 `web_app_static_func.cpp` 已被上面的过滤器摘掉(本机拿 WEBAPP=OFF 的树实测:配置通过、 目标确实不存在),直接 `set_tests_properties` 会报「找不到这个测试」。
Antruly
added a commit
that referenced
this pull request
Sep 20, 2026
本仓规矩:每推一个功能升一档 patch,`UVCPP_VERSION_IS_RELEASE` 保持 0。 Co-Authored-By: Claude Code <noreply@anthropic.com>
Owner
|
已合入 不是走 GitHub 的合并按钮,而是本地 验证与讨论都在 #17:本机全量 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#17 第 ④ 步:压缩变体表按句柄存/取
前三步把一条响应的体在走到 socket 之前的三次必付拷贝去掉了(① 静态层整份命中改递引用计数、
② 头体分两块交给
uv_write、③uvcpp_buf补上显式移动)。剩下热压缩响应上那最后一次:变体表里那份压缩产物是表自己持有一份拷贝,命中时再拷一份回来。这一步把它换成一个句柄。
(这个 PR 基于 #19 合入后的
master;#19 里那两处注释里写着"今天仍然照拷……那就是第 ④ 步",现在到期了,一起改掉。版本号没动 —— 按你的习惯,合并之后你自己升一档。)
改了什么
compress_variant的持有形状从uvcpp_buf data换成::std::shared_ptr<const ::std::string> data,于是压出来的那份字节只存在一份:resp.body.clear(); resp.body.clone(it->second.data);→resp.body.share(it->second.data);—— 递句柄,一个字节都不拷。(share()顺手把静态层借来的那份放掉:既不物化、也不多一次拷贝。)
clone(result.data)进resp.body,而是让产出落进一个共享串,响应与表共用它。这一份拷贝仍然在 —— 它是 deflate 的产出落进那个共享串(冷路径一次性的事);
④ 去掉的是另外两份:存表那份、每次命中拷回来的那份。
move 走")。"不能 move"不等于"只能拷",句柄把这句话兑现了。
compress_variants_bytes_改用新的bytes()记账。这一处必须跟着换—— 忘了换的话字节账恒为 0,字节那条淘汰腿就静默瘸了(条数那条还在,所以"能淘汰"这件
事不会立刻露馅,只在大文件上悄悄失效)。这条腿现在有自己的判据了,见下面 §自证 第三组。
表这边是
shared_ptr<const std::string>:拿不到可写指针。所以"共享出去之后被人改了"这件事由uvcpp_buf的静默物化兜着 —— 谁写谁自己拿到一份私有的,表里那份纹丝不动(下面有判据)。实测(同一份二进制,一个环境变量分臂)
探针树上量的(六档里只有压缩那两档动)。两臂是同一份
uvcpp.dll:UVCPP_VARIANT_HANDLE_OFF=1时退回 ④ 之前的形状(存表再拷一份、命中拷回来),不设时表与响应共用一个句柄。编译开关、链接、探针插点、机器状态全同一份,差值只能归给那几行。臂的物证是
服务端自己报的
variant_handle_off与六档的逐档结构。bytes=0-1023热那档降到 242 B/响应 —— 那是响应头本身,这一档到底了。冷那档剩的那一次
compress_out仍在(deflate 产出的落点)。
报文的字节两臂 md5 相同(同一臂里冷热两次也相同)。这一条与性能判据是分开的:装置数的是
拷贝,这里钉的是"别把字节换掉了"。
自证(本机)
门禁请跑这个 SHA:
60f4e84(已 rebase 到b82c44a= 1.1.29,你的c458be7「uvcpp_write 第 2 块槽位」之后)。ctest -C Release:79/79(本分支;perf(web): 响应体零拷贝接管(共享视图)+ 头体分两块写(nbufs = 2) #19 是 77/77。多出来的两个:本 PR 的新用例buf_share_handle_func,和你的test_write_second_slot_func)。跑之前核过 exe 旁边那份DLL 与构建产物 md5 一致(见下面那个坑)。
按你 07:23/07:39 那条建议,我提了那条超时预算(
tests/functional/CMakeLists.txt末尾,一条独立的 commit
60f4e84):机制本机两头复现了:
ctest -C Release --timeout 1 -R test_web_app_static_func⇒ 跑满16.89 s 通过;把属性暂时写成
TIMEOUT 2再跑同一命令 ⇒ 当场(Timeout)。也就是命令行那个数确实只是默认值,全局那 30 s 一点没松。
if(TARGET ...)是必须的:UVCPP_BUILD_WEBAPP默认 OFF,那种配置下目标已被过滤器摘掉(拿 WEBAPP=OFF 的树实测过配置通过、目标确实不存在),不加守卫会报「找不到这个测试」。
顺带把我在 CI 上量到的余量摆出来(
c851ec2那次,都是批处理之后的数):ubuntu16.5 s / macOS 19.1 s / Windows
web21.4 s(上限 30 s)。所以它确实是最紧的那一条,而你说的"时红时绿"我这边的数据也支持:
efad491在 macOS 上 30.05 s 红,内容更多的
0d94f2e反而全过。rebase 时在
web_app_static_func.cpp撞了一处冲突,你那一版把share_discard_count()的零拷贝记账块插在了
check(s.bytes > 0, ...)后面,而那一句正是我换成"算死的账"的地方。解决方式是两侧都留:先是我的
sum_cl断言,接着是你那段share_discard_count记账(连你那条独立对照一起),最后是那条
discarded == 0。两边的判据都还在、都跑绿。新用例
tests/functional/buf_share_handle_func.cpp守的是这件事带来的三条契约(都不是性能):(
share_discard_count()+1);clear()之后再写(响应对象复用的正常序列)= 一个字节都没白拷,所以不记账(判据写成 +1 的话就是在钉一个错的账);
成了引用计数必须撑住的东西;反过来(响应先走、表里还在)同理。
另外把
web_app_static_func里那条s.bytes > 0换成算死的账:表内字节数必须正好等于那三次响应的 Content-Length 之和。
你要的那条腿(你 06:43 点名的)——
test_variant_byte_account_on_evict:把条数上限跑满,存 1100 条(键不同、体完全一样,于是每条压缩后都是同一个长度 C),淘汰开始之后两条腿分开钉:
kCompressVariantMaxEntries);这是你要的那个形状 —— 钉的是"那个上限存在",不是一个恰好相等的数。旧的
test_compress_variant只有三条 entry、一次淘汰都没发生(bytes = 124是三次响应长度之和),所以它对"淘汰时少减一次"零信息量。新用例补的就是这中间那段。
这条用例要真打 1100 条请求,而 CI 那条
--timeout 30是硬线,所以形状是量出来的:每批 20 条走一条连接(批内最后一条带
Connection: close),0.37 秒。中间试废的两版:一请求一连接(正确,但 Windows 上每条连接约 4 ms ⇒ 4.5 秒,这个 PR 的第一版就是这么把
macOS CI 顶超时的);一口气灌 1100 条(0.2 秒,但会丢请求 —— 见下)。
变异验证(改一行、跑、再改回来,每次往返逐字节核对过):
bytes()归零 → "表内字节数 == 三条变体的压缩长度之和"那条红;命中那条路递空句柄 → 三条红,其中就有"第二次的字节与第一次逐字相同";
删掉
compress_variant_evict()里的compress_variants_bytes_ -= victim->second.bytes();→ 新用例
bytes = 47300 = 1100 × 43(应为1024 × 43 = 44032)红;而条数腿照样绿(
entries = 1024)、旧的test_compress_variant也照样绿(bytes = 124不变)。正是"一个仍会通过的旁路判据把主判据的失效盖住"那个形状 —— 你认的那条。
这条变异第一遍跑出来是假绿:单目标构建没刷新测试目录里那份 DLL(
copy_test_dlls是个ALL 目标),编出来的 exe 配的还是旧 DLL,读数一点没变。核 md5 才发现,走全量构建才复现。
记在这,免得别人也踩。
顺带撞到一个别的问题:跨 64 KiB 边界的请求会被丢掉
为了让这条用例跑得进 30 秒,我先试过"把 1100 条一口气灌进同一条连接"(HTTP 流水线)。
它丢请求,而且 100% 可复现 —— 所以这条用例改成小批灌入绕开它,但问题本身该报给你:
GET /vb/<i>(全 keep-alive,末尾跟一条Connection: close的终止请求)打进一条连接:1101 条里固定丢第 745 条。它的字节区间是
65450..65538,正好盖住 65536。服务端回 1099 条
/vb/的响应,那一条既没有响应、也没有进变体表(stored也是 1099)。连跑三次,每次都是 745。
65522..65634),而且它之后整段都不回(只回了 586 条)。跟着边界走 ⇒ 不是"某一条请求有问题"。
uvcpp_tcp_client::internal_alloc_cb用的是 libuv 建议的尺寸),所以"跨在单次 read 边界上的请求"被丢,看起来是解析累积那一侧的问题,不是 TCP 的事。
这一条不在本 PR 的范围里,也没有被本 PR 的判据碰到(用例改成每批 20 条之后每条都回、
每条都存)。要不要我给它单开一个 issue、再补一条最小的复现用例(比如"一条连接里第一
条请求故意跨 64 KiB"),你说一声。
三点想请你评审的地方
bytes()是这一版唯一"容易忘"的地方。 它没有任何编译期保护(data从uvcpp_buf换成了
shared_ptr,data.size()这种写法会编不过,但字节账写成常量 0 照样编得过)。现在有一条只钉字节腿的用例了(上面第三条变异证明它单独能红、且条数腿红不了)。不过那是运行时
的兜底 —— 如果你觉得该在类型上再收紧(比如让
compress_variants_bytes_只能由bytes()增减),说一声我改。
受害者就没了,② 拿别的调用点做判据 —— 这里只是把这件事再确认一遍,免得我理解偏了。
那条的口径落的:静默物化 + 计数器,不硬断言。新用例把三种写法的账都钉了(含"
clear()之后再写不记账"这条 —— 它记的是白拷,而
clear()一个字节都没拷)。