<?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-04-27T18:42:44+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">在 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-plaintext 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><entry><title type="html">在 Linux 上对 CPU 功耗进行带内测量与限制</title><link href="https://harrychen.xyz/2025/11/18/inband-cpu-power-measurement-and-limit-on-linux/" rel="alternate" type="text/html" title="在 Linux 上对 CPU 功耗进行带内测量与限制" /><published>2025-11-18T23:30:00+08:00</published><updated>2025-11-18T23:30:00+08:00</updated><id>https://harrychen.xyz/2025/11/18/inband-cpu-power-measurement-and-limit-on-linux</id><content type="html" xml:base="https://harrychen.xyz/2025/11/18/inband-cpu-power-measurement-and-limit-on-linux/"><![CDATA[<p>在高性能计算场景中，能有效地测量和控制 CPU 功耗对于优化性能和能效至关重要。硬件厂商（如 Intel、AMD）提供了一些接口和工具，允许用户在不依赖外部硬件的情况下，进行带内（inband）的 CPU 功耗测量与限制。本文将简述常用的方法和工具。</p>

<h2 id="基本概念">基本概念</h2>

<p>现代 CPU 的 TDP（热设计功耗）动辄上百瓦，服务器 CPU 更是达到了几百瓦的量级（如：AMD EPYC™ 9965 和 Intel® Xeon® 6962P Processor 的 TDP 都高达 500W）。再加上 turbo boost（睿频）等技术的加持，实际的 CPU 瞬时运行功耗也可能远超标称的 TDP。在特定场景下，管理员需要对 CPU 的功耗进行监控和限制，以防止过热、节省能源或优化性能。</p>

<p>X86 CPU 的大部分状态信息和控制接口都通过 MSR（Model-Specific Registers，型号特定寄存器）暴露给用户。Linux 提供了 <code class="language-plaintext highlighter-rouge">msr</code> 内核模块，可以通过它在用户态访问这些寄存器，也可以使用 <code class="language-plaintext highlighter-rouge">rdmsr</code> 和 <code class="language-plaintext highlighter-rouge">wrmsr</code> 等工具帮助进行读写。硬件厂商通常还会通过专门的内核模块（如 <code class="language-plaintext highlighter-rouge">intel_pstate</code>、<code class="language-plaintext highlighter-rouge">amd_pstate</code> 等）暴露更高层次的接口，如在 <code class="language-plaintext highlighter-rouge">hwmon</code> 子系统中提供功耗传感器，以进一步简化使用。</p>

<h2 id="动态频率调整">动态频率调整</h2>

<p>现代 CPU 通常支持动态频率调整技术，允许 CPU 根据负载动态调整频率和电压，在有必要时超频或者降频，以在性能和功耗之间取得平衡。此技术在不同厂商、不同产品上有着多样的名字，如：</p>

<ul>
  <li>Intel: Turbo Boost（睿频）、Speed Shift</li>
  <li>AMD: Turbo Core (EPYC)、Precision Boost (Ryzen)</li>
</ul>

<p>因此，最简单的功耗控制方式是直接关闭睿频。如此，CPU 最高只在基频下运行，功耗通常不会超过其 TDP 数值。然而，这显然也会牺牲一些原本可通过频率调整获得的性能。除了在 BIOS 的电源管理 / CPU 设置中永久关闭睿频外，还可以通过软件的方式在 Linux 中进行控制：</p>

<ul>
  <li>Intel：在加载 <code class="language-plaintext highlighter-rouge">intel-pstate</code> 模块后，可通过向 <code class="language-plaintext highlighter-rouge">/sys/devices/system/cpu/intel_pstate/no_turbo</code> 写入 1 来关闭睿频，写入 0 则开启。</li>
  <li>AMD：不同代 CPU 的控制方式不同。部分家用 Ryzen CPU 可通过 <a href="https://docs.kernel.org/admin-guide/pm/amd-pstate.html"><code class="language-plaintext highlighter-rouge">amd-pstate</code></a> 与 <code class="language-plaintext highlighter-rouge">amd_pstate_epp</code> 等模块控制，并提供 <code class="language-plaintext highlighter-rouge">/sys/devices/system/cpu/cpufreq/boost</code> 的接口；但我在多个 AMD EPYC 处理器上，均无法使用此方法。</li>
</ul>

<p>此外，控制 CPU 调速器（governor）也能在一定程度上影响功耗表现。常见的调速器有 <code class="language-plaintext highlighter-rouge">performance</code>（性能优先）、<code class="language-plaintext highlighter-rouge">powersave</code>（节能优先）、<code class="language-plaintext highlighter-rouge">ondemand</code>（按需调整）等，具体可通过 <code class="language-plaintext highlighter-rouge">cpupower</code> 工具进行查询和设置。此方式当然也无法直接限制功耗，也是通过影响频率来间接调节。</p>

<h2 id="rapl-技术">RAPL 技术</h2>

<p>显然，上述的方法都并不令人满意，因此现代 X86 CPU 引入了 RAPL (Running Average Power Limit) 技术，可通过硬件直接对功耗进行测量与限制，并能区分多个电源域（power domain）。RAPL 最初由 Intel 在 Sandy Bridge 架构中引入，随后 AMD 也在 Zen 2 架构中实现了类似的功能。RAPL 也通过 MSR 寄存器暴露给用户，可以读取各个功耗域的能耗数据，并设置功耗限制。</p>

<p>虽然 Intel 的文档厚得可怕，还是有不少文章对 RAPL 进行了很不错的介绍，例如<a href="https://haomenghit.github.io/2019/08/23/RAPL-Interface-%E4%BB%8B%E7%BB%8D/">《RAPL Interface 介绍》</a>、<a href="https://zhuanlan.zhihu.com/p/642218816">《CPU架构——RAPL，愿功耗被温柔以待》</a> 等。简而言之，RAPL 将单个 CPU 抽象为多个组成部分，包括核心（Core）、内存（DRAM）、SoC 等，每个部分都可以单独进行功耗测量和限制，当然也可以以整个 socket 为粒度。功耗通常分为三个级别：PL1、PL2、PL3，分别对应长期、短期和超短期的功耗限制。通过设置不同的 PL 值，可以实现对 CPU 功耗的精细控制。最简单地，如果将三个值设置为 CPU 的 TDP，则可预期 CPU 的功耗不会超过其标称值，但在允许范围内依旧具有睿频的能力。</p>

<p>注：作为<del>已经叛逃的</del> XPS 受害者和 ThrottleStop 用户，我对 PL1、PL2 这些名词自然是不能更熟悉。不过直到在摸到服务器 CPU 后，我才真正理解了这些概念。</p>

<h3 id="rapl-on-intel">RAPL on Intel</h3>

<p>Linux 内核从 3.13 开始加入了 <code class="language-plaintext highlighter-rouge">intel_rapl</code> 模块，支持 Intel CPU 的 RAPL 功能。加载该模块后，可以在 <code class="language-plaintext highlighter-rouge">/sys/devices/virtual/powercap</code> 目录下找到相关的接口。每个功耗域都有一个对应的子目录，里面包含了多个文件，用于读取能耗数据和设置功耗限制。关于功耗域的更多讨论，可以阅读 <a href="https://hubblo-org.github.io/scaphandre-documentation/explanations/rapl-domains.html"><em>Explanation on RAPL / Running Average Power Limit domains: what we (think we) know so far</em></a>。</p>

<p>例如，在一台有双路 Intel(R) Xeon(R) Gold 6252 CPU 的服务器上，这个目录的结构如下（方括号中是文件内容，省略了一些不重要的内容）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/sys/devices/virtual/powercap/intel-rapl
├── enabled
├── intel-rapl:0
│   ├── constraint_0_max_power_uw   [150000000]
│   ├── constraint_0_name           [long_term]
│   ├── constraint_0_power_limit_uw [150000000]
│   ├── constraint_0_time_window_us [55967744]
│   ├── constraint_1_max_power_uw   [376000000]
│   ├── constraint_1_name           [short_term]
│   ├── constraint_1_power_limit_uw [180000000]
│   ├── constraint_1_time_window_us [20468203520]
│   ├── device -&gt; ../../intel-rapl
│   ├── enabled                     [1]
│   ├── energy_uj                   [252257431123]
│   ├── max_energy_range_uj         [262143328850]
│   ├── name                        [package-0]
│   ├── intel-rapl:0:0
│   │   ├── constraint_0_max_power_uw    [41250000]
│   │   ├── constraint_0_name            [long_term]
│   │   ├── constraint_0_power_limit_uw  [0]
│   │   ├── constraint_0_time_window_us  [976]
│   │   ├── device -&gt; ../../intel-rapl:0
│   │   ├── enabled                      [1]
│   │   ├── energy_uj                    [63753574314]
│   │   ├── max_energy_range_uj          [65712999613]
│   │   ├── name                         [dram]
│   │   ├── power/ (...)
│   │   ├── subsystem -&gt; ../../../../../../class/powercap
│   │   └── uevent
│   ├── power/ (...)
│   ├── subsystem -&gt; ../../../../../class/powercap
│   └── uevent
├── intel-rapl:1/ (...)
</code></pre></div></div>

<p>可以看到，每个 CPU socket（package）都有对应的目录（如 <code class="language-plaintext highlighter-rouge">intel-rapl:0</code>），里面包含了该 socket 的功耗限制和能耗数据。此外，还有一个子目录（如 <code class="language-plaintext highlighter-rouge">intel-rapl:0:0</code>）表示 DRAM 功耗域。通过读取 <code class="language-plaintext highlighter-rouge">energy_uj</code> 文件，可以获取该域的累计能耗（单位为微焦耳）。大部份文件是只读的，可读写的文件有两个：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">constraint_X_power_limit_uw</code>：对应级别的功耗限制（单位为微瓦）。</li>
  <li><code class="language-plaintext highlighter-rouge">constraint_X_time_window_us</code>：对应级别的时间窗口（单位为微秒），但并非支持任意值，读取后再写入可以看到生效的值。</li>
</ul>

<p>更具体的介绍可见 <a href="https://www.kernel.org/doc/html/next/power/powercap/powercap.html">Linux 内核 powercap</a> 文档。</p>

<p>从上面的输出看，我的 CPU 支持两个级别的功耗限制：PL1 为每 56s 窗口内允许最大 150W 的平均功耗，目前也设置为 150W；PL2 为最大 376W，目前设置为 180W（读到的 <code class="language-plaintext highlighter-rouge">time_window_us</code> 值比较奇怪，实际这个值不会高于处理器的 <a href="https://www.hkepc.com/18598/%E4%BB%80%E9%BA%BC%E6%98%AF_TDPPL2Tau_%E5%80%BC__%E6%95%99%E4%BD%A0%E7%9C%8B%E6%87%82_Intel_CPU_%E7%9A%84%E7%9C%9F%E5%AF%A6%E5%8A%9F%E8%80%97">tau time</a>，通常只有若干秒）。从计数器重置以来，这个 CPU socket 已经消耗了约 252kJ 的能量，其中内存使用了约 63.7kJ。</p>

<p>在更新的 CPU 上，RAPL 功耗域和可调整的级别都可能更多，如 Xeon 5 (Sapphire Rapids) 支持 PL3 级别，能控制更短期的功耗峰值（通常是若干微秒）。调整这些数值时，需要确保：</p>

<ul>
  <li>数值不能超过硬件支持的最大值（由 <code class="language-plaintext highlighter-rouge">constraint_X_max_power_uw</code> 可知）；</li>
  <li>数值符合逻辑（即 PL1 ≤ PL2 ≤ PL3，并且任意子组件的功耗限制不高于整体的限制）；</li>
  <li>整个系统的散热能力充足；</li>
</ul>

<p>此外需要注意的是，RAPL 也可以被 BIOS 的电源管理相关特性所锁定，只允许用户读取能量，而不能控制功耗。这在 Dell 的服务器上尤其常见，此时相关 <code class="language-plaintext highlighter-rouge">enabled</code> 文件的值是 0，并且无法通过写入 1 来启用；此外各个 <code class="language-plaintext highlighter-rouge">constraint_*</code> 文件也会消失。此时，也可能在内核日志中发现类似如下的警告：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[  +0.018363] intel_rapl_common: Found RAPL domain package
[  +0.000467] intel_rapl_common: Found RAPL domain dram
[  +0.000407] intel_rapl_common: DRAM domain energy unit 15300pj
[  +0.000002] intel_rapl_common: RAPL package-0 domain package locked by BIOS
</code></pre></div></div>

