承接上一篇《性能三问:从一张 docx 对照表到 100C+100R》。又追了三条:
①数据浮动这么大,原因何在?②你取的数据有意义吗?③C 版本的 exec 是不是也这样?
先把三个答案摆在前面
Q1:C 版本的 exec 也是这样吗?——是
(先补一句背景:「同门」= 两臂走完全相同的内核路径与同一份二进制协议,唯一变量是用户态库由 C 还是 Rust 写;「门」= gdev 的两种部署形态——内核门(经过内核模块 gdev.ko)与用户门(纯用户态驱动)。本篇两臂都走内核门。详见上一篇。)
| 臂 | 发数 | 中位 | min–max | 跨度 |
|---|---|---|---|---|
| C(内核门) | 100 | 0.405 | 0.124–1.083 | 8.7× |
| Rust(内核门) | 100 | 0.317 | 0.117–1.179 | 10.1× |
同一个 GPU、同一扇门、同样 40µs 的活,两个语言版本的 Exec 服从同一条又宽又稳的分布(KS 检验 D=0.160 < 临界 0.192,无法区分)。所以「浮动」不是哪一版的毛病——它不由语言负责。
Q2:为什么浮动这么大?——「等完成」的唤醒路径里,藏着一个 0~1ms 的随机惩罚
Exec 那一段计的是「提交内核 + 等它算完」。插针版把它拆开(36 发带分项计时):
| 分量 | 实测 | 稳不稳 |
|---|---|---|
| 提交(cuLaunchGrid) | C 4–7µs / Rust 10–13µs | 稳 |
| GPU 真算(512×512 矩阵加) | ~40µs | 硬件确定性 |
| 等完成(cuCtxSynchronize,内核门) | 0.3–0.93ms | 全部方差在这 |
内核门的「等完成」是睡在内核里、由完成中断叫醒。唤醒函数里有一处竞争回退(gdev_sched_wakeup,gdev_drv.c:297-304):如果完成通知到得太早——等待方还没真正睡稳——通知方就自己睡到下一个时钟滴答再补叫。这台机器CONFIG_HZ=1000,一个滴答 = 1ms——这笔罚的上限因此是 1ms。
0.13ms 的地板(提交+执行+无竞争唤醒)+ 等待路径的随机延迟 = 0.13~1.2ms 的观测分布。
400 发(本轮 200 + 档案 200)的定量检验给出更细的图景(下图):约四成发走无竞争快车道(≤0.3ms),其余构成慢带——慢带不是均匀铺开的,而是右偏单峰(众数≈0.3ms,长尾到 1ms):主体是普通的调度唤醒延迟(中断到了、任务醒了,但 vCPU 排上真实 CPU 核要排队——天然右偏),滴答回退贡献 ≤1ms 的长尾成分。「慢带=单一均匀罚」的简化说法已被 KS 检验拒绝(D=0.226 ≫ 临界 0.086)——检验过程见「环境这一环」一节的证据分级表。
对照组一锤定音:用户态门的「等完成」是轮询自己 mmap 的寄存器,不睡内核、不进调度器——同负载 10 发只动 6µs(CV≈2%),稳如磐石。哪个环节在抖,一对照就现形。
三次独立采样(各 100 对)的 R/C =0.90 / 1.13 / 0.78,方向翻了两次、全部区间交叠、全部不显著。
三次都指向同一句话:同门之下,C 和 Rust 之间不存在值得立案的性能差。
代码现场:这段代码为什么这么写
根因代码原样贴出(内核模块mod/gdev/gdev_drv.c:292-306,C/Rust 两臂共享):
/* 等待方:睡进内核,不被信号打扰 */voidgdev_sched_sleep(void){set_current_state(TASK_UNINTERRUPTIBLE);schedule();}/* 通知方:叫醒等待者 */intgdev_sched_wakeup(void*task){if(!wake_up_process(task)){/* 返回 0 = 没叫醒:对方还没睡下 */schedule_timeout_interruptible(1);/* ← 波动源头:睡到下一个滴答,再补叫一次 */if(!wake_up_process(task))return-EINVAL;}return0;}完整链条(逐行核实过):用户进程发起同步等待 → 入队后按上面的模式睡下;GPU 算完触发中断 →通知处理器(gdev_drv.c:63)叫醒 gdev 的调度内核线程(:88)→ 调度器选出已完成的上下文、将其出队,再gdev_sched_wakeup(se->task)叫醒用户进程(common/gdev_sched.c:297-301)——竞争回退就发生在最后这一步。失败分支的报错文案写的就是
「Perhaps context %d is already up」(:303)。
在防什么:唤醒丢失(lost wakeup)
经典并发陷阱。wake_up_process()只能叫醒已经睡着的任务;如果通知到得太早——等待方还没执行到set_current_state那行、还在正常跑——这次唤醒就是无效的(返回 0),而通知是一次性的、不会排队。没有这段 fallback,等待方随后睡下就永远没人叫:每次撞上这个窗口= 死锁。所以修法是「等一个滴答(这时对方必然已走到睡点),再叫一次」——用有界的延迟换不丢唤醒,教科书级的正确性权衡。在 2012 年前后的裸机研究原型里(竞争窗口纳秒级、命中率极低、HZ=1000 下最多 1ms),这是个完全合理的选择。
环境这一环:哪些已证,哪些是推断
「赖虚拟机」恰恰是整条因果链里唯一没有直接测量的一环——全程只有虚拟机这一个环境,
没有裸机基线。把每一环按证据强度排开:
| 环节 | 等级 | 依据 |
|---|---|---|
| 抖动全部来自「等完成」路径 | 已证 | 插针分解(提交/GPU 执行均稳) |
| 慢带呈多峰离散结构(0.2-0.4 隆起、0.72-0.76 与 0.92-0.98 窄簇),非均匀非简单右偏 | 已证 | 400 发 0.02ms 细桶直方图;「单一均匀罚」被 KS 检验拒绝(D=0.226 ≫ 0.086) |
| 虚拟化放大命中频率 | 推断 | 机制成立(见下),缺裸机对照 |
机制(讲得通,未测):测试机是宿主机上的一台虚拟机(GTX 580 PCI 直通),GPU 完成中断要宿主机转发注入,虚拟机的 CPU 是宿主机上的线程、与同宿主其他虚拟机抢物理核。关键不是「通知来得早」,而是睡觉的一方被耽误——提交之后、走到睡觉代码之前,vCPU 一旦被宿主机调度走几十微秒,而完成链(另一个核)照常推进,「去叫时人还没睡下」即命中;虚拟机让这种
抢占频繁发生。
旁证与责任划分:同一台虚拟机里,走用户门(不睡内核、轮询等待)的对照组纹丝不动(CV≈2%)——虚拟机本身不给计时加噪,加噪的是这段对调度抖动脆弱的等待代码。所以最准确的说法是:根在代码的等待设计(对任何调度抖动都脆弱,裸机上同样会命中、或许罕见),虚拟机是疑似放大器。
验证(按代价排序,阶段一已完成):
- 阶段一(已完成):用 400 发既有数据检验模型的定量预测——「均匀罚」被拒绝,
模型细化为两成分;顺带发现两臂慢带命中率差 12 个百分点(上表新线索); - 阶段二(未执行):给
gdev_sched_wakeup回退分支挂 ftrace 计数,分臂
统计命中次数——一次实验同时裁两件事:命中率从推断变实测、两臂差异定论(不改源码)。 - 阶段三(未执行):宿主机给测试虚拟机绑独占物理核,看慢带占比是否随 vCPU 抢占消失而降
另两条已证的机制事实:
- 代价是随机的:
schedule_timeout_interruptible(1)睡到的是下一个滴答边界,落在1ms 周期内的哪个相位纯看运气——(0,1ms] 均匀分布,不是固定 1ms; - 另一方面:
TASK_UNINTERRUPTIBLE让等待不可被信号打断——GPU 楔死时,等待方kill -9都杀不掉(模块内 5 处睡眠点全是同一模式)。防丢唤醒的坚韧,和故障处置的困难 (每次楔死只能整机复位),是同一个设计决定的正反面。
意外收获:楔死的节律
复测途中楔死 38 发,全部留证。规律清晰得意外:每个「复位+装驱动」的新鲜周期,恰好能跑~19 发绿,然后楔死簇发(2~7 连楔);暂停 3 分钟/5 分钟/40 分钟都不能自愈,只有整机复位恢复。楔死瞬间的内核日志全是同句:nouveau PFIFO 写缺页(显存页不存在)——资源耗尽形态,已单独整理。
复测怎么跑的(一段工程花絮)
每个周期 = 整机复位 → 重装两个内核模块 → 建设备节点 → 从断点号续跑;runner 自带两臂计数、楔死穿透(单楔只记录不中止)、连续 3 楔自保停机。
数字
| 采样 | C med | R med | R/C | p(置换) |
|---|---|---|---|---|
| 档案同门轮 | 0.421 | 0.379 | 0.90 | 0.17 |
| 本轮预跑(18v19) | 0.282 | 0.319 | 1.13 | 0.87 |
| 本轮终版(100v100) | 0.405 | 0.317 | 0.78 | 0.094 |