ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

跨度的成因

跨度的成因

Exec 十倍跨度的成因,与一段被实测作废的解释

系列:gdev-master(NVIDIA/nouveau 用户态 GPGPU 运行时)从 C/C++ 到 Rust 的移植工程 ·


《性能三问》收尾时说:单次读数落在九倍区间的哪一点全看运气,「快 6 倍」就是这么来的,所以只看中位数。这句话把「语言无罪」钉死了,却把另一半问题按下了不表——运气本身是从哪来的?上一篇《Exec 跨度与复测》接过这句,给了一个答案:等完成的唤醒路径里藏着一笔 0~1ms 的随机罚——这个答案已被实测处决。数楔死的那批数据,每一发都在这堆乱跳里跑。本篇把跨度本身继续查

先把结论钉在桌上:

问题答案口径
Exec 跨度成因有且只有两条:①每次开机第一发的冷启动开销(首绿);②其余慢发=「裸自旋」执行模式干预实验级钉死;微架构归因收口到「执行侧重放」(§五第四轮)
慢发落在哪条代码上cuCtxSynchronize末尾 gbarrier 一条等待腿:while裸自旋狂读内存里的 fence 完成标志v7 逐 ioctl 配对计时 12/12 全对账;v31a 同工装克隆 48/48,合并 60/60
慢发是什么机制裸自旋执行模式:GPU 早已写回的标志翻转被 CPU「看晚」100–900µs——值不旧(跨核强制逐出治不好),是那条读的执行侧被拖;循环体内每圈一条强序事件即整条消失,纯 pause 部分恢复,体外干预无效v22+v26 剂量实验(p=1.3e-36)+ v28 pause 复刻 + v29 三判别臂(0/106 vs 53.8%)
与语言 / 移植有关吗无关。同门 R/C 比值反复 ≈1;连发口径下两栈也一个样v23 双栈 n=68/格 p=0.85;v27 四格 54–66% 互无显著差(22 周期 374 发)
旧解释(上一篇的「睡觉 + 1ms 唤醒罚」)作废:那段代码在整个测试里一次都没执行过缺席(168 发全 0,含 106 发慢发)+ 在场(每发上千圈在读)+ 身份 + 可操纵,四线合围(§二)

(「慢发」=Exec 落在 0.3 毫秒以上的那几次运行(判定线 0.3,下同),稳健性用 0.25/0.30/0.35 三档重切过;「发」=一次完整程序运行,楔死那发永远不进样本。)

一、要解释的现象,和第一段看起来很顺的解释

现象:快发恒在 0.12–0.14ms,慢发落在 0.3–0.9 毫秒,开机第一发可以到 1.1ms 以上。复测(C、Rust 各 100 发)之后,上一篇线上文章《Exec 跨度与复测》(10-10 发)把解释写成三步(同稿进了当周周报):

  1. 活本身很稳定——提交任务只要几微秒,GPU 真算约 40 微秒,都不抖;
  2. 等待的实现里有一笔「1 毫秒的罚」——程序等 GPU 时先睡觉,驱动叫醒它;叫醒的代码防「去叫的时候对方还没睡着」(唤醒会丢),防法是等 1 毫秒再叫一次(gdev_drv.c:301的回退分支)。VM 内核 1000Hz,1 滴答恰是 1ms;
  3. VM 调度让「罚」频繁命中——提交之后、走到睡觉代码之前,虚拟 CPU 被宿主机调度走几十微秒,竞争就撞上。

这个解释看起来非常顺:回退分支在源码里真实存在(那段代码在那篇文章里原样贴出);0~1ms 的罚恰好罩住 0.3–0.9 的慢发(快发=没撞上的地板);直方图「逐桶吻合」;VM 里调度抖动频繁也讲得通。但它有一个致命的结构问题,那篇文章自己就写在了纸面上:证据分级表里「虚拟化放大命中频率」一栏标的是推断——机制成立,缺裸机对照。所以文末排了两张未执行的验证单:阶段二,给回退分支挂 ftrace 计数、分臂统计命中——「命中率从推断变实测」;阶段三,宿主机给测试虚拟机绑独占物理核。

阶段二那张验证单,预言了它自己的死法。

二、推断是怎么死的