<p>有趣的是，RAPL 由于会暴露 CPU 的结构和负载情况（精确能量消耗），并且读取无需特权，也构成了一种有效的侧信道。例如，<a href="https://platypusattack.com/">PLATYPUS</a> 就是一种利用 RAPL 读数对 SGX 负载进行侧信道攻击的方法。为此，Intel 发布了相关的<a href="https://www.intel.com/content/www/us/en/developer/articles/technical/software-security-guidance/advisory-guidance/running-average-power-limit-energy-reporting.html">安全公告</a>，也有相关的 CVE 编号（CVE-2020-8694, CVE-2020-8695）。对应的缓解措施包括在启用 SGX 时，降低能量更新频率、增加随机噪声等。果然任何东西只要能测量得越精确，安全问题也会越多。</p>

<h3 id="rapl-on-amd">RAPL on AMD</h3>

<p>正如上面所述，AMD 从 Zen2 开始也加入了 RAPL 支持。然而，毫无意外地，AMD 对此的支持也非常碎片化。我将尝试总结各路文档，进行简要的说明。</p>

<p>AMD 的主要工具是 <a href="https://github.com/amd/esmi_ib_library"><code class="language-plaintext highlighter-rouge">e_smi_lib</code></a>，全称是 <a href="https://www.amd.com/en/developer/e-sms/e-smi-in-band-library.html">EPYC™ System Management Interface</a>。根据此项目的 README，RAPL 在不同代处理器上的支持情况如下：</p>

<ul>
  <li>AMD family 17h, model 30h (Zen2); AMD family 19h, model 00-0fh and 30-3fh (Zen3): 仅支持能量计数器读取，不支持功耗限制设置。并且此时处理器中的能量计数器是 32 位的 MSR，很容易溢出；通过安装 <a href="https://github.com/amd/amd_energy"><code class="language-plaintext highlighter-rouge">amd_energy</code></a> 模块（树外模块，需要通过 DKMS 编译加载），可以在 hwmon 子系统中提供准确的能量传感器读数（模块会定期处理溢出回绕，从而实现正确统计）。</li>
  <li>AMD family 19h, model 10-1fh (Zen4), a0-afh (Zen4C) and 90-9fh (MI300A APU): 能量计数器变为 64 位 MSR，无需额外处理即可准确统计。因此依旧可以使用 <code class="language-plaintext highlighter-rouge">amd_energy</code> 模块为 hwmon 提供读数，也可以直接读取对应 MSR（<code class="language-plaintext highlighter-rouge">msr</code> 或者 <code class="language-plaintext highlighter-rouge">msr_safe</code> 模块，发行版内核通常存在）获取数值。</li>
  <li>AMD family 0x1A, model 00-1fh (Zen5), 50-5fh (Zen6): 支持上述的 64 位 MSR，同时支持通过 <a href="https://docs.kernel.org/arch/x86/amd_hsmp.html">HSMP Mailbox</a> 读取功耗和设置限制，此时需要 <a href="https://github.com/amd/amd_hsmp"><code class="language-plaintext highlighter-rouge">amd_hsmp</code></a> 模块的支持。尽管此模块从 Linux 5.18 起已经包含于上游，很多发行版并未启用，因此也可以使用 DKMS 版本。
    <ul>
      <li>HSMP 功能需要在 BIOS 中启用后才能使用，选项为 Advanced &gt; AMD CBS &gt; NBIO Common Options &gt; SMU Common Options &gt; HSMP Support。部分 BIOS 可能隐藏了 AMD CBS 菜单，可以通过杰哥的奇妙小工具 <a href="https://github.com/jiegec/efiutils">uefitools</a> 尝试进入，<strong>风险自负</strong>！</li>
    </ul>
  </li>
</ul>

<p>上文中提到的相关 MSR 为：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">MSR_RAPL_POWER_UNIT</code> (<code class="language-plaintext highlighter-rouge">0xC0010299</code>): 用于获取功耗单位、能量单位。</li>
  <li><code class="language-plaintext highlighter-rouge">MSR_CORE_ENERGY_STATUS</code> (<code class="language-plaintext highlighter-rouge">0xC001029A</code>): 用于读取 core 级别的能量计数器。</li>
  <li><code class="language-plaintext highlighter-rouge">MSR_PACKAGE_ENERGY_STATUS</code> (<code class="language-plaintext highlighter-rouge">0xC001029B</code>): 用于读取 socket 级别的能量计数器。</li>
</ul>

<p>具体从读数到能耗的换算方式可见 <code class="language-plaintext highlighter-rouge">amd_energy</code> 模块的文档。在加载此模块后，可以在 <code class="language-plaintext highlighter-rouge">/sys/class/hwmon/hwmonX/</code> 目录下找到相关的能量传感器文件。如在具有双路 EYPC 7763 处理器的服务器上，可以看到：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/sys/class/hwmon/hwmon8
├── device -&gt; ../../../amd_energy.0
├── energy1_input   [41467652191]
├── energy1_label   [Ecore000]
├── ...
├── energy128_input [50239882568]
├── energy128_label [Ecore127]
├── energy129_input [9981788288726]
├── energy129_label [Esocket0]
├── energy130_input [9467966267730]
├── energy130_label [Esocket1]
├── name            [amd_energy]
├── power
│   ├── async
│   ├── autosuspend_delay_ms
│   ├── control
│   ├── runtime_active_kids
│   ├── runtime_active_time
│   ├── runtime_enabled
│   ├── runtime_status
│   ├── runtime_suspended_time
│   └── runtime_usage
├── subsystem -&gt; ../../../../../class/hwmon
└── uevent
</code></pre></div></div>

<p>可见此机器的 socket0 已经消耗了约 9.98MJ 的能量（约等于 2.77kWh），socket1 消耗了约 9.47MJ（约等于 2.63kWh）。</p>

<p>此外，<code class="language-plaintext highlighter-rouge">e_smi_lib</code> 中的工具也可以读取：</p>

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

============================= E-SMI ===================================

Error in initialising HSMP version sepcific info, Only energy data can be obtained...
Err[3]: HSMP driver not present

--------------------------------------
| CPU Family            | 0x19 (25 ) |
| CPU Model             | 0x1  (1  ) |
| NR_CPUS               | 128        |
| NR_SOCKETS            | 2          |
| THREADS PER CORE      | 1 (SMT OFF)|
--------------------------------------

------------------------------------------------------------------------
| Sensor Name                    | Socket 0         | Socket 1         |
------------------------------------------------------------------------
| Energy (K Joules)              | 10027.166        | 9511.913         |
| Power (Watts)                  | NA (Err: 20)     | NA (Err: 20)     |
| PowerLimit (Watts)             | NA (Err: 20)     | NA (Err: 20)     |
| PowerLimitMax (Watts)          | NA (Err: 20)     | NA (Err: 20)     |
| C0 Residency (%)               | NA (Err: 20)     | NA (Err: 20)     |
------------------------------------------------------------------------
</code></pre></div></div>

<p>由于与功耗控制相关的 HSMP Mailbox 在 7763 (Zen3) 上尚不可用，因此工具只能读取能量数据，无法读取功耗和设置限制。在更新的处理器上（如 Zen5），只要正确配置了 HSMP 驱动，这些功能也是可用的。AMD 的 RAPL 设置的功耗限制并没有 Intel 精细，只有单一的功耗限制值，没有区分级别。</p>

<p>与 Intel 的情况类似，直接读取精确的能耗数据也可能带来侧信道攻击的风险。AMD 的选择是让这些文件默认只有 root 可读，管理员可以为其他用户/组设置合适的权限。</p>

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

<p><a href="https://github.com/bpetit/awesome-energy"><code class="language-plaintext highlighter-rouge">awesome-energy</code></a> 项目收集了大量与能耗测量和管理相关的工具和资源，感兴趣的读者也可以前往查看。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="CPU" /><category term="HPC" /><summary type="html"><![CDATA[在高性能计算场景中，能有效地测量和控制 CPU 功耗对于优化性能和能效至关重要。硬件厂商（如 Intel、AMD）提供了一些接口和工具，允许用户在不依赖外部硬件的情况下，进行带内（inband）的 CPU 功耗测量与限制。本文将简述常用的方法和工具。]]></summary></entry><entry><title type="html">2024 年，我都去了哪？</title><link href="https://harrychen.xyz/2025/09/28/my-travel-2024/" rel="alternate" type="text/html" title="2024 年，我都去了哪？" /><published>2025-09-28T16:30:00+08:00</published><updated>2025-09-28T16:30:00+08:00</updated><id>https://harrychen.xyz/2025/09/28/my-travel-2024</id><content type="html" xml:base="https://harrychen.xyz/2025/09/28/my-travel-2024/"><![CDATA[<p>转眼间，2025 年已经快过去了（显然，我还没有毕业）。上周翻阅照片的时候，意识到我这两年还是去了不少地方，甚至有些地方都快忘记了。
正好有朋友催更我的博客已久，干脆挑选一些值得记录的，写一些流水账。也希望我在 2025 年结束前能写完它。</p>

<p>本篇仅包含 2024 年的行程，2025 年此后（希望）会另开一篇。</p>

<h2 id="ppopp24---英国爱丁堡-29-feb---8-mar">PPoPP’24 - 英国爱丁堡 (29 Feb - 8 Mar)</h2>

<h3 id="背景">背景</h3>

<p>这是疫情后，我第一次参加正经的学术会议（此前的 SC’23 实在太忙带比赛，只能算是顺带了）。起源是 aoao 的推荐系统文章连续第三次被拒绝，但这次 PPoPP 提供了一个作为 Poster 发表的机会。
于是我们作为唯二两个学生作者，一拍即合决定去英国看看。我们顺便也研究了这段时间的娱乐活动，aoao 预定了爱丁堡附近 Knockhill 赛道日的体验，而天天上墙的我自然只能作为观众，买了一张 Hamilton 音乐剧的票（刚好那几天在爱丁堡巡演）。</p>

<p>自然，去英国也要申请签证，填一堆复杂的表格，去两次朝阳的 VFS Global——好在过程还算顺利。北京到爱丁堡的直飞只有海航，而此时我刚刚飞出了国航金卡，aoao 是国航家属，自然就排除了这个选择。
经过一番对比和与票代的拉扯，我们买了国航的 PEK-LHR 往返（CA955/6）和英航的 LHR-EDI 往返。这不是联程票，但后者一天有十几个班次，就像城际公交一样，因此我们也并不担心有严重的延误导致旅程受阻。</p>

<h3 id="day-0">Day 0</h3>

<p>去希思罗的航班是我第一次坐国航 A359 的超经，座位比较宽敞，但遗憾的地方是扶手全是硬隔离，完全没法躺下来睡觉。由于超经人很少，乘务员还跑过来和我们聊了一会，我也不要脸地要了两杯两舱专属的红灯笼。
国航落地落地希思罗 T2 卫星厅，而英航从 T5 出发，过了边检之后需要走一段长长长长长的地下通道穿过跑道，然后选择地铁或者 Elizabeth Line。在 T5 重新过安检的时候，我从 PEK 休息室带的一罐无糖可乐也不幸被发现并没收了。
在 T5 闲逛的时候，我发现超市中有一种叫做“Meal Deal”的食物组合，一个三明治、一包零食、一罐饮料，加起来 4-5 英镑左右，于是就买了一份。没想到，这会成为我接下来一周在英国的主要食物来源。</p>

<p>英航的 LHR-EDI 航线用的是 A319，座位间距比较窄，整个航程只花了 40 多分钟，真的就像在坐大巴。
爱丁堡机场离市区很近，打车大概 20 分钟就到了。PPoPP 开会在西区（West End）的爱丁堡国际会议中心（EICC），我们就在附近找了一家步行 10 分钟左右的希尔顿欢朋酒店，价格勉强卡在报销标准之内。
因为时差的缘故，我们匆匆入住就休息了。第二天当然醒得很早，酒店提供的是标准的 continental breakfast，食物种类不多，但总是能吃饱的。</p>

<h3 id="day-1">Day 1</h3>

