
Создаёт отпечаток конкретного GPU, на котором вы работаете, даже среди идентичных GPU, по порядку, в котором выполняются его атомарные сложения. Точность 99,1%, сохраняется после перезагрузок, не поддаётся подделке, и обычный вызов torch.index_add_ раскрывает его.
Пост в блоге: Telling identical GPUs apart from their numerical output
Два GPU одной модели вычисляют одну и ту же сумму в слегка разном порядке, и этого различия достаточно, чтобы их различить. Небольшое ядро CUDA определяет, на каком из 19 идентичных NVIDIA A40 оно выполнялось, с точностью 99,1%, используя только вычисляемые им числа. Оно не читает серийный номер и не использует таймеры.
torch.index_add_ тоже его выдаёт. Чтение только обычного вывода этой операции идентифицирует чип со 100% точностью среди GPU на одном и том же драйвере и с 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 fwd/bwd, плотный GEMM, TF32 matmul | побитово идентичны между чипами, утечки нет | analysis/leak_analyze.py, analysis/channel_analyze.py |
Защита: torch.use_deterministic_algorithms(True) | пассивный канал устранён; стоимость backward 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 усредняет тепловой шум, поэтому P2 устойчив под нагрузкой: 100% повторная идентификация (18/18) после 60 с стресс-теста при полной нагрузке.Вычитание среднего по чипам удаляет всё, что у чипов общее (архитектура, драйвер, компилятор), и оставляет остаток, специфичный для каждого кристалла. Два измерения одного и того же чипа оказываются близко друг к другу; измерения разных чипов — нет.
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, а также тактовую частоту и температуру для каждой эпохи рядом с измерениями. Анализ группирует данные по имени модели и помечает чипы по 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 и предустановленным probe (docker build -f docker/Dockerfile -t gpu-instance-fingerprint .).
Все команды выполняются из корня репозитория на CPU. Скрипты самостоятельно находят свои данные в data/.