照单执行上一篇自己开的阶段二:挂上内核探针直接数数。尺子是 ftrace 的 kprobe 事件:把探针钉在内核函数或分支地址上,那段代码每执行一次,计数器加一——「它到底开没开火」从此是个数,不是推断。两记实锤:

  • 「1ms 罚」的回退分支:168 发逐发计数全 0。回退分支在源码里有名有姓——gdev_sched_wakeup(gdev_drv.c):先wake_up_process叫醒对方,没叫醒就schedule_timeout_interruptible(1)等一个 jiffy(内核 1000Hz,恰 1ms)再叫一次。kprobe 钉在这条回退上,两批合计 168 发:v2/v3 的 24 发含 0.588ms 慢发样本与 6 发楔死样本;v30 的 8 个周期 × 19 发(C/R 逐发交替,每周期独立重新开机,原版驱动正常形态),144 个绿发逐发计数全 0——其中 106 发正是慢发(74%,按旧解释它们发发都该踩回退),8 个楔死发也全 0。
  • 「睡觉」这个动作本身:同样 144 发全 0。针钉在睡眠函数gdev_sched_sleep的进/出双口上,v4 的 4 发(含 0.976 / 0.924 两发慢发样本)零命中;v30 同一批 144 发里这根针也在场——睡眠函数从头到尾一次都没被调用。

「全 0」会不会是尺子坏了?正控拿楔死样本校过:同机制的每圈探针钉在楔死发的自旋上——v12 单次 65 秒命中1.18 亿次;又拿四个楔死发各开一个 20 秒活体计数窗复测,四窗各数得5190 万~5280 万圈(互相复现,极差 1.6%)——折算每秒约 260 万圈、一圈约 0.38 微秒。所谓「上亿」不是什么神秘大数,就是「每秒两百万圈上下的死循环空转 × 几十秒」的算术。这种探针对「高频重复执行」完全数得动,绿跑里的 0 是真零,不是没量到。

零的对面还有「在场」证据。v5 把探针挂在 fence 读函数上数圈数:4 发每发等待窗内命中1332–1747 圈(5 个等待窗,进出计数对齐),挂针圈距 0.3µs、裸圈距纳秒级;v30b 的72 发每发 1497–1971 圈,无一发例外——等待的每一微秒,CPU 都在循环里转圈。睡觉的等待一圈也转不出来:「睡眠函数 0 命中」与「fence 读每发上千圈」是同一枚硬币的两面。

零的来历在源码与反汇编里对上:等待的实现根本不是睡觉,是忙轮询——gsync → gdev_poll,一个while (seq != fence_read(...))循环一圈圈读完成标志,每圈除了读就是取时间查超时(反汇编实证体内无 pause、无屏障),100ms 之后才轮到一次sched_yield。循环里没有睡眠,睡眠、唤醒、滴答回退三条路一条都不走——九个零一次得解。

复盘这次翻车,错在第三层:

旧解释的构件下场
「方差全在等待段」(拆段计时)活下来了——这一层是实测,v7/v8 以更高分辨率复核无误
「等待=睡觉,罚=1ms 回退」(源码静态推演)死了——那段代码从未执行

教训:「源码里真有这行代码」+「方差真在这段」拼起来,仍然缺一环——它真的在运行吗。静态推演给不出这一环,只有计数器给得出。而这次能翻得干净,靠的是当初把推断如实标成推断、并把验证方案写在了结论旁边——错误被自己1预告的观测处决,而不是被下一个受害者发现。

顺带一笔更正:楔死进程「kill -9 杀不掉」,旧叙述归因于不可中断睡眠,同样错了——忙轮询自旋永远不返回用户态,信号永远投递不进去(R 态、SIGKILL 免疫)。

把「不是睡觉」这个判决的证据,四条相互独立的线合围如下:

  1. 缺席:回退分支 168 发全 0——106 发慢发按旧解释发发必踩回退,计数仍是 0;睡眠函数 148 发全 0(其中 144 发与回退分支同批双针在场);
  2. 在场:fence 读探针 4 发每发 1332–1747 圈、72 发每发 1497–1971 圈(无一例外)——等待就在循环里;
  3. 身份:源码+反汇编对账,等待的实现就是 §四 贴的那段while,体内无 pause、无屏障、无任何睡眠调用;
  4. 可操纵:v22–v29 四轮约 1400 发——往循环体里每圈加一条指令,慢发应声归零;同样的干预挪到循环外,全部无效。等待若不是这个循环,改循环体不可能有这样精确的剂量效应。

四条合在一起没有活路。

上一篇逐条过账

上一篇《Exec 跨度与复测》不只有三步解释——还有一张证据分级表、一组「已证」结论、一条新线索、一段楔死归因。它的每一条说法,都在这篇的证据面前过一遍账(轮次号=主仓任务单编号,逐轮判据与原始输出见详记):