<p>落地第二天，会议还没正式开始，我们就去进行了一些非常经典的 city walk 活动，顺着王子街（Prince St）一路走到爱丁堡城堡（Edinburgh Castle），但并没有进去。在老火车站的广场上，等着苏格兰国家画廊（National Gallery of Scotland）开门，进去看了一圈。然后继续往前走，路过了 Scott Monument，然后就爬上了卡尔顿山（Calton Hill），能纵览整个老城的风貌，还可以远远地眺望远处的福斯湾（Firth of Forth）。
从山上下来继续往东边走，经过苏格兰议会大厦（Scottish Parliament）后，我们就开始攀登亚瑟王座（Arthur’s Seat）——当天风很大，又有一点小雨，所以路上比较泥泞，快到山顶的路也比较湿滑。
好在爬到山顶并不算太难，景色也很不错——可以俯瞰整个爱丁堡市区，然而风实在是太大了，把所有人（包括我们两个）都吹得很凌乱，我们只能草草结束行程下山。</p>

<p>因为起床太早，下山其实也就一点出头。感觉又累又饿，我们打了个车直达老城中心的 Royal Mile 终点——St Giles’ Cathedral 附近，找了一家小餐馆品尝苏格兰特色菜 haggis（羊杂碎）。怎么说呢，我们一致觉得还是 meal deal 比较有性价比。
饭后我们继续在老城内部 city walk，参观了若干教堂、Elephant House（J.K. Rowling 写哈利波特的地方），最终抵达了苏格兰国家博物馆（National Museum of Scotland）。
与其说是博物馆，不如说是一个大型的综合展览馆，里面有自然历史、科技、文化等各个方面的展览，有大型恐龙化石、苏格兰的地质构造、工业革命时期的机械设备等，内容还挺丰富有趣。</p>

<p>当天的晚饭我约了一个在爱丁堡读天文学的朋友，说来巧合，认识他也是因为超算比赛有一道相关的题目，他是我们找的后援。他带我们去了市中心的一家法餐馆，确实比英国人做的要强太多了。
饭后他问我们还走不走得动，要不要去他们学校逛逛——来都来了，当然要去。于是我们坐公交去了爱丁堡大学，没逛多久，这位朋友就遇到了他的同学们要去市中心的酒吧小聚。
我们又一路走回老城区，和大家随意聊天。我喝了一杯 Cider，感觉还不错。一直到下起雨来（这时我才意识到这是英国），我们才匆匆跳上一辆公交车，回酒店休息。</p>

<h3 id="day-2">Day 2</h3>

<p>第三天是会议开始注册的日子，我们一大早去会场 EICC 领到了 badge，除此之外还有一张写着日程的纸，再无他——如此简陋，这可是四个合办的著名会议（PPoPP、HPCA、CGO、CC）啊！虽然 EICC 的名字中有“国际”二字，但其实就是一个带有报告厅的四层小楼，整体是圆形的，并不大，相比 Colorado Convention Center 或者国内的大型会议中心，简直是无法比拟。</p>

<p>上午没有日程，aoao 本来想租辆车带我去 Knockhill 赛道先看看，也去周边的海边转转，熟悉一下右舵车。然而 Hertz app 上显示并没有可租的车，去线下的门店也获得了非常惊人的价格，我们只好作罢，选择前往低配的“海边”——乘坐爱丁堡市中心的电车前往 Leith  的海边，叫 New Haven 的小镇（吐槽一下英美地名的重复率）。那里有一个挺大的购物中心 Ocean Terminal，和停着著名的不列颠尼亚号（HMS Britannia）的码头。我们顺着海边的堤岸一路往前，直到抵达无法再前行的灯塔附近——令人惊喜的是此时天突然放晴，蓝天、白云、海鸥，还有远处的 Forth Bridge，真的很美。</p>

<p>回程时我们在 Ocean Terminal 里吃了午饭（并不好吃的中餐），然后乘电车回到市中心。虽然下午有一些 workshop，但我们都感觉实在是太累了，选择回酒店休息一阵子。没想到我这一觉就直接睡到了六点多。正在思考晚饭吃什么的我突然发现酒店附近的购物中心里有一家 IMAX 影院，而当时沙丘 2 刚好在英国提前上映！于是我果断购票，晚饭的选择当然也只剩下了 meal deal。电影本身的震撼不必多说，只是我同样也被电影前的贴片广告长度所震撼了。</p>

<h3 id="day-3">Day 3</h3>

<p>这天是 workshop 的第二天，我们决定不能再摸鱼了，仍旧早起吃完饭前往会场。当天有一些比较有趣的 workshop。一个 Meta 的工程师讲了他们是如何发现 AMD CPU 中一条浮点指令在特定情况下会给出错误结果的，让我大开眼界，这个 talk 也是我第一次接触到 SDC (Silent Data Corruption) 的概念。我还去给 D.K.Panda、香山团队的 workshop 都捧了场。会议的午餐相当……符合预期，是各种各样的冷热糊糊配上半生不熟的米饭；茶歇是各种甜到离谱的小蛋糕。谢谢你，英国人。</p>

<p>当天晚上是 PPoPP 的 Poster Session，我们作为 poster 作者当然是要全程参加的。我们已经能感觉大家会到 LLM 相关的内容更感兴趣，而对我们的工作（分布式推荐系统训练的加速）关注比较少。有趣的事情是有一位女士来我们的展位前问得比较详细，我本以为她单纯是感兴趣，aoao 翻了一下会议主页发现她是 poster chair——原来是来打分评奖的。</p>

<p>开会的一天就这样结束了。我们当天的晚饭是 fish &amp; chips——是的，到英国的第四天，终于吃上了鼎鼎大名的食物。我们只是随机挑选了一家看着人挺多的小店，要了一份炸碟鱼。事实证明，除了拿回酒店有点冷了，导致薯条比较软，味道整体还是不错的。只是，到底有谁能天天吃这个啊？</p>

<h3 id="day-4">Day 4</h3>

<p>今天是<del>比赛日</del>！aoao 和我一大早就打车到了 Knockhill 赛道，跨过了已经遥望多日的 Forth Bridge 抵达对岸，瞬间就抵达了我从电影中时常看到的“苏格兰”——一望无际的草场，连绵的山丘，还有路边的篱笆和石墙，而赛车场就隐藏在其中，围绕着一片小山坡。aoao 的 supercar 体验有两节，分别是法拉利和阿斯顿马丁；在开始前，我们在赛车场的停车场转了一圈，看到了各种神奇的车。我站在围场外边给他拍照、录视频，也是我第一次亲身见到房车（而非卡丁车）在赛道上驰骋。</p>

<p>比完赛我们简单在赛车场的餐厅吃了个汉堡，意识到了一件棘手的事情——打不到车回去。这可不是爱丁堡，荒郊野岭的哪里去找出租车呢？好在赛车场的前台看起来已经业务非常熟练，拨打了一个电话，过了会儿一个大爷就开着出租车过来了，把我们拉到了 Knockhill 所属的 Dunfermline 城市火车站——说是城市，其实就是个小镇，火车站也只有两个紧贴正线的站台，苏格兰标志性的英语和盖尔语的双语标牌。我们等了一会就等来了回爱丁堡的火车，是一辆已经几乎完全熏黑的柴联车，还在冒着黑烟。它花了四十分钟把我们送回了爱丁堡市中心的 Haymarket 火车站，我们又走回会场继续参加下午的议程——这次是 HPCA 的 poster session，我还是看到了不少有趣或者值得关注的工作。我还去报告厅听了 KAIST 的 <a href="https://ieeexplore.ieee.org/document/10476461/">DockerSSD</a>，属实是脑洞大开了。</p>

<p>当天晚上有一顿华为邀请的晚宴，在会场边上的（我们标准不够的）喜来登酒店，提供了一些冷餐和酒水。然而我的心并不在这里，倒不是因为不好吃，而是当晚就是我期盼已久的 Hamilton 演出。我随便吃了两口就坐上公交前往老城的 <a href="https://www.capitaltheatres.com/our-venues/festival-theatre/">Festival Theatre</a>（值得一提的是到现在它还在给我努力地发演出资讯），直到入场看到熟悉的布景，我才有了“我真的看上了 Hamilton 现场”的真实感。我买的票是第一排靠边的遮挡座位，所以有比较大的折扣；但到了现场发现比我预期的视角要好不少；并且我能清晰地看到乐池里面的指挥（兼键盘手）和乐队，这是绝大部分座位没有的特殊视野；最后，我还能把我的包和衣服放在前面的一大片空地上，后面可没有这样的待遇。</p>

<p>演出的整体体验当然是很棒的，比看官摄更有沉浸感。英国场的卡司都是英国人，听他们努力地说/唱美式英语还是有些好玩的。当然，也有几位卡司的表现没有及我的预期——但这个票价，要什么自行车呢？谢幕之后，还有一段我之前从未在 OST 之外的地方见过的散场曲，我还专门拍了<a href="https://www.bilibili.com/video/BV12m411z75d/">一段视频</a>记录。散场后 SD 的人也很多，我也去凑了个热闹，还和 Laurens 的演员合了个影。</p>

<h3 id="day-5">Day 5</h3>

<p>今天没有太多我感兴趣的 talk，于是我决定自己去附近的格拉斯哥转转。格拉斯哥以工业著名，是苏格兰最大的城市。爱丁堡——格拉斯哥的铁路早在 1838 年就已经建成，现在不到 80 km 的路线要开快一个小时，路上也都是苏格兰的田园景象。</p>

<p>我在格拉斯哥走的路线也比较常见，从 Queen Street 火车站出发，穿过乔治广场，就看到了著名的阵亡烈士纪念碑，参观了背后的市政厅（并发现格拉斯哥和大连是友好城市）。正逢初春，路上有不少桃树都开花了。不巧的是格拉斯哥大教堂正在修缮，开放范围有限；我进去简单进去转了转，还包括地下的墓室。教堂背后就是著名的格拉斯哥大目的（Necropolis），建于一座小山上，有几百年来的名人家族墓葬——很遗憾我基本都并不认识。顺着墓碑之间的小路一路往上爬升，也就能看到越来越多的城市剪影。</p>

<p>从墓地出来我继续往城市中心走去，路上参观了一个小小的宗教博物馆，展出了全世界的各种信仰。一路上有很多壁画（格拉斯哥的一大城市特色），还有很多中餐馆（比爱丁堡要更多），然而各种店似乎都并没有开门，有一种萧条的感觉。因此我的午饭，很不幸地，又变成了 meal deal——这次来自奥乐齐。一路上我还在玩 Ingress，做了一些任务，刷了不少 AP。</p>

<p>我的 city walk 终结在克莱德河（clyde river）边，然后我乘坐巴士到了格拉斯哥大学的附近准备继续参观。由于还是感觉饿，我在进入著名的格大画廊博物馆之前先在旁边买了一份经典欧洲美食希腊 gyro 并大快朵颐。画廊同样有古典、现代和过于现代的作品，我都停留并努力进行了欣赏。出来之后我又前往格大的校园——它以混合多样的建筑风格著称——进行了探索（需要爬不少坡），并购买了一些明信片作为纪念品。</p>

<p>作为这次短暂旅程的收尾，我从格大门口搭乘地铁前往来时的 Queen Street 火车站。这是全世界第三老的地铁系统，仅次于伦敦和布达佩斯；特点是以环线双向运行，全程一口价。地铁车辆非常矮小以至于是迷你，连我在里面站立都感觉局促——当然，肯定是没有什么手机信号的。</p>

<p>从格拉斯哥坐上火车，我又回到了爱丁堡，不过这次是从老火车站（Waverley）下车，因为要前往老城区的苏格兰国家博物馆，参加会议的 conference dinner——都花了注册费，那肯定不能错过。从火车站走到城区要穿过一个非常陡峭的台阶，颇有些费力。虽然到的第一天已经参观过博物馆，但晚宴的风格肯定是与白天开放不同的。晚宴还是华为赞助，是桌餐的形式。菜式还是比较 fancy 的，但依旧是真的很不好吃。我和 aoao 与一些华人研究者坐在一起，不至于太尴尬，毕竟还能吐槽英国的食物作为共同语瞬间。</p>

<h3 id="day-6">Day 6</h3>

<p>来到会议的最后一天，只有上午还剩下半天的议程。我去听了 HPCA 的 keynote talk，是Nir Shavit 关于我们需要更稀疏的神经网络演讲——正是 aoao 的老本行。而后我就和他出门转了一圈，在超市买了一些回头货（比国内便宜的 Biscoff 和巧克力），也爬到山顶上城堡的门口拍了几张照。原本我们打算在城堡边上的店里购买一些苏格兰威士忌，然而由于 aoao 没带护照而被店员拒绝（”look under 25?”）。中午我们在市中心高级购物中心的美食广场随便吃了一些海鲜，并不便宜，还是不好吃。</p>

