<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh-CN"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://harrychen.xyz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://harrychen.xyz/" rel="alternate" type="text/html" hreflang="zh-CN" /><updated>2026-08-25T13:54:57+08:00</updated><id>https://harrychen.xyz/feed.xml</id><title type="html">Harry Chen’s Blog</title><subtitle>The personal blog of Shengqi Chen &lt;i at harrychen dot xyz&gt;</subtitle><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><entry><title type="html">wg-vanity：（又一个）Rust 实现的 WireGuard 地址生成器</title><link href="https://harrychen.xyz/2026/08/25/wg-vanity/" rel="alternate" type="text/html" title="wg-vanity：（又一个）Rust 实现的 WireGuard 地址生成器" /><published>2026-08-25T21:45:00+08:00</published><updated>2026-08-25T21:45:00+08:00</updated><id>https://harrychen.xyz/2026/08/25/wg-vanity</id><content type="html" xml:base="https://harrychen.xyz/2026/08/25/wg-vanity/"><![CDATA[<p>众所周知，WireGuard 的公私钥是 base64 编码后的 X25519 密钥对，每个 peer 唯一对应一组密钥。为了更方便识别客户端，很多人都会想生成“好看”的公钥地址，也就是在中间包含特定的字符串或者前缀。
很明显这是一种类似于挖矿的暴力搜索，除了反复生成密钥之外别无他法。</p>

<p>几年前，我在 GitHub 上翻到了 <a href="https://github.com/warner/wireguard-vanity-address">wireguard-vanity-address</a> 这个项目：它用 Rust 实现了简单的地址生成器，并且支持 Rayon 做单机并行。
虽然功能非常简单，但也确实非常好用。在它和 7742 服务器的帮助下，我这几年生成了不少个人使用的密钥对（公钥基本都以 <code class="language-plaintext highlighter-rouge">harry</code> 开头）。</p>

<p>然而，在想要搜索更长的前缀时，单机就不够用了；此外，GPU 显然也很擅长这种尴尬并行的计算任务，但项目并不支持。我原本想给作者提交一些 PR，但发现项目已经有五年多没有活动了。
于是我就 <a href="https://github.com/Harry-Chen/wg-vanity">fork 了一份</a>，再加上一些并不复杂的许愿，在 crates 上发布了一个新的包：<a href="https://crates.io/crates/wg-vanity">wg-vanity</a>。</p>

<p>wg-vanity 相比于原来的版本，升级了 Rust 语言和各种依赖到最新的版本，并增加了可选的 CUDA、MPI 的支持（二者可独立启用），也有简单的 CI 测试。
于是，我现在就可以：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ srun -n 12 -N 12 --gres=gpu:8 wg-vanity-cuda harrych
expected work: about 8.590e9 candidates (case-insensitive estimate) to find one match
using 8 CUDA device(s) per MPI rank (12)
measured 9.011e8 keys/s; estimated time to a match: 9.5 s
private dK6VdXSO2Y0Nc/yjbavbvA9g//fmUVVWosUkGcsGKhc=  public harRYCh9czEQgKxrRjT3V/PJZT9Ow+mfubF3xcZ+/ic=
MPI: 12 ranks, 4069523456 candidates in 4.431s (9.185e8 keys/s)
</code></pre></div></div>

<p>我还打算增加更多的功能，比如正则 pattern、支持更多的 key 类型等。也欢迎帮我许愿，然后提出 PR！</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Rust" /><summary type="html"><![CDATA[众所周知，WireGuard 的公私钥是 base64 编码后的 X25519 密钥对，每个 peer 唯一对应一组密钥。为了更方便识别客户端，很多人都会想生成“好看”的公钥地址，也就是在中间包含特定的字符串或者前缀。 很明显这是一种类似于挖矿的暴力搜索，除了反复生成密钥之外别无他法。]]></summary></entry><entry><title type="html">RTX 5090 的细粒度温度传感器与物理 SM / TPC 映射探测</title><link href="https://harrychen.xyz/2026/07/24/rtx-5090-temperature-sensors-and-physical-sm-mapping-detection/" rel="alternate" type="text/html" title="RTX 5090 的细粒度温度传感器与物理 SM / TPC 映射探测" /><published>2026-07-24T06:30:00+08:00</published><updated>2026-07-24T06:30:00+08:00</updated><id>https://harrychen.xyz/2026/07/24/rtx-5090-temperature-sensors-and-physical-sm-mapping-detection</id><content type="html" xml:base="https://harrychen.xyz/2026/07/24/rtx-5090-temperature-sensors-and-physical-sm-mapping-detection/"><![CDATA[<p>七月初，超频爱好者们又给老黄带来了新的惊喜：RTX 5090 的若干非公开的温度传感器的 PCIe BAR 地址被逆向了出来： <a href="https://www.techpowerup.com/350888/per-module-gddr7-temperature-reading-unlocked-on-nvidia-geforce-rtx-50-blackwell">[1]</a><a href="https://www.tomshardware.com/pc-components/gpus/new-plugin-unlocks-granular-vram-temperature-tracking-on-nvidia-rtx-50-series-gpus-community-cracks-open-blackwells-forbidden-telemetry-sensors">[2]</a><a href="https://www.overclock.net/posts/29599734/">[3]</a>。这些传感器包含了四个热点（hotspot）CH0 到 CH3，以及每一片 GDDR7 显存模块的温度（根据型号不同，可能有 8 或者 16 个传感器）。这些热点传感器比公开 API（比如 <code class="language-plaintext highlighter-rouge">nvidia-smi</code> 或者 NVML）能读到的信息更丰富，更接近芯片的实际温度（比如结温），刷新率也更高，自然能给超频玩家更多的玩法。但我并不超频，它还有什么用呢？有的，这两天，在 GPT-5.6 Sol 的帮助下，我还是做了一些有趣的探究。</p>

<p>注：以下大部分实验是 AI 做的，结果数据、图表由 AI 收集生成，我已尽力进行审阅。</p>

<h2 id="背景传感器信息">背景：传感器信息</h2>

<p>5090 的 PCIe BAR 中，如下的地址有六个核心温度寄存器：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">0xAD0A90..0xAD0A9C</code>：四个独立通道 CH0-CH3</li>
  <li><code class="language-plaintext highlighter-rouge">0xAD0AA0</code>：硬件聚合最大值（Hotspot）</li>
  <li><code class="language-plaintext highlighter-rouge">0xAD0AA4</code>：硬件聚合平均值</li>
</ul>

<p>它们都使用低 16 位，以 1/256 摄氏度为单位的定点数表示温度。此外，还有 8 或者 16 个寄存器保存了当前的显存颗粒温度，格式和地址范围都比较复杂。这些寄存器具体的读取逻辑都可以参见 <a href="https://github.com/olealgoritme/gpuwatch/blob/master/lib/gpuwatch.c">gpuwatch</a> 项目。此外还需注意，还需要打开 <code class="language-plaintext highlighter-rouge">iomem=relaxed</code> 内核命令行选项，才能从用户态映射 PCIe BAR 空间。</p>

<h2 id="实验负载与温度变化">实验：负载与温度变化</h2>

<p>一些简单的 CUDA 负载测试结果：</p>

<table>
  <thead>
    <tr>
      <th>负载</th>
      <th style="text-align: right">功耗 W</th>
      <th style="text-align: right">Hotspot C</th>
      <th style="text-align: right">硬件平均 C</th>
      <th style="text-align: right">CH0</th>
      <th style="text-align: right">CH1</th>
      <th style="text-align: right">CH2</th>
      <th style="text-align: right">CH3</th>
      <th style="text-align: right">显存最高</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>空闲基线</td>
      <td style="text-align: right">22</td>
      <td style="text-align: right">26.4</td>
      <td style="text-align: right">24.1</td>
      <td style="text-align: right">26.3</td>
      <td style="text-align: right">22.1</td>
      <td style="text-align: right">23.4</td>
      <td style="text-align: right">26.4</td>
      <td style="text-align: right">32.0</td>
    </tr>
    <tr>
      <td>FP32，1 block</td>
      <td style="text-align: right">102</td>
      <td style="text-align: right">37.9</td>
      <td style="text-align: right">33.2</td>
      <td style="text-align: right">37.9</td>
      <td style="text-align: right">30.9</td>
      <td style="text-align: right">29.4</td>
      <td style="text-align: right">33.2</td>
      <td style="text-align: right">33.7</td>
    </tr>
    <tr>
      <td>FP32，22 blocks</td>
      <td style="text-align: right">137</td>
      <td style="text-align: right">57.2</td>
      <td style="text-align: right">37.6</td>
      <td style="text-align: right">57.2</td>
      <td style="text-align: right">33.6</td>
      <td style="text-align: right">33.1</td>
      <td style="text-align: right">36.2</td>
      <td style="text-align: right">35.0</td>
    </tr>
    <tr>
      <td>FP32，44 blocks</td>
      <td style="text-align: right">188</td>
      <td style="text-align: right">71.3</td>
      <td style="text-align: right">42.8</td>
      <td style="text-align: right">71.3</td>
      <td style="text-align: right">36.3</td>
      <td style="text-align: right">36.2</td>
      <td style="text-align: right">40.6</td>
      <td style="text-align: right">37.0</td>
    </tr>
    <tr>
      <td>FP32，88 blocks</td>
      <td style="text-align: right">284</td>
      <td style="text-align: right">84.0</td>
      <td style="text-align: right">54.4</td>
      <td style="text-align: right">84.0</td>
      <td style="text-align: right">44.4</td>
      <td style="text-align: right">40.7</td>
      <td style="text-align: right">55.7</td>
      <td style="text-align: right">40.1</td>
    </tr>
    <tr>
      <td>FP32，170 blocks</td>
      <td style="text-align: right">421</td>
      <td style="text-align: right">104.2</td>
      <td style="text-align: right">61.9</td>
      <td style="text-align: right">104.2</td>
      <td style="text-align: right">48.6</td>
      <td style="text-align: right">42.7</td>
      <td style="text-align: right">60.6</td>
      <td style="text-align: right">44.2</td>
    </tr>
    <tr>
      <td>FP32，680 blocks</td>
      <td style="text-align: right">414</td>
      <td style="text-align: right">104.2</td>
      <td style="text-align: right">61.9</td>
      <td style="text-align: right">104.2</td>
      <td style="text-align: right">48.6</td>
      <td style="text-align: right">42.9</td>
      <td style="text-align: right">60.5</td>
      <td style="text-align: right">44.8</td>
    </tr>
    <tr>
      <td>INT32，全负载</td>
      <td style="text-align: right">414</td>
      <td style="text-align: right">104.0</td>
      <td style="text-align: right">61.9</td>
      <td style="text-align: right">104.0</td>
      <td style="text-align: right">48.4</td>
      <td style="text-align: right">42.7</td>
      <td style="text-align: right">60.1</td>
      <td style="text-align: right">44.8</td>
    </tr>
    <tr>
      <td>L2，32 MiB</td>
      <td style="text-align: right">328</td>
      <td style="text-align: right">71.4</td>
      <td style="text-align: right">55.9</td>
      <td style="text-align: right">71.4</td>
      <td style="text-align: right">45.6</td>
      <td style="text-align: right">42.2</td>
      <td style="text-align: right">54.8</td>
      <td style="text-align: right">43.8</td>
    </tr>
    <tr>
      <td>GDDR7，2 GiB</td>
      <td style="text-align: right">319</td>
      <td style="text-align: right">65.1</td>
      <td style="text-align: right">51.7</td>
      <td style="text-align: right">65.1</td>
      <td style="text-align: right">44.4</td>
      <td style="text-align: right">40.8</td>
      <td style="text-align: right">49.3</td>
      <td style="text-align: right">51.5</td>
    </tr>
    <tr>
      <td>Tensor Core</td>
      <td style="text-align: right">449</td>
      <td style="text-align: right">93.0</td>
      <td style="text-align: right">62.9</td>
      <td style="text-align: right">93.0</td>
      <td style="text-align: right">51.0</td>
      <td style="text-align: right">45.3</td>
      <td style="text-align: right">59.5</td>
      <td style="text-align: right">51.5</td>
    </tr>
  </tbody>
</table>

<p>简单观察这些 CH 传感器的变化，可以得到如下结果：</p>

<ol>
  <li>Hotspot 是各通道最大值，而计算负载几乎总会使 CH0 成为最大值（远超平均温度，甚至超过 100 度）。</li>
  <li>几乎总是有 CH0 &gt; CH1 &gt; CH3 &gt; CH2。</li>
  <li>读到的 “平均温度”并不是四个通道的简单平均，但是与功耗的相关性更高，可能是某种加权平均。</li>
</ol>

<p>接着进行了一些脉冲测试，也就是启动较短时间的 kernel，观察温度变化，结果如下：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">脉冲长度</th>
      <th style="text-align: right">Hotspot 增量 C</th>
      <th style="text-align: right">硬件平均增量 C</th>
      <th style="text-align: right">Hotspot 峰值时间中位数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">50 ms</td>
      <td style="text-align: right">13.9</td>
      <td style="text-align: right">8.6</td>
      <td style="text-align: right">27 ms</td>
    </tr>
    <tr>
      <td style="text-align: right">100 ms</td>
      <td style="text-align: right">25.5</td>
      <td style="text-align: right">13.5</td>
      <td style="text-align: right">90 ms</td>
    </tr>
    <tr>
      <td style="text-align: right">200 ms</td>
      <td style="text-align: right">36.0</td>
      <td style="text-align: right">18.3</td>
      <td style="text-align: right">197 ms</td>
    </tr>
    <tr>
      <td style="text-align: right">500 ms</td>
      <td style="text-align: right">50.0</td>
      <td style="text-align: right">24.7</td>
      <td style="text-align: right">508 ms</td>
    </tr>
    <tr>
      <td style="text-align: right">1 s</td>
      <td style="text-align: right">59.8</td>
      <td style="text-align: right">29.5</td>
      <td style="text-align: right">981 ms</td>
    </tr>
    <tr>
      <td style="text-align: right">2 s</td>
      <td style="text-align: right">64.9</td>
      <td style="text-align: right">30.4</td>
      <td style="text-align: right">1.994 s</td>
    </tr>
    <tr>
      <td style="text-align: right">5 s</td>
      <td style="text-align: right">75.7</td>
      <td style="text-align: right">33.8</td>
      <td style="text-align: right">4.916 s</td>
    </tr>
    <tr>
      <td style="text-align: right">10 s</td>
      <td style="text-align: right">74.6</td>
      <td style="text-align: right">31.2</td>
      <td style="text-align: right">5.032 s</td>
    </tr>
  </tbody>
</table>

<p>看起来 Hotspot 的测量非常灵敏。在 kernel 结束后，其下降也非常快。通过更多的测试，可以确定这些传感器的更新频率在 50Hz 左右。</p>

<p>接下来，在单个固定的 SM 上，测试了 32-1024 个线程、1-6 个 256 线程 block，以及 32x8、64x4、128x2 等 warp 总数相同的配置。单 warp 场景消耗 99.6 W；其他所有场景约为 100.6-102.5 W。除第一个、温度最低的冷态实验以外，每种配置扣除基线后的 CH0 增量都是 8.89-9.04 C。其他通道同样几乎不变。也就是说，空闲功耗以上约 80 W 的大部分增量都来自使整个设备进入高性能时钟/功耗状态。只改变 170 个 SM 中一个 SM 的工作量，影响太小，几乎无法通过温度传感器区分。</p>

<p>注：此处的“单个 SM”并不是说我们能控制 CUDA 程序运行在某个物理 SM 上。事实上，我们总是可以占满 GPU，并通过读到的 SM ID 决定来哪些 SM 实际运行负载，让剩下的 SM 处于空闲状态。下面的实验均使用了类似的方法：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>smid = read_special_register("%smid");
if (!active_sms[smid])
    return;
selected = atomicAdd(&amp;per_sm_counter[smid], 1) &lt; 4;
if (!selected)
    return;
while (globaltimer &lt; deadline) {
    // 大量寄存器内 FP32 FMA
}
</code></pre></div></div>

<p>也就是说，最先抵达这个 SM 的四个 block（每个 256 线程，已经能足够打满 SM 的功耗）会一直执行，直到全局计时器到达截止时间。</p>

<h2 id="实验温度与降频">实验：温度与降频</h2>

<p>可以确定的是，CH0 的温度参与了 GPU 对温度降频（thermal throttling）的决策。在一个持续 90 s、使用 170 个 block 的 FP32 负载中，第一次报告硬件温度降频事件是在 kernel 启动 6.50 s 后可观察到：</p>

<ul>
  <li>隐藏 hotspot：99.94 C</li>
  <li>硬件平均值：59.69 C</li>
  <li>驱动 GPU 温度：59 C</li>
  <li>板卡功耗：446 W</li>
  <li>图形时钟：2775 MHz</li>
</ul>

<p>此时驱动温度还比较低，只有 60 度，但 GPU 已经开始报告温度降频事件发生。随后过一段时间，hotspot 保持在约 104.5 C，而负载的三个三等分区间变化如下：</p>

<table>
  <thead>
    <tr>
      <th>区间</th>
      <th style="text-align: right">Hotspot 平均 C</th>
      <th style="text-align: right">硬件平均 C</th>
      <th style="text-align: right">功耗 W</th>
      <th style="text-align: right">图形时钟 MHz</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>前 30 s</td>
      <td style="text-align: right">103.3</td>
      <td style="text-align: right">61.2</td>
      <td style="text-align: right">421.6</td>
      <td style="text-align: right">2719</td>
    </tr>
    <tr>
      <td>中间 30 s</td>
      <td style="text-align: right">104.6</td>
      <td style="text-align: right">63.0</td>
      <td style="text-align: right">399.0</td>
      <td style="text-align: right">2597</td>
    </tr>
    <tr>
      <td>后 30 s</td>
      <td style="text-align: right">104.6</td>
      <td style="text-align: right">63.5</td>
      <td style="text-align: right">391.6</td>
      <td style="text-align: right">2570</td>
    </tr>
  </tbody>
</table>

<p>可以推测，当 hotspot 超过 100 度后，就可能触发降频保护机制。此时 GPU 整体（或者说外界可见）温度和功耗都远远没有达到极限。</p>

<p>我还尝试了基于 hotspot 温度的简单计算控制，也就是在温度即将到达阈值时暂停计算，来试图避免降频，获得更高的计算性能。然而由于同步和调度等开销，这种“占空比”的控制方法并没有获得太好的效果。</p>

<h2 id="实验温度与-sm-映射">实验：温度与 SM 映射</h2>

<p>由于单活动 SM 的实验没有什么效果（背景温升盖过了单个 SM），我选择用一组 42 个 SM 来测试（这个数字是随便选的，大致是 170 个 SM / 4 个传感器），每次只在一组上施加负载。除了连续的四组 SM 之外，还有一个实验，是每隔四个 SM 选一个，同样有 42 个。结果如下：</p>

<table>
  <thead>
    <tr>
      <th>SM ID 集合</th>
      <th style="text-align: right">稳态功耗 W</th>
      <th style="text-align: right">CH0 温升 C</th>
      <th style="text-align: right">CH1 温升 C</th>
      <th style="text-align: right">CH2 温升 C</th>
      <th style="text-align: right">CH3 温升 C</th>
      <th style="text-align: right">平均温升 C</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>q0，0-41</td>
      <td style="text-align: right">188.24</td>
      <td style="text-align: right">50.80</td>
      <td style="text-align: right">13.75</td>
      <td style="text-align: right">11.76</td>
      <td style="text-align: right">12.88</td>
      <td style="text-align: right">18.98</td>
    </tr>
    <tr>
      <td>q1，42-83</td>
      <td style="text-align: right">191.15</td>
      <td style="text-align: right">33.95</td>
      <td style="text-align: right">12.23</td>
      <td style="text-align: right">9.38</td>
      <td style="text-align: right">19.86</td>
      <td style="text-align: right">19.72</td>
    </tr>
    <tr>
      <td>q2，84-125</td>
      <td style="text-align: right">191.34</td>
      <td style="text-align: right">44.83</td>
      <td style="text-align: right">12.96</td>
      <td style="text-align: right">9.57</td>
      <td style="text-align: right">14.21</td>
      <td style="text-align: right">18.86</td>
    </tr>
    <tr>
      <td>q3，126-167</td>
      <td style="text-align: right">189.45</td>
      <td style="text-align: right">29.76</td>
      <td style="text-align: right">11.15</td>
      <td style="text-align: right">8.94</td>
      <td style="text-align: right">23.19</td>
      <td style="text-align: right">19.59</td>
    </tr>
    <tr>
      <td>条纹，0,4,…,164</td>
      <td style="text-align: right">189.92</td>
      <td style="text-align: right">36.60</td>
      <td style="text-align: right">13.40</td>
      <td style="text-align: right">9.23</td>
      <td style="text-align: right">18.41</td>
      <td style="text-align: right">18.24</td>
    </tr>
  </tbody>
</table>

<p>很明显，不同的活跃 SM 集合对不同的传感器影响不同。因此我猜测这些传感器在物理上的分布并不一致。</p>

<h2 id="实验温度传感器对少量-sm-负载的响应">实验：温度传感器对少量 SM 负载的响应</h2>

<p>我让 Codex 用不同的组大小进行了充分的扫描，发现在组大小为 1 或者 2 的时候，对传感器的影响最为显著。</p>

<center>
<img alt="sensor-response-to-sm-group" src="/assets/static/img/blackwell-temp/sm_tomography_profile.png" width="800" />
</center>

<p>需要注意的是，这张图里的线代表了“传感器的温度变化减去基线温度变化”的值，也就是说，纵轴的零点代表了某个传感器在负载开始前的温度均值。从图中可以看到，CH0 对某几个 SM 的响应非常明显，CH3 也有一些点比较明显，并且这些点的编号似乎有某种周期性。这些信号都增强了我的猜测，即这些温升的变化，和传感器与 SM 的物理距离有关系。</p>

<p>然而，想要直接弄明白传感器的位置，还需要先知道 SM 的物理位置，这还需要更多的实验。</p>

<h2 id="实验sm-id-到-gpctpc-的映射">实验：SM ID 到 GPC/TPC 的映射</h2>

<p>根据如下图中 <a href="https://x.com/Kurnalsalts/status/1883153126011892140/photo/1">5090 的 die shot</a>，它由 12 个 GPC 组成，每个 GPC 上有 8 个 TPC，每个 TPC 上又有两个 SM。虽然总共有 192 个 SM，但由于良率或者其他原因，实际只启用了 170 个。<a href="https://www.techpowerup.com/review/nvidia-geforce-rtx-5090-founders-edition/2.html">根据分析</a>，其中有 3 个 GPC 只启用了 7 个 TPC，有 8 个 GPC 启用了全部的 8 个 TPC，还有一个 GPC 完全被禁用。</p>

<center>
<img alt="rtx-5090-dieshot" src="/assets/static/img/blackwell-temp/5090_dieshot.jpg" width="400" />
</center>

<p>因此，第一步是把 SM ID 映射到 GPC 中，才能对应到 die shot。这一步并用不到温度传感器。</p>

