
Empreinte digitale de l'GPU exact sur lequel vous exécutez, même parmi des GPU identiques, à partir de l'ordre dans lequel ses additions atomiques arrivent. Précision de 99,1 %, survit aux redémarrages, impossible à falsifier, et un appel torch.index_add_ par défaut la divulgue.
Article de blog : Telling identical GPUs apart from their numerical output
Deux GPU du même modèle calculent la même somme dans un ordre légèrement différent, et cette différence suffit à les distinguer. Un petit noyau CUDA identifie sur lequel de 19 NVIDIA A40 identiques il s'est exécuté avec une précision de 99,1 %, en utilisant uniquement les nombres qu'il calcule. Il ne lit aucun numéro de série et n'utilise aucun minuteur.
torch.index_add_ par défaut le révèle aussi. En ne lisant rien d'autre que la sortie ordinaire de cette opération, on identifie la puce avec une précision de 100 % parmi les GPU sur le même pilote, et de 78 % entre pilotes mixtes.Pourquoi cela fonctionne : lorsque des milliers de blocs de threads se disputent la mise à jour d'un même compteur, le matériel décide qui arrive en premier. De minuscules différences de fabrication entre les cœurs d'une puce biaisent cet ordre de la même manière à chaque fois.
Le mode déterministe de PyTorch supprime la fuite de index_add_. Le dépôt contient les sondes, toutes les mesures derrière les chiffres ci-dessous, et des scripts qui reproduisent chaque résultat sur le CPU d'un ordinateur portable.
| Résultat | Nombre | Reproduire |
|---|---|---|
Sonde active, 19 A40 identiques, canal P3 (sortie numérique uniquement, sans %smid) | 99,1 % LOO (111/112), hasard 5,3 %, séparation 4,28x | analysis/pooled_analyze.py |
| Sonde active, 8 A40 | P3 100 % LOO, séparation 4,09x, p de permutation = 0,0005 | analysis/analyze_puf.py |
| Confusion liée au pilote : 4 A40 sur le pilote identique (580.159.04) | P3 100 % (6,10x), P2 100 % (5,43x) | analysis/analyze_puf.py |
| Redémarrage : redémarrage du conteneur, même die vérifié par UUID (4 A40) | 100 % de ré-identification (12/12), les deux canaux | analysis/temporal_analyze.py |
| RTX 3090, 7 puces sur 4 versions de pilote | P3 100 % LOO (4,55x), P2 100 % (8,12x), p = 0,0005 | analysis/cross_model_analyze.py |
| RTX 4090, 2 puces sur le même hôte et le même pilote | P3 6/6 (12,5x), P2 6/6 (17,8x) | analysis/cross_model_analyze.py |
| L40S, 2 puces | P3 4/4 (17,2x), P2 4/4 (13,8x) | analysis/cross_model_analyze.py |
torch.index_add_ passif, K=65536, 6 A40 | 100 % même pilote (9/9), 78 % entre pilotes | analysis/passive_analyze.py |
| Capacité de signature passive, K=65536 | ~217 bits indépendants (A40), ~239 (RTX 4090) | analysis/roc_capacity.py |
| Falsification : l'attaquant balaie tous les réglages de lancement qu'il contrôle | meilleure falsification 0,616 de corrélation contre 0,92 de plancher authentique | analysis/forge_analyze.py |
| Limite : transformer vanilla fwd/bwd, GEMM dense, matmul TF32 | identique bit à bit entre puces, aucune fuite | analysis/leak_analyze.py, analysis/channel_analyze.py |
Défense : torch.use_deterministic_algorithms(True) | canal passif éliminé ; coût de rétropropagation 2,5-3,6x ; sonde active non affectée | analysis/defense_analyze.py |
LOO = classification par plus proche centroïde en leave-one-out de mesures individuelles par rapport aux centroïdes par puce. Séparation = distance entre puces / distance intra-puce.
La sonde (probes/probe_puf.py) est un petit noyau cupy. Elle lance 4096 blocs de 128 threads. Chaque thread exécute la même chaîne fixe de FMA, puis le thread 0 de chaque bloc prend un ticket auprès d'un compteur global avec atomicAdd(counter, 1). Le ticket est le rang d'arrivée de ce bloc. Rien dans l'entrée ne diffère entre les puces ; la seule chose qui varie est quels blocs arrivent en premier.
Cet ordre n'est pas aléatoire. Le distributeur de travail mappe les indices de blocs sur les SM selon un schéma quasi fixe, et chaque SM d'un die donné s'exécute légèrement plus vite ou plus lentement que ses voisins en raison des variations de fabrication. Moyenné sur 3000 lancements, chaque indice de bloc se stabilise vers un rang d'arrivée moyen qui reflète la vitesse du SM sur lequel il atterrit.
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
Trois canaux ressortent d'une même exécution :
%smid, pas de minuteries, pas de compteurs privilégiés. C'est le canal principal.%smid. Le regroupement par SM physique moyenne le bruit thermique, de sorte que P2 tient sous charge : 100 % de ré-identification (18/18) après 60 s de stress à pleine charge.Soustraire la moyenne entre les puces élimine tout ce que les puces partagent (architecture, pilote, compilateur) et laisse le résidu propre à chaque die. Deux mesures de la même puce se rapprochent ; les mesures de puces différentes ne se rapprochent pas.
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
Lancer l'analyse sur les données incluses (CPU, aucun GPU nécessaire) :
python analysis/pooled_analyze.py # 19 A40s: 111/112 = 99.1%
python analysis/analyze_puf.py "a40_8chip/*.json" # 8 A40s: P3 100%
Exécuter la sonde sur un 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
Collectez quelques réplicas de chacune de deux GPU ou plus du même modèle, puis :
python analysis/analyze_puf.py "path/to/*.json"
Chaque enregistrement JSON consigne l'UUID du GPU, le pilote, la version de cupy ainsi que l'horloge et la température par époque, à côté des mesures. L'analyse regroupe par nom de modèle et étiquette les puces par UUID.
Pour exécuter sur des machines distantes via SSH, scripts/run_remote.sh déploie une sonde, l'exécute sur plusieurs hôtes en parallèle et collecte le JSON :
OUT_DIR=data/my_run REPS=4 scripts/run_remote.sh probe gpu1=root@HOST1:PORT gpu2=root@HOST2:PORT
docker/Dockerfile construit une image avec CUDA 12.8, cupy et la sonde préinstallée (docker build -f docker/Dockerfile -t gpu-instance-fingerprint .).
Toutes les commandes s'exécutent depuis la racine du dépôt sur un CPU. Les scripts trouvent leurs données dans data/ par eux-mêmes.