<p>下午，我们决定再去看看海，至少得有个海滩而不是堤岸。我又进行了一番搜索，我们跳上一辆公交车来到了 Portobello 海滩。然而此时天气突又转阴，放眼望去尽是灰蒙蒙的；海滩上没什么人，甚至还风吹得有点冷。我们躲进了岸边一家看起来非常 80 年代风格的游戏厅取暖，里面是各种大家熟悉的街机游戏；于是我们也换了几个代币（用掉身上的零钱）投进了推币机。完全不意外地，我们一无所获。</p>

<p>坐上公交车回到城区，我们又走上了第一天来的 Prince Street，逛了 Apple Store 和商场，就到了晚饭的时间。考虑是在爱丁堡最后一个晚上，我们实在是不想吃奇怪的东西或者 meal deal 了，于是咬咬牙找了一家 steak house，点了一份巨大的战斧牛排。现在回忆起来，这应该是我们在英国吃的最像话的一顿了——当然，代价也不小，折合人民币人均将近 500。</p>

<p>晚上回去，我终于不再拖延（也没时间了），写完了给几个朋友的明信片。刚好当时是爱德华王子 60周岁生日，英国 Royal Mail 推出了特别的邮戳（<a href="https://www.royalmail.com/sites/royalmail.com/files/2024-02/2024-02-23-postmarks.pdf">这个文件</a> 的第五页中间），还有一些其他的常设的纪念邮戳，都需要通过把明信片套寄到集邮中心的形式获取。——事后收到这些明信片，我发现 Royal Mail 给每张明信片盖戳之后贴心地套上了塑料膜，于是中国的邮戳一个也没有盖上。</p>

<h3 id="day-7">Day 7</h3>

<p>这天是返程的日子，我们特意买了几乎最早的航班前往伦敦，以获取一些在伦敦玩的时间（所谓“来都来了”正是如此）。不幸的是，航班起飞时间有所延误，而我们的行李又晚出来了一些（因为有酒而不得不托运），因此我们从希思罗地铁站出发时已经早上十点了。在 Piccadilly Line 包浆的座位上上晃了一个多小时（地下段自然是没什么信号），我们抵达了伦敦之旅的第一站：King’s Cross St. Pancras。</p>

<p>为什么是 King’s Cross？理由是非常显然的。在一家小店寄存完行李，我们就直奔火车站。然而著名的九又四分之三站台打卡点（其实是站台外面的一面墙）前面排满了人，授权商店也因为装修而暂时关闭，让我有点失望。于是我选择去真的 9 和 10 站台之间的闸机前拍了一张照，买了一个昂贵而干硬的三明治作为我们的午餐，就离开了车站。</p>

<p>从 King’s Cross 出来，我们坐（巨热的）Central Line 抵达了 Victoria 站，顺着 Buckingham Gate 开始了又一天的 city walk，探访了西敏教堂（cathedral，并非 abbey），经过了著名的甘地和丘吉尔像，路过大本钟和威斯敏斯特宫，在威斯敏斯特桥上拍摄了景点打卡照，走到了泰晤士河对岸的伦敦眼。我们本想坐一下摩天轮，但考虑到天气不好视野不佳，结合排队的长度，最终还是放弃了。</p>

<p>我原本打算坐上双层巴士在泰晤士河南岸游览，但 aoao 发现有一种神奇的交通方式是河上的 Uber Boat，刚好有一班要发出，我们就从伦敦眼边上了船。这种体验确实很新奇，能纵览两岸风光，也很舒适。我们一路经过著名的千禧桥（当然是因为它被食死徒拆过一次）、伦敦桥、贝尔法斯特号，最终在塔桥（Tower Bridge）前的码头下了船。这附近就是伦敦金融城的核心了，再往东去就有点远了。</p>

<p>我们在塔丘附近转了转，参观了万圣教堂（All Hallows by the Tower）和伦敦大火纪念碑（并没有上楼），也感受了金融城的繁华。我在名字很有纪念意义的 City of London Post Office 寄出了我的明信片们，和 aoao 一起跳上了回 King’s Cross 的双层巴士，路过了金融城的各个地标，也算用 bus 代替 walk 了。我们提前了一些下车，从狄更斯故居门口一直走回到了火车站。</p>

<p>取上行李，我们又坐上 Piccadilly Line，回到了希思罗 T2。这还是我第一次持星盟金卡在国外坐国际航班，那当然是要去蹭一蹭休息室的。希思罗 T2 的星盟休息室有汉莎、加航、新航，然而它们要不是太远，就是在装修，或者已经过了营业时间，只剩下美联航的 United Club 还在营业——那也比没有好嘛。由于当天吃得不多，我已经很饿了，进去随机地捞了一大堆看起来像食物的东西，没想到居然挺不错，似乎超出了英国的一般水平。休息室里还有挺专业的酒吧，我先是要了一杯血腥玛丽（然后立刻后悔了，这都是什么味道啊），后续又要了一杯长岛冰茶。调酒师还是很舍得给酒的，我后面在飞机上强撑着才没有在起飞时就立刻睡着（为了吃我订的海鲜特餐，是一块挺大的鱼肉），后续的整个航程也睡得非常沉。这次超经的人比较满，我就没有再要红灯笼了。</p>

<p>一觉醒来，飞机已经在乌兰察布，不到一个小时就降落在了首都 T3。我愉快地蹭上了 aoao 的接机，然后立刻发现我这天在伦敦的交通费用一共有了 20 多英镑。这还是 TfL 对信用卡支付有日限额的情况，伦敦的消费水平从中可见一斑。</p>

<h2 id="debconf24---韩国釜山-27-jul---2-aug">DebConf’24 - 韩国釜山 (27 Jul - 2 Aug)</h2>

<h3 id="背景-1">背景</h3>

<p><a href="https://www.debconf.org/">DebConf</a> 是 Debian 发行版的年度会议。从 2023 年底开始，我被 aron 和 lumin 拉入坑，比较积极地开始维护一些 Debian 软件包，尤其是 <code class="language-plaintext highlighter-rouge">zfs-linux</code> 和 <code class="language-plaintext highlighter-rouge">pytorch</code> 相关的，还有一些科学计算相关软件。我在 24 年中获得了 Debian Maintainer 的身份，并在年底正式成为了 Debian Developer。</p>

<p>为什么要申请 DM 呢？一方面是方便上传一些包，而不需要每次通过 DD 签名赞助上传；另一个就是（由经验丰富的 aron 提醒）能让 Debian 更名正言顺地赞助我参与 DebConf。这类赞助分两类，分别是会议期间的食宿和参与会议的所有费用（如往返旅费、签证费等）。考虑到每个人原则上只能申请一次全部费用的报销，而北京往返釜山的机票并不贵，我还有一张没用过的韩国五年签证，我就只申请了前者，并很快获得了批准。因为 aron 有公司报销自己在外面住，我就又叫上了坏人和我分享大学宿舍的双人间。</p>

<h3 id="day-0-1">Day 0</h3>

<p>DebConf 的实际召开时间是 7/28 - 8/3（此前还有 DebCamp），而我们三人在 7/27 就飞到了釜山。由于我和 aron 的航班（著名的 CA129）时间比较早，我们前一晚就抵达了首都机场，在 T3 附近的东海康得思酒店住了一晚。北京到韩国其实很近，哪怕到东南角的釜山都只要一个多小时。飞机进近时，直到离机场很近的地方才快速下高度，然后几乎是一个掉头就从第三边对准了跑道，然后就接地了；我从来没有经历过这么迅速的进场程序，还是感到有一些震撼的。</p>

<p>今年的 DebConf 在釜山的<a href="https://www.pknu.ac.kr/eng">釜庆大学（PKNU）</a>召开，从釜山机场坐地铁或者打车都挺快。必须要吐槽的是，韩国的打车或者地图软件（比如 Kekao 或者 Naver Map）的移动端国际化支持并不好，使用英语地名，甚至是韩语的罗马拼写都经常搜不到地方（而 Uber 就好一些）；而我又实在是没有识读韩文的能力，因此在每天在规划出行上都会花不少时间。</p>

<p>aron 的酒店在釜山著名的海云台海滩边，第一天我们就在附近进行了游玩，在 36 度气温、没有遮挡的海滩上喂鸽子，在附近的小店吃了猪肉汤，然后乘坐著名的米浦小火车，沿着海边的铁路往返了松亭站。坏人于当晚到达，我们在学校周围又吃了一顿正宗的韩式烤肉，我和坏人在宿舍楼的 DebConf 前台办理了入住登记，前往宿舍休息。主办方只为我们提供了被褥、毛巾、杯子，还有一些纪念品，还好我提前准备了一些一次性生活用品，比如浴巾、牙具等，才能满足基本的生活需要。</p>

<h3 id="day-1---3">Day 1 - 3</h3>

<p>第二天是正式的会议，我们一早就前往食堂吃免费早餐。会场的餐饮由一家外包公司负责，不管是哪一顿，肉、蛋白质、主食通常都是齐备的，平心而论并不难吃。然而想吃菜的话，那通常只有无穷无尽的各类泡菜可选。偶尔有几顿饭提供了蔬菜沙拉和水果，那简直成了我的救星。</p>

<p>DebConf 的会议内容我并不想过多赘述，一方面技术内容太多，可能显得无趣；另一方面当然也是因为我的记忆已经比较模糊了。值得一提的是整个会议期间都有持续的 Key Signing Party，具体形式是，在会议开始前，各位参会者提交自己的 key id，并（通常由 Gunar Wolf）形成一份清单，包含每个人的编号、姓名和 key id，并生成清单的 hash。大家自行打印这个列表（gwolf 也会打印一些分发）带到会场，确认上面自己的 key 是否正确，并在会议开始时共同核对列表的 hash。这样大家互相签名时，就只需要核验身份、记下编号，而不需要确认 key id，十分方便。</p>

<p>我在会场见到了不少“网友”——或者说是只在邮件里见过的名字，很多人都比较符合我对 geek 的刻板印象，甚至有些人真的看起来非常仙风道骨。本届 Debian Project Leader (DPL) Andreas Tille 是德国人，是二十多年的用户，也是从维护科学计算、生物医院相关软件包开始自己的贡献的。参会者当然还有不少来自国内的老熟人，比如直升机（zhsj）、劳模肥猫、Ciel，Bill Chen 等。Aron 也给我介绍了不少他熟悉的 DD，在此略过不表。</p>

<p>当然，来都来了，怎么能每天只是开会呢？我们在没有感兴趣的 session 的时候，还有会议结束后的晚上，也会（顶着大热天）跑出去玩。我们逛了学校周边的釜山博物馆、联合国军（正是抗美援朝中的“美”，但不止是美军）纪念墓园、五六岛、广安里海滩等各个景点，也和不同的朋友们在晚上去釜山市中心附近吃了更多的韩式烤肉、烤鳗鱼、雪冰、炸鸡、鸡汤（咦？为什么吃的这么像五道口呢？），还在超市买了便宜的牛奶、不怎么便宜的海鲜。在韩国使用信用卡十分方便，只是很多地方并不能 tap 只能插卡；公共交通都使用 T-Money，我在出发前找朋友要了一张卡，美中不足是充值只能用现金。</p>

<p>让我印象比较深刻的景点是 7/30 去的甘川文化村，它离釜山市中心并不近，在一座山的半山腰上，坐公交或者打车都要四十多分钟。整个村子就是由山上的建筑和穿过其中的曲折的小径构成，房子的屋顶被涂成了各种颜色，远远看起来颇是扎眼。游览的过程就是穿过小径，一路上上下下，最终抵达山下；一路上分布着各类文创和手工艺制品店，还有普通的民居。其中最著名的地标莫过于在一片开阔隘口的小王子雕像了，还有旁边楼上大幅的喷绘。游览给我整体的感受是，和国内千篇一律的各种古街、文创街相比，还是更胜一筹，也完全没有“被小红书骗过来”的懊悔。</p>

<h3 id="day-4-1">Day 4</h3>

<p>7/31 是 DebConf 官方组织的 Day Trip，我们三人都报名了蔚山（Ulsan）一日游，因为对其他的韩服文化体验等选项实在没有兴趣。蔚山是釜山旁的著名工业城市，同样临海。主办方为我们提供了大巴车和英语导游。</p>

<p>我们前往的第一个经典是蔚山的国家公园（太和江国家庭院），其实就是河边一片茂密的竹林，没有太多的景观。好在竹子够高，挡住了绝大部分的阳光，因此我们愉快地在里面走了一个来回，互相聊天，分享八卦（有人的地方当然就有八卦，Debian 的精彩故事会一点不比外面少）。下一站是蔚山大桥观景塔，能俯瞰蔚山的城市和码头区域。导游介绍说蔚山有过捕鲸的历史，因此有专门的码头是为捕鲸船准备的。</p>