上一篇的说法裁断依据
「C 版本的 exec 也是这样」(KS D=0.160 < 0.192)活,且加固v23 同门双栈 n=68/格,R/C p=0.85;v27 连发口径四格都在 54–66% 之内(22 周期 374 发,两两 p≥0.17)
「等完成是睡在内核里、由完成中断叫醒」死(事实性错误)源码+反汇编:等待是while忙轮询,100ms 才sched_yield一次;睡眠函数gdev_sched_sleep探针 148 发全 0(含多发慢发样本)
「罚=竞争回退schedule_timeout_interruptible(1)」死回退分支探针 168 发全 0(106 发慢发反例);正控同尺在楔死自旋上数得动上千万圈,证 0 是真零
「0.13ms 地板=提交+执行+无竞争唤醒」数对,归属改地板实为快发自旋 38–172µs(v7 三段拆分);「无竞争唤醒」这条路根本不存在——等待从不睡觉
「慢发主体=调度唤醒延迟,滴答回退贡献长尾」死慢发与调度/打断无关:窗内只打断 102/149 p=1.0、窗外针 96/142 无差(§三判别表);慢发机制整个换成裸自旋(§四)
「虚拟化放大命中频率」=推断随机制死被放大的那个回退,命中实测=0,无物可放大;慢发与 vCPU 抢占无关(上一行同据)——阶段三绑核对照因此不再需要
R/C=0.90/1.13/0.78「无立案差异」活,且加固v23 p=0.85;v27 把两栈等价从交替设计扩展到连发设计(§五)
「楔死 kill -9 杀不掉=不可中断睡眠」更正楔死栈定格在自旋循环、进程 R 态而非 D 态:忙轮询不返回用户态,信号递不进去(本节末)
阶段二 / 阶段三验证单已执行 / 不再需要阶段二即本节两记实锤——处决了它自己的 Q2;阶段三的前提(vCPU 抢占致慢发)已被判别表裁掉

上一篇立起来的东西,经得住的比想象多;倒下去的全倒在同一个缺口上:把「代码存在」当成了「代码在执行」。

三、追凶:从「在哪条腿」到「哪类活动能治」

机制重新开放之后,重新测试十几轮,全部同 boot 对照设计(同一次开机内逐发交替实验臂/对照臂,环境态自己跟自己比)。主线四步:

第一步:定位到一条腿。逐 ioctl 配对计时(v7):提交(GLAUNCH)恒 4–7µs、等内核算完(GSYNC)恒 93–98µs,只有 gbarrier 一条腿分快慢——快发 38–172µs vs 慢发 234–488µs。复测:三条腿的恒常性原样复现——GLAUNCH 3–11µs、GSYNC(非首绿)92–113µs、GBARRIER 快发 19–136µs、慢发 200–803µs,两组数值完全不重叠。再拆(v8):barrier 里推送部分恒 0–1µs,时长 100% 是那个自旋

第二步:排除一批嫌疑。同一二进制只换库路径、同 boot 逐发交替(v11):内核门 14/16 慢 vs 用户门 0/16——GPU 的 fence 物理不慢,慢发挂在内核门路径上;慢发之后紧接的用户门发立刻快回(14 对)。楔发现行犯抓获(v12)与慢发解离;dmesg 里慢发零异常,缺页出局。

第三步:一个意外转折——探针自己会治病。观测轮发现挂着探针的跑全部快发、摘了探针慢发回来——工装在干预被测系统。顺藤摸瓜做出判别实验(v14–v17):探针开着 0/8 慢、关着 6/9 慢(秩完全分离);针必须打在 barrier 自旋窗口内,窗外无效;「挂而不放」(只吃中断不写记录)无效——打断不承重,承重的是每圈往 trace 缓冲区写内存。合并成四行判别表:

条件慢发比例vs 基线
无针基线138/211 = 65%—
窗内针 + 每圈记录0/76(另跨 6 个新 boot 0/54)p≈0,完全分离
窗内只打断不记录102/149 = 68%p=1.0,无差
窗外针 + 记录96/142 = 68%无差

第四步:剂量实验收机制名。判别表还是间接证据(借探针的手),单独编译实验模块直接在自旋体内加指令,四臂轮转:原版基线78% 慢(中位 0.451)vs 每圈clflush轮询行0/40、每圈clflush无关行0/32、每圈pause(零内存流量)0/32(max 0.236 恒不越线)。两个旧子句当场双死:「被轮询那行陈旧」死(刷无关行照样治好),「需要内存写流量」死(pause 不碰内存也够)。统一问题——裸自旋执行模式:循环体内每圈一条杂指令即可恢复可见性,体外打断无效。

四、跨度有且只有两条成因

成因①:首绿冷开销。每次开机(复位+重装模块)后的第一发带一笔 0.27–1.1ms 的超额,这笔账只打第一发,第二发起就没了

成因②:慢发 = 裸自旋执行模式(执行侧重放)。

先看等待的真身。C 和 Rust 两臂共享同一段内核代码(gdev_nvidia_compute.c的gdev_poll(),188 行起),等 GPU 的地方就一个while循环:

while(seq!=compute->fence_read(ctx,seq)){/* 读内存里的完成标志 */gdev_time_stamp(&time_now);/* 打时间戳 */gdev_time_sub(&time_elapse,&time_now,&time_start);if(gdev_time_ge(&time_elapse,&time_relax))/* 满 100ms 才让出一次 CPU */SCHED_YIELD();if(timeout&&gdev_time_ge(&time_elapse,timeout))return-ETIME;}

