我写了个探针,检查三张显卡两两之间的显存直连通没通。跨卡拷贝那一项,6 个方向 6 次全过,拷过去的数据一个比特都没错。
而那时候直连一条都没开。
更尴尬的是,我想用带宽把它揪出来,测出 1.627 GB/s。社区里有人在真打通直连的机器上测的是 1.68。差 3%,等于没差。
想直接拿判据的看第 4 章;想看带宽数字怎么从两个方向骗人,看第 2、3 章。
1. 没通的卡,交出了一张一半及格的答卷
先交代机器。三张挖矿版的数据中心卡,原厂 8GB,社区工具解锁到每张 64GB,每张只接了 PCIe 2.0 x4,窄得可怜。
显存直连(P2P)说白了就是:一张卡直接读写另一张卡的显存,不绕 CPU 那边的内存。多卡跑模型时,卡之间递数据要不要绕一圈,就看它。
社区里已经有人把这个功能也解开了。我打补丁之前想先想清楚一件事:打完之后,我拿什么判断它真的通了?
所以我反着来,先在没解锁的状态下把探针跑一遍,看看「没通」长什么样。结果是这样:
| 检查项 | 结果 |
|---|---|
| 驱动自报的能力位 | 全部 NO |
| 开启对等访问 | 全部报「无效设备」 |
| 跨卡拷贝(6 个有向对) | 6/6 通过 |
| 让 B 卡跑内核直接读 A 卡显存 | 0/6,非法地址 |
| 让 A 卡跑内核直接写 B 卡显存 | 0/6,非法地址 |
拷贝那一行差点骗了我。
原因说穿了很简单:拷贝接口发现两张卡之间没有直连,会合法地退回主机中转。先把数据从 A 搬回 CPU 内存,再从内存搬给 B。内容完全正确,返回值也是成功。站在它的角度,活确实干完了。
所以「拷贝成功」只证明数据到了,不证明数据走的是哪条路。
这一节我自己还错过一次。能力位那一栏,系统工具在三张卡两两之间都写着 GNS,我第一反应读成「硬件不支持」。错了。那是当前驱动报的能力位,解锁补丁要改的恰恰就是它。拿驱动自报的标签去判硬件,跟拿成功返回值去判路径,是同一种偷懒。
2. 我想拿带宽区分,结果是 1.627 对 1.68
我当时的想法很朴素:中转要搬两趟,总该慢一截吧。
测出来 1.627 GB/s。别人在打通直连的机器上报的是 1.68 GB/s。
先算这根链路本身能跑多快。公式就一个乘法:
链路单向上限 = 每条通道速率 × 通道数 × 编码效率 ÷ 8
PCIe 2.0 每条通道 5 GT/s,用 8b/10b 编码,每 10 位里有 8 位是有效数据,效率 0.8。代进去:
5 × 4 × 0.8 ÷ 8 = 2.0 GB/s
1.627 ÷ 2.0 = 81%
1.68 ÷ 2.0 = 84%
两个数都贴着天花板。那为什么分不出来?画一下两条路就明白了:
直连:A 的 x4 链路 → 主板转发 → B 的 x4 链路
中转:A 的 x4 链路 → CPU 内存 → B 的 x4 链路
两条路都得过 A 那根 x4 和 B 那根 x4。中间那段,CPU 内存比 2 GB/s 宽得多。只要驱动是分块流水搬的(前一块往内存回的同时,后一块已经在往 B 送),两趟能重叠,最后谁都卡在那根 x4 上。最窄的一段一样,结果就一样。
把它写成一个比值,就是带宽能不能用来区分两条路的判据:
鉴别比 = 中转路径上限 ÷ 直连路径上限
= min(链路A, 主机内存, 链路B) ÷ min(链路A, 主板转发, 链路B)
这个比值离 1 越近,带宽越没用。我这台两边都卡在 2.0,比值约等于 1。实测 1.627 ÷ 1.68 = 0.968。
这里有两处我得自己先认:
- 「驱动分块流水」是我从数字倒推的。如果它不流水、老老实实一趟搬完再搬下一趟,中转应该掉到 1.0 左右,实测没掉,所以我这么判断。驱动源码我没读。
- 1.68 是别人的机器、别人的主板,我自己的 1.627 只测了一次,没测重复跑的波动有多大。在这种条件下,3% 的差距什么都说明不了。
3. 反方向的骗法:6.7 GB/s,一个字节都没搬
带宽在我这台上是「分不出来」。另一位社区作者碰到的是反过来的情况,而且狠得多。
他测的是上游一个分支走的另一条解锁路线。结果一片漂亮:能力位报可以,拓扑工具报 OK,单向带宽 6.7 GB/s,双向 13.4,延迟 1.7 到 1.9 微秒。
然后他去看内容:跨卡拷贝返回成功,目标显存里的值没变。多卡一起做求和的集合通信,在第一个 4 字节的操作上就挂死了。他把那份报告撤回了。
这个数其实拿第 2 章的乘法一算就该起疑:
6.7 ÷ 2.0 = 3.35 倍
测得的带宽超过链路的物理上限,数据就不可能真过了那根链路。这一条比任何复杂分析都快。
这条推论有个前提,我得标出来:它假设他那几张卡也跑在 Gen2 x4。这代卡的链路速率是出厂钳死在 Gen2 的,社区里有人用对照实验判过死;但他那台机器每个插槽的实际宽度,我没去单独确认。
还有一件事值得知道:显卡厂商自带的那个点对点带宽延迟测试,只计时,不校验内容。传对了和传了个寂寞,它给出的读数一样。
所以带宽在这件事上是两头骗:没通的时候,它跟通了一样(我这台);假通的时候,它比真通还好看(他那台)。
4. 真正能判通断的:让显卡自己去摸对方的显存
拷贝会绕路,带宽没鉴别力,那剩下的办法就是不给它绕路的机会。
做法是在 B 卡上跑一个极小的内核,直接按地址去读 A 卡显存里的一个值;再在 A 卡上跑一个内核,直接往 B 卡显存里写一个值。这一步没有中转可退。直连没开,结局只有一个:非法地址。通就是通,不通就是报错,没有中间地带。
光有这个还不够,几个细节决定它是不是一把真尺子:
每个有向对用不同的期望值。 三张卡有 6 个方向,每个方向跑 3 种检查,每次用的值都不一样。那份撤回报告里就出现过读到「本地别名」的现象:看起来读到了对方,其实读的是自己身上某块映射。值各不相同,这种碰巧就蒙不过去了。
目标先清零,再拷。 防止上一轮留下的值冒充这一轮的成功。
先跑负对照。 往一段显存里写一个值,确认读出来不等于哨兵值。这个对照很朴素,只能证明比较逻辑不会无条件返回「相等」。但我被永远报绿的探针坑过不止一次,这一步就留着了。比较逻辑要是连这关都过不去,整个探针直接中止,不给结果。
每个检查关进一个单独的子进程。 这是我踩出来的。非法地址这个错误在设备级是「粘」的:一旦踩了,同一个进程里后面所有调用都继续报错。我一开始写了个函数,想销毁上下文再重建来复位,结果连创建上下文那一步都报同一个错。18 个检查跑在一个进程里的话,第一个踩雷之后,剩下 17 个全被污染。放进子进程,脏了就跟着进程一起死,母进程只读退出码。
最后的结果签名是这样:
pairs=6 checks=18 failures=12
12 个失败正好是 6 次读加 6 次写,拷贝那 6 次都过了。没解锁的状态下,这就是正确答案。
这个签名可以提前算出来。n 张卡:
有向对数 = n × (n − 1)
检查总数 = 3 × n × (n − 1)
未解锁时的失败数 = 2 × n × (n − 1)
三张卡就是 6、18、12。跑出来跟预期对不上,先怀疑探针,别急着下结论。
判读规则也很直接:全过,说明真通;全挂(或者只有拷贝过),符合没解锁;部分通过最危险,意味着有几对卡会悄悄把数据传错,这种要当失败处理,并把具体哪几对列出来。
5. 我这边还没做完的
- 探针只在锁定状态下跑过。 我最后没打补丁:按我这台机器的负载算了一笔账,换成要靠直连的切法是净亏的,不值得冒改驱动的风险。所以「全过 = 真通」这一侧,是别人的机器证明的;我这里只证明了「没通」能被正确报出来。
- 1.627 只测了一次,没有波动数据。
- 6.7 超上限的推论,依赖他那台卡也是 Gen2 x4。
- 驱动分块流水那一句是从数字倒推的,没读源码。
- 我这台三张卡分别挂在三个不同的上游桥下面,有一张挂在芯片组下;内核启动参数里还关掉了 BAR 重分配。这些在解锁之后会不会挡路,没测。
- 集合通信那一步,也就是真让几张卡一起做一次最小的求和,我没跑。那份撤回报告就是死在这一步。
6. 照着做的顺序
- 先用乘法算链路单向上限:每条通道速率 × 通道数 × 编码效率 ÷ 8。测得带宽超过它,直接判数据没过那条链路,后面不用看了。
- 再画出直连和中转两条路,算鉴别比。接近 1,就别指望拿带宽区分它们。
- 驱动能力位、拓扑表只当参考。它们是驱动自报的,解锁改的正是它们。
- 拷贝接口返回成功,只说明数据到了,不说明路径。
- 判据用内核直接读写对方显存的指针,每个有向对一个不同的值,目标先清零。
- 跑之前先做负对照,尺子不过关就中止。
- 每个检查一个子进程,别让一次非法地址污染后面所有结果。
- 先按 n × (n − 1) 算出预期签名,再对照实际结果。
- 部分通过当失败处理,列出具体哪几对。
- 最后跑一次最小的集合通信求和,能跑完才算能用。