<p>中午的团餐是韩式拌饭（bibimbap），还有海鲜饼，Debian 的旅游团几乎占满了整个店。食物意外地还挺好吃。下午第一站是长生浦鲸鱼文化村——这是一个由于捕鲸而兴起的村落，随着行业而衰落，最终成为了一个经典。让我印象最深刻的不是各类模仿八九十年代的生活展览，而是村子正中央的雕塑：一条鲸鱼躺在地上，身上扎着巨大的鱼叉，还有鲜红的血。离开村子之后的下一站是瓮器（Onggi）博物馆，展示了蔚山发展出的多种多样的陶制瓮器文化——什么，你要问它们是干什么用的？当然是腌泡菜啦。</p>

<p>一日游的最后一站是艮绝岬，这是韩国最东的海角，是朝鲜半岛陆地上迎接第一缕阳光的地方，据说天气好的时候还能看到对面日本的福冈。自然景观对我来说总是有吸引力的，尽管它其实也没有太多特别之处，还有就是真的很热很晒。浅浅逛了四十多分钟，大巴又把我们拉回了釜庆大学。</p>

<p>这一天晚上没有晚餐提供，我们坐着公交去了釜山火车站逛了一圈，站后有一个很大的观景平台。在 aron 买到了他心心念念的 釜山吉祥物 Boogi 的手办后，我们去吃了一家评分还不错的炖鸡（以及熟悉的炸鸡、雪冰），结束了旅游的一天。</p>

<h3 id="day-5---6">Day 5 - 6</h3>

<p>8/1 又是开会的一天，但不同的是晚上有官方的会议晚宴，在学校的体育馆进行。晚宴是自助餐的形式，提供了各类韩餐、西餐和酒水。大家都吃得很愉快、聊得很愉快，只是……体育馆没有空调，只有几个大电扇和一些冰块，所以里面的气氛变得越来越焦灼与热烈。不得不承认我们都无法承受这种热情，只好吃到一半带着 zhsj 溜出去，又跑到了广安里海滩上。这次，海滩上和海滩前路上的人都比此前多了很多，于是我们……又去吃了一碗雪冰，就这样跳上公交车，结束了在釜山的最后一个夜晚。</p>

<p>8/2 是会议的最后一天，我和 aron 在中午先行离开了。釜山机场很小，不过国航签约休息室里面的餐食还不错，至少有热菜。我们登机前，最后在免税店买了一些巧克力，就坐上了回北京的 CA130 航班。</p>

<p>如果要问我这次旅程印象最深的部分，排名第一的是所有人手机每天都会同时同时响起若干次的、声音巨大的运营商警报，内容通常是高温预警（会有国家、道、城市几级重复发送）或者海浪预警；第二是花样无穷的泡菜（多半从山东进口）；第三才是会议的本身——Debian 和以自己的方式热爱 Debian 的这群人。诚然，Debian 不是完美的系统或者社区，但它在这个分裂的世界中也已经是一座灯塔。</p>

<h2 id="cncc24---浙江横店-23-27-oct">CNCC’24 - 浙江横店 (23-27 Oct)</h2>

<p>这不是我第一年参加 CNCC，此前我参加过 CNCC’19（苏州）、CNCC’20（合肥）、CNCC’21（深圳）、CNCC’23（沈阳），但这次一定是最特别的一次。此前的会议都在会展中心或者大型酒店举行，分会场都是正经的会议室。而这次 CCF 把会开到了横店的圆明新园景区里——这是一个完全复刻了未被毁坏的圆明园的园区，也是横店影视基地的一部分。</p>

<p>考虑到横店的交通并不像大城市那么方便，主办方为我们提供了方便的接驳车，第一天直接把我们从萧山机场接到了会议酒店，此后每天早晚都有接驳车往返会场和各个酒店。巧合的是，我选择的酒店刚好是 CCF 接待专家贵宾的酒店（但价钱比起横店影视城的其实更便宜），第一天我甚至不慎坐上了 VIP 接驳车，享受了交警沿线封路、门对门直接送进园区的待遇。</p>

<p>圆明新园园区很大，会场分布在各个建筑中，那么与会者要怎么移动呢？走当然是可以，但还有更高级的方式：电瓶车！会议这几天，园区内原本供的电瓶车都可以由我们随便使用，但很遗憾数量并不太多。于是我们每天还需要和数以万计的参会者争夺这些电瓶车的使用权，谁占上一辆，立刻能呼朋引伴带上三个同行人，成为这一片园区最风光的崽。</p>

<p>会议的开幕式在“海岳开襟”前的半露天大剧场举行，十分震撼，就像是皇家的仪式。我把开幕式的录像发给了 ACM 主席 Yannis，他连呼后悔，说应该来体验一下的。会场后面有横店政府组织的美食街，有当地的各种特色。当天的会议午餐直接分发到了大剧场的座位上，也是非常熟悉的江南味道（比贵校的 50 块盒饭好太多了）。分会场的论坛在各种不同特色的建筑中举办，可谓移步换景，十分有趣。</p>

<p>会议第二天间歇，我前往离圆明新园不远处，CCF 计算机博物馆的工地参观。这是 CNCC 2019 就已经宣布的项目，今年 CNCC 在横店召开应当也与此有关。博物馆的主体结构已经完工，目前正在进行后期施工，工地中有一些建设时使用的先进技术的展示，还有建成后的场馆示意图。虽然平日对 CCF 的部分行为和言论持保留意见，我觉得计算机博物馆项目绝对是非常有意义的工程，也很希望在它建成后再次参观。</p>

<p>当地政府对 CNCC 的支持力度非常大，除了上面所述以外，还有更重磅的一项福利：所有会议参与者可以在晚上会议日程结束后，免费进入横店影视城所有景点游玩！要知道这些景点的门票标价可都有 100-200 元，五六个加起来不是小数目。我和组里的同学一起去了不少景点，有一些确实比较噱头，但我还是对两个项目有很好的印象：一是广州街香港街景区的“走进电影”项目，用不到一个小时的时间带我们体验了经典影片中的经典剧情，和电影拍摄的技巧和艺术，非常有趣；二是梦幻谷水世界的两个实景演出，虽然剧情很老调，但场面宏大、布景专业，足见横店的实力。</p>

<p>在离开前的最后一个下午，我还特地去坐刚开通的金华轨道交通金义东线前往东阳市区转了一圈，逛了东阳卢宅。很多人可能不知道横店虽然以影视行业闻名世界，但本身在行政区划上只是一个镇，辖于东阳县级市，又辖于金华地级市。这次旅程并没有太多惊喜或者意外，更多的属于探索——除了我发现卢宅的一个展品从墙上掉了下来，并向 12345 反映了（第二天东阳文旅局就回复我说修好了，果然是浙江速度）。会议结束后，主办方又把我们送到了义乌机场——到底是国际大都市，机场的英语标识甚至比首都机场还要好。这次航班上认识的人实在是太多了，几乎是被北京的老师同学们包机了。</p>

<h2 id="sc24---美国亚特兰大-15-24-nov">SC’24 - 美国亚特兰大 (15-24 Nov)</h2>

<p>TBD</p>

<h2 id="cecc24---福建厦门-6-9-dec">CECC’24 - 福建厦门 (6-9 Dec)</h2>

<p>TBD</p>

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

<h3 id="开源软件论坛---南京大学-aug-2023-2025">开源软件论坛 - 南京大学 (Aug 2023-2025)</h3>

<p>TBD</p>

<h3 id="深圳--香港---若干次">深圳 / 香港 - 若干次</h3>

<p>TBD</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="生活" /><category term="SCC" /><category term="旅行" /><category term="会议" /><summary type="html"><![CDATA[转眼间，2025 年已经快过去了（显然，我还没有毕业）。上周翻阅照片的时候，意识到我这两年还是去了不少地方，甚至有些地方都快忘记了。 正好有朋友催更我的博客已久，干脆挑选一些值得记录的，写一些流水账。也希望我在 2025 年结束前能写完它。]]></summary></entry><entry><title type="html">又踩了 CMap 的坑——探究字体与 PDF 文件中的字符映射表</title><link href="https://harrychen.xyz/2025/03/17/pitfalls-from-cmap-in-font-and-pdf/" rel="alternate" type="text/html" title="又踩了 CMap 的坑——探究字体与 PDF 文件中的字符映射表" /><published>2025-03-17T22:30:00+08:00</published><updated>2025-03-17T22:30:00+08:00</updated><id>https://harrychen.xyz/2025/03/17/pitfalls-from-cmap-in-font-and-pdf</id><content type="html" xml:base="https://harrychen.xyz/2025/03/17/pitfalls-from-cmap-in-font-and-pdf/"><![CDATA[<p>今天是研究生毕业论文提交初稿的日子（<del>当然和我没什么关系</del>）。中午有组里的同学来找我，说 GPT 老师找出了如下的问题：</p>

<blockquote>
  <ol>
    <li>（英文关键词部分）所有英文分号”;”显示异常（显示为希腊问号字符U+037E），应统一改为标准英文分号”;”。</li>
  </ol>
</blockquote>

<p>打开 thuthesis 生成的 PDF 尝试复制了一下，确实是这样。我感觉有点奇怪，就去 thuthesis 的仓库看了一眼，对应代码是这样写的：</p>

<div class="language-latex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\thu</span>@clist@use<span class="p">{</span><span class="k">\thu</span>@keywords@en<span class="p">}{</span>; <span class="p">}</span><span class="c">%</span>
</code></pre></div></div>

<p>看起来完全没有问题。那是怎么回事呢？</p>

<p>TL;DR: 是 PDF feature，无法彻底解决。</p>

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

<p>去除各种无关因素后，使用 XeLaTeX 编译如下的 MWE 就可以复现问题：</p>

<div class="language-latex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\documentclass</span><span class="p">{</span>minimal<span class="p">}</span>
<span class="k">\usepackage</span><span class="na">[utf8]</span><span class="p">{</span>inputenc<span class="p">}</span>
<span class="k">\usepackage</span><span class="p">{</span>fontspec<span class="p">}</span>
<span class="k">\newfontfamily</span><span class="p">{</span><span class="k">\tnr</span><span class="p">}{</span>Times New Roman<span class="p">}</span>

<span class="nt">\begin{document}</span>
Hello; World;
<span class="k">\tnr</span> Hello; World;
<span class="nt">\end{document}</span>
</code></pre></div></div>

<p>其中每一行的第一个“分号”复制出来是 U+003B Semicolon，第二个分号是 U+037E Greek Question Mark。</p>

<p>为了更好地进行演示，我做了一个简单的 demo 仓库：<a href="https://github.com/Harry-Chen/font-pdf-cmap-analysis">Harry-Chen/font-pdf-cmap-analysis</a>。本文的提及的所有源代码和结果文件都在仓库中，读者可以自己实验。</p>

<p>在各类桌面阅读器（如 Acrobat Reader、Foxit Reader、SumatraPDF）、浏览器（如 Chrome、Edge）中打开生成的 <code class="language-plaintext highlighter-rouge">1.semicolon-tex.pdf</code> 文件，分别复制两行的前后分号，就会发现第一行的两个“分号”是 U+003B，第二行的两个“分号”都是 U+037E。而如果使用 PDF.js 渲染（如 GitLab、TeXPage 等平台），所有的四个分号全变成了 U+003B。</p>

<p>再更换最新最热的排版引擎 Typst（仓库中的 <code class="language-plaintext highlighter-rouge">1.semicolon-typ.typ</code>），选择默认的 Libertine 时，表现符合预期；而如果用 Times New Roman 字体，则复制出来的分号都是 U+037E。</p>

<h2 id="问题溯源">问题溯源</h2>

<p>每次遇到 PDF 字体渲染、编码之类的问题，我都会第一时间想起 Clerk Ma，毕竟他是我心目中的世界级 PDF 专家。收到文件后，他立刻点明了导致问题的直接原因：Times New Roman 字体的 CMap 表把这两个 Unicode 码位都映射到了同一个 GID（Glyph ID，字形 ID）上，而 LaTeX 生成的 PDF 嵌入的 ToUnicode 表里面（通常也叫做 CMap），这个字形又唯一指向了 U+037E 的码位。同理可得，Typst 的表中，应该都指向了 U+003B。</p>

<p>等等，这都是什么鬼？</p>