<p>CUDA 编程模型保证，thread-block cluster 中所有 block 会同时调度在同一个 GPC。因此只要让 cluster 内每个 block 读取 <code class="language-plaintext highlighter-rouge">%smid</code>，就可以把这些对应的 SM ID 归类到同一个 GPC 中。经过充分的数据获取和处理处理（一些简单的图上连通分量分析），可以获得 11 个逻辑 GPC 对应的 SM ID 集合：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">逻辑 GPC</th>
      <th style="text-align: right">TPC 数</th>
      <th style="text-align: right">SM 数</th>
      <th>每个 TPC 的 SM ID 对</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">0</td>
      <td style="text-align: right">7</td>
      <td style="text-align: right">14</td>
      <td>0/1, 22/23, 44/45, 66/67, 88/89, 110/111, 132/133</td>
    </tr>
    <tr>
      <td style="text-align: right">1</td>
      <td style="text-align: right">7</td>
      <td style="text-align: right">14</td>
      <td>2/3, 24/25, 46/47, 68/69, 90/91, 112/113, 134/135</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td style="text-align: right">7</td>
      <td style="text-align: right">14</td>
      <td>4/5, 26/27, 48/49, 70/71, 92/93, 114/115, 136/137</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>6/7, 28/29, 50/51, 72/73, 94/95, 116/117, 138/139, 154/155</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>8/9, 30/31, 52/53, 74/75, 96/97, 118/119, 140/141, 156/157</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>10/11, 32/33, 54/55, 76/77, 98/99, 120/121, 142/143, 158/159</td>
    </tr>
    <tr>
      <td style="text-align: right">6</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>12/13, 34/35, 56/57, 78/79, 100/101, 122/123, 144/145, 160/161</td>
    </tr>
    <tr>
      <td style="text-align: right">7</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>14/15, 36/37, 58/59, 80/81, 102/103, 124/125, 146/147, 162/163</td>
    </tr>
    <tr>
      <td style="text-align: right">8</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>16/17, 38/39, 60/61, 82/83, 104/105, 126/127, 148/149, 164/165</td>
    </tr>
    <tr>
      <td style="text-align: right">9</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>18/19, 40/41, 62/63, 84/85, 106/107, 128/129, 150/151, 166/167</td>
    </tr>
    <tr>
      <td style="text-align: right">10</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">16</td>
      <td>20/21, 42/43, 64/65, 86/87, 108/109, 130/131, 152/153, 168/169</td>
    </tr>
  </tbody>
</table>

<p>之所以称为“逻辑 GPC”，是因为我们依旧不知道它的物理位置，只是赋予了一个唯一编号（按照 SM ID 排列）。这与上面的 die shot、以及温度传感器对 SM 的响应分析能互相验证：编号相邻的两个 SM 对温度的影响几乎是相同的，因此应当确实属于同一个 TPC。</p>

<p>我们将每个 TPC 中的两个 SM 视作一个整体，用上述的逻辑 GPC、TPC 编号，绘制所有温度传感器对不同 SM 的响应图示：</p>

<center>
<img alt="die-layout" src="/assets/static/img/blackwell-temp/gpc_tpc_channel_map.png" width="800" />
</center>

<p>显然，从这张图中，我们已经能获得很多有用的信息，甚至已经可以进行一些基于人工智能（指人脑）的重新排布。但为了让结论更有说服力，我们还需要进行一些定量的分析。</p>

<h2 id="分析gpctpcsm-id-到物理位置的映射">分析：GPC/TPC/SM ID 到物理位置的映射</h2>

<p>现在就剩下了最后的两个问题：</p>

<ol>
  <li>8 个 TPC 在 GPC 中的物理排布是怎么样的？</li>
  <li>12 个 GPC 在整个 die 上的物理排布是怎么样的？</li>
</ol>

<p>为了解决这个问题，我们可以先进行一些符合直觉的假设来缩小搜索空间：</p>

<ol>
  <li>GPC 的编号应当在物理上连续；不同的 GPC 中，TPC 的排布方式应当相同；</li>
  <li>TPC 在 GPC 中的排布应该使得 SM 编号遵循一定的顺序：如从左到右、从上到下递增，相邻两个编号连续或者有固定的 stride。注：规定一种排列方向会消除很多种可能的镜像排布，我们将它们视作相同的方案。</li>
  <li>由物理定律可知：离热源越远的地方，温升越小，并且会形成连续的温度梯度。</li>
</ol>

<p>我们不妨假设温度传感器就位于响应最高的 SM 附近（ID=96，GPC=4，TPC=4）。接着，尝试枚举所有遵循上述编号方式约束的可能 TPC、GPC 排布方式，并对以下的指标进行优化：</p>

<ul>
  <li>每个热点附近的温度梯度，也就是温升与物理距离（曼哈顿距离，单位为 SM 单位格）的相关性（Spearman 相关系数）</li>
  <li>热点附近其他 SM 诱发的 1 秒内的温升比例（温度升高值与稳态温度的比例）与物理距离的相关性。</li>
</ul>

<p>最终，可以获得如下的“镜像列主序”的排列方案，满足所有约束，并且在上述指标上表现良好：</p>

<center>
<img alt="die-layout" src="/assets/static/img/blackwell-temp/die_layout_channel_map.png" width="1000" />
</center>

<p>也就是说，12 个 GPC 很可能是从左到右、从上到下依次编号。每个 GPC 中的八个 TPC 编号方式可能是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TPC4  TPC0
TPC5  TPC1
TPC6  TPC2
TPC7  TPC3
</code></pre></div></div>

<p>如上所述，还有几种全局镜像的排布方式，我们视同等价。从这张按位置映射重绘的热力图上，我们能获得以下的观察：</p>

<ul>
  <li>CH0、CH2、CH3 都有比较直观的温度梯度，因此我们的排布应当是基本合理的。</li>
  <li>图上的几个标记是我推测的传感器可能的位置（CH1 无法判断）。然而这几个传感器的响应绝对幅度并不相同，因此一定还有其他的差异（比如垂直距离区别）。</li>
  <li>每个传感器对 SM0/1 对应的 TPC 都有明显高于其所在位置的响应，这意味着 SM0/1 对于这四个传感器都有更高的升温作用。目前我对此尚没有合理的解释。</li>
</ul>

<p>事实上，还有一种更符合温度梯度的方案，是 <code class="language-plaintext highlighter-rouge">[4,0] [5,1] [6,2] [3,7]</code>；但是这样的编号实在太扭曲，再考虑到温度测量可能带有不确定的噪音，我最终舍弃了这种方案。</p>

<h2 id="总结">总结</h2>

<p>在 GPT-5.6 的帮助下，我（基本）成功从高精度的温度传感器读数中，推测出了 5090 的一种可能的物理 SM 排布方式。尽管这些信息或许可以从其他渠道简单地获得，也很可能还有错误，对日常使用也没有太多的帮助，但依旧是个非常有趣的过程。</p>

<h2 id="附录">附录</h2>

<p>实验过程中还进行了一些可能有用的测试，摘录如下。</p>

<table>
  <thead>
    <tr>
      <th>负载</th>
      <th style="text-align: right">稳态功耗 W</th>
      <th style="text-align: right">Hotspot 峰值 C</th>
      <th style="text-align: right">硬件温度事件占比</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>FP32 FMA</td>
      <td style="text-align: right">452</td>
      <td style="text-align: right">107.6</td>
      <td style="text-align: right">1.7%</td>
    </tr>
    <tr>
      <td>INT32 MAD</td>
      <td style="text-align: right">431</td>
      <td style="text-align: right">109.2</td>
      <td style="text-align: right">31.1%</td>
    </tr>
    <tr>
      <td>FP64 FMA</td>
      <td style="text-align: right">167</td>
      <td style="text-align: right">55.8</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>SFU sine</td>
      <td style="text-align: right">323</td>
      <td style="text-align: right">90.2</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>共享内存</td>
      <td style="text-align: right">363</td>
      <td style="text-align: right">97.4</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>全局原子操作</td>
      <td style="text-align: right">162</td>
      <td style="text-align: right">51.5</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>L2 读写</td>
      <td style="text-align: right">324</td>
      <td style="text-align: right">71.5</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>GDDR7 读写</td>
      <td style="text-align: right">318</td>
      <td style="text-align: right">65.4</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>FP16 Tensor GEMM</td>
      <td style="text-align: right">446</td>
      <td style="text-align: right">94.0</td>
      <td style="text-align: right">0%</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="NVIDIA" /><category term="GPU" /><summary type="html"><![CDATA[七月初，超频爱好者们又给老黄带来了新的惊喜：RTX 5090 的若干非公开的温度传感器的 PCIe BAR 地址被逆向了出来： [1][2][3]。这些传感器包含了四个热点（hotspot）CH0 到 CH3，以及每一片 GDDR7 显存模块的温度（根据型号不同，可能有 8 或者 16 个传感器）。这些热点传感器比公开 API（比如 nvidia-smi 或者 NVML）能读到的信息更丰富，更接近芯片的实际温度（比如结温），刷新率也更高，自然能给超频玩家更多的玩法。但我并不超频，它还有什么用呢？有的，这两天，在 GPT-5.6 Sol 的帮助下，我还是做了一些有趣的探究。]]></summary></entry><entry><title type="html">6000 端口占用导致 SLURM 卡死诊断一例</title><link href="https://harrychen.xyz/2026/06/10/slurm-hung-diagnosis/" rel="alternate" type="text/html" title="6000 端口占用导致 SLURM 卡死诊断一例" /><published>2026-06-10T07:15:00+08:00</published><updated>2026-06-10T07:15:00+08:00</updated><id>https://harrychen.xyz/2026/06/10/slurm-hung-diagnosis</id><content type="html" xml:base="https://harrychen.xyz/2026/06/10/slurm-hung-diagnosis/"><![CDATA[<p>今天在组里的某个 GPU 集群上部署了一套 SLURM，方便多人使用。我本以为只是从已有集群复制配置、启动服务，十分钟之内就能搞定，没想到因为一个玄学的问题整整卡了一个多小时。</p>

<p>问题现象：各个节点上的 <code class="language-plaintext highlighter-rouge">slurmctld</code>、<code class="language-plaintext highlighter-rouge">slurmd</code>、<code class="language-plaintext highlighter-rouge">slurmdbd</code> 都能正常启动，然而只要试图提交任务，机器上的 <code class="language-plaintext highlighter-rouge">slurmd</code> 和派生的 <code class="language-plaintext highlighter-rouge">slurmstepd</code> 就会卡死（任务并没有开始运行），不再有任何响应（<code class="language-plaintext highlighter-rouge">slurmctld</code> 也无法联系它），只能靠 SIGKILL 杀死后重新启动。各个进程的日志中都看不出什么异常；能看到的只有超过一定时间后，<code class="language-plaintext highlighter-rouge">slurmctld</code> 认为 <code class="language-plaintext highlighter-rouge">slurmd</code> 超时没有响应而产生的抱怨。</p>

<p>考虑到我也在 SLURM 上踩过各种各样的坑，我花了半个多小时，排除了以下问题：</p>

<ul>
  <li>文件系统问题：就算没有共享文件系统，<code class="language-plaintext highlighter-rouge">whoami</code> 之类的命令还是会工作的。并且我们的 NFS 看起来非常正常。</li>
  <li>hostname 问题：如果机器名称和 IP 的解析/反向解析有误，会导致各个 daemon 之间通信异常：机器上都有正确的 hosts，并且我额外还配置了 <code class="language-plaintext highlighter-rouge">NodeAddr</code>。而且只要不提交任务，其他的一切都正常。</li>
  <li><code class="language-plaintext highlighter-rouge">slurmdbd</code> / 数据库问题：注释掉相应部分，故障依旧。</li>
  <li>cgroups 问题：都 6202 年了，不可能还有 cgroups v1 的问题了；并且关闭相应部分，故障依旧。</li>
</ul>

<p>并且最关键的一点是，相同的配置文件，在同样的系统、硬件上（我也是从这里复制的），是完全能工作的。那到底是怎么回事呢？</p>

<p>在 Claude 的提示下，我用 <code class="language-plaintext highlighter-rouge">gdb</code> 对卡死的 <code class="language-plaintext highlighter-rouge">slurmstepd</code> 进程打印了调用栈，结果还真有惊奇的发现：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ gdb -p $(pgrep slurmstepd) -batch \
  -ex "set pagination 0" \
  -ex "thread apply all bt"

Thread 1 (Thread 0x151e80a3fc00 (LWP 261104) "slurmstepd"):
#0  0x0000151e806a59ee in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#1  0x0000151e8069a668 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#2  0x0000151e8069a6ad in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#3  0x0000151e8070e9c6 in poll () from /lib/x86_64-linux-gnu/libc.so.6
#4  0x0000151e7fa7928b in ?? () from /lib/x86_64-linux-gnu/libxcb.so.1
#5  0x0000151e7fa76b8e in xcb_connect_to_fd () from /lib/x86_64-linux-gnu/libxcb.so.1
#6  0x0000151e7fa7b279 in xcb_connect_to_display_with_auth_info () from /lib/x86_64-linux-gnu/libxcb.so.1
#7  0x0000151e7eaf20c2 in _XConnectXCB () from /lib/x86_64-linux-gnu/libX11.so.6
#8  0x0000151e7eae1b45 in XOpenDisplay () from /lib/x86_64-linux-gnu/libX11.so.6
#9  0x0000151e8036437b in ?? () from /usr/lib/x86_64-linux-gnu/hwloc/hwloc_gl.so
#10 0x0000151e80bba58e in ?? () from /lib/x86_64-linux-gnu/libhwloc.so.15
#11 0x0000151e80bc1c31 in hwloc_topology_load () from /lib/x86_64-linux-gnu/libhwloc.so.15
#12 0x0000151e80379047 in pmixp_libpmix_job_set () from /usr/lib/x86_64-linux-gnu/slurm-wlm/mpi_pmix_v5.so
#13 0x0000151e8037d3d9 in pmixp_stepd_init () from /usr/lib/x86_64-linux-gnu/slurm-wlm/mpi_pmix_v5.so
#14 0x0000151e80375dbb in mpi_p_slurmstepd_prefork () from /usr/lib/x86_64-linux-gnu/slurm-wlm/mpi_pmix_v5.so
#15 0x0000561857a128df in job_manager ()
#16 0x0000561857a06eb9 in main ()
</code></pre></div></div>

<p>这 <code class="language-plaintext highlighter-rouge">poll</code> 是在干啥？为啥会有 <code class="language-plaintext highlighter-rouge">xcb_connect_to_fd</code> 这种奇怪的函数？我又不是在图形界面上运行 SLURM，怎么会有 X11 的调用？</p>

<p>又经过一番调研，原来是 SLURM 的任务管理插件 PMIx 会使用 <code class="language-plaintext highlighter-rouge">libhwloc</code> 来探测硬件相关信息，而其中一个插件是 <code class="language-plaintext highlighter-rouge">hwloc_gl</code>，它会尝试连接 X11 来获取 GPU 的信息。在我的机器上，这个连接不知道为什么就卡住了，陷入了无限的等待（<code class="language-plaintext highlighter-rouge">poll</code>）。在发现是 <code class="language-plaintext highlighter-rouge">hwloc</code> 问题后，我意识到完全不需要 SLURM 这么复杂，直接使用 <code class="language-plaintext highlighter-rouge">lstopo</code> 工具就能复现问题。</p>

<p><code class="language-plaintext highlighter-rouge">hwloc</code> 的文档说，用 <code class="language-plaintext highlighter-rouge">HWLOC_COMPONENTS=-gl</code> 环境变量可以禁用掉这个插件。果然，加上之后 <code class="language-plaintext highlighter-rouge">lstopo</code> 就能工作了，而将它写入 <code class="language-plaintext highlighter-rouge">/etc/default/slurmd</code> 之后，重启 <code class="language-plaintext highlighter-rouge">slurmd</code>，任务就可以正常提交了。</p>

