博客文章:通过数值输出区分相同的 GPU
两块相同型号的 GPU 以略微不同的顺序计算同一个求和,而这一差异足以将它们区分开来。 一个小型 CUDA 内核仅凭其计算出的数值,就能以 99.1% 的准确率识别出它运行在 19 块相同的 NVIDIA A40 中的哪一块上。它不读取序列号,也不使用计时器。
torch.index_add_ 调用也会泄露它。 仅读取该操作的普通输出,就能在同一驱动上的 GPU 中以 100% 的准确率识别出芯片,在混合驱动下则为 78%。原理:当数千个线程块争相更新同一个计数器时,硬件决定谁先到达。芯片核心之间微小的制造差异会以相同的方式偏置这一顺序。
PyTorch 的确定性模式消除了 index_add_ 泄露。该仓库包含探针、下文所有数字背后的每一次测量,以及可在笔记本电脑 CPU 上复现每项结果的脚本。
| 结果 | 数值 | 复现 |
|---|---|---|
主动探针,19 块相同的 A40,P3 通道(仅数值输出,无 %smid) | 99.1% LOO(111/112),随机概率 5.3%,分离度 4.28x | analysis/pooled_analyze.py |
| 主动探针,8 块 A40 | P3 100% LOO,分离度 4.09x,置换检验 p = 0.0005 | analysis/analyze_puf.py |
| 驱动混杂因素:4 块 A40 运行在完全相同的驱动(580.159.04)上 | P3 100%(6.10x),P2 100%(5.43x) | analysis/analyze_puf.py |
| 重启:容器重启,通过 UUID 验证为同一芯片(4 块 A40) | 100% 重新识别(12/12),两个通道均如此 | analysis/temporal_analyze.py |
| RTX 3090,7 块芯片跨 4 个驱动版本 | P3 100% LOO(4.55x),P2 100%(8.12x),p = 0.0005 | analysis/cross_model_analyze.py |
| RTX 4090,2 块芯片位于同一主机和驱动 | P3 6/6(12.5x),P2 6/6(17.8x) | analysis/cross_model_analyze.py |
| L40S,2 块芯片 | P3 4/4(17.2x),P2 4/4(13.8x) | analysis/cross_model_analyze.py |
被动 torch.index_add_,K=65536,6 块 A40 | 100% 同驱动(9/9),78% 跨驱动 | analysis/passive_analyze.py |
| 被动签名容量,K=65536 | 约 217 个独立比特(A40),约 239 个(RTX 4090) | analysis/roc_capacity.py |
| 伪造:攻击者遍历其控制的所有启动旋钮 | 最佳伪造相关性 0.616,对比真实下限 0.92 | analysis/forge_analyze.py |
| 边界:普通 transformer 前向/反向、稠密 GEMM、TF32 矩阵乘法 | 跨芯片逐位相同,无泄露 | analysis/leak_analyze.py、analysis/channel_analyze.py |
防御:torch.use_deterministic_algorithms(True) | 被动通道被消除;反向传播开销 2.5-3.6x;主动探针不受影响 | analysis/defense_analyze.py |
LOO = 对单次测量相对于各芯片质心进行留一法最近质心分类。分离度 = 芯片间距离 / 芯片内距离。
探针(probes/probe_puf.py)是一个小型 cupy 内核。它启动 4096 个块,每块 128 个线程。每个线程执行相同的固定 FMA 链,然后每个块的线程 0 通过 atomicAdd(counter, 1) 从全局计数器领取一个票号。该票号就是该块的到达槽位。输入在不同芯片之间没有任何差异;唯一变化的是哪些块先到达。
这一顺序并非随机。工作分配器以近乎固定的模式将块索引映射到 SM 上,而给定芯片上的每个 SM 由于制造差异,运行速度比其同类略快或略慢。在 3000 次启动上取平均后,每个块索引会稳定到一个平均到达排名,该排名反映了它所落在的 SM 的速度。
fixed kernel, fixed input
|
v
blocks race to atomicAdd --> arrival slot per block (hardware-decided)
|
v average over 3000 launches
mean arrival rank per block index (P3) / per physical SM via %smid (P2)
|
v subtract the cross-chip mean (removes the architecture)
per-chip residual --> nearest-centroid match against enrolled chips
一次运行会产生三个通道:
%smid,不使用计时器,不使用特权计数器。这是主要通道。%smid 寄存器按物理 SM 分箱的相同到达时隙。按物理 SM 分箱可以平均掉热噪声,因此 P2 在负载下依然可靠:在 60 秒满载压力测试后仍保持 100% 重识别率(18/18)。减去各芯片之间的均值可以消除芯片共有的部分(架构、驱动、编译器),只留下每个裸片的残差。同一芯片的两次测量结果彼此接近;不同芯片的测量结果则不会。
git clone https://github.com/divagr18/gpu-instance-fingerprint.git
cd gpu-instance-fingerprint
pip install -r requirements.txt # numpy + scipy, enough for every analysis
在包含的数据上运行分析(CPU,无需 GPU):
python analysis/pooled_analyze.py # 19 A40s: 111/112 = 99.1%
python analysis/analyze_puf.py "a40_8chip/*.json" # 8 A40s: P3 100%
在 GPU 上运行探测:
pip install numpy cupy-cuda12x
python probes/probe_puf.py 3 0 > gpu_a_r0.json # 3 epochs, replicate 0 (about 2 s per epoch)
python probes/probe_puf.py 3 1 > gpu_a_r1.json
从同一型号的两块或多块 GPU 上各采集若干次重复数据,然后:
python analysis/analyze_puf.py "path/to/*.json"
每条 JSON 记录都会在测量数据旁记录 GPU UUID、驱动、cupy 版本以及每个 epoch 的时钟和温度。分析按型号名称分组,并按 UUID 标记芯片。
要通过 SSH 在远程机器上运行,scripts/run_remote.sh 会推送一个探测程序,在多个主机上并行运行并收集 JSON:
OUT_DIR=data/my_run REPS=4 scripts/run_remote.sh probe gpu1=root@HOST1:PORT gpu2=root@HOST2:PORT
docker/Dockerfile 构建一个预装 CUDA 12.8、cupy 和探针的镜像(docker build -f docker/Dockerfile -t gpu-instance-fingerprint .)。
所有命令均在仓库根目录下于 CPU 上运行。脚本会自行在 data/ 中查找其数据。
| 结果 | 命令 | 数据 |
|---|---|---|
| 8 块 A40:P3 100%(4.09x,p=0.0005),P2 94%(4.63x),P1 28%(1.20x) | python analysis/analyze_puf.py "a40_8chip/*.json" | data/a40_8chip(8 块芯片 x 4 次重复) |
| 同驱动 4 块 A40:P3 100%(6.10x),P2 100%(5.43x) | python analysis/analyze_puf.py "a40_8chip/puf8_[4678]_*.json" | data/a40_8chip |
| 汇总:12 块芯片 98.6%(71/72);19 块芯片 99.1%(111/112),4.28x,97% 的芯片对之间的差异超过芯片内噪声的 2 倍 | python analysis/pooled_analyze.py | a40_8chip、a40_stability(T1)、a40_reboot(T1)、a40_scale |
| 重启:P3(裕度 5.07x)和 P2(裕度 5.03x)的重新识别率均为 100%(12/12) | python analysis/temporal_analyze.py a40_reboot | data/a40_reboot(4 块芯片) |
| 60 秒压力测试:基线 LOO 96%(23/24);P2 重新识别 100%(18/18) | python analysis/temporal_analyze.py a40_stability | data/a40_stability(6 块芯片) |
| RTX 3090 x7、RTX 4090、3090 对、L40S 对 | python analysis/cross_model_analyze.py | data/rtx3090、data/rtx4090、data/cross_model |
| 被动 index_add(K=65536、K=4096)、稠密对照、scatter-overwrite | python analysis/passive_analyze.py | data/passive_* |
| ROC 与比特容量 | python analysis/roc_capacity.py | 所有 A40 探针数据、passive_k4096、passive_k65536、rtx4090 |
| 伪造:最佳伪造 0.616 对比真实 0.946(下限 0.916),冒充者 0.174 | python analysis/forge_analyze.py | data/forge(3 块 A40) |
| Transformer 边界与确定性模式开销 | python analysis/leak_analyze.py | data/transformer_leak(3 块 A40) |
| TF32 matmul 与 DVFS 边界 | python analysis/channel_analyze.py | data/channels(3 块 A40) |
| 针对被动信道的防御 | python analysis/defense_analyze.py | data/defense(2 块 A40) |
analysis/analyze_puf.py 对每个信道运行 2000 次打乱的置换检验,因此较大的数据集需要几分钟。
每一对单次测量都会被评分(真实 = 相同 UUID,冒充 = 不同 UUID),且不进行注册平均,因此这些是悲观的单次操作点。自由度的计算方式与虹膜识别类似,DoF = p(1-p)/var(HD),基于冒充者汉明距离。
| 签名 | 对数 | 评分 | AUC | EER | 容量 |
|---|---|---|---|---|---|
| 主动 P3,19 块 A40 | 644 真实 / 9367 冒充 | 残差相关性 | 0.974 | 9.3%(d' = 2.95) | |
| 主动 P3,19 块 A40 | 符号汉明距离 | 0.847 | 26.9% | ~5 比特 | |
| 被动 K=4096,8 块 A40 | 符号汉明距离 | 0.878 | 21.0% | ~97 比特 | |
| 被动 K=65536,6 块 A40 | 符号汉明距离 | 0.952 | 11.1% | ~217 比特 | |
| 被动 K=65536,3 块 RTX 4090 | 符号汉明距离 | 0.0% | ~239 比特 |
主动信道最好通过残差相关性来解读:其符号模式在空间上是相关的,因此身份信息存在于幅度之中。被动符号签名的行为类似于一个长比特串,其容量随 K 增长:在 K=65536 时约有 200+ 个独立比特,足以索引一个非常庞大的集群。
torch.index_add_主动探针需要运行自定义内核。被动变体只读取标准 PyTorch 算子的普通输出。
CUDA 上的 torch.index_add_ 使用原子操作进行累加,因此每个输出 bin 中贡献到达的顺序由硬件决定。在通常的稠密场景下(每个 bin 有大量贡献),float32 求和会将该顺序平均掉:在 2^20 个贡献分配到 256 个 bin 的情况下,每个特征都只能以随机水平分类(在 6 块 A40 上,合并准确率为 17%,与 17% 的随机水平相当)。解决办法是采用稀疏、高动态范围的场景:
随后,每个芯片稳定的原子排序会产生稳定的输出。签名是相对于 float64 参考值的平均 ULP 残差的逐 bin 符号,即一个 K 位字符串。
pip install numpy torch
python probes/passive_index_add.py 0 65536 > passive_r0.json # replicate 0, K=65536
A40 上的结果(K=65536,6 块芯片):同一驱动版本内 100%(9/9),混合驱动下 78%(随机概率 17%)。原始算子输出还带有依赖驱动的成分,而主动探测器的顺序残差没有这一成分,因此被动通道在同一驱动内最为干净。在 K=4096 时,跨 8 块芯片达到 67%。散射覆盖(scatter_ 使用重复索引,最后写入者胜出)在 5 块芯片上达到 60%。在 RTX 4090 上,被动签名可区分全部 3 块芯片(6/6)。该机制出现在真实工作负载中,例如 MoE 分发、GNN 聚合以及存在大量冲突的 embedding-bag 反向传播。
实例泄漏仅由硬件调度的原子竞争顺序承载,别无其他。所有测试过的确定性路径在相同芯片之间均逐位一致:
因此,在 GPU 上运行普通 ML 本身并不会暴露芯片。暴露需要顺序主导的机制:主动探测器,或具有大量高动态范围原子贡献的算子。
能否让芯片 A 发出芯片 B 的指纹?攻击者无法改变硅片或设置原子顺序,但可以控制启动:网格大小、块大小、原子操作前的计算消耗,以及注入的自旋延迟。experiments/forge_probe.py 在 3 块 A40 上各扫描 10 种配置(每种 2 次运行),并记录每种配置的每 SM 签名。
| 比较 | 相关性 |
|---|---|
| 真实:同一芯片,运行间 | 0.946(下限 0.916) |
| 冒充:芯片 A 对芯片 B | 0.174 |
| 伪造:A 在整个配置组中的最佳配置对 B | 0.616 最大 |