<h3 id="从码位到字形字体-cmap-表">从码位到字形：字体 CMap 表</h3>

<p>众所周知，PDF 是一种画板，你看到的一切都是用某种方式绘制出来的，文字也不例外。LaTeX 生成的 PDF 中，文字的绘制方式由字体（font）文件决定，而每个字体文件又可以看作大量相同风格字形（glyph）的集合，每个字形都有自己的特别的编号。</p>

<p>对于 TrueType 字体文件，可以想象每个字体文件都定义了两个映射 <code class="language-plaintext highlighter-rouge">f: CH -&gt; GID</code>， <code class="language-plaintext highlighter-rouge">g: GID -&gt; glyph</code>，其中 CH 是文字在字符集（这里只考虑 Unicode）中的码位（code point），GID 是字形的编号（通常是连续排列），glyph 是具体的字形。<code class="language-plaintext highlighter-rouge">f</code> 通常就存储在字体文件的 <code class="language-plaintext highlighter-rouge">CMap</code> 表中，而 <code class="language-plaintext highlighter-rouge">g</code> 存储在 <code class="language-plaintext highlighter-rouge">glyf</code> 表中。</p>

<p>需要注意到，<code class="language-plaintext highlighter-rouge">f</code> 通常既不是单射，也不是满射；前者是因为一个码位可以对应多个字形（如正常、粗体、斜体，这由字体中的不同属性控制），后者是因为多个语义不同的码位也可能共享同一个字形，就比如上文提到的分号和希腊问号，典型的例子还有多种不同语义的空格。</p>

<p>还以上文提到的 Times New Roman 字体中的分号为例，从文本到字形，经过的环节包括：</p>

<ul>
  <li>解码：将以 UTF-8 编码的字符转换为 Unicode 码位：<code class="language-plaintext highlighter-rouge">0x3B -&gt; U+003B</code>。</li>
  <li>映射：在 Times New Roman 字体的 CMap 表中查找 <code class="language-plaintext highlighter-rouge">U+003B</code> 对应的 GID，是 <code class="language-plaintext highlighter-rouge">semicolon</code>，ID 为 30。</li>
  <li>生成：从字体的 <code class="language-plaintext highlighter-rouge">glyf</code> 表中找到 ID 为 30 的字形（其实是一段可执行的字节码），转化为可以用来绘制的曲线。</li>
</ul>

<p>事实上，上面 “映射”这一步是经过简化的，比如每个字体文件中的 CMap 表很可能有多个，对应不同的平台、不同的字符集。此外，在 PostScript、OpenType 等字体技术中，为了节约资源或者其他考虑，码位需要先转化为 CID（这也是 CMap 的名字由来）然后再检索字形。具体的细节可以参考 <a href="https://www.zhihu.com/question/49487030">CID-Key 在 OpenType 扮演什么角色？</a>。</p>

<p>再顺便提一句，后续的文字渲染和排印过程非常复杂，要考虑大量的因素——在这里指路喵喵上上周的精彩 Tunight <a href="https://tuna.moe/event/2025/text-rendering/">《金枪鱼之夜：PowerTUNA &amp; 数字文本渲染 101》</a>（以及也请务必关注<a href="https://layered.meow.plus/">可爱喵喵</a>！）。</p>

<p>仓库中的 <code class="language-plaintext highlighter-rouge">utils/font-cmap.py</code> 可以从 TTF 文件中提取 <code class="language-plaintext highlighter-rouge">CMap</code> 表，并寻找映射了多于一个码位的字形。如对于 <code class="language-plaintext highlighter-rouge">times.ttf</code> 运行，就可以获得如下的结果（节选）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Subtable 1:
 Format: 4
 Platform ID: 0
 Platform Encoding ID: 3
 Language: 0
 Number of mappings: 3677
Glyph ID 3 name 'space':
 - ' ' (U+0020, SPACE)
 - ' ' (U+00A0, NO-BREAK SPACE)
Glyph ID 16 name 'hyphen':
 - '-' (U+002D, HYPHEN-MINUS)
 - '­' (U+00AD, SOFT HYPHEN)
Glyph ID 30 name 'semicolon':
 - ';' (U+003B, SEMICOLON)
 - ';' (U+037E, GREEK QUESTION MARK)
Glyph ID 257 name 'periodcentered':
 - '·' (U+00B7, MIDDLE DOT)
 - 'ꞏ' (U+A78F, LATIN LETTER SINOLOGICAL DOT)
Glyph ID 1316 name 'uni018F':
 - 'Ə' (U+018F, LATIN CAPITAL LETTER SCHWA)
 - 'Ә' (U+04D8, CYRILLIC CAPITAL LETTER SCHWA)
</code></pre></div></div>

<p>这里 Platform ID 0 代表 Windows，Encoding ID 3 代表 Unicode。可以看到，<code class="language-plaintext highlighter-rouge">30 (semicolon)</code> 这个字形映射了两个码位，正是 U+003B 和 U+037E。其他的多重映射也是可以理解的，比如空格、连字符、中心点等。</p>

<blockquote>
  <p>Clerk Ma: 实际上你用思源黑体，正文和康熙部首区也是共用一套glyph的</p>
</blockquote>

<h3 id="从字形到码位pdf-tounicode-表">从字形到码位：PDF ToUnicode 表</h3>

<p>在排版工具（如 LaTeX、Typst、Word）将渲染完的字形写入 PDF 时，通常会选择嵌入字体文件，这样可以保证在不同的设备上都能正确显示。换句话说，PDF 中的每个“字”其实都是对字体文件（可能被按需裁剪）中某个字形的引用，而并没有原始文本相关的信息（比如码位或者编码后的字节）。为了使得阅读器可以复制文本，PDF 采取的方式通常是嵌入一个 <code class="language-plaintext highlighter-rouge">ToUnicode</code> 表，用来存储字形到码位的映射 <code class="language-plaintext highlighter-rouge">f': (FN, GID) -&gt; [CH]</code>，其中 FN 是字体名称，而码位可以是一个序列。<code class="language-plaintext highlighter-rouge">f'</code> 被要求是单射，也就是一个字形只能有唯一的映射；但由于 <code class="language-plaintext highlighter-rouge">f</code> 既不是单射也不是满射，所以不存在直接可计算的逆映射。多个字形对应一个码位的情况显然是容易处理的，但如果多个码位对应一个字形，就面临如何选择码位的问题。</p>

<p>仓库中的 <code class="language-plaintext highlighter-rouge">utils/pdf-cmap.py</code> 可以从 PDF 文件中提取 <code class="language-plaintext highlighter-rouge">ToUnicode</code> 表。以 LaTeX 的例子 <code class="language-plaintext highlighter-rouge">1.semicolon-tex.pdf</code> 为例，可以看到如下的结果（节选）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Font F1:
  BaseFont: PKDHXX+LMRoman10-Regular-Identity-H
...
&lt;002F&gt; &lt;0064&gt;
&lt;0032&gt; &lt;0065&gt;
&lt;003E&gt; &lt;0048&gt;
&lt;0048&gt; &lt;006C&gt;
&lt;0051&gt; &lt;006F&gt;
&lt;0060&gt; &lt;0072&gt;
&lt;0063&gt; &lt;003B&gt;
&lt;0071&gt; &lt;0057&gt;
...

Font F3:
  BaseFont: SNVJFG+TimesNewRomanPSMT
...
&lt;001E&gt; &lt;037E&gt;
&lt;002B&gt; &lt;0048&gt;
&lt;003A&gt; &lt;0057&gt;
&lt;0047&gt; &lt;0064&gt;
&lt;0048&gt; &lt;0065&gt;
&lt;004F&gt; &lt;006C&gt;
&lt;0052&gt; &lt;006F&gt;
&lt;0055&gt; &lt;0072&gt;
...
</code></pre></div></div>

<p>注意力比较强的读者已经关注到了这两行：<code class="language-plaintext highlighter-rouge">&lt;0063&gt; &lt;003B&gt;</code> 和 <code class="language-plaintext highlighter-rouge">&lt;001E&gt; &lt;037E&gt;</code>，这就是导致问题的罪魁祸首。这里的 <code class="language-plaintext highlighter-rouge">001E</code> 是 GID，<code class="language-plaintext highlighter-rouge">037E</code> 是码位，因此在复制时，Time New Roman 中的分号字形就变成了希腊问号 U+037E，而 Latin Modern 中都是 U+003B（也可以推断这个字形的 GID 是 0x63 = 99）。</p>

<p>而在 Typst 的例子如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Font F0:
  Subtype: Type0
  BaseFont: NBEHZB+LibertinusSerif-Regular-Identity-H
...
&lt;0002&gt; &lt;003B&gt;
&lt;0005&gt; &lt;037E&gt;
...

Font F1:
  BaseFont: CTSHTC+TimesNewRomanPSMT
...
&lt;0002&gt; &lt;003B&gt;
...
</code></pre></div></div>

<p>同样可以看到，Libertine 中的两个码位映射到了两个不同的字形（尽管长得很像），所以也有不同的表项；而 Times New Roman 中的两个码位在映射到同一个字形后，就只存在一个表项。</p>

<p>注意到这里字体的 GID 是 2，与 LaTeX 和 TTF 文件中的 30 不同。这是因为 Typst 在裁剪字体（又称为子集化）时，使用 <a href="https://docs.rs/subsetter/latest/subsetter/"><code class="language-plaintext highlighter-rouge">subsetter</code></a> 对字形进行了重新编号。</p>

<h3 id="最后一块拼图">最后一块拼图</h3>

<p>那么为什么 LaTeX 和 Typst 会选择不同的码位作为逆映射？Clerk 告诉我前者的行为由 xdvipdfmx 决定。经过一通代码阅读，我最终只能找到以下的两个函数（LaTeX 部分参考了 tectonic 的代码，在 xdvipdfmx 部分是基本一致的）：</p>

<ul>
  <li>LaTeX: <a href="https://github.com/tectonic-typesetting/tectonic/blob/c2ae25ff1facd9e9cce31b48944b867773f709ec/crates/pdf_io/pdf_io/dpx-tt_cmap.c#L764"><code class="language-plaintext highlighter-rouge">add_ToUnicode_via_glyph_name</code></a> 最终调用 <a href="https://github.com/tectonic-typesetting/tectonic/blob/c2ae25ff1facd9e9cce31b48944b867773f709ec/crates/pdf_io/pdf_io/dpx-agl.c#L725"><code class="language-plaintext highlighter-rouge">agl_get_unicodes</code></a> 从字体中，根据字形名称查找码位，具体的逻辑比较复杂。</li>
  <li>Typst: <a href="https://github.com/typst/typst/blob/1b2714e1a758d6ee0f9471fd1e49cb02f6d8cde4/crates/typst-pdf/src/font.rs#L264"><code class="language-plaintext highlighter-rouge">font::create_cmap</code></a> 查阅了由 <a href="https://github.com/typst/typst/blob/1b2714e1a758d6ee0f9471fd1e49cb02f6d8cde4/crates/typst-pdf/src/content.rs#L477-L479"><code class="language-plaintext highlighter-rouge">content::write_normal_text</code></a> 缓存的文本 -&gt; 字形的映射。</li>
</ul>

<p>也就是说，LaTeX 选择码位的方式与具体排版的内容无关，而 Typst 会选择某个字形第一次被使用时的码位作为逆映射。这可以通过交换 Typst 源文本中两个“分号”的顺序得到验证，此时复制出来的文本都会变成 U+037E。</p>

<h3 id="等一下那-pdfjs-呢">等一下！那 PDF.js 呢？</h3>

<p>刚才提到，如果用 PDF.js 打开 LaTeX 的文件，那么四个“分号”全变成了 U+003B——难道它不会尊重 <code class="language-plaintext highlighter-rouge">ToUnicode</code> 表的内容吗？</p>

<blockquote>
  <p>Clerk Ma: 做了normalization了</p>
</blockquote>

<p>我恍然大悟，在 PDF.js 的文档进行检索后，确实找到了<a href="https://github.com/mozilla/pdf.js/blob/e37236e9af81a2436b68527bef69959726862064/src/display/api.js#L1218-L1226">相关的内容</a>。为了搜索方便，它会对 PDF 中的文本进行 Unicode 规范化——这又是一个巨大的坑，尤其是在文件系统等处，在此就不展开了。</p>

<p>事实上，阅读器也会对文本（以及搜索的关键词）做规范化，所以在阅读器中输入 U+003B 和 U+037E 进行搜索，都能搜到所有的“分号”。然而 PDF.js 或许是为了偷懒，直接把可复制的文本进行了规范化，就导致了这个令人困惑的现象。</p>