<p>但是，巧夺麻袋，这确实能解决问题，然而我还是没搞明白，为什么别的机器上没有这个问题？我给 <code class="language-plaintext highlighter-rouge">lstopo</code> 挂上了 strace，发现它做了如下的尝试：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[pid 266805] connect(3&lt;UNIX-STREAM:[264153037]&gt;, {sa_family=AF_UNIX, sun_path=@"/tmp/.X11-unix/X0"}, 20) = -1 ECONNREFUSED (Connection refused)
[pid 266805] connect(3&lt;UNIX-STREAM:[264153038]&gt;, {sa_family=AF_UNIX, sun_path="/tmp/.X11-unix/X0"}, 110) = -1 ENOENT (No such file or directory)
[pid 266805] connect(3&lt;UNIX-STREAM:[264153039]&gt;, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = 0
[pid 266805] poll([{fd=3&lt;UNIX-STREAM:[264153039-&gt;264130315]&gt;, events=POLLIN|POLLERR|POLLHUP}], 1, 5000) = 1 ([{fd=3, revents=POLLIN|POLLHUP}])
[pid 266805] connect(3&lt;UDP:[264202942]&gt;, {sa_family=AF_INET, sin_port=htons(6000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
[pid 266805] connect(3&lt;UDPv6:[264202943]&gt;, {sa_family=AF_INET6, sin6_port=htons(6000), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::1", &amp;sin6_addr), sin6_scope_id=0}, 28) = 0
[pid 266805] connect(3&lt;TCPv6:[264202944]&gt;, {sa_family=AF_INET6, sin6_port=htons(6000), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::1", &amp;sin6_addr), sin6_scope_id=0}, 28) = 0
[pid 266805] poll([{fd=3&lt;TCPv6:[[::1]:39034-&gt;[::1]:6000]&gt;, events=POLLIN|POLLOUT}], 1, -1) = 1 ([{fd=3, revents=POLLOUT}])
[pid 266805] poll([{fd=3&lt;TCPv6:[[::1]:39034-&gt;[::1]:6000]&gt;, events=POLLIN}], 1, -1 &lt;unfinished ...&gt;
[pid 266805] &lt;... poll resumed&gt;)        = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
</code></pre></div></div>

<p>看起来是非常标准的流程：先尝试连接 abstract socket，再尝试普通 socket，最后才是 TCP 连接。前两者都失败了，可在一个 headless 服务器上，6000 端口上怎么会有程序在监听？我到处 <code class="language-plaintext highlighter-rouge">ss</code> 了一番，发现居然是组里其他同学在上面跑的 Megatron-LM 训练脚本把它作为 <code class="language-plaintext highlighter-rouge">MASTER_PORT</code> 了。于是，整个流程就非常明确了（这一段由 Claude 生成）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Megatron 用 MASTER_PORT=6000 起 TCPStore，rank0 监听 [::]:6000
        ↓
这台是无头节点，没有 /tmp/.X11-unix/X0
        ↓
slurmstepd 起新 PMIx 任务 → PMIx 调 hwloc_topology_load → hwloc_gl 探测 :0
        ↓
:0 的 Unix socket 不存在 → xcb fallback 到 TCP localhost:6000
        ↓
connect([::1]:6000) 成功 —— 连进了 TCPStore！
        ↓
xcb 发 12 字节 X11 握手，TCPStore 看不懂 X 协议、不回应
        ↓
hwloc 永久卡在 poll(POLLIN)

</code></pre></div></div>

<p>这下终于明白了！在对相关同学进行殴打之后，我了解到这个端口来自 Megatron-LM 的样例脚本。在相关仓库中<a href="https://github.com/search?q=repo%3ANVIDIA%2FMegatron-LM%206000&amp;type=code">搜索 6000</a>，能得到非常惊人的结果：几乎所有脚本都默认使用 6000 作为 <code class="language-plaintext highlighter-rouge">MASTER_PORT</code>；如果把搜索范围扩大到整个 GitHub，能看到无数的脚本已经被传染了。老黄，真有你的。我去给 Megatron <a href="https://github.com/NVIDIA/Megatron-LM/issues/5232">开了个 issue #5232</a>，建议他们还是换个默认端口。</p>

<p>那为什么在其他机器上没有这个问题呢？因为：</p>

<ol>
  <li>我并没有在集群被已有作业占用的时候部署 SLURM，也没有在这个情况下测试提交。</li>
  <li>同学往现有的 SLURM 集群提交使用 6000 端口的任务时，不会影响到 <code class="language-plaintext highlighter-rouge">slurmstepd</code> 的启动流程。</li>
  <li>我们通常占用全机训练，所以也不会出现这个端口正在被占用时，<code class="language-plaintext highlighter-rouge">slurmstepd</code> 又开始启动的情况。</li>
</ol>

<p>更新：在我写完博客后，@taoky 去研读了一下 hwloc 的代码，来搞明白为什么它在没有 <code class="language-plaintext highlighter-rouge">DISPLAY</code> 环境变量的情况下，还要尝试打开到 X11 的连接。<a href="https://github.com/open-mpi/hwloc/blob/9f5b8c6a87f049d578560676dcbf068a4f580f70/hwloc/topology-gl.c#L45-L53">结果</a>令人昏厥，简要逻辑摘要如下：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">HWLOC_GL_SERVER_MAX</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">snprintf</span><span class="p">(</span><span class="n">displayName</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">displayName</span><span class="p">),</span> <span class="s">":%u"</span><span class="p">,</span> <span class="n">i</span><span class="p">);</span>
  <span class="n">display</span> <span class="o">=</span> <span class="n">XOpenDisplay</span><span class="p">(</span><span class="n">displayName</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">display</span><span class="p">)</span>
    <span class="k">continue</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>也就是说它不分青红皂白，直接从 0 开始遍历 10 个可能的显示器，直到找到一个能连接上的为止。那我还能说什么？它和 Megatron 在对这次事故的贡献中，确实是卧龙凤雏、难分伯仲。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="SLURM" /><category term="调试" /><summary type="html"><![CDATA[今天在组里的某个 GPU 集群上部署了一套 SLURM，方便多人使用。我本以为只是从已有集群复制配置、启动服务，十分钟之内就能搞定，没想到因为一个玄学的问题整整卡了一个多小时。]]></summary></entry><entry><title type="html">在 RTX 5090 上启用 GPUDirect RDMA 通信支持</title><link href="https://harrychen.xyz/2026/05/20/enable-gpudirect-rdma-on-rtx-5090/" rel="alternate" type="text/html" title="在 RTX 5090 上启用 GPUDirect RDMA 通信支持" /><published>2026-05-20T01:00:00+08:00</published><updated>2026-05-20T01:00:00+08:00</updated><id>https://harrychen.xyz/2026/05/20/enable-gpudirect-rdma-on-rtx-5090</id><content type="html" xml:base="https://harrychen.xyz/2026/05/20/enable-gpudirect-rdma-on-rtx-5090/"><![CDATA[<p class="warning"><strong>免责声明：</strong>
本文涉及对 GPU 驱动的修改，作者不对任何因此产生的后果负责。</p>

<p>两个月前，我通过安装打补丁的驱动，成功为 RTX 5090 <a href="/2026/03/22/enable-pcie-p2p-on-rtx-5090/">启用了 PCIe P2P 支持</a>。就像我在那篇博客最后说的，另一个美好的愿望是，让 5090 这类消费级 GPU 也支持 GPUDirect RDMA，进一步减少跨机通信需要的 CPU 参与。而今天借助 Codex，我居然把这个愿望实现了。</p>

<h2 id="技术背景">技术背景</h2>

<p>GPUDirect RDMA 的核心思想就是让 GPU 可以和网卡直接通信，绕过 CPU 和系统内存进行数据传输。既然驱动已经提供了 GPU 之间互相通信的能力，那么让网卡用上应该没有本质的困难。NVIDIA 也有专门的文档描述如何使用此功能：<a href="https://docs.nvidia.com/cuda/gpudirect-rdma/">Developing a Linux Kernel Module using GPUDirect RDMA</a>，看起来第三方的硬件也是可以用上这个特性的，更何况是同属 NV 产品线的 IB 网卡。与此对应的是，IB 卡与其他设备直接进行通信的特性称为 <a href="https://docs.nvidia.com/doca/sdk/nvidia-peerdirect/index.html">Peer Direct</a>，GPUDirect RDMA 就是其中很常见的用途；此外 IB 卡也可以与 NVMe SSD、FPGA 等 PCIe 设备进行点对点直接通信，不在本文的讨论范围内。</p>

<p>从 CUDA 11.4 开始，NVIDIA 提供了内核模块 <code class="language-plaintext highlighter-rouge">nvidia-peermem</code>（最早叫作 <code class="language-plaintext highlighter-rouge">nv-peer-mem</code>，甚至不和驱动一起分发）来提供 GPUDirect RDMA 的支持。它的原理是，当构建 NVIDIA DKMS 模块时，如果系统中已经安装了 OFED 驱动，则 <code class="language-plaintext highlighter-rouge">peermem</code> 模块就能接入 RDMA 内核栈，让网卡驱动把 GPU 内存注册成 RDMA 可访问的内存区域。这里就产生了一些隐式的依赖，也给我这个运维带来了不少麻烦（如果安装的顺序不正确，或者中途升级了 OFED，那么就可能导致 <code class="language-plaintext highlighter-rouge">peermem</code> 模块无法加载，GPUDirect 功能静默失效）。</p>

<p>Linux 对于此类能将自己的内存共享给其他设备的外设，其实有自己的解决方案：<a href="https://docs.kernel.org/driver-api/dma-buf.html"><code class="language-plaintext highlighter-rouge">dma-buf</code></a> 子系统。根据 2020 年提出的 <a href="https://www.openfabrics.org/wp-content/uploads/2020-workshop-presentations/303.-OFI-GPU-DMA-BUF-OFA2020v2.pdf">RDMA WITH GPU MEMORY VIA DMA-BUF</a> 的设想，和 <a href="https://medium.com/@datenlord/the-evolution-and-implementation-of-gpudirect-rdma-19751f7b9413">The Evolution and Implementation of GPUDirect RDMA</a> 中的时间轴，2021 年内核的 RDMA 子系统以及 Mellanox 设备驱动都增加了对 dma-buf 的支持。从此不同的设备驱动不再需要互相耦合，而可以通过统一的接口进行注册、映射地址，最终发起 IO。近年来的 NVIDIA 开源驱动（主要是 12.8 以后）在较新的内核上，已经支持通过 dma-buf 来映射 GPU 显存，供 GPUDirect RDMA 通信使用。此外，目前 Intel 显卡（i915 / xe 模块）和 AMD 显卡（amdgpu 模块）都也已经支持了 dma-buf。</p>

<p>由于 dma-buf 子系统中传递的都是 buffer 的 fd，CUDA API 中提供了 <a href="https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__MEM.html#group__CUDA__MEM_1g51e719462c04ee90a6b0f8b2a75fe031"><code class="language-plaintext highlighter-rouge">cuMemGetHandleForAddressRange</code></a> 来将 GPU 上的内存（指针）转换为可用的文件描述符。然而，即使在启用了 P2P 的 5090 上，调用此 API 也会直接返回 <code class="language-plaintext highlighter-rouge">CUDA_ERROR_NOT_SUPPORTED</code>。此外，使用文档中的 <code class="language-plaintext highlighter-rouge">CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED</code> 对设备进行查询，也会得到 <code class="language-plaintext highlighter-rouge">0</code>。</p>

<p>考虑到和 5090 同源的 RTX Pro 6000 是支持 GPUDirect RDMA 的，我猜想 GPU 的硬件上可能并没有缺失功能（也没有必要做这个限制），而又是老黄的刀法。也就是说，软件链路上（用户态 <code class="language-plaintext highlighter-rouge">libcuda</code> 或者内核态 <code class="language-plaintext highlighter-rouge">nvidia</code> 模块）一定有地方拦住了这个功能。</p>

<h2 id="最终结论tldr">最终结论（TL;DR）</h2>

<p>让 5090 用上 GPUDirect RDMA 的关键不在内核态驱动，而在用户态的 <code class="language-plaintext highlighter-rouge">libcuda.so</code>。在我的环境下，最小可工作的 patch 是复制 <code class="language-plaintext highlighter-rouge">libcuda.so.590.48.01</code>，并修改一个字节：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>file offset: 0x42d69b
old:    0x40
new:    0x60
</code></pre></div></div>

<p>即对应的 X86 反汇编从 <code class="language-plaintext highlighter-rouge">or $0x40,%edx</code> 变成 <code class="language-plaintext highlighter-rouge">or $0x60,%edx</code>。</p>

<p>这会在 libcuda 的私有 device structure 里多设置一个 <code class="language-plaintext highlighter-rouge">0x20</code> bit。这个 bit 会影响：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED</code></li>
  <li><code class="language-plaintext highlighter-rouge">CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED</code></li>
  <li><code class="language-plaintext highlighter-rouge">CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WITH_CUDA_VMM_SUPPORTED</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuMemGetHandleForAddressRange(... DMA_BUF_FD, flags=0)</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuMemCreate(... CU_MEM_CREATE_USAGE_GPU_DIRECT_RDMA ...)</code></li>
</ul>

<p>此后，使用 <code class="language-plaintext highlighter-rouge">LD_LIBRARY_PATH</code> 加载这个修改过的 <code class="language-plaintext highlighter-rouge">libcuda.so</code> 即可。如果使用 NCCL，则可能还需要根据机器拓扑（<code class="language-plaintext highlighter-rouge">nvidia-smi topo -m</code>）对应调整环境变量：</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">NCCL_NET_GDR_LEVEL</span><span class="o">=</span>SYS
</code></pre></div></div>

<p>端到端验证中，24 卡（三台机器，每机一张 400Gbps NDR IB 卡）进行 allreduce 的性能（<code class="language-plaintext highlighter-rouge">busbw</code>），从约 <code class="language-plaintext highlighter-rouge">8.87 GB/s</code> 提升到了 <code class="language-plaintext highlighter-rouge">19.93 GB/s</code>。并且可以从日志中看到如下的信息，证明 NCCL 确实检测到并且在使用 GPUDirect RDMA 功能：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node1:496222:496276 [7] NCCL INFO DMA-BUF is available on GPU device 7
node1:496222:496272 [3] NCCL INFO DMA-BUF is available on GPU device 3
node1:496222:496270 [1] NCCL INFO DMA-BUF is available on GPU device 1
...
node1:496222:496297   [6] NCCL INFO Channel 01/0 : 6[6] -&gt; 14[6] [send] via NET/IB/0/GDRDMA
node2:2686390:2686463 [7] NCCL INFO Channel 01/0 : 15[7] -&gt; 14[6] via P2P/direct pointer
node1:496222:496297   [6] NCCL INFO Channel 01/0 : 14[6] -&gt; 6[6] [receive] via NET/IB/0/GDRDMA
node3:2526722:2526797 [6] NCCL INFO Channel 01/0 : 22[6] -&gt; 7[7] [send] via NET/IB/0/GDRDMA
node1:496222:496297   [6] NCCL INFO Channel 00/0 : 6[6] -&gt; 22[6] [send] via NET/IB/0/GDRDMA
node2:2686390:2686464 [6] NCCL INFO Channel 00/0 : 14[6] -&gt; 23[7] [send] via NET/IB/2/GDRDMA
node3:2526722:2526801 [2] NCCL INFO Channel 00/0 : 18[2] -&gt; 23[7] via P2P/direct pointer
node1:496222:496298   [5] NCCL INFO Channel 00/0 : 5[5] -&gt; 0[0] via P2P/direct pointer
node3:2526722:2526801 [2] NCCL INFO Channel 01/0 : 18[2] -&gt; 23[7] via P2P/direct pointer
node3:2526722:2526798 [5] NCCL INFO Channel 00/0 : 21[5] -&gt; 16[0] via P2P/direct pointer
node3:2526722:2526798 [5] NCCL INFO Channel 01/0 : 21[5] -&gt; 16[0] via P2P/direct pointer
...
node2:2686390:2686476 [3] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686474 [5] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686475 [4] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686473 [6] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node3:2526722:2526813 [0] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node3:2526722:2526812 [1] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686472 [7] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
</code></pre></div></div>

<p>注：此修改只适用于 dma-buf 相关 API，而不能让老的 <code class="language-plaintext highlighter-rouge">nvidia-peermem</code> 模块也工作起来。我并无兴趣对此进行更多探索。</p>

<h2 id="调试过程">调试过程</h2>

<p>下面的内容几乎都是 Codex 生成的，调试也是它独立完成的，大概消耗了一个小时。</p>

<p>我在其中唯一的作用，是在它每次想要 sudo 的时候，检查一下命令内容，并决定是不是批准。过程基于驱动 NVIDIA 590.48.01、CUDA 13.1 进行。</p>

<h3 id="背景为什么一开始看-kernel-driver">背景：为什么一开始看 kernel driver</h3>

<p>RTX 5090 在官方驱动里本来不暴露 CUDA DMA-BUF / GPUDirect RDMA capability。IB
perftest 里如果使用：</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--use_cuda_dmabuf</span>
</code></pre></div></div>

<p>它会走 CUDA driver API 检查 dma-buf 可用性：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cuDeviceGetAttribute</span><span class="p">(</span><span class="o">&amp;</span><span class="n">is_supported</span><span class="p">,</span>
                     <span class="n">CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED</span><span class="p">,</span>
                     <span class="n">cuda_ctx</span><span class="o">-&gt;</span><span class="n">cuDevice</span><span class="p">)</span>
</code></pre></div></div>

<p>把这段检查硬注释掉继续跑，后面仍然失败：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cuMemGetHandleForAddressRange error=801
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">801</code> 是 <code class="language-plaintext highlighter-rouge">CUDA_ERROR_NOT_SUPPORTED</code>。这说明问题不是 perftest 自己的前置检查，而是 CUDA driver API 真的拒绝了 dma-buf export。</p>

<p>当时的初始假设是：open GPU kernel module 里某个 capability 没打开，
导致用户态 CUDA 判断 RTX 5090 不支持 dma-buf。</p>

<h3 id="第一阶段在-kernel-driver-里强行打开-dmabuf-capability">第一阶段：在 kernel driver 里强行打开 DMABUF capability</h3>

<p>先从 kernel module 里能看到的 DMABUF capability 入手。</p>

<p>关键位置包括：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">osinit.c</code></li>
  <li><code class="language-plaintext highlighter-rouge">subdevice_ctrl_gpu_kernel.c</code></li>
  <li><code class="language-plaintext highlighter-rouge">rmapi_cache.c</code></li>
  <li><code class="language-plaintext highlighter-rouge">control.c</code></li>
  <li><code class="language-plaintext highlighter-rouge">nv-dmabuf.c</code></li>
</ul>

<p>做过的实验包括：</p>

<ol>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">osinit.c</code> 里打印并强制：</p>

    <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">nv</span><span class="o">-&gt;</span><span class="n">dma_buf_supported</span> <span class="o">=</span> <span class="n">NV_TRUE</span><span class="p">;</span>
</code></pre></div>    </div>

    <p>dmesg 能看到：</p>

    <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DMABUF osinit: status=0x0 raw_cap=1 dma_buf_supported=1 before force
DMABUF osinit: forced dma_buf_supported=1
</code></pre></div>    </div>
  </li>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">subdevice_ctrl_gpu_kernel.c</code> 里对：</p>

    <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY</span>
</code></pre></div>    </div>

    <p>强制返回：</p>

    <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY_YES</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">rmapi_cache.c</code> 里避免 RM cache 把这个 capability 缓存成 NO。</p>
  </li>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">control.c</code> 里打印 RM control 调用，确认用户态请求和 kernel 返回。</p>
  </li>
  <li>
    <p>在 <code class="language-plaintext highlighter-rouge">nv-dmabuf.c</code> 里打印 <code class="language-plaintext highlighter-rouge">nv_dma_buf_create()</code>，用于确认最终有没有进入
dma-buf export 路径。</p>
  </li>
</ol>

<p>结果：kernel 里看起来已经是 YES，但 CUDA API 仍然报告：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED = 0
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED = 0
cuMemGetHandleForAddressRange(...) = CUDA_ERROR_NOT_SUPPORTED
</code></pre></div></div>

<p>这一步非常关键：它说明 kernel 侧 <code class="language-plaintext highlighter-rouge">NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY</code>
不是唯一 gate，甚至不是最后那个决定 CUDA API 行为的 gate。</p>

<h3 id="第二阶段写-cuda-probe把问题缩小到-libcuda">第二阶段：写 CUDA probe，把问题缩小到 libcuda</h3>

<p>写了一个很小的 CUDA driver API probe，检查这些 attribute：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CU_DEVICE_ATTRIBUTE_POSIX_FILE_DESCRIPTOR_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WITH_CUDA_VMM_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_FLUSH_WRITES_OPTIONS
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WRITES_ORDERING
CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED
CU_DEVICE_ATTRIBUTE_HOST_ALLOC_DMA_BUF_SUPPORTED
</code></pre></div></div>

<p>原始结果大概是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POSIX_FD_SUPPORTED                         = 1
GPU_DIRECT_RDMA_WITH_CUDA_VMM              = 0
GPU_DIRECT_RDMA_SUPPORTED                  = 0
GPU_DIRECT_RDMA_FLUSH_WRITES_OPTIONS       = 1
GPU_DIRECT_RDMA_WRITES_ORDERING            = 100
DMA_BUF_SUPPORTED                          = 0
HOST_ALLOC_DMA_BUF_SUPPORTED               = 0

cuMemGetHandleForAddressRange flags=0      = CUDA_ERROR_NOT_SUPPORTED
</code></pre></div></div>

<p>注意这里 <code class="language-plaintext highlighter-rouge">POSIX_FD_SUPPORTED=1</code>，说明 CUDA VMM 普通 fd export 能力和
dma-buf / GPUDirect RDMA gate 不是同一个东西。</p>

<p>这时继续只改 kernel 没有效果，所以开始反汇编 <code class="language-plaintext highlighter-rouge">libcuda.so.590.48.01</code>。</p>

<h3 id="第三阶段反汇编-cudevicegetattribute">第三阶段：反汇编 cuDeviceGetAttribute</h3>

<p>先找 <code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code>：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public wrapper: 0x30ca40
real target:    0x31bef0
attribute read: 0x25ce00
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code> 内部有一个 attribute jump table。关键分支包括：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>attr 103 POSIX_FD_SUPPORTED                  -&gt; 0x25d676
attr 110 GPU_DIRECT_RDMA_WITH_CUDA_VMM       -&gt; 0x25d6f6
attr 116 GPU_DIRECT_RDMA_SUPPORTED           -&gt; 0x25d604
attr 124 DMA_BUF_SUPPORTED                   -&gt; 0x25cf91
</code></pre></div></div>

<p>其中 <code class="language-plaintext highlighter-rouge">attr 116</code> 的逻辑很直接：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xor    %eax,%eax
testb  $0x20,0x5f14(%rdi)
je     ...
cmpb   $0x0,global_gdr_enabled
setne  %al
</code></pre></div></div>

<p>也就是说，<code class="language-plaintext highlighter-rouge">GPU_DIRECT_RDMA_SUPPORTED</code> 至少要满足：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dev_struct[0x5f14] &amp; 0x20 != 0
global_gdr_enabled != 0
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">attr 124</code> 也会检查类似条件：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>testb $0x8,0x5e80(...)
testb $0x20,0x5f14(...)
cmpb  $0x0,global_gdr_enabled
</code></pre></div></div>

<p>所以问题逐渐变成：RTX 5090 的 libcuda device structure 里，为什么缺了
<code class="language-plaintext highlighter-rouge">0x5f14</code> 的 <code class="language-plaintext highlighter-rouge">0x20</code> bit？</p>

<h3 id="第四阶段直接-poke-libcuda-的私有-device-structure">第四阶段：直接 poke <code class="language-plaintext highlighter-rouge">libcuda</code> 的私有 device structure</h3>

<p>为了验证这个猜想，没有马上 patch 文件，而是写了一个进程内 poke 程序。它通过 <code class="language-plaintext highlighter-rouge">dladdr(cuDeviceGetAttribute)</code> 找到当前进程加载的 libcuda base，然后定位 <code class="language-plaintext highlighter-rouge">libcuda</code> 的 device table：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>device_slot = base + 0x5e32260 + 0x328 + dev * 8
device_struct = *(uintptr_t *)device_slot
</code></pre></div></div>

<p>再临时改三个位置：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dev_struct</span><span class="p">[</span><span class="mh">0x5f14</span><span class="p">]</span> <span class="o">|=</span> <span class="mh">0x20</span><span class="p">;</span>
<span class="n">dev_struct</span><span class="p">[</span><span class="mh">0x5e80</span><span class="p">]</span> <span class="o">|=</span> <span class="mh">0x08</span><span class="p">;</span>
<span class="o">*</span><span class="p">(</span><span class="n">base</span> <span class="o">+</span> <span class="mh">0x5e38650</span><span class="p">)</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<p>这些 magic number 的定位过程是这样的：</p>

<ol>
  <li>先从 <code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code> 反汇编看 attribute 逻辑</li>
</ol>

<p>比如 <code class="language-plaintext highlighter-rouge">CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED</code> 分支里有：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>testb  $0x20,0x5f14(%rdi)
cmpb   $0x0,0x5e38650(...)
</code></pre></div></div>

<p>这说明它在检查某个 device struct 的 <code class="language-plaintext highlighter-rouge">0x5f14</code> 偏移，bit 是 <code class="language-plaintext highlighter-rouge">0x20</code>，还检查一个全局 byte。</p>

<p><code class="language-plaintext highlighter-rouge">DMA_BUF_SUPPORTED</code> 分支里也有：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>testb $0x8,0x5e80(...)
testb $0x20,0x5f14(...)
cmpb  $0x0,0x5e38650(...)
</code></pre></div></div>

<p>所以 <code class="language-plaintext highlighter-rouge">0x5f14</code> bit <code class="language-plaintext highlighter-rouge">0x20</code> 和 <code class="language-plaintext highlighter-rouge">0x5e80</code> bit <code class="language-plaintext highlighter-rouge">0x08</code> 是从 attribute 分支直接读出来的。</p>

<ol>
  <li>device table 是从 API wrapper 里推出来的</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code> 的真实实现里有多处：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lea ... # 0x5e32260
</code></pre></div></div>

<p>然后根据 CUdevice dev ordinal 去索引一个指针表。结合运行时观察，实际是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>device_slot = base + 0x5e32260 + 0x328 + dev * 8;
device_struct = *(uintptr_t *)device_slot;
</code></pre></div></div>

<p>这个不是符号告诉我的，是看它怎么从 dev ordinal 找内部对象，再用 poke 程序验证出来的。</p>

<p>改之前，RTX 5090 上 <code class="language-plaintext highlighter-rouge">dev+0x5f14</code> 是 <code class="language-plaintext highlighter-rouge">0x58</code>，改之后变成 <code class="language-plaintext highlighter-rouge">0x78</code>，测试结果立刻变成：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GPU_DIRECT_RDMA_WITH_CUDA_VMM = 1
GPU_DIRECT_RDMA_SUPPORTED     = 1
DMA_BUF_SUPPORTED             = 1

cuMemGetHandleForAddressRange(flags=0) = CUDA_SUCCESS
</code></pre></div></div>

<p>同时 <code class="language-plaintext highlighter-rouge">dmesg</code> 开始出现：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nv_dma_buf_create: dma_buf_supported=1 ... mappingType=0 ...
</code></pre></div></div>

<p>这一步证明了两件事：</p>

<ol>
  <li>kernel 的 dma-buf export 路径本身能被走到；</li>
  <li>CUDA API 失败的主要原因是 libcuda 用户态私有 flag 没设置。</li>
</ol>

<h3 id="第五阶段做-shim只作为调试工具">第五阶段：做 shim，只作为调试工具</h3>

<p>接着写了一个 <code class="language-plaintext highlighter-rouge">libcuda.so</code> shim，它会加载真实的 <code class="language-plaintext highlighter-rouge">libcuda.so.590.48.01</code>，然后在这些调用前后 patch 私有 device structure：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">cuInit</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuDeviceGet</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuMemGetHandleForAddressRange</code></li>
  <li><code class="language-plaintext highlighter-rouge">cuMemCreate</code></li>
</ul>

<p>shim 能让 probe 成功，也能让 perftest 走到：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Calling ibv_reg_mr_ex with dmabuf(offset=0, size=..., addr=..., fd=...)
</code></pre></div></div>

<p>但是 shim 后来被放弃作为最终方案，原因是：</p>

<ul>
  <li>CUDA runtime 和 NCCL 通过 <code class="language-plaintext highlighter-rouge">cuGetProcAddress()</code> 使用很多 driver API；</li>
  <li>一个不完整的 libcuda forwarding shim 很容易漏函数；</li>
  <li>
    <p>漏函数时 CUDA runtime / NCCL 会报：</p>

    <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>API call is not supported in the installed CUDA driver
</code></pre></div>    </div>
  </li>
</ul>

<p>所以 shim 只适合作为 reverse engineering / probe 工具，不适合作为 NCCL
性能测试方案。</p>

<h3 id="第六阶段寻找真正的初始化代码">第六阶段：寻找真正的初始化代码</h3>

<p>poke 证明了要改 <code class="language-plaintext highlighter-rouge">dev+0x5f14</code> 的 <code class="language-plaintext highlighter-rouge">0x20</code> bit。
下一步是找这个字段在哪里初始化。</p>

<p>在 <code class="language-plaintext highlighter-rouge">libcuda.so.590.48.01</code> 里搜索对 <code class="language-plaintext highlighter-rouge">0x5f14</code> 的引用，最终找到一个非常关键的
device initialization 片段：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x42d690: movzbl 0x5f14(%rdi),%eax
0x42d697: mov    %eax,%edx
0x42d699: or     $0x40,%edx
0x42d69c: cmpl   $0x8,0xc60(%rdi)
0x42d6a3: mov    %dl,0x5f14(%rdi)
</code></pre></div></div>

<p>这段代码的意思是：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">flags</span> <span class="o">=</span> <span class="n">dev</span><span class="o">-&gt;</span><span class="n">field_5f14</span><span class="p">;</span>
<span class="n">flags</span> <span class="o">|=</span> <span class="mh">0x40</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">dev</span><span class="o">-&gt;</span><span class="n">field_c60</span> <span class="o">&lt;=</span> <span class="mi">8</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">dev</span><span class="o">-&gt;</span><span class="n">field_5f14</span> <span class="o">=</span> <span class="n">flags</span><span class="p">;</span>
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>RTX 5090 的这个字段本来会被初始化成含 <code class="language-plaintext highlighter-rouge">0x40</code>，但不含 <code class="language-plaintext highlighter-rouge">0x20</code>。而暴露我们需要的 CUDA attribute 分支需要的是 <code class="language-plaintext highlighter-rouge">0x20</code>。</p>

<p>因此最小 patch 很自然：把 <code class="language-plaintext highlighter-rouge">or $0x40</code> 改成 <code class="language-plaintext highlighter-rouge">or $0x60</code>。也就是保留原来的 <code class="language-plaintext highlighter-rouge">0x40</code>，再额外加上 <code class="language-plaintext highlighter-rouge">0x20</code>。机器码上：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>83 ca 40   -&gt;   83 ca 60
</code></pre></div></div>

<p>只改中间那个 immediate byte：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>file offset 0x42d69b: 0x40 -&gt; 0x60
</code></pre></div></div>

<h3 id="第七阶段用-patched-libcuda-副本验证">第七阶段：用 patched libcuda 副本验证</h3>

<p>没有直接改系统 libcuda，而是复制了一份：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>libcuda-onebyte-patch/libcuda.so.590.48.01
libcuda-onebyte-patch/libcuda.so.1 -&gt; libcuda.so.590.48.01
libcuda-onebyte-patch/libcuda.so   -&gt; libcuda.so.590.48.01
</code></pre></div></div>

<p>使用方式：</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">LD_LIBRARY_PATH</span><span class="o">=</span>/path/to/libcuda-onebyte-patch:<span class="k">${</span><span class="nv">LD_LIBRARY_PATH</span><span class="k">:-}</span>
</code></pre></div></div>

<p>CUDA probe 结果：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GPU_DIRECT_RDMA_WITH_CUDA_VMM = 1
GPU_DIRECT_RDMA_SUPPORTED     = 1
DMA_BUF_SUPPORTED             = 1
cuMemGetHandleForAddressRange(flags=0) = CUDA_SUCCESS
</code></pre></div></div>

<p>这个 patched copy 比 shim 更适合 NCCL，因为它仍然是完整的官方 libcuda，
只是改了一个初始化 immediate。</p>

<h2 id="方法迁移">方法迁移</h2>

<p>如果 CUDA 版本变了，显然这个静态 patch 不能假定仍然可用。不过上面 Codex 的分析流程其实足够通用：寻找 <code class="language-plaintext highlighter-rouge">cuDeviceGetAttribute</code> 中，某个特定的 attribute 的检查逻辑，找到对应的 bit flag 和全局变量，然后反过来在 <code class="language-plaintext highlighter-rouge">libcuda</code> 里搜索这个 bit flag 的初始化位置，最后根据需要修改控制流。</p>

<p>我还让 Codex 帮我生成了一个扫描某些 pattern 并尝试自动 patch 的脚本：<a href="https://gist.github.com/Harry-Chen/0d8c941e80c84e5482a46e3f5bcdb63d"><code class="language-plaintext highlighter-rouge">auto_patch_libcuda_dmabuf.py</code></a>。欢迎尝试，但请务必先备份原始 <code class="language-plaintext highlighter-rouge">libcuda.so</code>，并且在测试环境中多加验证。</p>

<h2 id="总结">总结</h2>

<p>基于 GPT-5.5 的 Codex 在本次调试过程中，呈现出了非常强的分析和调试能力，特别是对于二进制中控制流的理解。我原本完全没有预期它能在一个小时内解决此问题；当然这也要归因于 NVIDIA 没有真的砍掉硬件功能，也没有尝试在驱动里设置什么复杂的障碍。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="NVIDIA" /><category term="Linux" /><category term="GPU" /><summary type="html"><![CDATA[免责声明： 本文涉及对 GPU 驱动的修改，作者不对任何因此产生的后果负责。]]></summary></entry><entry><title type="html">PERC RAID5 阵列离线后数据救援一例</title><link href="https://harrychen.xyz/2026/05/15/rescue-of-offline-perc-raid5-array/" rel="alternate" type="text/html" title="PERC RAID5 阵列离线后数据救援一例" /><published>2026-05-15T18:00:00+08:00</published><updated>2026-05-15T18:00:00+08:00</updated><id>https://harrychen.xyz/2026/05/15/rescue-of-offline-perc-raid5-array</id><content type="html" xml:base="https://harrychen.xyz/2026/05/15/rescue-of-offline-perc-raid5-array/"><![CDATA[<p>TL;DR:</p>

<ul>
  <li>我不是神仙，RAID5 真坏两块盘是很难救回来的。坏了要及时换盘。</li>
  <li>记得备份，备份，备份！</li>
  <li>ZFS 在数据恢复中是好文明，压缩、快照、克隆等功能都非常有用。</li>
</ul>

<p>前两天<strong>学校停电倒闸</strong>后，杰哥告诉我机房有一台 Dell 服务器的 RAID5 VD 坏了一块盘，让我有空的时候换掉（注：其实它两年前已经开始报告错误了）。但当我今天再登录这台服务器的时候，却发现这个 RAID5 对应的目录已经开始报告 <code class="language-plaintext highlighter-rouge">Input/output error</code> 了。再随手一看：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@raid-server:~$ mount | grep sda
/dev/sda1 on /data type ext4 (rw,relatime,errors=remount-ro)

harry@raid-server:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/sdb1       366G  233G  115G  68% /
/dev/sda1        11T  1.6T  8.8T  15% /data

harry@raid-server:~$ lsblk
NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
sdb      8:16   0 446.6G  0 disk
├─sdb1   8:17   0 372.6G  0 part /
├─sdb2   8:18   0     1K  0 part
└─sdb5   8:21   0    74G  0 part
sdc      8:32   0  10.9T  0 disk
</code></pre></div></div>

<p>原本的 <code class="language-plaintext highlighter-rouge">sda</code> 已经从系统中消失，变成了同样大小的 <code class="language-plaintext highlighter-rouge">sdc</code>（访问也报 <code class="language-plaintext highlighter-rouge">EIO</code>），但挂载的文件系统还有残留。<code class="language-plaintext highlighter-rouge">dmesg</code> 中有大量的 <code class="language-plaintext highlighter-rouge">megaraid_sas</code> 和 <code class="language-plaintext highlighter-rouge">ata</code> 报告的硬盘 IO 错误，还有 <code class="language-plaintext highlighter-rouge">ext4</code> 报告的文件系统错误。这个文件系统里有一些比较有价值的数据，这下麻烦了。</p>

<h2 id="数据恢复过程">数据恢复过程</h2>

<h3 id="强制上线-raid-并导出磁盘镜像">强制上线 RAID 并导出磁盘镜像</h3>

<p>使用 <code class="language-plaintext highlighter-rouge">perccli</code>（也就是 <code class="language-plaintext highlighter-rouge">storcli</code> 的 Dell OEM 版本）检查硬盘（截取了一些输出），发现了非常坏的消息：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@raid-server:~$ sudo perccli64 /call/eall/sall show all
...
----------------------------------------------------------------
DG/VD TYPE    State Access Consist Cache Cac sCC       Size Name
----------------------------------------------------------------
0/0    RAID1  OptL   RW    No      NRWTD -   0FF 446.625 GB
1/1    RAID5  OfLn   RW    No      NRWTD -   0FF  10.914 TB
----------------------------------------------------------------
...
------------------------------------------------------------------------------
EID:Slt DID State DG      Size Intf Med SED PI SeSz           Model    Sp Type
------------------------------------------------------------------------------
32:0    0 Onln    0 446.625 GB SATA SSD N    N  512B SSDSC2KB480G8R      U -
32:1    1 Onln    0 446.625 GB SATA SSD N    N  512B SSDSC2KB480G8R      U -
32:4    4 Onln    1   3.637 TB SATA HDD N    N  512B ST4000NM0033-9ZM170 U -
32:5    5 Failed  1   3.637 TB SATA HDD N    N  512B ST4000NM0033-9ZM170 U -
32:6    6 Failed  1   3.637 TB SATA HDD N    N  512B ST4000NM0033-9ZM170 U -
32:7    7 Onln    1   3.637 TB SATA HDD N    N  512B ST4000NM0033-9ZM170 U -
------------------------------------------------------------------------------
</code></pre></div></div>

<p>这下大事不妙了，四盘 RAID5 有两块盘（槽位 5 和 6）被控制器标记为了 <code class="language-plaintext highlighter-rouge">Failed</code>。正当我觉得已经彻底完蛋，决定开始手工从别的地方恢复一些数据的时候，SMART 数据给了我一些希望：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@raid-server:~$ sudo smartctl -a /dev/sdc -d megaraid,5
ID# ATTRIBUTE_NAME          FLAG     VALUE WORST THRESH TYPE      UPDATED  WHEN_FAILED RAW_VALUE
  1 Raw_Read_Error_Rate     0x010f   061   061   ---    Pre-fail  Always       -       7418009
  3 Spin_Up_Time            0x0103   092   092   ---    Pre-fail  Always       -       0
  4 Start_Stop_Count        0x0032   100   100   ---    Old_age   Always       -       138
  5 Reallocated_Sector_Ct   0x0133   021   021   ---    Pre-fail  Always       -       13048
  7 Seek_Error_Rate         0x000f   090   061   ---    Pre-fail  Always       -       1116579259
  9 Power_On_Hours          0x0032   027   027   ---    Old_age   Always       -       64444
 10 Spin_Retry_Count        0x0013   100   100   ---    Pre-fail  Always       -       0
 12 Power_Cycle_Count       0x0032   100   100   ---    Old_age   Always       -       138
184 End-to-End_Error        0x0032   100   100   ---    Old_age   Always       -       0
187 Reported_Uncorrect      0x0032   095   095   ---    Old_age   Always       -       5
188 Command_Timeout         0x0032   100   095   ---    Old_age   Always       -       4295032838
189 High_Fly_Writes         0x003a   100   100   ---    Old_age   Always       -       0
190 Airflow_Temperature_Cel 0x0022   069   052   ---    Old_age   Always       -       31 (Min/Max 30/36)
191 G-Sense_Error_Rate      0x0032   100   100   ---    Old_age   Always       -       0
192 Power-Off_Retract_Count 0x0032   100   100   ---    Old_age   Always       -       132
193 Load_Cycle_Count        0x0032   056   056   ---    Old_age   Always       -       89153
194 Temperature_Celsius     0x0022   031   048   ---    Old_age   Always       -       31 (0 14 0 0 0)
195 Hardware_ECC_Recovered  0x001a   015   005   ---    Old_age   Always       -       7418009
196 Reallocated_Event_Count 0x0032   000   000   ---    Old_age   Always       -       13113
197 Current_Pending_Sector  0x0012   080   080   ---    Old_age   Always       -       3336
198 Offline_Uncorrectable   0x0010   100   100   ---    Old_age   Offline      -       0
199 UDMA_CRC_Error_Count    0x003e   200   200   ---    Old_age   Always       -       0
240 Head_Flying_Hours       0x0000   100   253   ---    Old_age   Offline      -       39850 (101 109 0)
241 Total_LBAs_Written      0x0000   100   253   ---    Old_age   Offline      -       5038718942
242 Total_LBAs_Read         0x0000   100   253   ---    Old_age   Offline      -       1089162748411

harry@raid-server:~$ sudo smartctl -a /dev/sdc -d megaraid,6
ID# ATTRIBUTE_NAME          FLAG     VALUE WORST THRESH TYPE      UPDATED  WHEN_FAILED RAW_VALUE
  1 Raw_Read_Error_Rate     0x010f   081   063   ---    Pre-fail  Always       -       143337086
  3 Spin_Up_Time            0x0103   093   092   ---    Pre-fail  Always       -       0
  4 Start_Stop_Count        0x0032   100   100   ---    Old_age   Always       -       102
  5 Reallocated_Sector_Ct   0x0133   100   100   ---    Pre-fail  Always       -       0
  7 Seek_Error_Rate         0x000f   091   060   ---    Pre-fail  Always       -       1395588958
  9 Power_On_Hours          0x0032   018   018   ---    Old_age   Always       -       72016
 10 Spin_Retry_Count        0x0013   100   100   ---    Pre-fail  Always       -       0
 12 Power_Cycle_Count       0x0032   100   100   ---    Old_age   Always       -       100
184 End-to-End_Error        0x0032   100   100   ---    Old_age   Always       -       0
187 Reported_Uncorrect      0x0032   094   094   ---    Old_age   Always       -       6
188 Command_Timeout         0x0032   100   100   ---    Old_age   Always       -       0
189 High_Fly_Writes         0x003a   100   100   ---    Old_age   Always       -       0
190 Airflow_Temperature_Cel 0x0022   068   052   ---    Old_age   Always       -       32 (Min/Max 31/37)
191 G-Sense_Error_Rate      0x0032   100   100   ---    Old_age   Always       -       0
192 Power-Off_Retract_Count 0x0032   100   100   ---    Old_age   Always       -       97
193 Load_Cycle_Count        0x0032   041   041   ---    Old_age   Always       -       119629
194 Temperature_Celsius     0x0022   032   048   ---    Old_age   Always       -       32 (0 16 0 0 0)
195 Hardware_ECC_Recovered  0x001a   021   003   ---    Old_age   Always       -       143337086
196 Reallocated_Event_Count 0x0032   000   000   ---    Old_age   Always       -       21845
197 Current_Pending_Sector  0x0012   100   100   ---    Old_age   Always       -       0
198 Offline_Uncorrectable   0x0010   100   100   ---    Old_age   Offline      -       0
199 UDMA_CRC_Error_Count    0x003e   200   200   ---    Old_age   Always       -       0
240 Head_Flying_Hours       0x0000   100   253   ---    Old_age   Offline      -       39054 (140 124 0)
241 Total_LBAs_Written      0x0000   100   253   ---    Old_age   Offline      -       5257600282
242 Total_LBAs_Read         0x0000   100   253   ---    Old_age   Offline      -       1497495161858
</code></pre></div></div>

<p>虽说这两块硬盘的数据都已经非常糟糕了（除了上面的数据，还有一大堆 error log），但至少看起来 6 号盘还有救（<code class="language-plaintext highlighter-rouge">Reallocated_Sector_Ct</code> 和 <code class="language-plaintext highlighter-rouge">Current_Pending_Sector</code> 都是 0）。于是我强制让 6 号盘上线：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@raid-server:~$ sudo perccli64 /c0/e32/s6 set online
</code></pre></div></div>

<p>幸好命令成功了，此时 <code class="language-plaintext highlighter-rouge">sdc</code> 变成了可读的状态！这已经非常幸运了，如果阵列无法上线，我将不得不对每块物理磁盘分别镜像，并在拯救文件系统之前先尝试组装 RAID。</p>

<p>话虽如此，我当然是不敢挂载文件系统的，只读也不敢。我立刻找了另一台空间足够的 ZFS 的服务器，新建了一个 dataset 用 NFS 挂到这台服务器上。然后使用 <code class="language-plaintext highlighter-rouge">ddrescue</code> 来对 <code class="language-plaintext highlighter-rouge">sdc</code> 进行镜像：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ddrescue -d -D -n -r0 -v --ask -S -c 2048 /dev/sdc /mnt/backup/data/raid.img /mnt/backup/data/raid.map
</code></pre></div></div>

<p>其中：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">img</code> 文件是获得的镜像，<code class="language-plaintext highlighter-rouge">map</code> 文件是读取状态的记录文件，方便中断后继续。</li>
  <li><code class="language-plaintext highlighter-rouge">-d</code>, <code class="language-plaintext highlighter-rouge">-D</code>：对于源和目的都使用直接 IO（<code class="language-plaintext highlighter-rouge">O_DIRECT</code>），绕过内核缓存。生成镜像时读写都是一次性的，没必要污染缓存；有缓存还会导致速度统计不准确。</li>
  <li><code class="language-plaintext highlighter-rouge">-n</code>, <code class="language-plaintext highlighter-rouge">-r0</code>：跳过 scrape 阶段，不对坏块进行重试，先把容易读出的数据复制出来。</li>
  <li><code class="language-plaintext highlighter-rouge">-v</code>, <code class="language-plaintext highlighter-rouge">--ask</code>：显示详细的进度信息，并且在开始之前确认参数。</li>
  <li><code class="language-plaintext highlighter-rouge">-S</code>：写入稀疏文件，一定要打开，否则会导致非常大的空间浪费。</li>
  <li><code class="language-plaintext highlighter-rouge">-c 2048</code>：把 IO 的粒度改成 2048 扇区，即 1MB。</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">ddrescue</code> 在顺利地读取了大概 6TB 的数据后（没有遇到坏块），就卡住再也不动了。这时我从 <code class="language-plaintext highlighter-rouge">dmesg</code> 中又看到了新的、极其大量的错误：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[  +0.011405] megaraid_sas 0000:e1:00.0: 252132 (832085413s/0x0002/FATAL) - Unrecoverable medium error during recovery on PD 04(e0x20/s4) at e8d85fbe
[  +0.011081] megaraid_sas 0000:e1:00.0: 252133 (832085413s/0x0002/FATAL) - Unrecoverable medium error during recovery on PD 04(e0x20/s4) at e8d85fbe
[  +0.011220] megaraid_sas 0000:e1:00.0: 252134 (832085413s/0x0002/FATAL) - Unrecoverable medium error during recovery on PD 04(e0x20/s4) at e8d85fbe
</code></pre></div></div>

<p>晴天霹雳！本来状态良好的 4 号盘也出现了问题。我再尝试读取它的 SMART 信息，已经完全得不到任何响应（但 <code class="language-plaintext highlighter-rouge">perccli</code> 还显示它正常）。看来，要么控制器进入了某些不正确的状态，要么就是这块盘也彻底坏了。在尝试重启机器继续之前，我首先进行了一些恢复的尝试。</p>

<h3 id="从不完整的磁盘镜像恢复文件">从不完整的磁盘镜像恢复文件</h3>

<p>在接收备份侧，我们已经能看到大小不完整的 <code class="language-plaintext highlighter-rouge">raid.img</code> 文件了，它是稀疏的（全 0 部分不实际分配空间），并且我的 ZFS 还开启了压缩，因此实际占用的空间要小得多。RAID 的大小是大概 10TB，我们已经复制出了 6TB，在稀疏化后，它的体积是 1.54TB（此为 ZFS 报告的 logical used），经过 ZSTD 压缩后，实际占用只有 1.3TB（此为 ZFS 的 used）。</p>

<p>保险起见，在做任何事情之前，我先 snapshot 并 clone 了一份新的数据集，这样就能随便进行写入操作来尝试恢复，而如果有问题就可以直接回滚；并且如果原始 RAID5 能恢复，我也可以在这个 snapshot 上继续进行写入。</p>

<p>由于磁盘上的分区是 GPT 的，可以尝试用 <code class="language-plaintext highlighter-rouge">gdisk</code> 来分析：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># gdisk raid.img
GPT fdisk (gdisk) version 1.0.10

Warning! Disk size is smaller than the main header indicates! Loading
secondary header from the last sector of the disk! You should use 'v' to
verify disk integrity, and perhaps options on the experts' menu to repair
the disk.
Caution: invalid backup GPT header, but valid main header; regenerating
backup header from main header.

Warning! Error 25 reading partition table for CRC check!
Warning! One or more CRCs don't match. You should repair the disk!
Main header: OK
Backup header: ERROR
Main partition table: OK
Backup partition table: ERROR

Partition table scan:
  MBR: protective
  BSD: not present
  APM: not present
  GPT: damaged

****************************************************************************
Caution: Found protective or hybrid MBR and corrupt GPT. Using GPT, but disk
verification and recovery are STRONGLY recommended.
****************************************************************************

Command (? for help): p
Disk raid.img: 23438819295 sectors, 10.9 TiB
Sector size (logical): 512 bytes
Disk identifier (GUID): [REDACTED]
Partition table holds up to 128 entries
Main partition table begins at sector 2 and ends at sector 33
First usable sector is 2048, last usable sector is 23438819294
Partitions will be aligned on 2048-sector boundaries
Total free space is 0 sectors (0 bytes)

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048     23438819294   10.9 TiB    8300
</code></pre></div></div>

<p>毫无意外地是损坏的，因为镜像并不完整，但还是能看到分区。于是尝试从分区起始处挂载：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># losetup -o 1048576 -r /dev/loop1 raid.img
# mount -o ro /dev/loop1 /mnt/recovery
</code></pre></div></div>

<p>但挂不上，日志说：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[May14 22:23] loop1: detected capacity change from 0 to 11719479296
[  +3.289364] mount: attempt to access beyond end of device
[May14 22:24] EXT4-fs (loop1): bad geometry: block count 2929852155 exceeds size of device (1464934912 blocks)
</code></pre></div></div>

<p>这比较好解决，我 <code class="language-plaintext highlighter-rouge">truncate -s</code> 了一下这个镜像，让它的大小延伸到分区末尾对应的位置（这也是稀疏的，因此瞬间就能完成）。然后再重新尝试，这次能挂上了！但是尝试 <code class="language-plaintext highlighter-rouge">ls</code> 根目录时，获得了一大堆错误：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ls -alih /mnt/recovery
ls: cannot access 'harry': Bad message
ls: cannot access 'docker': Bad message
...
total 121G
        2 drwxrwxrwx 24 root root 4.0K May 30  2025 .
  1831425 drwxr-xr-x  5 root root 4.0K May 14 22:18 ..
        ? d?????????  ? ?    ?       ?            ? docker
151519233 drwx--x--x 14 root root 4.0K Mar 26  2021 docker.old
        ? d?????????  ? ?    ?       ?            ? harry
 31195137 drwxr-xr-x 10 root root 4.0K Nov  2  2020 k8s
       11 drwxrw-rw-  2 root root  16K Aug  3  2020 lost+found
  5439489 drwxr-xr-x 10 1006 1006 4.0K Jul 26  2024 static

# dmesg -H 
[May14 22:31] loop1: detected capacity change from 0 to 23438817247
[May14 22:32] EXT4-fs (loop1): mounted filesystem [REDACTED] ro without journal. Quota mode: none.
[  +2.797028] EXT4-fs error (device loop1): ext4_lookup:1819: inode #229244929: comm ls: iget: checksum invalid
[  +0.001432] EXT4-fs error (device loop1): ext4_lookup:1819: inode #335085569: comm ls: iget: checksum invalid
[  +0.001535] EXT4-fs error (device loop1): ext4_lookup:1819: inode #252116993: comm ls: iget: checksum invalid
[  +0.099194] EXT4-fs error (device loop1): ext4_lookup:1819: inode #262864897: comm ls: iget: checksum invalid
[  +0.017124] EXT4-fs error (device loop1): ext4_lookup:1819: inode #342818817: comm ls: iget: checksum invalid
[  +0.023974] EXT4-fs error (device loop1): ext4_lookup:1819: inode #187039745: comm ls: iget: checksum invalid
[  +0.019762] EXT4-fs error (device loop1): ext4_lookup:1819: inode #265945089: comm ls: iget: checksum invalid
</code></pre></div></div>

<p>似乎这些目录项指向的 inode 或相关元数据没有在复制过来的镜像范围中，因此无法访问了。很不幸的是，我的目录（<code class="language-plaintext highlighter-rouge">harry</code>）也在其中。Claude 教我用 <code class="language-plaintext highlighter-rouge">debugfs</code> 的 <code class="language-plaintext highlighter-rouge">inode_dump</code> 来查看 inode 的内容，然而：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># debugfs /dev/loop1
debugfs 1.47.2 (1-Jan-2025)
/dev/loop1: Block bitmap checksum does not match bitmap while reading allocation bitmaps
debugfs:  inode_dump 229244929
inode_dump: Filesystem not open
</code></pre></div></div>

<p>看来并不奏效。我尝试对着镜像运行了 <code class="language-plaintext highlighter-rouge">e2fsck -fy</code>，它确实修理了大量的问题。再次 mount 时，EXT4 就不再报那些错误了，然而这些文件夹同样再也找不回来了；对应地，文件系统使用的空间缩小到了 1TB 不到，说明 <code class="language-plaintext highlighter-rouge">fsck</code> 真的删掉了很多东西。</p>

<h3 id="从几乎完整的磁盘镜像恢复文件">从几乎完整的磁盘镜像恢复文件</h3>

<p>那怎么办？在我准备掏出 <code class="language-plaintext highlighter-rouge">photorec</code> 作为最后手段前，抱着死马当活马医的心态，我强制重启了一下这台有 RAID5 磁盘的服务器。没想到重启完之后，阵列状态又变成了可用（当然，磁盘 5 依旧是坏的）。于是我继续开始运行 <code class="language-plaintext highlighter-rouge">ddrescue</code>，静候花开。</p>

<p>我原本决定在第一次运行结束之后，去掉 <code class="language-plaintext highlighter-rouge">-n -r0</code> 重新运行一遍 <code class="language-plaintext highlighter-rouge">ddrescue</code>，让它对原本读取不成功的部分再尝试一下。然而第二天起床时，发现它卡在了 backwards pass，并且又触发了类似的 <code class="language-plaintext highlighter-rouge">megaraid_sas</code> 错误。此时它有 1MB 多的 non-trimmed 数据，和 53MB 的 non-tried 数据，剩下都已经成功读取了，大小也能对上了。考虑到我们的磁盘其实非常稀疏，我直接挂上了这个镜像，没想到一点错误也没有，连 <code class="language-plaintext highlighter-rouge">e2fsck</code> 也没有发现任何问题。于是这次数据救援就宣告结束了。我们把其中有用的数据复制出来，恢复了大部分的服务；其他数据也就留在了这个 RAID1 ZFS 上作为存档（和最后的备份）。</p>

<h2 id="其他">其他</h2>

<h3 id="zfs-异常性能诊断">ZFS 异常性能诊断</h3>

<p>在我一开始用 <code class="language-plaintext highlighter-rouge">ddrescue</code> 从 RAID 设备读出镜像到远端 NFS 时，IO 性能非常差：<code class="language-plaintext highlighter-rouge">zpool iostat</code> 报告只有 40MB/s 左右。考虑到这个 pool 是两块盘的镜像，每块盘上的 IO 应该和 pool 一致。虽说 HDD 的 IOPS 比较差，但 <code class="language-plaintext highlighter-rouge">ddrescue</code> 原理上在大部分时间产生的 IO 都应该是连续的，完全没道理会这么慢。</p>

<p>于是我进行了如下的尝试：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">zfs set sync=disabled</code>：zpool 写入速度变成了 50MB/s；</li>
  <li><code class="language-plaintext highlighter-rouge">ddrescue -c 2048</code>（把 IO 的粒度改成 2048 扇区，即 1MB）：写入速度变成了 60MB/s；</li>
  <li><code class="language-plaintext highlighter-rouge">zfs set recordsize=1M</code> 并且给 NFS 设置挂载选项 <code class="language-plaintext highlighter-rouge">rsize=1048576,wsize=1048576</code>：速度上升到 75MB/s（然而收益可能并非来自于此，见下）。</li>
</ul>

<p>尽管确实获得了一些提升，但依旧离我期待的性能（基本打满目标硬盘的带宽）相差甚远。</p>

<p>首先看一下 <code class="language-plaintext highlighter-rouge">ddrescue</code> 是不是真的在连续写入：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># strace -p $(pgrep ddrescue) -e t=write
write(4, "[REDACTED]"..., 1048576) = 1048576
write(1, "\r\33[A\33[A\33[A\33[A\33[A\33[A     ipos:   "..., 95) = 95
write(1, "     opos:    7974 GB, non-scrap"..., 76) = 76
write(1, "non-tried:    4026 GB,  bad-sect"..., 76) = 76
write(1, "  rescued:    7973 GB,   bad are"..., 76) = 76
write(1, "pct rescued:   66.44%, read erro"..., 76) = 76
</code></pre></div></div>

<p>看起来没问题。然而到 ZFS 这一侧，IO 模式就变成了：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># zpool iostat 1
              capacity     operations     bandwidth
pool        alloc   free   read  write   read  write
mypool      2.88T  13.5T    172    587   692K  42.8M
mypool      2.88T  13.5T    239    643   959K  81.2M
mypool      2.88T  13.5T    210    560   842K  70.1M
mypool      2.88T  13.5T    237    582   949K  72.9M
mypool      2.88T  13.5T    204    532   819K  70.0M
（省略若干行大概 75 MB/s 左右的写入）
mypool      2.88T  13.5T    231    596   925K  76.7M
mypool      2.88T  13.5T    240    656   961K  83.2M
mypool      2.88T  13.5T    212    535   849K  67.3M
mypool      2.88T  13.5T    214    599   857K  79.2M
mypool      2.88T  13.5T    220    635   881K  80.0M
mypool      2.88T  13.5T    130    427   622K  39.7M
mypool      2.88T  13.5T      0  12.2K      0   295M
mypool      2.88T  13.5T      0  11.5K      0   282M
mypool      2.88T  13.5T    205    709   822K  72.3M
mypool      2.88T  13.5T    282    778  1.10M  97.3M
mypool      2.88T  13.5T    269    744  1.05M  94.1M
mypool      2.88T  13.5T    238    643   955K  81.3M
mypool      2.88T  13.5T    215    551   863K  71.8M
mypool      2.88T  13.5T    230    610   923K  79.0M
mypool      2.88T  13.5T    205    559   822K  71.9M
mypool      2.88T  13.5T    237    639   950K  81.8M
...（重复以上模式）
</code></pre></div></div>

<p>我把这些内容复制给 Claude 分析，它说 <code class="language-plaintext highlighter-rouge">recordsize=1M</code> 其实没有生效：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>正常行: 227 read / 674 write / 910K / 84.9M

Read  IO size: 910K / 227  ≈ 4.0 KB   ← 和之前完全一样
Write IO size: 84.9M / 674 ≈ 126 KB   ← 还是 128K，不是 1MB！

写入一个 128KB 数据块的完整流程：

1. 从磁盘读取父级 indirect block（~4KB）   ← 你看到的 read IOPS
2. 在 ARC 中更新 block pointer
3. COW 写新的数据块（128KB）               ← 你看到的 write IOPS
4. COW 写新的 indirect block（~4KB）        ← 计入 write IOPS

损失去哪了？
每次写 128KB 数据，还要额外做一次 4KB 随机读
HDD 随机读：~100-150 IOPS ≈ 约 200-230 IOPS 的 overhead
这些随机读直接消耗了磁盘的寻道时间
</code></pre></div></div>

<p>这确实很有道理。但其实此时的读写比例（略高于 1:3）还要远高于它描述的场景（每写 132K 读 4K），那么多出来的这么多 4K 读 IO 是在做什么呢？这次我没有问 LLM 就意识到了问题：接收镜像的 dataset 继承了原本用于节省 home 目录空间占用的 <strong>dedup 开关</strong>，这些 IO 其实是写入之前在读取 pool 的 DDT 哈希表。然而在我们的场景下（要尽快完成镜像），dedup 的意义并不大。</p>

<p>于是我暂时停止传输，关闭 dedup，并进行了 <code class="language-plaintext highlighter-rouge">zfs send | zfs recv</code> 来重新创建整个 dataset。然后再继续进行传输：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>              capacity     operations     bandwidth
pool        alloc   free   read  write   read  write
----------  -----  -----  -----  -----  -----  -----
mypool      3.27T  13.1T      0  12.3K      0   302M
mypool      3.27T  13.1T      0  12.5K      0   308M
mypool      3.27T  13.1T      0  12.6K      0   312M
mypool      3.27T  13.1T      0  12.5K      0   309M
mypool      3.27T  13.1T      0  12.5K      0   310M
mypool      3.27T  13.1T      0  12.5K      0   308M
mypool      3.27T  13.1T      0  12.2K      0   298M
</code></pre></div></div>

<p>这看起来正常多了！虽然此时的每个写 IO 大小也只有 24K 左右，但很明显它们已经是连续的了，因此能比较充分地利用 HDD 的带宽。果然 <code class="language-plaintext highlighter-rouge">dedup</code> 总是影响 ZFS 性能的万恶之源，尤其是在机械硬盘上。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="Dell" /><category term="RAID" /><summary type="html"><![CDATA[TL;DR:]]></summary></entry><entry><title type="html">在 RTX 5090 (SM120) 上补全 NVFP4 量化相关 kernel</title><link href="https://harrychen.xyz/2026/04/14/polyfill-nvfp4-quantization-kernels-on-rtx-5090/" rel="alternate" type="text/html" title="在 RTX 5090 (SM120) 上补全 NVFP4 量化相关 kernel" /><published>2026-04-14T00:45:00+08:00</published><updated>2026-04-14T00:45:00+08:00</updated><id>https://harrychen.xyz/2026/04/14/polyfill-nvfp4-quantization-kernels-on-rtx-5090</id><content type="html" xml:base="https://harrychen.xyz/2026/04/14/polyfill-nvfp4-quantization-kernels-on-rtx-5090/"><![CDATA[<p class="info">本文部分内容由 GPT 5.4 根据大纲写成，我进行了校对和风格修改。如仍有生硬之处，敬请谅解。本文中的 vibe coding 过程使用 GPT 5.4 / Claude Opus 4.6 完成，使用了 Cursor 和 Claude Code 作为不同阶段的辅助工具。</p>

<p>最近组里同学在折腾 5090 上的原生低精度预训练，换句话说，不是用 BF16 训练完再量化，而是在训练时就是用更低精度的格式（如 FP8 甚至 FP4，不过 attention 层还是 BF16）。英伟达的 <a href="https://github.com/NVIDIA/TransformerEngine">Transformer Engine</a> 提供了许多不同数据格式的 recipe 的支持，也就是对于每一层的前向、反向，究竟是用什么数据格式，并为此提供了优化过的 kernel。然而虽然它声称为 Blackwell 优化，但其实指的是 SM100 家族，也就是 B200 / GB200 这类数据中心卡；而 RTX 5090 这样的消费级卡（SM120 家族，还包括 RTX Pro 专业卡、DGX Spark 等），并不存在开箱即用的体验。</p>

<p>MXFP8 的部分似乎还好，只需修改编译选项，为 SM120 编译 kernel 就能工作。但一到 Blackwell 专属的 NVFP4 部分，我们立刻就遭遇了<a href="https://github.com/NVIDIA/TransformerEngine/issues/2255">老黄精准的刀法</a>。受到老板的感召，我花了不到两天的时间，几乎完全靠 vibe coding，补全了 TE 2.12 在 SM120 家族上的 NVFP4 kernel 支持，主要包括两个技术：stochastic rounding（随机舍入）和 Random Hadamard Transform GEMM（随机 Hadamard 变换矩阵乘）。</p>

<p>所有的代码都已经在 <a href="https://github.com/Harry-Chen/fp4_sm120"><code class="language-plaintext highlighter-rouge">fp4_sm120</code></a> 开源。本文简单记录过程和一些心得。</p>

<h2 id="fp4-量化技术">FP4 量化技术</h2>

<p>显然，FP4 的动态范围和精度都很小。以 NVFP4 用的 E2M1 为例，能表示的数（绝对值）只有 <code class="language-plaintext highlighter-rouge">0, 0.5, 1, 1.5, 2, 3, 4, 6</code>。除了常见的 block-wise scale 以外，Transformer Engine 在 NVFP4 的训练 recipe 里增加了以下的两个技术，来提供更高的训练稳定性和更好的精度。</p>

<h3 id="随机舍入">随机舍入</h3>

<p>目前硬件上进行量化时，舍入方法（也是 IEEE 754 规定的）通常是 round-to-nearest，也就是把一个值 <code class="language-plaintext highlighter-rouge">x</code> 量化成离它最近的可表示值。然而，当可表示的值只有这么几个，并且间距并不相同时，这样很可能引入系统性的误差。比如 <code class="language-plaintext highlighter-rouge">1.6</code> 和 <code class="language-plaintext highlighter-rouge">2.4</code> 都会被量化成 <code class="language-plaintext highlighter-rouge">2</code>，</p>

<p>而随机舍入（stochastic rounding）的做法是，如果一个值 <code class="language-plaintext highlighter-rouge">x</code> 落在两个可表示值 <code class="language-plaintext highlighter-rouge">a</code> 和 <code class="language-plaintext highlighter-rouge">b</code> 之间，那么应该按比例随机地向上或向下取：</p>

<p><code class="language-plaintext highlighter-rouge">P(round to b) = (x - a) / (b - a)</code></p>

<p>这样做的好处是，量化的行为在期望上是无偏的。在 SM100 上，NVIDIA 提供了 <code class="language-plaintext highlighter-rouge">cvt.rs.satfinite.e2m1x4.f32</code> 这条指令来实现硬件级别的随机舍入，它的格式是 <code class="language-plaintext highlighter-rouge">cvt.rs.satfinite.e2m1x4.f32 %0, {%1, %2, %3, %4}, %5</code>，其中 <code class="language-plaintext highlighter-rouge">%1</code> 到 <code class="language-plaintext highlighter-rouge">%4</code> 是四个输入的 FP32 值，<code class="language-plaintext highlighter-rouge">%5</code> 是一个随机 32 位整数，输出 <code class="language-plaintext highlighter-rouge">%0</code> 是四个值量化成 NVFP4 后 pack 成一个 16-bit 寄存器的结果。这条指令一次性能对四个数进行量化；当然，所有输入值需要事先经过 block-wise scale 处理，本身不能超出 NVFP4 的表示范围。在 Transformer Engine 的 <a href="https://github.com/NVIDIA/TransformerEngine/blob/v2.12/transformer_engine/common/util/ptx.cuh"><code class="language-plaintext highlighter-rouge">ptx.cuh</code></a> 中，如下的函数使用内联 PTX 汇编的方式，调用此指令实现了量化：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">mul_cvt_bf16_to_fp4_4x_with_stochastic_rounding</code></li>
  <li><code class="language-plaintext highlighter-rouge">mul_cvt_fp32_to_fp4_4x_with_stochastic_rounding</code></li>
  <li><code class="language-plaintext highlighter-rouge">mul_cvt_bf16_to_fp4_8x_stochastic_rounding</code></li>
</ul>

<h3 id="随机-hadamard-变换">随机 Hadamard 变换</h3>

<p>随机 Hadamard 变换（Random Hadamard Transform，RHT）是一种在量化前对数据进行随机旋转的技术。它的主要作用是将原本集中在少数通道上的异常值（outlier）打散，使得后续的 block-wise scale 更容易工作。虽然形式上它能写成一个很大的矩阵乘法 $G_{\text{rht}} = GH$（被称为 RHT GEMM），但实际实现上 $H$ 是一个很小（如 $16 \times 16$）Hadamard 矩阵（元素均为 <code class="language-plaintext highlighter-rouge">+1</code> 或 <code class="language-plaintext highlighter-rouge">-1</code> 的正交矩阵），作用在沿着最后一个维度分块后的 $G$ 上。换句话说，相当于沿着最后一维把 $G$ 切成许多个 16 元素的小块，每个小块都被一个（每次迭代随机生成的）Hadamard 矩阵所打散。这使得 RHT GEMM 并非一个真正 $O(mnk)$ 的 GEMM 操作，而是降低到了 $O(\vert G \vert)$ 的复杂度，因此它是个 memory bound 的算子。</p>

<p>Transformer Engine 在 SM100 上为 RHT 提供了专门的 kernel，叫做 <a href="https://github.com/NVIDIA/TransformerEngine/blob/v2.12/transformer_engine/common/hadamard_transform/hadamard_transform_cast_fusion.cu#L559"><code class="language-plaintext highlighter-rouge">rht_gemm_ntt_w_sfc</code></a>。它的输入是高精度（如 BF16）的矩阵，输出是 NVFP4 的矩阵；在这个 kernel 里，随机旋转和量化被融合了。也就是说，输入值在被乘以 Hadamard 矩阵打散以后，会直接调用前面提到的 <code class="language-plaintext highlighter-rouge">cvt.rs</code> 指令进行随机舍入量化。因此，RHT kernel 的实现除了 GEMM 本身，也依赖于 <code class="language-plaintext highlighter-rouge">cvt.rs</code> 的支持。而这里的乘法使用的是 WGEMM，也就是基于 warp-group 的矩阵乘法，依赖于 SM100 上的 <code class="language-plaintext highlighter-rouge">tcgen05</code> 指令。</p>

<h2 id="rtx-5090-sm120-现状">RTX 5090 (SM120) 现状</h2>

<p>虽然 SM120 支持 NVFP4 矩阵乘法（由 CUTLASS / cublas 提供），但要用上完整的 NVFP4 recipe，还有以下的问题：</p>

<ul>
  <li>SM120 架构不存在 <code class="language-plaintext highlighter-rouge">cvt.rs.satfinite.e2m1x4.f32</code> 指令。事实上，它完全不支持 <code class="language-plaintext highlighter-rouge">.rs</code> 指令后缀。</li>
  <li>SM120 架构虽然配备了第五代 Tensor Core，但不支持 SM100 上的 <code class="language-plaintext highlighter-rouge">tcgen05</code> 指令 / UMMA / TMEM 路径，shared memory 也更小（99KB vs 232KB）。因此 Transformer Engine 现有的 RHT kernel 完全无法使用，只能改走 Hopper WMMA 的实现。</li>
</ul>

<p>但这些并不是不可逾越的障碍。前者完全可以通过软件来模拟，而后者也可以 fallback 到更古老的实现。考虑到这两个 kernel 都是 memory bound 的，大概也不会有什么性能损失。</p>

<h3 id="随机舍入指令半软件模拟">随机舍入指令：（半）软件模拟</h3>

<p>通过多次尝试，我（通过古法）发现 SM120 上最接近 <code class="language-plaintext highlighter-rouge">cvt.rs.satfinite.e2m1x4.f32</code> 的指令是 <code class="language-plaintext highlighter-rouge">cvt.rn.satfinite.e2m1x2.f32</code>，它一次只能处理两个输入值，并且不支持随机舍入。这并不困难，因为随机舍入的核心其实就是在输入值上加一个小噪声，使得它在两个可表示值之间随机变化，而后依然可以使用 round-to-nearest 的指令来实现随机舍入的效果。具体地，先根据 E2M1 的 ULP 给每个输入值加上一个对称的小噪声，再调用两次 <code class="language-plaintext highlighter-rouge">cvt.rn.satfinite.e2m1x2.f32</code>，最后手工把结果 pack 成原来 <code class="language-plaintext highlighter-rouge">e2m1x4</code> 的布局。这样虽然底层不是原生 <code class="language-plaintext highlighter-rouge">cvt.rs</code>，但最终满足的概率分布是一致的。</p>

<p>除了依赖 SM120 指令的版本，我还要求 Claude 生成了一个纯软件版本（不使用任何 <code class="language-plaintext highlighter-rouge">cvt</code> 指令），方便在别的架构（如果还有的话）上验证语义。我同时也增加了 SM100 的原生实现来对拍，结果是：</p>

<ul>
  <li>SM120 polyfill 和原生 <code class="language-plaintext highlighter-rouge">cvt.rs</code> 可以做到 bit-exact（控制提供的随机 bit 一致）；</li>
  <li>纯软件版本在统计意义上和 <code class="language-plaintext highlighter-rouge">cvt.rs</code> 等价；</li>
</ul>

<p>在这过程中，我被各种细节坑过，特别是指令的输入、输出顺序反复修改了好几遍。最终是通过在 SM100 上对拍了几组数据，才确保了实现的正确性。令我震惊的是，一个有错误输出顺序的实现，居然能让真实训练的 loss 曲线在前几个 iteration 上下降不少，到后面才暴露出问题。</p>

<p>在接入 RS 支持后，NVFP4 的训练稳定性有了显著的提升，并且性能只有非常轻微的下降。毕竟它是个 element-wise 的算子，这也是预期之内的。</p>

<h3 id="rht-gemm从头重写">RHT GEMM：从头重写</h3>

<p>接下来，需要把 Transformer Engine 的 <code class="language-plaintext highlighter-rouge">rht_gemm_ntt_w_sfc</code> 在 SM120 上重写。具体做法包括：</p>

<ul>
  <li>用 WMMA 分块做 <code class="language-plaintext highlighter-rouge">16x16</code> Hadamard 矩阵乘法；</li>
  <li>NVFP4 stochastic rounding 直接复用上一节提供的 polyfill；</li>
</ul>

<p>在 RTX 5090 上，较大尺寸的测试能跑到大约 <code class="language-plaintext highlighter-rouge">1270 GB/s</code>，差不多是显存峰值带宽的 71%。我也把它拿到 SM100 上和 TE 做了二进制对拍；在 fast-math 模式下，数值对比只需要放宽到 $&lt;0.1\%$ 的 FP4 差异容忍度就能通过。</p>

<p>当然这个过程也绝非一帆风顺：虽然 GEMM vibe 起来很轻松，但还是有不少细节经过了多次修复，尤其是对 NaN 的传播处理花费了不少时间。我也要求 Claude 自己通过 nsys 和 ncu 来分析性能表现，进行了多轮优化，才获得了目前的性能数据。</p>

<p>在 vibe 完成后，我们也把 RHT GEMM kernel 接入了 TE 的训练 recipe，然而它的表现却十分诡异：性能有符合预期的少许下降，然而 loss 并没有显著变好，反而是最后一层输出的范数剧烈飙升。我一开始以为是实现问题，就 PUA AI 反复进行验证都没看出问题；直到后面我在 GB200 上用原版 TE 测试了一下，发现几乎完美复刻了 5090 上的曲线，才意识到大概不是 kernel 的问题。</p>

<h2 id="vibe-coding-过程">Vibe Coding 过程</h2>

<p>注：这一节基本也是 vibe 出来的，所以味比较冲。</p>

<p>其实本来我让 Claude 和 ChatGPT 各自实现了一个版本，仓库早期的提交里，甚至直接就有 <code class="language-plaintext highlighter-rouge">cvt.chatgpt.cu</code> 和 <code class="language-plaintext highlighter-rouge">cvt.claude.cu</code> 两个文件。我一开始做的事情非常朴素：把背景、目标指令和期望语义喂给模型，让它先给出一个能编译、能跑 benchmark、最好还能带上测试的初版。这个阶段 AI 的效率确实很高，尤其适合：</p>

<ul>
  <li>快速铺出大块 CUDA/C++ 样板代码；</li>
  <li>把分散的 PTX 细节和 wrapper 函数拼起来；</li>
  <li>顺手生成一堆测试和 benchmark 骨架。</li>
</ul>

<p>但接下来就进入了传统工程阶段。很多 bug 其实都不是“算法错了”，而是非常 GPU、非常细碎的那种：</p>

<ul>
  <li>随机数取法有 bias；</li>
  <li><code class="language-plaintext highlighter-rouge">cvt</code> 的操作数顺序写反了；</li>
  <li>pack 出来的 nibble 顺序不对；</li>
  <li>Philox counter layout 和 TE 的实现并不一致；</li>
  <li><code class="language-plaintext highlighter-rouge">fminf</code> / <code class="language-plaintext highlighter-rouge">fmaxf</code> 会吞掉 NaN，而 reference 选择保留 NaN；</li>
  <li>fast-math 模式下，本来就不该追求逐 bit 一致。</li>
</ul>

<p>也就是说，AI 很适合先把 70 分的代码搭出来；但从 70 分到能真正替代原始 kernel，靠的还是对拍、读 PTX、看硬件行为，以及不断修正那些一眼看不出来的小坑。</p>

<h3 id="时间线">时间线</h3>

<p>从提交记录来看，这个项目的推进速度其实相当符合“先 vibe 出原型，再用测试把它打磨成工程”的模式：</p>

<table>
  <thead>
    <tr>
      <th>时间</th>
      <th>提交</th>
      <th>发生了什么</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>03-24 14:43</td>
      <td><code class="language-plaintext highlighter-rouge">e4419c8</code></td>
      <td>初始提交，生成 <code class="language-plaintext highlighter-rouge">cvt.chatgpt.cu</code> 和 <code class="language-plaintext highlighter-rouge">cvt.claude.cu</code> 两个版本。</td>
    </tr>
    <tr>
      <td>03-24 15:48</td>
      <td><code class="language-plaintext highlighter-rouge">1a53719</code></td>
      <td>修掉 <code class="language-plaintext highlighter-rouge">rand_byte</code> 的 bias，说明问题已经从“能跑”进入“语义是否正确”。</td>
    </tr>
    <tr>
      <td>03-24 21:30</td>
      <td><code class="language-plaintext highlighter-rouge">9c5ba8a</code> / <code class="language-plaintext highlighter-rouge">d162a57</code></td>
      <td>一边修 PTX 操作数顺序，一边开始加入 SM100 对拍测试。</td>
    </tr>
    <tr>
      <td>03-25 00:47</td>
      <td><code class="language-plaintext highlighter-rouge">0e25ab8</code></td>
      <td>第一版能在 RTX 5090 上工作的 RHT GEMM 跑起来了。</td>
    </tr>
    <tr>
      <td>03-25 01:03</td>
      <td><code class="language-plaintext highlighter-rouge">9cd64ab</code></td>
      <td>做了 multi-group-per-block 优化，把带宽推到约 <code class="language-plaintext highlighter-rouge">1270 GB/s</code>。</td>
    </tr>
    <tr>
      <td>03-25 12:29</td>
      <td><code class="language-plaintext highlighter-rouge">1500443</code></td>
      <td>把自己的 kernel 和 TE reference 放进同一个 binary，在 SM100 上正面对拍。</td>
    </tr>
    <tr>
      <td>03-25 23:21</td>
      <td><code class="language-plaintext highlighter-rouge">705eecc</code></td>
      <td>修 NaN 传播和 Philox RNG 布局，开始解决真正影响训练语义的细节。</td>
    </tr>
    <tr>
      <td>03-25 23:49</td>
      <td><code class="language-plaintext highlighter-rouge">36de459</code></td>
      <td>给 fast-math 模式加上 <code class="language-plaintext highlighter-rouge">&lt;0.1%</code> 容忍度，项目基本收尾。</td>
    </tr>
  </tbody>
</table>

<p>如果把上面这些 commit 连起来看，会发现这次工作并不是“一句话让 AI 自动写完了 NVFP4 kernel”，而更像是：先让模型把原型和脚手架堆出来，然后人拿着真实硬件、reference kernel 和测试，一路把语义修到能用。</p>

<h2 id="总结">总结</h2>

<p>这次 vibe coding 整体还算顺利，尤其是考虑到我对 NVFP4 相关的细节并不熟悉。现代 AI 模型在代码实现、性能分析方面的能力也让我比较满意。然而我也深刻感受到，（至少目前）在此类工程问题上，人类经验依旧是不可或缺的；否则，只会变成白白浪费 token 让模型在一些细节上反复纠结，生产出一坨又一坨的意大利面。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="NVIDIA" /><category term="Linux" /><category term="GPU" /><summary type="html"><![CDATA[本文部分内容由 GPT 5.4 根据大纲写成，我进行了校对和风格修改。如仍有生硬之处，敬请谅解。本文中的 vibe coding 过程使用 GPT 5.4 / Claude Opus 4.6 完成，使用了 Cursor 和 Claude Code 作为不同阶段的辅助工具。]]></summary></entry><entry><title type="html">在 RTX 5090 上启用 PCIe P2P 通信支持</title><link href="https://harrychen.xyz/2026/03/22/enable-pcie-p2p-on-rtx-5090/" rel="alternate" type="text/html" title="在 RTX 5090 上启用 PCIe P2P 通信支持" /><published>2026-03-22T22:45:00+08:00</published><updated>2026-03-22T22:45:00+08:00</updated><id>https://harrychen.xyz/2026/03/22/enable-pcie-p2p-on-rtx-5090</id><content type="html" xml:base="https://harrychen.xyz/2026/03/22/enable-pcie-p2p-on-rtx-5090/"><![CDATA[<p class="warning"><strong>免责声明：</strong>
本文涉及对 GPU 驱动的修改，作者不对任何因此产生的后果负责。</p>

<p><em>注：在写完之后，我在知乎上找到了一篇内容几乎一样的文章<a href="https://zhuanlan.zhihu.com/p/1895881099793634602">《(并非)破解驱动榨干4090(5090)的最后一丝性能》</a>。如果早点读到，可能我就不重复造这个轮子了。</em></p>

<p>PCIe P2P 访问是 GPU 之间进行通信的重要途径。它使得不同设备间的通信无需经过内存中转，直接通过 PCIe 总线传输，在很多时候能显著提升通信性能。当然，现实中的 P2P 性能也受到很多因素影响：比如 AMD EPYC 7002 家族的 PCIe Root Complex 转发性能就比较差；而如果主板上有 PCIe Switch 芯片，那么很多情况下几乎就能打满线速。但总体来说，P2P 的性能几乎总是比经过 GPU-内存-GPU 的路径要好。</p>

<p>然而众所周知，老黄精准的刀法砍掉了所有家用级显卡的 PCIe P2P 通信支持。换句话说：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@gpu:~$ nvidia-smi topo -p2p r
        GPU0    GPU1    GPU2    GPU3    GPU4    GPU5    GPU6    GPU7
 GPU0   X       GNS     GNS     GNS     GNS     GNS     GNS     GNS
 GPU1   GNS     X       GNS     GNS     GNS     GNS     GNS     GNS
 GPU2   GNS     GNS     X       GNS     GNS     GNS     GNS     GNS
 GPU3   GNS     GNS     GNS     X       GNS     GNS     GNS     GNS
 GPU4   GNS     GNS     GNS     GNS     X       GNS     GNS     GNS
 GPU5   GNS     GNS     GNS     GNS     GNS     X       GNS     GNS
 GPU6   GNS     GNS     GNS     GNS     GNS     GNS     X       GNS
 GPU7   GNS     GNS     GNS     GNS     GNS     GNS     GNS     X
</code></pre></div></div>

<p>这里的 <code class="language-plaintext highlighter-rouge">GNS</code> 表示 <code class="language-plaintext highlighter-rouge">GPU Not Supported</code>。但真的是硬件不支持吗？似乎并不是这样。</p>

<h2 id="前置工作">前置工作</h2>

<p>在计划使用 P2P 通信之前，首先要确认一些前置条件是否具备：</p>

<ul>
  <li>硬件拓扑：通常来说，同一个服务器 CPU Socket 上安装的 GPU 之间共享同一个 Root Complex，一般都能支持 P2P；跨 socket 通常也是可行的。而如果有 PCIe Switch，则可能有更复杂的拓扑（比如特定 GPU 之间有更高的带宽）。<code class="language-plaintext highlighter-rouge">nvidia-smi topo -m</code> 输出的结果可能受很多因素的影响：如在我的 AMD EPYC 9354 上，给出的所有结果都是 <code class="language-plaintext highlighter-rouge">SYS</code>，这和 CPU 配置成了 NPS4 有关；如果启用 NPS2，那么相邻的两个 GPU 之间会就变成 <code class="language-plaintext highlighter-rouge">NODE</code>；可以类推如果使用 NPS1，那么同一个 socket 上的四个 GPU 应当都是 <code class="language-plaintext highlighter-rouge">NODE</code>。</li>
  <li>BIOS 配置：
    <ul>
      <li>关闭 PCIe ACS (Access Control Service)，建议关闭 IOMMU（可在内核选项中配置 <code class="language-plaintext highlighter-rouge">iommu=pt</code> 绕过）和虚拟化。这些功能虽然能提供更高的安全性，但也会让 PCIe 上的通信变得更复杂，很可能导致 P2P 性能急剧下降或者完全不可用。</li>
      <li>启用 PCIe Resizable BAR (ReBAR) 支持。</li>
    </ul>
  </li>
</ul>

<h2 id="魔改驱动">魔改驱动</h2>

<p>著名 iPhone 越狱黑客 Geohot 在几年前发起了一个精简的深度学习框架 <a href="https://tinygrad.org/">tinygrad</a>，力争通过最小的软件 overhead 实现深度学习框架。而这个框架的一个副产品就是，魔改过的 NVIDIA 的开源 GPU 驱动（<a href="https://github.com/tinygrad/open-gpu-kernel-modules">tinygrad/open-gpu-kernel-modules</a>），在不少家用级显卡上启用了 PCIe P2P 通信支持。所有的修改只有<a href="https://github.com/tinygrad/open-gpu-kernel-modules/commit/9e39420bc4cb50cd7f8033ac6dd5c5583fd07567">不到一百行的改动</a>，大部分都是关掉判断，或者 patch 掉某些函数虚表，使用对应的数据中心卡的实现。所以可以推断，很大程度上，这些限制完全是来自于软件。真有你的，老黄。</p>

<p>虽说这个仓库的最新版本只到 565，另有高人搞出了基于 585 和 595 的版本（<a href="https://github.com/aikitoria/open-gpu-kernel-modules">aikitoria/open-gpu-kernel-modules</a>），顺便也就支持了 RTX 5090，其实核心改动基本还是差不多。</p>

<p>作者给出的安装脚本有些简单粗暴，直接当场构建模块并 <code class="language-plaintext highlighter-rouge">insmod</code>。这显然有点脏了。考虑到目前的模块都是 <code class="language-plaintext highlighter-rouge">dkms</code> 安装的，最好的办法其实是解包 NVIDIA 提供的 <code class="language-plaintext highlighter-rouge">nvidia-kernel-open-dkms</code> 包并替换文件。如果不打算频繁更新驱动的话，也可以采用一种略微少脏一些的办法：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 安装官方驱动</span>
apt <span class="nb">install </span>nvidia-driver-pinning-590.48.01
apt <span class="nb">install </span>nvidia-open<span class="o">=</span>590.48.01-1 nvidia-kernel-open-dkms<span class="o">=</span>590.48.01-1
<span class="c"># 卸载模块</span>
rmmod nvidia_drm nvidia_modeset nvidia_uvm nvidia
<span class="c"># 下载魔改驱动</span>
git clone git@github.com:aikitoria/open-gpu-kernel-modules.git
<span class="c"># 备份原有的驱动源代码</span>
<span class="nb">cd</span> /usr/src/nvidia-590.48.01/
<span class="nb">mkdir </span>backup
<span class="nb">mv </span>kernel-open src backup/
<span class="c"># 打补丁</span>
<span class="nb">cd</span> ~/open-gpu-kernel-modules/
<span class="nb">cp</span> <span class="nt">-r</span> kernel-open src /usr/src/nvidia-590.48.01/
<span class="c"># 重新构建驱动并安装</span>
dkms build <span class="nt">-m</span> nvidia/590.48.01 <span class="nt">--force</span>
dkms <span class="nb">install</span> <span class="nt">-m</span> nvidia/590.48.01 <span class="nt">--force</span>
<span class="c"># 重新加载模块</span>
modprobe nvidia
</code></pre></div></div>

<p>此时可以再次测试，是否已经化腐朽为神奇：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harry@gpu:~$ nvidia-smi topo -p2p r
        GPU0    GPU1    GPU2    GPU3    GPU4    GPU5    GPU6    GPU7
 GPU0   X       OK      OK      OK      OK      OK      OK      OK
 GPU1   OK      X       OK      OK      OK      OK      OK      OK
 GPU2   OK      OK      X       OK      OK      OK      OK      OK
 GPU3   OK      OK      OK      X       OK      OK      OK      OK
 GPU4   OK      OK      OK      OK      X       OK      OK      OK
 GPU5   OK      OK      OK      OK      OK      X       OK      OK
 GPU6   OK      OK      OK      OK      OK      OK      X       OK
 GPU7   OK      OK      OK      OK      OK      OK      OK      X
</code></pre></div></div>

<p>此外，从 <code class="language-plaintext highlighter-rouge">lspci</code> 的输出也可以看到，此时 5090 映射了所有显存到 BAR（如下的 Region 1）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>01:00.0 VGA compatible controller: NVIDIA Corporation GB202 [GeForce RTX 5090] (rev a1) (prog-if 00 [VGA controller])
        Subsystem: NVIDIA Corporation Device 2059
        Physical Slot: 5
        Control: I/O+ Mem+ BusMaster+ SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR- FastB2B- DisINTx+
        Status: Cap+ 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast &gt;TAbort- &lt;TAbort- &lt;MAbort- &gt;SERR- &lt;PERR- INTx-
        Latency: 0
        Interrupt: pin A routed to IRQ 583
        NUMA node: 3
        IOMMU group: 17
        Region 0: Memory at f0000000 (32-bit, non-prefetchable) [size=64M]
        Region 1: Memory at 3f800000000 (64-bit, prefetchable) [size=32G]
        Region 3: Memory at 40012000000 (64-bit, prefetchable) [size=32M]
        Region 5: I/O ports at 2000 [size=128]
</code></pre></div></div>

<h2 id="性能测试">性能测试</h2>

<h3 id="带宽延迟测试">带宽延迟测试</h3>

<p>CUDA Samples 中的 <code class="language-plaintext highlighter-rouge">p2pBandwidthLatencyTest</code> 可以测试 GPU 之间的通信带宽和延迟。启用 P2P 前：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Unidirectional P2P=Disabled Bandwidth Matrix (GB/s)
   D\D     0      1      2      3      4      5      6      7
     0 1528.86  31.57  31.12  30.91  28.82  29.39  29.51  29.86
     1  30.66 1556.32  31.00  30.29  29.12  29.57  29.39  29.87
     2  30.69  31.64 1556.27  30.29  31.22  31.26  31.37  31.14
     3  31.12  31.58  30.96 1550.15  29.04  29.55  29.41  29.69
     4  29.99  31.49  31.22  29.60 1553.28  31.22  31.44  31.13
     5  30.18  31.72  31.42  29.80  30.61 1559.38  31.42  29.78
     6  30.31  31.72  31.55  29.96  30.47  31.38 1559.38  30.73
     7  30.03  31.39  31.04  29.63  30.83  31.05  31.41 1553.18

Bidirectional P2P=Disabled Bandwidth Matrix (GB/s)
   D\D     0      1      2      3      4      5      6      7
     0 1528.82  32.56  32.29  32.40  31.64  31.93  31.88  31.96
     1  32.53 1540.88  32.61  32.53  31.77  32.06  32.08  31.68
     2  32.28  32.52 1539.34  32.18  32.21  32.29  32.23  32.23
     3  32.47  32.46  32.08 1540.88  31.50  31.80  31.89  31.83
     4  31.54  32.03  32.36  31.41 1537.82  32.26  32.53  32.22
     5  31.66  32.23  32.38  31.81  32.16 1539.36  32.52  32.07
     6  31.80  32.19  32.53  31.78  32.34  32.65 1539.36  32.29
     7  31.76  32.30  32.39  31.81  32.44  32.16  32.66 1540.88

P2P=Disabled Latency Matrix (us)
   GPU     0      1      2      3      4      5      6      7
     0   2.07  14.25  14.34  12.65  14.08  14.30  14.30  14.30
     1  14.30   2.07  13.91  12.45  14.25  14.16  14.25  14.25
     2  14.19  14.26   2.07  14.33  14.33  14.31  14.33  14.33
     3  14.32  14.32  13.81   2.07  14.25  14.26  14.16  14.32
     4  14.34  14.32  14.33  14.31   2.07  14.32  14.32  14.32
     5  14.31  14.31  14.33  14.33  14.33   2.07  14.33  14.33
     6  14.32  14.32  14.35  14.32  14.33  14.34   2.07  14.33
     7  14.32  14.31  14.34  14.31  14.32  14.31  14.35   2.07
</code></pre></div></div>

<p>启用 P2P 后：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Unidirectional P2P=Enabled Bandwidth (P2P Writes) Matrix (GB/s)
   D\D     0      1      2      3      4      5      6      7
     0 1516.99  48.54  48.50  48.53  48.45  48.54  48.56  48.48
     1  48.76 1543.97  48.64  48.61  48.62  48.58  48.62  48.57
     2  48.75  48.74 1537.89  48.76  48.76  48.76  48.76  48.76
     3  48.75  48.76  48.74 1543.97  48.76  48.74  48.76  48.74
     4  48.75  48.76  48.76  48.76 1540.93  48.74  48.74  48.76
     5  48.74  48.76  48.76  48.76  48.76 1547.03  48.76  48.74
     6  48.73  48.65  48.64  48.64  48.64  48.65 1547.03  48.66
     7  48.75  48.69  48.55  48.64  48.61  48.61  48.60 1544.02

Bidirectional P2P=Enabled Bandwidth Matrix (GB/s)
   D\D     0      1      2      3      4      5      6      7
     0 1530.32  97.42  97.44  97.46  97.41  97.45  97.44  97.44
     1  97.43 1540.88  97.44  97.44  97.45  97.45  97.44  97.44
     2  97.43  97.44 1539.34  97.44  97.45  97.44  97.44  97.45
     3  97.44  97.44  97.41 1539.34  97.44  97.45  97.45  97.45
     4  97.45  97.45  97.47  97.44 1537.82  97.45  97.46  97.47
     5  97.46  97.45  97.45  97.44  97.44 1540.88  97.46  97.46
     6  97.43  87.75  97.44  97.44  97.44  97.44 1540.86  97.45
     7  97.44  97.45  97.45  97.45  97.46  97.44  97.44 1540.86

P2P=Enabled Latency (P2P Writes) Matrix (us)
   GPU     0      1      2      3      4      5      6      7
     0   2.07   0.37   0.35   0.37   0.43   0.37   0.36   0.36
     1   0.36   2.07   0.35   0.37   0.36   0.43   0.43   0.42
     2   0.38   0.43   2.07   0.38   0.38   0.44   0.37   0.38
     3   0.38   0.43   0.37   2.07   0.36   0.36   0.37   0.37
     4   0.45   0.37   0.44   0.38   2.07   0.45   0.37   0.43
     5   0.36   0.35   0.36   0.36   0.37   2.07   0.43   0.35
     6   0.43   0.42   0.43   0.36   0.36   0.43   2.07   0.43
     7   0.36   0.44   0.36   0.36   0.36   0.36   0.36   2.07
</code></pre></div></div>

<p>也就是说，在启用 P2P 前后，各个指标都有了显著提升：</p>

<ul>
  <li>单向带宽：31.5 GB/s（由于需要经过内存，产生两次拷贝，理论上限为 PCIe 5.0 x16 带宽的一半即 32 GB/s）-&gt; 48.5 GB/s（理论上限为 64 GB/s）；</li>
  <li>双向带宽：32 GB/s（理论值为 64 GB/s）-&gt; 97.4 GB/s（理论值为 128 GB/s）；注意到表格中有一个异常值（GPU 1 &lt;-&gt; GPU 6），应该是偶发情况。</li>
  <li>通信延迟：14.3 us -&gt; 0.4 us。</li>
</ul>

<p>如果修改 BIOS 把 NPS4 切换成 NPS2，那么还能获得进一步的提升。同一个 NUMA Node 上的两个 GPU 之间，单向带宽变成了 56 GB/s，双向带宽 111 GB/s，延迟没有太大的变化。</p>

<h3 id="集合通信测试">集合通信测试</h3>

<p>下面用 NCCL 来测试对多卡深度学习影响非常显著的 AllReduce 性能。</p>

<p>八卡启用 P2P 前：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#                                                              out-of-place                       in-place
#       size         count      type   redop    root     time   algbw   busbw  #wrong     time   algbw   busbw  #wrong
#        (B)    (elements)                               (us)  (GB/s)  (GB/s)             (us)  (GB/s)  (GB/s)
        4096          1024     float     sum      -1    51.03    0.08    0.14       0    50.82    0.08    0.14       0
        8192          2048     float     sum      -1    51.58    0.16    0.28       0    51.36    0.16    0.28       0
       16384          4096     float     sum      -1    52.67    0.31    0.54       0    52.63    0.31    0.54       0
       32768          8192     float     sum      -1    54.57    0.60    1.05       0    54.74    0.60    1.05       0
       65536         16384     float     sum      -1    59.77    1.10    1.92       0    60.10    1.09    1.91       0
      131072         32768     float     sum      -1    62.38    2.10    3.68       0    62.12    2.11    3.69       0
      262144         65536     float     sum      -1    71.12    3.69    6.45       0    70.86    3.70    6.47       0
      524288        131072     float     sum      -1   104.97    4.99    8.74       0   104.45    5.02    8.78       0
     1048576        262144     float     sum      -1   163.53    6.41   11.22       0   161.47    6.49   11.36       0
     2097152        524288     float     sum      -1   275.28    7.62   13.33       0   273.02    7.68   13.44       0
     4194304       1048576     float     sum      -1   534.66    7.84   13.73       0   537.23    7.81   13.66       0
     8388608       2097152     float     sum      -1  1015.74    8.26   14.45       0  1010.40    8.30   14.53       0
    16777216       4194304     float     sum      -1  1999.61    8.39   14.68       0  2002.71    8.38   14.66       0
    33554432       8388608     float     sum      -1  4011.43    8.36   14.64       0  4013.64    8.36   14.63       0
    67108864      16777216     float     sum      -1  7972.72    8.42   14.73       0  7970.34    8.42   14.73       0
   134217728      33554432     float     sum      -1  15721.4    8.54   14.94       0  15733.6    8.53   14.93       0
   268435456      67108864     float     sum      -1  31422.7    8.54   14.95       0  31431.6    8.54   14.95       0
   536870912     134217728     float     sum      -1  62893.2    8.54   14.94       0  62859.7    8.54   14.95       0
  1073741824     268435456     float     sum      -1   128117    8.38   14.67       0   127351    8.43   14.75       0
</code></pre></div></div>

<p>由于 NCCL 默认并不愿意为 <code class="language-plaintext highlighter-rouge">SYS</code> 连接的 GPU 启用 P2P，因此需要通过 <code class="language-plaintext highlighter-rouge">NCCL_P2P_LEVEL=SYS</code> 强制打开。判断是否真的启用了 P2P 的方法是，设置 <code class="language-plaintext highlighter-rouge">NCCL_DEBUG=INFO</code> 后，查看日志中是否出现了类似的字样：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[0] NCCL INFO Channel 00/0 : 0[0] -&gt; 1[1] via P2P/direct pointer
[5] NCCL INFO Channel 01/0 : 1[5] -&gt; 0[4] via P2P/direct pointer
[1] NCCL INFO Channel 00/0 : 1[1] -&gt; 0[0] via P2P/direct pointer
[0] NCCL INFO Channel 01/0 : 0[0] -&gt; 1[1] via P2P/direct pointer
[4] NCCL INFO Channel 01/0 : 0[4] -&gt; 1[5] via P2P/direct pointer
[1] NCCL INFO Channel 01/0 : 1[1] -&gt; 0[0] via P2P/direct pointer
[7] NCCL INFO Channel 00/0 : 1[7] -&gt; 0[6] via P2P/direct pointer
[7] NCCL INFO Channel 01/0 : 1[7] -&gt; 0[6] via P2P/direct pointer
</code></pre></div></div>

<p>此时性能结果为：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#                                                              out-of-place                       in-place
#       size         count      type   redop    root     time   algbw   busbw  #wrong     time   algbw   busbw  #wrong
#        (B)    (elements)                               (us)  (GB/s)  (GB/s)             (us)  (GB/s)  (GB/s)
        4096          1024     float     sum      -1    38.81    0.11    0.18       0    36.53    0.11    0.20       0
        8192          2048     float     sum      -1    38.20    0.21    0.38       0    37.00    0.22    0.39       0
       16384          4096     float     sum      -1    39.47    0.42    0.73       0    38.28    0.43    0.75       0
       32768          8192     float     sum      -1    40.94    0.80    1.40       0    39.82    0.82    1.44       0
       65536         16384     float     sum      -1    42.48    1.54    2.70       0    41.36    1.58    2.77       0
      131072         32768     float     sum      -1    43.65    3.00    5.25       0    42.99    3.05    5.34       0
      262144         65536     float     sum      -1    49.97    5.25    9.18       0    47.18    5.56    9.72       0
      524288        131072     float     sum      -1    63.10    8.31   14.54       0    62.07    8.45   14.78       0
     1048576        262144     float     sum      -1    84.45   12.42   21.73       0    85.24   12.30   21.53       0
     2097152        524288     float     sum      -1   149.68   14.01   24.52       0   149.70   14.01   24.52       0
     4194304       1048576     float     sum      -1   280.51   14.95   26.17       0   278.13   15.08   26.39       0
     8388608       2097152     float     sum      -1   545.43   15.38   26.91       0   552.92   15.17   26.55       0
    16777216       4194304     float     sum      -1  1091.57   15.37   26.90       0  1090.04   15.39   26.93       0
    33554432       8388608     float     sum      -1  2169.79   15.46   27.06       0  2162.90   15.51   27.15       0
    67108864      16777216     float     sum      -1  4336.85   15.47   27.08       0  4327.66   15.51   27.14       0
   134217728      33554432     float     sum      -1  8640.99   15.53   27.18       0  8627.71   15.56   27.22       0
   268435456      67108864     float     sum      -1  17226.8   15.58   27.27       0  17207.1   15.60   27.30       0
   536870912     134217728     float     sum      -1  34359.1   15.63   27.34       0  34359.3   15.63   27.34       0
  1073741824     268435456     float     sum      -1  68660.4   15.64   27.37       0  68652.1   15.64   27.37       0
</code></pre></div></div>

<p>也就是说，八卡 AllReduce 在 PCIe 上的带宽（<code class="language-plaintext highlighter-rouge">busbw</code>）从 14.75 GB/s 提升到了 27.34 GB/s。如果在一个 Socket 的四张 GPU 上测试，则提升会更明显，从 16.33 GB/s 提升到了 46.31 GB/s，大概是原来的 2.8 倍。</p>

<p>需要注意，设置 <code class="language-plaintext highlighter-rouge">NCCL_P2P_LEVEL=SYS</code> 也可能会导致 NCCL 过分激进地使用这些 P2P 连接，在复杂的应用中（如多维并行的分布式深度学习），未必能带来性能收益。如果可能，建议修改 NPS 配置，使得 NCCL 在默认配置下也能使用 P2P 连接，并合理分配不同并行方式的维度。如对于 NPS2 的八卡机集群，可以考虑 TP=2, PP=4, DP=N 的配置，其中 N 为机器的总数。</p>

<h2 id="神秘修复">神秘修复</h2>

<p>那么这些性能提升是完全免费的吗？是，也不是。我发现在魔改驱动后，一旦进程使用了 P2P 功能，那么有概率存在部分 GPU，在所有进程均退出后依旧维持 100% 的利用率和相当高的功耗。但只要再次在这些 GPU 上创建 CUDA Context，什么都不做并立刻销毁，就能使得它恢复正常静息状态。可以合理推测，魔改后的驱动应该存在一定的资源泄露问题。</p>

<p>为了缓解此问题，我在系统中增加了一个 systemd timer，定期检查是否有 GPU 无进程但高占用的情况，如果有则运行上述的简单修复程序。虽然方法比较土，但可以有效地缓解这个问题。</p>

<h2 id="one-more-thing">One More Thing</h2>

<p>虽然目前的驱动已经支持 P2P，但是还是不能使用 GPUDirect RDMA 功能，而这是提升跨机通信性能的关键。既然 GPU 之间能互相通信，那么理论上 IB 卡和 GPU 通信应该也不困难。此外，从 580 开始， NVIDIA 驱动已经切换到了 Linux 官方支持的 <a href="https://docs.kernel.org/driver-api/dma-buf.html">dma-buf</a> 来提供此支持，而不需要 <code class="language-plaintext highlighter-rouge">nvidia-peermem</code> 模块了。但很遗憾，这个魔改的驱动还没有打开此支持，而已经有人许下了<a href="https://github.com/tinygrad/open-gpu-kernel-modules/issues/46">良好的愿望</a>。此外，GPUDirect Storage 也有着相同的原理，但它也还是一个<a href="https://github.com/aikitoria/open-gpu-kernel-modules/issues/17">愿望</a>。</p>

<p>此外，近日我在小红书上还刷到了国内的神人通过 Claude 魔改驱动，还给 5090 增加了 NVLink 支持，甚至涉及到了关于 BAR 相关逻辑的修改。虽说确实 5090 的 PCB 上有此物理接口，但真的要支持并且用起来，又是另一个级别的工作了。如果此事真的可行，那只能说老黄的刀在当前的 vibe coding 时代，可能要再磨一磨了。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="NVIDIA" /><category term="Linux" /><category term="GPU" /><summary type="html"><![CDATA[免责声明： 本文涉及对 GPU 驱动的修改，作者不对任何因此产生的后果负责。]]></summary></entry><entry><title type="html">在 Kubernetes 上部署 JupyterHub 的经验记录</title><link href="https://harrychen.xyz/2026/03/16/deploy-jupyterhub-on-kubernetes/" rel="alternate" type="text/html" title="在 Kubernetes 上部署 JupyterHub 的经验记录" /><published>2026-03-16T15:45:00+08:00</published><updated>2026-03-16T15:45:00+08:00</updated><id>https://harrychen.xyz/2026/03/16/deploy-jupyterhub-on-kubernetes</id><content type="html" xml:base="https://harrychen.xyz/2026/03/16/deploy-jupyterhub-on-kubernetes/"><![CDATA[<p>为了方便各类课程给学生提供开箱即用的 Python 环境，我在教学实验室的 Kubernetes 上（由杰哥强力驱动）部署了一套 JupyterHub，并进行了一些必要的自定义。本文介绍整个部署过程，并记录一些经验和踩过的坑。</p>

<p>JupyterHub 官方提供了一个比较成熟的 Helm chart：<a href="https://zero-to-jupyterhub.readthedocs.io/en/stable/">Zero to JupyterHub with Kubernetes</a>，文档写得比较好，基本上照着做就能跑起来。</p>

<h2 id="服务基本配置">服务基本配置</h2>

<p>首先安装 Helm Chart：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>helm repo add jupyterhub https://jupyterhub.github.io/helm-chart/
helm repo update
</code></pre></div></div>

<p>然后新建 <code class="language-plaintext highlighter-rouge">config.yaml</code>，文件由以下的部分拼接（以及合并）而成。</p>

<h3 id="oauth-认证">OAuth 认证</h3>

<p>我们已有一个统一的 OAuth 认证门户（感谢喵喵！），JupyterHub 通过 <code class="language-plaintext highlighter-rouge">GenericOAuthenticator</code> 接入：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">hub</span><span class="pi">:</span>
  <span class="na">baseUrl</span><span class="pi">:</span> <span class="s">/jupyterhub</span>
  <span class="na">config</span><span class="pi">:</span>
    <span class="na">Authenticator</span><span class="pi">:</span>
      <span class="na">admin_users</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">admin1</span>
        <span class="pi">-</span> <span class="s">admin2</span>
      <span class="na">auto_login</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">allow_all</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">JupyterHub</span><span class="pi">:</span>
      <span class="na">authenticator_class</span><span class="pi">:</span> <span class="s">generic-oauth</span>
    <span class="na">GenericOAuthenticator</span><span class="pi">:</span>
      <span class="na">client_id</span><span class="pi">:</span> <span class="s">jupyterhub</span>
      <span class="na">client_secret</span><span class="pi">:</span> <span class="s">&lt;your_oauth_client_secret&gt;</span>
      <span class="na">oauth_callback_url</span><span class="pi">:</span> <span class="s">https://example.com/jupyterhub/hub/oauth_callback</span>
      <span class="na">authorize_url</span><span class="pi">:</span> <span class="s">https://example.com/portal/api/authorize</span>
      <span class="na">token_url</span><span class="pi">:</span> <span class="s">https://example.com/portal/api/token</span>
      <span class="na">userdata_url</span><span class="pi">:</span> <span class="s">https://example.com/portal/api/self</span>
      <span class="na">username_key</span><span class="pi">:</span> <span class="s">user_name</span>
</code></pre></div></div>

<p>部分需要注意的点：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">baseUrl</code> 设为 <code class="language-plaintext highlighter-rouge">/jupyterhub</code> 可以与其他服务共享域名。</li>
  <li><code class="language-plaintext highlighter-rouge">auto_login: true</code> 配合 <code class="language-plaintext highlighter-rouge">allow_all: true</code>，用户访问时会自动跳转 OAuth 登录，登录成功即可创建新用户，无需手动维护白名单（注：JupyterHub 5.0 开始必须有 <code class="language-plaintext highlighter-rouge">allow_all</code>，否则新用户无法登录）。</li>
  <li><code class="language-plaintext highlighter-rouge">username_key</code> 需要根据 OAuth Provider 返回的用户信息字段来设置。</li>
</ul>

<h3 id="网络与-ingress">网络与 Ingress</h3>

<p>由于我们的 JupyterHub 在子路径下，需要把 proxy 的 Service 类型设为 <code class="language-plaintext highlighter-rouge">ClusterIP</code>（而不是默认的 <code class="language-plaintext highlighter-rouge">LoadBalancer</code>），然后通过公共的 Ingress 暴露：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">proxy</span><span class="pi">:</span>
  <span class="na">service</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">ClusterIP</span>
</code></pre></div></div>

<p>对应的 Ingress 资源 <code class="language-plaintext highlighter-rouge">jupyterhub-ingress.yaml</code>：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Ingress</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">jupyterhub-ingress</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">jupyterhub-ingress</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">rules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">http</span><span class="pi">:</span>
      <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/jupyterhub</span>
        <span class="na">pathType</span><span class="pi">:</span> <span class="s">Prefix</span>
        <span class="na">backend</span><span class="pi">:</span>
          <span class="na">service</span><span class="pi">:</span>
            <span class="na">name</span><span class="pi">:</span> <span class="s">proxy-public</span>
            <span class="na">port</span><span class="pi">:</span>
              <span class="na">number</span><span class="pi">:</span> <span class="m">80</span>
</code></pre></div></div>

<p>这里的 <code class="language-plaintext highlighter-rouge">proxy-public</code> 是 Helm chart 中自动创建的服务名称，通常不需要更改。</p>

<h3 id="用户环境与-profile">用户环境与 Profile</h3>

<p>JupyterHub 的一大优势就是可以提供多种预配置环境供用户选择，通过 <code class="language-plaintext highlighter-rouge">profileList</code> 定义和覆盖：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">singleuser</span><span class="pi">:</span>
  <span class="na">image</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">quay.io/jupyter/scipy-notebook</span>
    <span class="na">tag</span><span class="pi">:</span> <span class="s">python-3.13</span>
  <span class="na">profileList</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">display_name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">数据分析环境</span><span class="nv"> </span><span class="s">(Python</span><span class="nv"> </span><span class="s">3.13)"</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s2">"</span><span class="s">安装了常用的数据分析包，如</span><span class="nv"> </span><span class="s">numpy,</span><span class="nv"> </span><span class="s">scipy,</span><span class="nv"> </span><span class="s">sklearn,</span><span class="nv"> </span><span class="s">statsmodel,</span><span class="nv"> </span><span class="s">sympy,</span><span class="nv"> </span><span class="s">matplotlib,</span><span class="nv"> </span><span class="s">pandas"</span>
      <span class="na">default</span><span class="pi">:</span> <span class="no">true</span>
    <span class="pi">-</span> <span class="na">display_name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">最小环境</span><span class="nv"> </span><span class="s">(Python</span><span class="nv"> </span><span class="s">3.13)"</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s2">"</span><span class="s">仅包含</span><span class="nv"> </span><span class="s">Python</span><span class="nv"> </span><span class="s">和必要的工具"</span>
      <span class="na">kubespawner_override</span><span class="pi">:</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">quay.io/jupyter/minimal-notebook:python-3.13</span>
</code></pre></div></div>

<p>默认使用 <code class="language-plaintext highlighter-rouge">scipy-notebook</code> 镜像，对于大多数数据分析相关的课程场景已经够用。</p>

<h3 id="资源限制与调度">资源限制与调度</h3>

<p>由于服务器资源有限（甚至 k8s 是跑在 VMware 上的），我给每个用户 Pod 设了比较宽松的 limit 和较低的 guarantee，适合教学场景下大量用户同时在线，但不会持续高负载的情况：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">singleuser</span><span class="pi">:</span>
  <span class="na">cpu</span><span class="pi">:</span>
    <span class="na">limit</span><span class="pi">:</span> <span class="m">4</span>
    <span class="na">guarantee</span><span class="pi">:</span> <span class="m">0.05</span>
  <span class="na">memory</span><span class="pi">:</span>
    <span class="na">limit</span><span class="pi">:</span> <span class="s">3G</span>
    <span class="na">guarantee</span><span class="pi">:</span> <span class="s">512M</span>
</code></pre></div></div>

<p>调度方面，启用了 <code class="language-plaintext highlighter-rouge">userPlaceholder</code> 来预热节点，这样用户启动 Pod 时不用等节点调度和拉取镜像：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">scheduling</span><span class="pi">:</span>
  <span class="na">userScheduler</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">podPriority</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">userPlaceholder</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">replicas</span><span class="pi">:</span> <span class="m">5</span>
  <span class="na">userPods</span><span class="pi">:</span>
    <span class="na">nodeAffinity</span><span class="pi">:</span>
      <span class="na">matchNodePurpose</span><span class="pi">:</span> <span class="s">prefer</span>
</code></pre></div></div>

<p>关闭了 <code class="language-plaintext highlighter-rouge">userScheduler</code> 是因为我们集群的默认调度器已经足够。<code class="language-plaintext highlighter-rouge">matchNodePurpose: prefer</code> 则是让用户 Pod 优先调度到标记了 <code class="language-plaintext highlighter-rouge">purpose: user</code> 的节点上，但在资源不足时，也允许调度到其他节点。</p>

<h3 id="存储选择">存储选择</h3>

<p>用户的持久化存储使用我们自建的 <code class="language-plaintext highlighter-rouge">rook-cephfs</code>，每人 5GB 空间：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">singleuser</span><span class="pi">:</span>
  <span class="na">storage</span><span class="pi">:</span>
    <span class="na">capacity</span><span class="pi">:</span> <span class="s">5Gi</span>
    <span class="na">dynamic</span><span class="pi">:</span>
      <span class="na">storageClass</span><span class="pi">:</span> <span class="s">rook-cephfs</span>
</code></pre></div></div>

<p>选择 CephFS 而不是 RBD 的原因是 CephFS 支持 <code class="language-plaintext highlighter-rouge">ReadWriteMany</code>，在需要共享数据时更灵活。实际上我们也用到了这一特性：下文的共享字体就是通过一个 <code class="language-plaintext highlighter-rouge">ReadWriteMany</code> 的 PVC 挂载到所有用户 Pod 中的。</p>

<h3 id="资源回收">资源回收</h3>

<p>为了避免用户忘记关闭 Notebook（真的有人会记得吗？）导致资源浪费，启用了 culler：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">cull</span><span class="pi">:</span>
  <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">timeout</span><span class="pi">:</span> <span class="m">3600</span>
  <span class="na">every</span><span class="pi">:</span> <span class="m">300</span>
</code></pre></div></div>

<p>空闲 1 小时后自动关闭用户 Pod，每 5 分钟检查一次。</p>

<h2 id="hacks">Hacks</h2>

<h3 id="额外依赖">额外依赖</h3>

<p>由于某些课程需要额外的 Python 依赖，而我并不想维护额外的镜像。经过多次测试，可以通过覆盖启动命令，在容器启动时 <code class="language-plaintext highlighter-rouge">pip install</code> 额外的包，然后再启动镜像中给 JupyterHub 用的 single-user server。这样做的代价是启动总要装包，慢一两分钟，好处是不用跟着主镜像版本升级而重新构建镜像。<del>毕竟维护镜像的活最后还是会落到我头上。</del></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">singleuser</span><span class="pi">:</span>
  <span class="na">profileList</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">display_name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">XXXX</span><span class="nv"> </span><span class="s">课程环境</span><span class="nv"> </span><span class="s">(Python</span><span class="nv"> </span><span class="s">3.13)"</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s2">"</span><span class="s">在数据分析环境基础上安装了课程所需依赖(可能需要</span><span class="nv"> </span><span class="s">1-2</span><span class="nv"> </span><span class="s">分钟启动)"</span>
      <span class="na">kubespawner_override</span><span class="pi">:</span>
        <span class="na">cmd</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">/bin/bash</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">-c"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">pip</span><span class="nv"> </span><span class="s">install</span><span class="nv"> </span><span class="s">jupyterlab_rise</span><span class="nv"> </span><span class="s">jupyter-archive</span><span class="nv"> </span><span class="s">ipywebrtc</span><span class="nv"> </span><span class="s">'ipywidgets&lt;8';</span><span class="nv"> </span><span class="s">jupyterhub-singleuser"</span>
</code></pre></div></div>

<p>注：不像下面一样使用 <code class="language-plaintext highlighter-rouge">postStart</code> hook 的原因是，容器是没有状态的，每次启动都是空的；而部分包必须在 Jupyter 本身启动之前安装（比如 <code class="language-plaintext highlighter-rouge">jupyterlab_rise</code>），因此只能覆盖启动命令。</p>

<h3 id="中文字体">中文字体</h3>

<p>官方的 JupyterLab 镜像里不包含中文字体，用 matplotlib 画图时中文会变成方块。当然，重新打镜像也能解决此问题，但我显然还是不想这么做。</p>

<p>偷懒问了一下 Claude，解决方案是创建一个共享的字体 PVC，挂载到每个用户 Pod 中：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">PersistentVolumeClaim</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">jupyterhub-fonts</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">accessModes</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">ReadWriteMany</span>
  <span class="na">storageClassName</span><span class="pi">:</span> <span class="s">rook-cephfs</span>
  <span class="na">resources</span><span class="pi">:</span>
    <span class="na">requests</span><span class="pi">:</span>
      <span class="na">storage</span><span class="pi">:</span> <span class="s">1Gi</span>
</code></pre></div></div>

<p>使用 <code class="language-plaintext highlighter-rouge">kubectl apply -f</code> 应用此文件，然后在配置中把这个 PVC 挂载到用户 Pod，并通过 <code class="language-plaintext highlighter-rouge">postStart</code> hook 刷新字体缓存和 matplotlib 的字体管理器缓存（matplotlib 有自己的缓存，如果不刷新还是看不到新安装的字体，不知为何有这么糟糕的设计）：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">singleuser</span><span class="pi">:</span>
  <span class="na">storage</span><span class="pi">:</span>
    <span class="na">extraVolumes</span><span class="pi">:</span>
      <span class="na">1-extra-fonts</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">extra-fonts</span>
        <span class="na">persistentVolumeClaim</span><span class="pi">:</span>
          <span class="na">claimName</span><span class="pi">:</span> <span class="s">jupyterhub-fonts</span>
          <span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">extraVolumeMounts</span><span class="pi">:</span>
      <span class="na">1-extra-fonts</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">extra-fonts</span>
        <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/usr/share/fonts/extra</span>
        <span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">lifecycleHooks</span><span class="pi">:</span>
    <span class="na">postStart</span><span class="pi">:</span>
      <span class="na">exec</span><span class="pi">:</span>
        <span class="na">command</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">sh"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">-c"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">fc-cache</span><span class="nv"> </span><span class="s">-f</span><span class="nv"> </span><span class="s">/usr/share/fonts/extra</span><span class="nv"> </span><span class="s">&amp;&amp;</span><span class="nv"> </span><span class="s">python</span><span class="nv"> </span><span class="s">-c</span><span class="nv"> </span><span class="se">\"</span><span class="s">import</span><span class="nv"> </span><span class="s">matplotlib.font_manager;</span><span class="nv"> </span><span class="s">matplotlib.font_manager._load_fontmanager(try_read_cache=False)</span><span class="se">\"</span><span class="nv"> </span><span class="s">||</span><span class="nv"> </span><span class="s">true"</span>
</code></pre></div></div>

<p>末尾的 <code class="language-plaintext highlighter-rouge">|| true</code> 是为了防止在 matplotlib 未安装的环境（如最小环境）中，容器因为找不到包而启动失败。</p>

<p>往 PVC 里增加字体的方式比较朴素——启动一个临时 Pod 挂载这个 PVC，然后用 <code class="language-plaintext highlighter-rouge">kubectl cp</code> 把字体文件拷进去。临时 Pod 资源描述 <code class="language-plaintext highlighter-rouge">temp-font-pod.yaml</code> 如下：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Pod</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">font-loader</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">loader</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">busybox</span>
    <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">sleep"</span><span class="pi">,</span> <span class="nv">3600</span><span class="pi">]</span>
    <span class="na">volumeMounts</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">fonts</span>
      <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/fonts</span>
  <span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">fonts</span>
    <span class="na">persistentVolumeClaim</span><span class="pi">:</span>
      <span class="na">claimName</span><span class="pi">:</span> <span class="s">jupyterhub-fonts</span>
</code></pre></div></div>

<p>执行完后记得删除：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl <span class="nt">-n</span> jupyterhub apply <span class="nt">-f</span> temp-font-pod.yaml
kubectl <span class="nt">-n</span> jupyterhub <span class="nb">cp</span> /path/to/your/fonts font-loader:/fonts/
kubectl <span class="nt">-n</span> jupyterhub delete pod font-loader
</code></pre></div></div>

<h2 id="部署脚本">部署脚本</h2>

<p>上文的所有配置可以写成一个部署脚本，方便后续升级和回滚：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="nb">set</span> <span class="nt">-x</span>
<span class="nb">set</span> <span class="nt">-e</span>

<span class="nv">NAMESPACE</span><span class="o">=</span>jupyterhub

helm upgrade <span class="nt">--cleanup-on-fail</span> <span class="se">\</span>
  <span class="nt">--install</span> jupyterhub jupyterhub/jupyterhub <span class="se">\</span>
  <span class="nt">--namespace</span> <span class="nv">$NAMESPACE</span> <span class="se">\</span>
  <span class="nt">--create-namespace</span> <span class="se">\</span>
  <span class="nt">--version</span><span class="o">=</span>4.3.2 <span class="se">\</span>
  <span class="nt">--values</span> config.yaml

kubectl <span class="nt">-n</span> <span class="nv">$NAMESPACE</span> apply <span class="nt">-f</span> shared-fonts-pvc.yaml
kubectl <span class="nt">-n</span> <span class="nv">$NAMESPACE</span> apply <span class="nt">-f</span> jupyterhub-ingress.yaml
</code></pre></div></div>

<h2 id="小结">小结</h2>

<p>总体来说，Zero to JupyterHub 这个 Helm chart 做得还不错，上课用起来体验比较好。当然，为了实现额外需求还是需要一些 hack，也需要在文档缺乏的地方自己摸索。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Kubernetes" /><category term="JupyterHub" /><summary type="html"><![CDATA[为了方便各类课程给学生提供开箱即用的 Python 环境，我在教学实验室的 Kubernetes 上（由杰哥强力驱动）部署了一套 JupyterHub，并进行了一些必要的自定义。本文介绍整个部署过程，并记录一些经验和踩过的坑。]]></summary></entry><entry><title type="html">IPMI SDR 和坑爹的 BMC 电源读数一例</title><link href="https://harrychen.xyz/2026/03/10/ipmi-sdr-record-and-weird-bmc/" rel="alternate" type="text/html" title="IPMI SDR 和坑爹的 BMC 电源读数一例" /><published>2026-03-10T21:30:00+08:00</published><updated>2026-03-10T21:30:00+08:00</updated><id>https://harrychen.xyz/2026/03/10/ipmi-sdr-record-and-weird-bmc</id><content type="html" xml:base="https://harrychen.xyz/2026/03/10/ipmi-sdr-record-and-weird-bmc/"><![CDATA[<p>最近组里新购入了一些杂牌 GPU 服务器，为了监控功耗，我部署了 Prometheus 社区的 <a href="https://github.com/prometheus-community/ipmi_exporter"><code class="language-plaintext highlighter-rouge">ipmi_exporter</code></a> 从 BMC 读取带外数据，并使用 Grafana 制作了 Dashboard。然而很快我就发现了奇怪的现象：</p>

<center><img alt="Strange power curve from IPMI" src="/assets/static/img/strange-ipmi-power-curve.jpg" width="500" /></center>

<p>从图中可以看到，17:30 左右我向 GPU 施加了一些负载，使其温度上升到了 70 度以上。然而，整机的功耗曲线就像一潭死水，完全没有可见的变化。在 18:08 左右我又重启了 exporter，此时功耗读数有了一些变化，然而还是在此后的时间中维持不变。这是我用了这么多年的 IPMI，第一次遇到如此诡异的事情。</p>

<h2 id="问题复现">问题复现</h2>

<p>我首先怀疑是 BMC 本身有故障，但从网页上能获得正确的读数和曲线（尽管采样率比较低）。于是我又怀疑是 IPMI SDR 有问题，但我用 <code class="language-plaintext highlighter-rouge">ipmitool</code> 能正确读取实际的功率数字。最后怀疑对象就来到了 <code class="language-plaintext highlighter-rouge">ipmi_exporter</code> 上，它使用的是 <a href="https://www.gnu.org/s/freeipmi/">FreeIPMI</a> 工具中的 <code class="language-plaintext highlighter-rouge">ipmi-sensors</code> 来读取数据。果然，用它就能复现问题：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@foo# ipmi-sensors -D LAN_2_0 -h ipmi_host -u root -p FOO -r 105
Caching SDR repository information: /root/.freeipmi/sdr-cache/sdr-cache-foo.ipmi_host
Caching SDR record 157 of 157 (current record ID 352)
ID  | Name        | Type                     | Reading    | Units | Event
105 | Total_Power | Other Units Based Sensor | 670.00     | W     | 'OK'

(启动重型 GPU 负载并等待几秒钟)

root@foo# ipmitool -I lanplus -H ipmi_host -U root -P FOO sdr get 'Total_Power'
Sensor ID              : Total_Power (0x69)
 Entity ID             : 21.0 (Power Management)
 Sensor Type (Threshold)  : Other (0x0b)
 Sensor Reading        : 4500 (+/- 0) Watts
 Status                : ok

root@foo# ipmi-sensors -D LAN_2_0 -h ipmi_host -u root -p FOO -r 105
ID  | Name        | Type                     | Reading    | Units | Event
105 | Total_Power | Other Units Based Sensor | 670.00     | W     | 'OK'

root@foo# ipmi-sensors -D LAN_2_0 -h ipmi_host -u root -p FOO -r 105
ID  | Name        | Type                     | Reading    | Units | Event
105 | Total_Power | Other Units Based Sensor | 667.32     | W     | 'OK'

</code></pre></div></div>

<p>我拿着这个现象去问了 Claude，它提醒我这很可能和 IPMI SDR 的读取方式有问题，于是我就趁机进行了一些学习。</p>

<h2 id="ipmi-sdr-格式与读取命令">IPMI SDR 格式与读取命令</h2>

<p>SDR (Sensor Data Record) 是 <a href="https://extras.csc.fi/mgrid/docs/IPMI_v1.5_spec.pdf">IPMI 1.5 标准</a>中定义的用于描述传感器信息的数据结构，BMC 会将这些记录存储在一个 SDR Repository 中。每个记录都有一个唯一的 ID，可以通过 IPMI 命令来读取。</p>

<p>可以用如下的命令读取 SDR Repository：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@foo# ipmitool -I lanplus -H ipmi_host -U root -P FOO sdr info
SDR Version                         : 0x51
Record Count                        : 157
Free Space                          : unspecified
Most recent Addition                : NA
Most recent Erase                   : NA
SDR overflow                        : no
SDR Repository Update Support       : unspecified
Delete SDR supported                : no
Partial Add SDR supported           : no
Reserve SDR repository supported    : no
SDR Repository Alloc info supported : no
</code></pre></div></div>
<p>这其实等价于直接发送 <code class="language-plaintext highlighter-rouge">Get SDR Repository Info</code> 命令:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@foo# ipmitool -I lanplus -H ipmi_host -U root -P FOO raw 0x0a 0x20
 51 f5 00 58 13 ff ff ff ff ff ff ff ff 42
</code></pre></div></div>

<p>可以看到 SDR Repository 中有 157 条记录。而从上面可以看到 <code class="language-plaintext highlighter-rouge">Total_Power</code> 的编号是 0x69 (105)。使用 <code class="language-plaintext highlighter-rouge">Get SDR</code> 命令可以读取：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@foo# ipmitool -I lanplus -H ipmi_host -U root -P FOO raw 0x0a 0x23 0x00 0x00 0x69 0x00 0x00 0xFF
 6a 00 69 00 51 01 3b 20 00 69 15 00 00 00 0b 01
 00 00 00 00 00 00 00 06 00 00 0d 40 00 00 00 e0
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
 00 0b 54 6f 74 61 6c 5f 50 6f 77 65 72 00 00 00
 00 00
</code></pre></div></div>

<p>或者使用 <code class="language-plaintext highlighter-rouge">ipmitool sdr dump</code>，也可以获得完整的 SDR 记录。其中与上面对应的是（截掉了一些末尾的 0）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>00001a40: 6900 5101 3b20 0069 1500 0000 0b01 0000  i.Q.; .i........
00001a50: 0000 0000 0006 0000 0d40 0000 00e0 0000  .........@......
00001a60: 0000 0000 0000 0000 0000 0000 0000 000b  ................
00001a70: 546f 7461 6c5f 506f 7765 7200 0000 0000  Total_Power.....
</code></pre></div></div>

<p>其中第一个字节 <code class="language-plaintext highlighter-rouge">0x69</code> 对应了 ID，第四个字节 <code class="language-plaintext highlighter-rouge">0x01</code> 说明这是 Full Sensor Record。各种字段的详细定义在此略去，也可参见 <a href="https://openipmc.gitlab.io/openipmc/structsdr__type__01__t.html">OpenIPMC 提供的头文件</a>。这条记录中的重要字段包括：</p>

<table>
  <thead>
    <tr>
      <th>偏移</th>
      <th>长度</th>
      <th>值</th>
      <th>字段名</th>
      <th>说明</th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0x00</td>
      <td>2</td>
      <td>0x69 0x00</td>
      <td>Record ID</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x02</td>
      <td>1</td>
      <td>0x51</td>
      <td>SDR Version</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x03</td>
      <td>1</td>
      <td>0x01</td>
      <td>Record Type</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x04</td>
      <td>1</td>
      <td>0x3b</td>
      <td>Record Length</td>
      <td>59</td>
      <td> </td>
    </tr>
    <tr>
      <td>0x15</td>
      <td>1</td>
      <td>0x06</td>
      <td>Base Unit</td>
      <td>Watt</td>
      <td> </td>
    </tr>
    <tr>
      <td>0x17</td>
      <td>1</td>
      <td>0x00</td>
      <td>Linearization</td>
      <td>0</td>
      <td>Linear</td>
    </tr>
    <tr>
      <td>0x18</td>
      <td>1</td>
      <td>0x0d</td>
      <td>M[7:0]</td>
      <td>系数</td>
      <td> </td>
    </tr>
    <tr>
      <td>0x19</td>
      <td>1</td>
      <td>0x40</td>
      <td>{M[9:8], Tolerance}</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x1a</td>
      <td>1</td>
      <td>0x00</td>
      <td>B[7:0]</td>
      <td>系数</td>
      <td> </td>
    </tr>
    <tr>
      <td>0x1b</td>
      <td>1</td>
      <td>0x00</td>
      <td>{B[9:8], Accuracy LSB}</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x1c</td>
      <td>1</td>
      <td>0x00</td>
      <td>Accuracy</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>0x1d</td>
      <td>1</td>
      <td>0xe0</td>
      <td>{Rexp[3:0], Bexp[3:0]}</td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>也就是说，这个 SDR 定义了一个带线性系数的传感器。当读出数字是 <code class="language-plaintext highlighter-rouge">raw</code>，其真实值为：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>y = (M * raw + B * 10^Bexp) × 10^Rexp，其中：
M = (0b01 &lt;&lt; 8) | 0x0D = 256 + 13 = 269, B = 0
Rexp = (uint4_t) 0b1110 = -2, Bexp = 0
</code></pre></div></div>

<p>代入后，可知当前真实值是读出值的 2.69 倍。而是用以下命令可以读取传感器原始值：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@foo# ipmitool -I lanplus -H ipmi_host -U root -P FOO raw 0x04 0x2d 0x69
 f9 c0 00 00
</code></pre></div></div>

<p>因此当前的功率是 0xf9 * 2.69 = 670W。与 <code class="language-plaintext highlighter-rouge">ipmi-sensors</code> 读出的值基本一致。</p>

<p>我多次在不同负载下进行读取测试，发现传感器的原始值几乎不发生变化，总是 250 左右，而 SDR 中的系数却总是在变化。我又让 Claude 给我 vibe 了一个脚本同时检测 SDR 记录和传感器原始值，记录如下：</p>

<table>
  <thead>
    <tr>
      <th>Timestamp</th>
      <th style="text-align: right">M</th>
      <th style="text-align: right">R_exp</th>
      <th style="text-align: right">B</th>
      <th style="text-align: right">B_exp</th>
      <th style="text-align: right">Raw</th>
      <th style="text-align: right">Converted</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>20:40:11</td>
      <td style="text-align: right">291</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">249</td>
      <td style="text-align: right">724.59</td>
    </tr>
    <tr>
      <td>20:40:17</td>
      <td style="text-align: right">27</td>
      <td style="text-align: right">-1</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">249</td>
      <td style="text-align: right">672.30</td>
    </tr>
    <tr>
      <td>20:40:22</td>
      <td style="text-align: right">267</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">250</td>
      <td style="text-align: right">667.50</td>
    </tr>
    <tr>
      <td>20:40:28</td>
      <td style="text-align: right">268</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">250</td>
      <td style="text-align: right">670.00</td>
    </tr>
    <tr>
      <td>20:40:34</td>
      <td style="text-align: right">265</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">249</td>
      <td style="text-align: right">659.85</td>
    </tr>
    <tr>
      <td>20:40:41</td>
      <td style="text-align: right">413</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">249</td>
      <td style="text-align: right">1028.37</td>
    </tr>
    <tr>
      <td>20:40:46</td>
      <td style="text-align: right">419</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">249</td>
      <td style="text-align: right">1043.31</td>
    </tr>
    <tr>
      <td>20:40:52</td>
      <td style="text-align: right">267</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">250</td>
      <td style="text-align: right">667.50</td>
    </tr>
    <tr>
      <td>20:40:58</td>
      <td style="text-align: right">268</td>
      <td style="text-align: right">-2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">250</td>
      <td style="text-align: right">670.00</td>
    </tr>
  </tbody>
</table>

<p>到这里，答案基本上已经水落石出。</p>

<h2 id="问题根因">问题根因</h2>

<p>观察 <code class="language-plaintext highlighter-rouge">ipmi-sensors</code> 的代码（或者帮助文档），甚至是直接运行一下，都可以发现它维护了一个 SDR 的 cache（还记得上面的 <code class="language-plaintext highlighter-rouge">Caching SDR repository information</code> 吗？）。这个 cache 只在 SDR repository 的时间戳发生变化时，才会被工具认为失效并重新获取。</p>

<p>不巧的是，这台服务器的 BMC 选择了修改 SDR 记录中的线性系数（M、Rexp 等）来达成修改最终读数的目的，而不是直接修改传感器的原始值（这真的对吗？）。这样一来，<code class="language-plaintext highlighter-rouge">ipmi-sensors</code> 就一直在使用第一次缓存的系数与读出的原始值进行计算，导致最终结果几乎不变。而 <code class="language-plaintext highlighter-rouge">ipmitool</code> 则每次都重新读取 SDR 与原始值，得到的结果自然是正确的。</p>

<h2 id="解决方案">解决方案</h2>

<p>由于 <code class="language-plaintext highlighter-rouge">ipmi_exporter</code> 和 FreeIPMI 耦合比较紧，而 <code class="language-plaintext highlighter-rouge">ipmi-sensors</code> 命令也没有选项允许跳过或者禁用 SDR cache，于是我对 exporter 的代码进行了一些 dirty fix：</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- a/collector.go
</span><span class="gi">+++ b/collector.go
</span><span class="p">@@ -114,7 +114,8 @@</span> func (c metaCollector) Collect(ch chan&lt;- prometheus.Metric) {
                        }
                        args := collector.Args()
                        cfg := config.GetFreeipmiConfig()
<span class="gd">-
</span><span class="gi">+                       // remove all cache
+                       freeipmi.Execute(fqcmd, []string{"--flush-cache"}, cfg, target.host, logger)
</span>                        result = freeipmi.Execute(fqcmd, args, cfg, target.host, logger)
                }
</code></pre></div></div>

<p>考虑到功率读取的频率不高（每 15s 一次），这么做虽然简单粗暴，但也不至于有太大的影响。这样一来，我的 Grafana 上终于有了正确的功率曲线。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="Server" /><category term="BMC" /><category term="IPMI" /><summary type="html"><![CDATA[最近组里新购入了一些杂牌 GPU 服务器，为了监控功耗，我部署了 Prometheus 社区的 ipmi_exporter 从 BMC 读取带外数据，并使用 Grafana 制作了 Dashboard。然而很快我就发现了奇怪的现象：]]></summary></entry><entry><title type="html">用于提供现代 PyPI 镜像的 NGINX 配置</title><link href="https://harrychen.xyz/2025/11/26/nginx-conf-to-serve-modern-pypi/" rel="alternate" type="text/html" title="用于提供现代 PyPI 镜像的 NGINX 配置" /><published>2025-11-26T01:00:00+08:00</published><updated>2025-11-26T01:00:00+08:00</updated><id>https://harrychen.xyz/2025/11/26/nginx-conf-to-serve-modern-pypi</id><content type="html" xml:base="https://harrychen.xyz/2025/11/26/nginx-conf-to-serve-modern-pypi/"><![CDATA[<p>众所周知，在镜像站界，PyPI 是个难伺候的主：大量的硬盘占用、巨大的流量、频繁的更新，还有不靠谱的同步工具 <a href="https://bandersnatch.readthedocs.io/">bandersnatch</a>。你说为什么不靠谱？听说过其他 <em>没有能力</em> 删除上游删掉了的文件的同步工具吗？</p>

<p>好在，伟大的 taoky 在去年写出了一个科学的同步工具 <a href="https://github.com/taoky/shadowmire">shadowmire</a>，其中一大重要功能，就是能移除上游已经消失的版本和相关文件，为广大教育网镜像站节省了不少硬盘空间，也缓解了供应链攻击的风险。</p>

<p>然而，提供用户可用的 PyPI 镜像依旧没有那么简单。任何 HTTP 站点要被视作有效的 Python 仓库，需要遵守 <a href="https://packaging.python.org/en/latest/specifications/simple-repository-api/#simple-repository-api">Simple repository API</a> 或者称为 <a href="https://docs.pypi.org/api/index-api/">Index API</a> 的要求。具体来说，两种类型的 API：列出仓库所有的项目（包），以及列出每个包的信息；而每种 API 又需要提供 JSON 和 HTML 两种格式，根据客户端的 <code class="language-plaintext highlighter-rouge">Accept</code> 请求头来决定返回哪种格式。当然，这些规范并不是一蹴而就的，最早 <a href="https://peps.python.org/pep-0503/">PEP503</a> 定义了所谓的“简单”格式（Simple API），<a href="https://peps.python.org/pep-0691/">PEP691</a> 则在此基础上添加了 JSON 格式的支持，最终形成了现在的标准。</p>

<p>而 PyPI 本身的复杂程度不止如此，它又提供了 <a href="https://warehouse.pypa.io/api-reference/xml-rpc/">XMLRPC API</a>，以及更现代的 <a href="https://docs.pypi.org/api/json/">JSON API</a>。<code class="language-plaintext highlighter-rouge">pip</code> 或者 <code class="language-plaintext highlighter-rouge">uv</code> 等工具并不会用到这些 API，但镜像站的同步需要用到它们来获取包的元数据（比如最重要的：最后更新时间）。因此，我们也可以提供力所能及的服务。</p>

<p>考虑到镜像站一般只提供静态文件服务，因此需要同步工具与 NGINX 配合，才能满足上述要求。目前 Shadowmire 对于一个包 <code class="language-plaintext highlighter-rouge">foo</code>，会生成如下的文件夹结构：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>packages/ # 存放实际的 wheel 文件，略去
simple/ # Simple API
- index.html -&gt; index.v1_html
- index.v1_html
- index.v1_json
- foo/
  - index.html -&gt; index.v1_html
  - index.v1_html
  - index.v1_json
json/ # JSON API
- foo # 包 foo 的 JSON API 内容（如果能从上游获取）
</code></pre></div></div>

<p>首先，对于 Simple API 的目录，我们需要配置 NGINX 根据 HTTP 请求头来返回不同的文件：</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 放置在 server block 外</span>
<span class="k">map</span> <span class="nv">$http_accept</span> <span class="nv">$pypi_mirror_suffix</span> <span class="p">{</span>
    <span class="kn">default</span> <span class="s">".html"</span><span class="p">;</span>
    <span class="kn">"~*application/vnd\.pypi\.simple\.v1\+json"</span> <span class="s">".v1_json"</span><span class="p">;</span>
    <span class="kn">"~*application/vnd\.pypi\.simple\.v1\+html"</span> <span class="s">".v1_html"</span><span class="p">;</span>
    <span class="kn">"~*text/html"</span> <span class="s">".html"</span><span class="p">;</span> <span class="c1"># 老旧的 pip 或者第三方工具可能使用</span>
<span class="p">}</span>

<span class="c1"># 放置在 server block 内</span>
<span class="c1"># 此处 /pypi/web/simple/ 是 TUNA 长期使用的路径前缀，可以根据需要修改</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">^/pypi/web/simple/[^/]*</span> <span class="p">{</span> <span class="c1"># match simple/ and simple/foo</span>
    <span class="kn">index</span> <span class="s">index</span><span class="nv">$pypi_mirror_suffix</span> <span class="s">index.html</span><span class="p">;</span>
    <span class="kn">types</span> <span class="p">{</span>
        <span class="kn">application/vnd.pypi.simple.v1+json</span> <span class="s">v1_json</span><span class="p">;</span>
        <span class="kn">application/vnd.pypi.simple.v1+html</span> <span class="s">v1_html</span><span class="p">;</span>
        <span class="kn">text/html</span> <span class="s">html</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="kn">default_type</span> <span class="s">"text/html"</span><span class="p">;</span>
    <span class="kn">try_files</span> <span class="nv">$uri$pypi_mirror_suffix</span> <span class="nv">$uri</span> <span class="nv">$uri</span><span class="n">/</span> <span class="p">=</span><span class="mi">404</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>其次，对于 JSON API（<code class="language-plaintext highlighter-rouge">/pypi/&lt;pkg_name&gt;/json</code>），我们需要倒转路径中的包名和 <code class="language-plaintext highlighter-rouge">json</code> 两部分，以适应 Shadowmire 下载的目录结构（<code class="language-plaintext highlighter-rouge">/pypi/json/&lt;pkg_name&gt;</code>）：</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># PyPI JSON API: https://docs.pypi.org/api/json/</span>
<span class="c1"># pattern: /pypi/&lt;package_name&gt;/json</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">^/pypi/[^/]+/json$</span> <span class="p">{</span>
    <span class="kn">rewrite</span> <span class="s">^/pypi/([^/]+)/json</span>$ <span class="n">/pypi/web/json/</span><span class="nv">$1</span> <span class="s">break</span><span class="p">;</span>
    <span class="kn">types</span> <span class="p">{</span> <span class="p">}</span>
    <span class="kn">default_type</span> <span class="s">"application/json</span><span class="p">;</span> <span class="kn">charset=utf-8"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>然而，事情到这里还没有结束。PyPI 还额外要求，路径中所有的包名都要进行<a href="https://packaging.python.org/en/latest/specifications/simple-repository-api/#normalized-names">“正规化”（normalization）</a>，即将大写字母转换为小写字母，并将 <code class="language-plaintext highlighter-rouge">-_.</code> 这三个字符都替换为连字符 <code class="language-plaintext highlighter-rouge">-</code>。Shadowmire 在下载时会自动处理这个问题，但在 NGINX 配置中，我们也需要确保用户请求路径被正确地正规化。因此，需要通过 <a href="https://github.com/nginx/njs"><code class="language-plaintext highlighter-rouge">njs</code></a> 脚本来处理这些路径转换：</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pypi.njs</span>

<span class="kd">function</span> <span class="nx">canonicalizeName</span><span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">l</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">();</span>
  <span class="c1">// njs &lt; 0.7.10 does not have `String.replaceAll`</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">l</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">l</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">_</span><span class="dl">'</span> <span class="o">||</span> <span class="nx">l</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">.</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">l</span> <span class="o">=</span> <span class="nx">l</span><span class="p">.</span><span class="nx">substring</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">-</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">l</span><span class="p">.</span><span class="nx">substring</span><span class="p">(</span><span class="nx">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nx">l</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">/// &lt;reference path="ngx_http_js_module.d.ts" /&gt;</span>
<span class="cm">/**
 * @param {NginxHTTPRequest} r
 */</span>
<span class="kd">function</span> <span class="nx">redirectToCanonicalizedName</span><span class="p">(</span><span class="nx">r</span><span class="p">)</span> <span class="p">{</span>

  <span class="kd">const</span> <span class="nx">uri</span> <span class="o">=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">uri</span><span class="p">.</span><span class="nx">trim</span><span class="p">();</span>
  <span class="nx">r</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`pypi.njs: original URI to canonicalize: </span><span class="p">${</span><span class="nx">uri</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">parts</span> <span class="o">=</span> <span class="nx">uri</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">let</span> <span class="nx">matched</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">parts</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="mi">5</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// match `/pypi/web/simple/&lt;pkg_name&gt;`</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">pypi</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">parts</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">web</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">parts</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">simple</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">parts</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span> <span class="o">=</span> <span class="nx">canonicalizeName</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">4</span><span class="p">]);</span>
      <span class="nx">matched</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span> 
  
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">matched</span> <span class="o">&amp;&amp;</span> <span class="nx">parts</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// match `/pypi/&lt;pkg_name&gt;/json`</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">pypi</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">parts</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">json</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">parts</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">=</span> <span class="nx">canonicalizeName</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">2</span><span class="p">]);</span>
      <span class="nx">matched</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">matched</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">r</span><span class="p">.</span><span class="nx">warn</span><span class="p">(</span><span class="s2">`pypi.njs: unknown redirection for URL </span><span class="p">${</span><span class="nx">uri</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="nx">r</span><span class="p">.</span><span class="k">return</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">newUri</span> <span class="o">=</span> <span class="nx">parts</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">r</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`pypi.njs: redirecting to new URI: </span><span class="p">${</span><span class="nx">newUri</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="nx">r</span><span class="p">.</span><span class="k">return</span><span class="p">(</span><span class="mi">302</span><span class="p">,</span> <span class="nx">newUri</span><span class="p">);</span>
  <span class="p">}</span>

<span class="p">}</span>

<span class="k">export</span> <span class="k">default</span> <span class="p">{</span> <span class="nx">redirectToCanonicalizedName</span> <span class="p">}</span>
</code></pre></div></div>

<p>继续增加 NGINX 配置处理这些路径（要放置在上面配置之前，以优先处理）：</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">js_import</span> <span class="s">pypi</span> <span class="s">from</span> <span class="s">pypi.njs</span><span class="p">;</span>
<span class="c1"># explicitly disable canonicalization of these URLs with dots</span>
<span class="k">location</span> <span class="p">=</span> <span class="n">/pypi/web/simple/index.html</span> <span class="p">{}</span>
<span class="k">location</span> <span class="p">=</span> <span class="n">/pypi/web/simple/index.v1_html</span> <span class="p">{}</span>
<span class="k">location</span> <span class="p">=</span> <span class="n">/pypi/web/simple/index.v1_json</span> <span class="p">{}</span>
<span class="c1"># match urls with unnormalized names and handle to js</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">^/pypi/web/simple/[^/]*[A-Z_.][^/]*</span> <span class="p">{</span>
    <span class="kn">js_content</span> <span class="s">pypi.redirectToCanonicalizedName</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">^/pypi/[^/]*[A-Z_.][^/]*/json</span> <span class="p">{</span>
    <span class="kn">js_content</span> <span class="s">pypi.redirectToCanonicalizedName</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>为了减少 JS 的调用量，上述配置里只匹配了“未正规化”的 URL，即包含大写字母或者 <code class="language-plaintext highlighter-rouge">_.</code> 字符的。然而，这样写就会把 <code class="language-plaintext highlighter-rouge">/pypi/web/simple/index.html</code> 之类的 URL 也匹配进去，产生非预期的错误（如 pip 请求 <code class="language-plaintext highlighter-rouge">/simple/</code> 的 JSON 格式，首先被 NGINX 重写为 <code class="language-plaintext highlighter-rouge">/simple/index.v1_json</code>，又被上述规则命中重写成 <code class="language-plaintext highlighter-rouge">/simple/index-v1-json</code>，最终导致 404）。考虑到 <code class="language-plaintext highlighter-rouge">simple/</code> 目录下目前只有这几个文件名可能被访问，因此我们可以通过 <code class="language-plaintext highlighter-rouge">location = </code> 的精确匹配来显式禁用这些 URL 的特殊处理，避免误伤。</p>

<p>最后，为了用户体验（是的，我的 Chrome 被卡死过若干次），也为了降低服务器负担，还可以增加规则禁用一些路径的浏览器访问（依旧需要插入到在上述规则的更前面，使其最早生效）：</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server block 外</span>
<span class="k">map</span> <span class="nv">$http_user_agent</span> <span class="nv">$is_browser</span> <span class="p">{</span>
  <span class="kn">default</span> <span class="mi">0</span><span class="p">;</span>
  <span class="kn">"~*validation</span> <span class="s">server"</span> <span class="mi">0</span><span class="p">;</span>
  <span class="kn">"~*mozilla"</span> <span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1"># server block 内</span>
<span class="c1"># disable browser viewing of some too large directories / files in PyPI</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">^/pypi/web/simple/(index\.(html|v1_html)|json/|pypi/)$</span> <span class="p">{</span>
    <span class="kn">default_type</span> <span class="s">'text/html'</span><span class="p">;</span>
    <span class="kn">if</span> <span class="s">(</span><span class="nv">$is_browser</span><span class="s">)</span> <span class="p">{</span>
        <span class="kn">return</span> <span class="mi">413</span> <span class="s">"This</span> <span class="s">page</span> <span class="s">is</span> <span class="s">too</span> <span class="s">large</span> <span class="s">for</span> <span class="s">browsers."</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>这样，终于可以提供一个比较完整的“现代” PyPI 镜像服务了。如果你也在尝试搭建 PyPI 镜像站，希望能有所帮助。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="NGINX" /><category term="Python" /><summary type="html"><![CDATA[众所周知，在镜像站界，PyPI 是个难伺候的主：大量的硬盘占用、巨大的流量、频繁的更新，还有不靠谱的同步工具 bandersnatch。你说为什么不靠谱？听说过其他 没有能力 删除上游删掉了的文件的同步工具吗？]]></summary></entry></feed>