圈体=一次读+一次取时间+减法+比较,再无别的(反汇编对账:无 pause、无屏障、无任何睡眠调用);实测挂针圈距 0.3µs(≈探针自身开销,单针;三针 0.38µs),裸圈距纳秒级,每发转约 1300~2000 圈。这就是「裸自旋」四个字的含义:等待=光读标志、别的什么都不干的狂转。

机制图景一张图:

GPU 侧:算完 ──► 内存里的完成标志 0 → 1 (这一步不慢:用户门直读同一块内存,0/51 慢发) 时间 ──────────────────────────────────────────► │ │ 从 t0 起,标志在内存里就已经是 1 ▼ CPU 侧(等待循环,一圈 ~百纳秒): 圈1 读到 0 ┐ 圈2 读到 0 │ 这 100–900µs 里 CPU 一直在转圈: …… │ 不睡觉(睡眠探针 144 发 0 命中) 圈N 读到 0 ┘ 不等中断(回退探针 144 发 0 命中) 圈N+1 读到 1 ←── 「看见」被押后到这里(快的发,几圈内就看见) 治法:每圈一条强序事件 ⇒ 下一圈必看见,慢发整条消失 纯 pause(只停流水、无内存序事件)⇒ 只治约 97%,残留 2~3%

三条已钉死的事实托住这张图:GPU 写回不慢(—同一块内存换个读法立刻是新值);缓存里的值也不旧(另一个核每 ~10µs 用架构级指令强制逐出该行,照样约 50% 慢);循环外任何干预无效(打断、窗外针,均与基线无差)。于是只剩一个位置:那条读指令的执行侧——圈体里几乎只有这条读时,这次访问在 CPU 内部被长期「押后」,翻转就躺在内存里,CPU 却像一直盯着手里那份旧报纸、没抬头看门铃,一押就是 100–900µs;每圈塞进任何一条强序指令(清缓存行/带锁读/普通写,连不走缓存的读都算),等于强迫这一圈落一次章,下一圈立刻看见。CPU 内部具体哪个环节在押后,perf 级微架构观测工具在直通 VM 里不可用——这是本篇证据够得着的边界,到此收界、不硬猜。

检验这张图景的检测数据,一表收拢(判定线 0.3ms,0.25/0.30/0.35 三档重切零翻转;各干预臂 vs 同 boot 基线全部 p 天文小,v26 合并 p=1.3e-36):

等待循环每一圈干什么慢发比例发数与轮次
什么都不加(原版基线)65% / 78% / 54% / 68% / 65%(五轮各自量得,随环境态漂但从不清零)211+32+100+212+107
clflush轮询的那行0(0/165)v22 40 + v26 125
clflush无关行0(0/132)v22 32 + v26 100
pause(零内存流量)8/345 ≈ 2.3%v22 32 + v26 100 + v28 213
换成带锁读lock cmpxchg0(0/106,慢的一发都没有,值域 0.117–0.139)v29
换成不走缓存的 UC 读0(0/106)v29
——以下干预加在循环外——
另一核每 ~10µs 强制逐出该行53.8%(不治)v29 106
挂针只打断不写记录68%(无差)v15/16 149
针挂在等待窗口外68%(无差)v17 142

这个图景顺手解释了两个旧观察:gsync 循环为什么恒 93–98µs(它圈体里本来就有取时间的活儿,从不是裸读);用户门为什么 0/48 无慢发——大头是轮询形态不同(它主要等待段本来就不是裸圈),而它尾巴上那段同样的裸圈为什么不慢,只答到「形态不同」不够——二进制对表(两侧裸圈逐指令一个模子)与换映射判别给出答案:进了内核这个门,读哪份内存都一样慢(§七)。

单发只测一次为什么也能差好几倍:慢发是一发一发独立撞上的——同一台机器连跑 200 发,约三分之二的发撞上。撞与不撞,读数就是 0.13 与 0.3–0.9 的两回事;只测一次等于只抽一发,两边各抽一发、谁撞谁没撞全看当天——docx 对照表「C 和 Rust 都大」同理(与首绿无关:运行中的机器没有首绿可进样)。


这一篇的一句话:跨度查到底,靠的不是聪明解释,而是两条纪律——推断必须标成推断、留一条自己能被处决的路(旧解释死在它自己预告的观测上);结论必须先写好翻案条款再加固(pause 的 3/100、销案的 48 发、把「值旧」送上审判台的跨核逐出臂,都是条款在响)。「讲得通」是形状,「在执行」才是性质——“18.形状不是性质“的错,这次在机制归因上又现形了一回,好在这次有计数器在场。


RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进