<h2 id="解决方案和更多">解决方案和更多</h2>

<p>再次请出伟大的 Clerk Ma：</p>

<blockquote>
  <p>解决方案是俩，一个是dvipdfmx增加一步normalization，另一个是字体同时有一套encoding和tounicode<br />
后者就变成，用户自定义编码-&gt;glyph id, 用户自定义编码-&gt;unicode</p>
</blockquote>

<p>前者似乎会导致和 PDF.js 一样的令人困惑的问题，因为规范化不一定是用户想要的结果；后面提到的 encoding 是一种<a href="https://stackoverflow.com/questions/40036588/in-pdf-if-encoding-and-tounicode-are-both-present-in-pdf-how-to-map-the-text-e">很老的字体功能</a>，用起来也不方便（需要自定义编码）。</p>

<blockquote>
  <p>常见解决方案是用 \XeTeXgenerateactualtext1</p>
</blockquote>

<p>天神降临！确实在打开开关后，复制出来的文本就完全正确了。但这并不是修改了上述的映射，只是让 XeTeX 向 PDF 的 <code class="language-plaintext highlighter-rouge">/ActualText</code> 表中写入了实际的文本（见<a href="https://xetex.tug.narkive.com/Z7TVL1Wh/potential-new-feature-generateactualtext#post2">讨论</a>），这样阅读器就可以直接使用这里的文本，而不需要通过上面的手段逆向映射字形。然而，并不是所有的阅读器都支持此特性（如 SumatraPDF 还不支持），并且启用后 Acrobat 的复制行为变得奇怪（无法单字选择）。</p>

<p>通过搜索此命令，还可以看到更多 CMap / ToUnicode 映射相关的问题，比如</p>

<ul>
  <li><a href="https://tug.org/pipermail/xetex/2017-June/027142.html">[XeTeX] XeTeX/xdvipdfmx: PDF text copying with double encoded fonts
</a> 和相关的 <a href="https://github.com/CTeX-org/ctex-kit/issues/286">中文讨论</a>：与本文的问题本质相同，而<a href="https://tug.org/pipermail/xetex/2017-June/027147.html">提出的补丁</a>只是修改了选择码位的逻辑，并不能改变一个字形只能映射到一个码位的限制。</li>
  <li><a href="https://tex.stackexchange.com/questions/454858/who-changed-my-chinese-character">Who changed my Chinese character?</a>：同样的问题，也通过此选项绕过。</li>
</ul>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="PDF" /><category term="Font" /><summary type="html"><![CDATA[今天是研究生毕业论文提交初稿的日子（当然和我没什么关系）。中午有组里的同学来找我，说 GPT 老师找出了如下的问题：]]></summary></entry><entry><title type="html">glibc 的特性（features）宏与其对 ABI 兼容性的影响</title><link href="https://harrychen.xyz/2024/10/14/glibc-feature-macros-and-abi-compatibility/" rel="alternate" type="text/html" title="glibc 的特性（features）宏与其对 ABI 兼容性的影响" /><published>2024-10-14T22:30:00+08:00</published><updated>2024-10-14T22:30:00+08:00</updated><id>https://harrychen.xyz/2024/10/14/glibc-feature-macros-and-abi-compatibility</id><content type="html" xml:base="https://harrychen.xyz/2024/10/14/glibc-feature-macros-and-abi-compatibility/"><![CDATA[<p>我在为 Debian 打包 <a href="https://github.com/marijnheule/drat-trim">drat-trim</a> 项目时，发现生成的可执行文件居然依赖 <code class="language-plaintext highlighter-rouge">glibc &gt;= 2.39</code>，而我打包的另一个纯 C 项目 <a href="https://github.com/arminbiere/kissat">kissat</a> 则只依赖 <code class="language-plaintext highlighter-rouge">glibc &gt;= 2.34</code>。明明都只是用了简单的 C 标准库，怎么会有这样的差别呢？</p>

<p>用 <code class="language-plaintext highlighter-rouge">readelf</code> 检查后，能看到 <code class="language-plaintext highlighter-rouge">drat-trim</code> 的可执行文件依赖了以下的符号：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Symbol table '.dynsym' contains 31 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __isoc23_fscanf@GLIBC_2.38 (4)
    19: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __isoc23_strtol@GLIBC_2.38 (4)
</code></pre></div></div>

<p>从符号名可以看出，这两个函数应该在 C23 中定义发生了变动，因此 GLIBC 提供了新版的符号，这也可以查阅到相关的<a href="https://gustedt.gitlabpages.inria.fr/c23-library/#strtol">变更说明</a>。</p>

<p>然而问题是，明明我用了 <code class="language-plaintext highlighter-rouge">-std=c++99</code> 编译，怎么会依赖上如此新版的符号呢？经过对 glibc 源码的阅读，我终于发现了元凶：我在编译时为了使用 POSIX 扩展的标准库函数，定义了 <code class="language-plaintext highlighter-rouge">_GNU_SOURCE</code> 特性宏。</p>

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

<p>上述现象用以下的简单测试就可以复现（测试环境为 gcc 14, glibc 2.40）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/tmp# cat test.c
#include &lt;stdio.h&gt;
int main(int argc) {
  return fscanf(stdin, "%d", &amp;argc);
}

# w/ _GNU_SOURCE
/tmp# gcc -std=c99 -D_GNU_SOURCE -o test test.c
/tmp# nm test | grep 'fscanf'
                 U __isoc23_fscanf@GLIBC_2.38

# w/o _GNU_SOURCE
/tmp# gcc -std=c99 -o test test.c
/tmp# nm test | grep 'fscanf'
                 U __isoc99_fscanf@GLIBC_2.7
</code></pre></div></div>

<p>可以看到只要定义了 <code class="language-plaintext highlighter-rouge">_GNU_SOURCE</code>，就会导致 <code class="language-plaintext highlighter-rouge">fscanf</code> 变成 <code class="language-plaintext highlighter-rouge">GLIBC_2.38</code> 版本的符号。</p>

<h2 id="问题溯源">问题溯源</h2>

<p>阅读 glibc 2.40 源码中实际声明 <code class="language-plaintext highlighter-rouge">fscanf</code> 函数的头文件 <a href="https://elixir.bootlin.com/glibc/glibc-2.40.9000/source/libio/stdio.h#L451"><code class="language-plaintext highlighter-rouge">libio/stdio.h</code></a>，其中涉及 <code class="language-plaintext highlighter-rouge">fscanf</code> 的部分，去除了一些无关语句后可以等价为：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp"># if __GLIBC_USE (C23_STRTOL)
</span><span class="k">extern</span> <span class="kt">int</span> <span class="nf">__isoc23_fscanf</span> <span class="p">(</span><span class="kt">FILE</span> <span class="o">*</span><span class="kr">__restrict</span> <span class="n">__stream</span><span class="p">,</span>
			    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="kr">__restrict</span> <span class="n">__format</span><span class="p">,</span> <span class="p">...)</span> <span class="n">__wur</span>
  <span class="n">__nonnull</span> <span class="p">((</span><span class="mi">1</span><span class="p">));</span>
<span class="cp">#   define fscanf __isoc23_fscanf
# else
</span><span class="k">extern</span> <span class="kt">int</span> <span class="nf">__isoc99_fscanf</span> <span class="p">(</span><span class="kt">FILE</span> <span class="o">*</span><span class="kr">__restrict</span> <span class="n">__stream</span><span class="p">,</span>
			    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="kr">__restrict</span> <span class="n">__format</span><span class="p">,</span> <span class="p">...)</span> <span class="n">__wur</span>
  <span class="n">__nonnull</span> <span class="p">((</span><span class="mi">1</span><span class="p">));</span>
<span class="cp">#   define fscanf __isoc99_fscanf
#  endif
</span></code></pre></div></div>

<p>再跟踪 <code class="language-plaintext highlighter-rouge">C23_STRTOL</code>，可以从 <a href="https://elixir.bootlin.com/glibc/glibc-2.40.9000/source/include/features.h#L481"><code class="language-plaintext highlighter-rouge">include/features.h</code></a> 中看到一些关键的定义链条：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* If _GNU_SOURCE was defined by the user, turn on all the other features.  */</span>
<span class="cp">#ifdef _GNU_SOURCE
# undef  _ISOC95_SOURCE
# define _ISOC95_SOURCE	1
# undef  _ISOC99_SOURCE
# define _ISOC99_SOURCE	1
# undef  _ISOC11_SOURCE
# define _ISOC11_SOURCE	1
# undef  _ISOC23_SOURCE
# define _ISOC23_SOURCE	1
# undef  _POSIX_SOURCE
# define _POSIX_SOURCE	1
# undef  _POSIX_C_SOURCE
# define _POSIX_C_SOURCE	200809L
# undef  _XOPEN_SOURCE
# define _XOPEN_SOURCE	700
# undef  _XOPEN_SOURCE_EXTENDED
# define _XOPEN_SOURCE_EXTENDED	1
# undef	 _LARGEFILE64_SOURCE
# define _LARGEFILE64_SOURCE	1
# undef  _DEFAULT_SOURCE
# define _DEFAULT_SOURCE	1
# undef  _ATFILE_SOURCE
# define _ATFILE_SOURCE	1
# undef  _DYNAMIC_STACK_SIZE_SOURCE
# define _DYNAMIC_STACK_SIZE_SOURCE 1
#endif
</span>
<span class="cm">/* This is to enable the ISO C23 extension.  */</span>
<span class="cp">#if (defined _ISOC23_SOURCE \
     || (defined __STDC_VERSION__ &amp;&amp; __STDC_VERSION__ &gt; 201710L))
# define __GLIBC_USE_ISOC23	1
#else
# define __GLIBC_USE_ISOC23	0
#endif
</span>
<span class="p">...</span>

<span class="cp">#if __GLIBC_USE (ISOC23)
# define __GLIBC_USE_C23_STRTOL 1
#else
# define __GLIBC_USE_C23_STRTOL 0
#endif
</span></code></pre></div></div>

<p>也就是说，定义 <code class="language-plaintext highlighter-rouge">_GNU_SOURCE</code> 导致了 <code class="language-plaintext highlighter-rouge">_ISOC23_SOURCE</code> 被定义，最终导致 glibc 使用了新的符号版本。</p>

<p>进一步阅读 <code class="language-plaintext highlighter-rouge">features.h</code> 上面关于这些特性宏的说明，有：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__STRICT_ANSI__	ISO Standard C.
_ISOC99_SOURCE	Extensions to ISO C89 from ISO C99.
_ISOC11_SOURCE	Extensions to ISO C99 from ISO C11.
_ISOC23_SOURCE	Extensions to ISO C99 from ISO C23.
_ISOC2X_SOURCE	Old name for _ISOC23_SOURCE.
__STDC_WANT_LIB_EXT2__
  Extensions to ISO C99 from TR 27431-2:2010.
__STDC_WANT_IEC_60559_BFP_EXT__
  Extensions to ISO C11 from TS 18661-1:2014.
__STDC_WANT_IEC_60559_FUNCS_EXT__
  Extensions to ISO C11 from TS 18661-4:2015.
__STDC_WANT_IEC_60559_TYPES_EXT__
  Extensions to ISO C11 from TS 18661-3:2015.
__STDC_WANT_IEC_60559_EXT__
  ISO C23 interfaces defined only in Annex F.

_POSIX_SOURCE	IEEE Std 1003.1.
_POSIX_C_SOURCE	If ==1, like _POSIX_SOURCE; if &gt;=2 add IEEE Std 1003.2;
  if &gt;=199309L, add IEEE Std 1003.1b-1993;
  if &gt;=199506L, add IEEE Std 1003.1c-1995;
  if &gt;=200112L, all of IEEE 1003.1-2004
  if &gt;=200809L, all of IEEE 1003.1-2008
_XOPEN_SOURCE	Includes POSIX and XPG things.  Set to 500 if
  Single Unix conformance is wanted, to 600 for the
  sixth revision, to 700 for the seventh revision.
_XOPEN_SOURCE_EXTENDED XPG things and X/Open Unix extensions.
_LARGEFILE_SOURCE	Some more functions for correct standard I/O.
_LARGEFILE64_SOURCE	Additional functionality from LFS for large files.
_FILE_OFFSET_BITS=N	Select default filesystem interface.
_ATFILE_SOURCE	Additional *at interfaces.
_DYNAMIC_STACK_SIZE_SOURCE Select correct (but non compile-time constant)
  MINSIGSTKSZ, SIGSTKSZ and PTHREAD_STACK_MIN.
_GNU_SOURCE		All of the above, plus GNU extensions.
_DEFAULT_SOURCE	The default set of features (taking precedence over
  __STRICT_ANSI__).

_FORTIFY_SOURCE	Add security hardening to many library functions.
  Set to 1, 2 or 3; 3 performs stricter checks than 2, which
  performs stricter checks than 1.

_REENTRANT, _THREAD_SAFE
  Obsolete; equivalent to _POSIX_C_SOURCE=199506L.
</code></pre></div></div>

<p>可以看到定义 <code class="language-plaintext highlighter-rouge">_GNU_SOURCE</code> 会开启文件中所有的特性，也就是无条件启用了当前 glibc 支持的所有 C 特性和扩展，因此导致 glibc 选择了更新的实现 <code class="language-plaintext highlighter-rouge">__isoc23_fscanf</code>。</p>

<p>这和我一贯的印象（以及若干 TUNA 群友的印象）并不一致。我们都觉得，顾名思义，它之应该启用标准库中的部分 GNU 扩展函数，而具体的语言级别、标准库行为都应该由编译器选项来统一控制。万万没想到，有这样的一些宏，能让 glibc 无视编译器的语言版本限制，影响自身的具体行为。</p>

<p>我对 glibc 的此行为感到有些困惑，因为很多时候用户不会预期（也不期望） glibc 因此使用最新的特性和符号。如此实现会不必要地破坏部分代码的 ABI 兼容性，甚至很可能影响程序的可观测行为。</p>

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

<ul>
  <li>如果你不需要更好的向后 ABI 兼容性，也不在乎可能新标准库可能有变化的行为，那么这不是什么问题，只管用就可以了；</li>
  <li>否则，似乎并没有什么好办法。在可能的情况下（比如只是要用一些 POSIX 的库函数），尽量少使用 <code class="language-plaintext highlighter-rouge">_GNU_SOURCE</code>，而是用 <code class="language-plaintext highlighter-rouge">_DEFAULT_SOURCE</code> 等不会影响 glibc 语言等级的开关代替。</li>
</ul>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="C" /><summary type="html"><![CDATA[我在为 Debian 打包 drat-trim 项目时，发现生成的可执行文件居然依赖 glibc &gt;= 2.39，而我打包的另一个纯 C 项目 kissat 则只依赖 glibc &gt;= 2.34。明明都只是用了简单的 C 标准库，怎么会有这样的差别呢？]]></summary></entry><entry><title type="html">msmtp 配置 Outlook / O365 邮箱的 OAuth2 认证</title><link href="https://harrychen.xyz/2024/09/25/msmtp-outlook-oauth/" rel="alternate" type="text/html" title="msmtp 配置 Outlook / O365 邮箱的 OAuth2 认证" /><published>2024-09-25T22:30:00+08:00</published><updated>2024-09-25T22:30:00+08:00</updated><id>https://harrychen.xyz/2024/09/25/msmtp-outlook-oauth</id><content type="html" xml:base="https://harrychen.xyz/2024/09/25/msmtp-outlook-oauth/"><![CDATA[<p>我的 WSL 日常使用 msmtp 作为 MTA，它通过 starttls 连接到 Outlook 邮箱的 SMTP 服务器（<code class="language-plaintext highlighter-rouge">smtp-mail.outlook.com</code>）。此前 Outlook 的安全策略强制要求多因素认证（MFA），但允许使用应用密码，因此我一直在使用传统的 GPG 加密应用密码的方法。但今天我尝试发送邮件时，得到了以下的错误：</p>

<blockquote>
  <p>535 5.7.139 Authentication unsuccessful, basic authentication is disabled. [SI2P153CA0032.APCP153.PROD.OUTLOOK.COM 2024-09-25T08:14:40.271Z 08DCDD1F9EAD5AFB]</p>
</blockquote>

<p>在 TUNA 群中为此询问群友，获得了微软的<a href="https://support.microsoft.com/en-us/office/modern-authentication-methods-now-needed-to-continue-syncing-outlook-email-in-non-microsoft-email-apps-c5d65390-9676-4763-b41f-d7986499a90d">公告链接</a>。简而言之，从 2024/9/16 开始，Outlook 的服务器不再支持传统的 SMTP AUTH 方式进行认证，而必须使用 OAuth2。</p>

<p>对于邮件客户端来说，OAuth2 有两种常用的认证方式，bearer token 或者 SASL XOAUTH2；Outlook 使用后者，Gmail 是前者。msmtp 两种都支持，只要对应修改 <code class="language-plaintext highlighter-rouge">auth</code> 选项即可。然而，msmtp 和其他大多数命令行 MTA 一样，只能使用而不能管理 OAuth2 token（也就是说，用户需要负责获取和定期刷新）。因此，需要借助第三方工具来管理 OAuth 的状态。</p>

<p>Arch Wiki 的 <a href="https://wiki.archlinux.org/title/Msmtp#oama">msmtp</a> 页面推荐使用的工具是 <a href="https://github.com/pdobsan/oama">oama</a>，这是一个 Haskell 写的小工具，可以用在 msmtp 的 <code class="language-plaintext highlighter-rouge">password-eval</code> 中。然而，使用起来遇到了一些棘手的问题。</p>

<p>具体来说，oama 需要自己配置 OAuth 使用的 client_id 和 client_secret。网上能公开获取到的几个 client_id 都需要配置对应的回调 URL（尽管其实最终客户端可以不真的通过回调获取 code，直接在最后的 302 请求中截获即可；但如果不按配置填写，根本无法进入授权流程），如 ThunderBird 的要求 <code class="language-plaintext highlighter-rouge">http://localhost:8080</code> 或者 <code class="language-plaintext highlighter-rouge">https://localhost</code>。但 oama 的配置只支持形如 <code class="language-plaintext highlighter-rouge">http://127.0.0.1[:port]</code> 的格式，其他格式要么报无法解析的错误，要么无法启动 Web Server listen。我完全不会 Haskell，这个修不来。后续又找到了别的 client_id，但发现无法使用个人的 Outlook 账号登录，只能用组织账号（Microsoft 365），这显然也不是我想要的。</p>

<p>于是我只能选择在 Office 365 中创建了一个 App，配上所需的几个权限（用 OAuth 术语来说叫 scope），但发现微软接受的本地回调只能以 <code class="language-plaintext highlighter-rouge">http://localhost</code> 开头，看来 oama 是没法用了。又经过一番搜索，我找到了 mutt 带的 <a href="https://github.com/muttmua/mutt/blob/master/contrib/mutt_oauth2.py"><code class="language-plaintext highlighter-rouge">mutt_oauth2.py</code></a>。把这个文件复制出来，修改其中的 client 相关配置，然后执行：</p>

<div class="language-terminal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">user@~:$</span><span class="w"> </span>python3 ~/.local/bin/mutt_oauth2.py <span class="nt">-t</span> ~/.local/var/email/outlook.gpg <span class="nt">--authorize</span>
<span class="go">OAuth2 registration: microsoft
Preferred OAuth2 flow ("authcode" or "localhostauthcode" or "devicecode"): localhostauthcode
Account e-mail address: user@outlook.com
</span></code></pre></div></div>

<p>后续在给出的链接完成认证即可。如果脚本报告 POP3 鉴权失败，很可能是 Outlook 的设置中没有打开 POP3 服务。最后，修改 msmtp 的配置文件：</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">account</span> <span class="err">outlook</span>
<span class="err">host</span> <span class="err">smtp-mail.outlook.com</span>
<span class="err">from</span> <span class="err">user@outlook.com</span>
<span class="err">user</span> <span class="err">user@outlook.com</span>
<span class="err">tls_starttls</span> <span class="err">on</span>
<span class="err">auth</span> <span class="err">xoauth2</span>
<span class="err">passwordeval</span> <span class="err">python3</span> <span class="err">~/.local/bin/mutt_oauth2.py</span> <span class="err">~/.local/var/outlook.gpg</span>
</code></pre></div></div>

<p>在每次发送邮件时，<code class="language-plaintext highlighter-rouge">mutt_oauth2.py</code> 会检查 token 的可用性，并在需要时刷新。如果 refresh token 也过期了，则会自动重新发起授权。这样，我又可以愉快地使用 <code class="language-plaintext highlighter-rouge">mail</code> 和 <code class="language-plaintext highlighter-rouge">git-sendmail</code> 等工具了。</p>

<p>就在我以为完事大吉的时候，还是遇到了新的问题（但和 Outlook 没有关系）：如果同时调用 gpg 和 msmtp（如 <code class="language-plaintext highlighter-rouge">foo | gpg --clear-sign | msmtp foo@bar.com</code>），则只有第一个 gpg 可以正确调用 <code class="language-plaintext highlighter-rouge">pinentry</code> 来使用智能卡进行签名，而 msmtp 的 <code class="language-plaintext highlighter-rouge">passwordeval</code> 脚本调用的 gpg 无法正常触发 <code class="language-plaintext highlighter-rouge">pinentry</code>，会卡在 <code class="language-plaintext highlighter-rouge">gpg --decrypt</code> 中。尝试使用 shell substitution 也遇到了同样的问题（<code class="language-plaintext highlighter-rouge">msmtp foo@bar.com &lt;(foo | gpg --clear-sign)</code>）。似乎是因为我使用了 <code class="language-plaintext highlighter-rouge">gpg-agent</code>，而上述命令中都有两个 gpg 进程同时启动，就会有一个无法连接到 agent。目前我只能通过每次只执行一个 gpg 来绕过问题。</p>

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

<p>为方便起见，公开我的 OAuth Application 配置（由于只在本地使用，公开 secret 没有太大的安全风险）：</p>

<ul>
  <li>名称：<code class="language-plaintext highlighter-rouge">msmtp OAuth</code></li>
  <li>Client ID: <code class="language-plaintext highlighter-rouge">1ba11cc8-c6d1-4ae6-bd88-6becf878f8df</code></li>
  <li>Client Secret: <code class="language-plaintext highlighter-rouge">lBm8Q~_IfyNpFUZ6KydTc4QHjLl1IwcCxFhxqa7n</code>（过期日：2026/9/24）</li>
  <li>Redirect URI: <code class="language-plaintext highlighter-rouge">http://localhost</code>（经测试，可以增加任意端口号）</li>
  <li>Scope: <code class="language-plaintext highlighter-rouge">IMAP.AccessAsUser.All</code>, <code class="language-plaintext highlighter-rouge">POP.AccessAsUser.All</code>, <code class="language-plaintext highlighter-rouge">SMTP.Send</code>, <code class="language-plaintext highlighter-rouge">User.Read</code>，此外 <code class="language-plaintext highlighter-rouge">offline_access</code> 会由客户端额外请求</li>
</ul>

<p class="warning"><strong>说明：上述配置仅供测试，我不以任何形式保证其可用性或对此负任何责任。如选择使用，您需要承担任何可能的后果。</strong></p>

<p>在 Microsoft Entra 平台上配置 OAuth App 时，我还遇到了一个坑点：如果授权时指定的 <code class="language-plaintext highlighter-rouge">tenate</code> 是 <code class="language-plaintext highlighter-rouge">common</code>，那么 <code class="language-plaintext highlighter-rouge">signInAudience</code> 必须设置成 <code class="language-plaintext highlighter-rouge">AzureADandPersonalMicrosoftAccount</code> 或者 <code class="language-plaintext highlighter-rouge">AzureADMyOrg</code>（此选项会导致被标记成 <code class="language-plaintext highlighter-rouge">Consumer</code> 的 Outlook 用户无法登录，只有组织用户可以登录）。我原来设定的是 <code class="language-plaintext highlighter-rouge">PersonalMicrosoftAccount</code>，但会引发授权的 <code class="language-plaintext highlighter-rouge">invalid_request</code> 错误。</p>]]></content><author><name>Shengqi Chen</name><email>i@harrychen.xyz</email></author><category term="技术" /><category term="Linux" /><category term="邮件" /><summary type="html"><![CDATA[我的 WSL 日常使用 msmtp 作为 MTA，它通过 starttls 连接到 Outlook 邮箱的 SMTP 服务器（smtp-mail.outlook.com）。此前 Outlook 的安全策略强制要求多因素认证（MFA），但允许使用应用密码，因此我一直在使用传统的 GPG 加密应用密码的方法。但今天我尝试发送邮件时，得到了以下的错误：]]></summary></entry></feed>