Привет, Хабр!

Много лет можно наблюдать один и тот же ритуал: человек берёт фиксированный seed, торжественно записывает его в три места, запускает обучение и искренне ожидает, что всё будет повторяться до бита. А потом accuracy скачет на третьем знаке, лосс уплывает и приходит вопрос: «Почему не детерминируется?» А потому что детерминизм в ML это не один флажок. Это сумма десятка мелких факторов, от выбора алгоритма в cuDNN до порядка файлов в каталоге.

Что считаем детерминизмом и почему он ускользает

Есть повторяемость метрик в среднем при разных перезапусках, этого часто достаточно для продукта. А есть строгий детерминизм: бит‑в-бит одинаковые веса и предсказания на одинаковом железе и софте. Второе сложнее и дороже.

Сильные источники недетерминизма, которые чаще всего пропускают:

  • Выбор алгоритма в cuDNN и cuBLAS. Даже при одинаковых размерах тензоров бенчмаркер может выбрать разный путь. Нужны явные флаги.

  • Операции без детерминированной реализации. Пример: часть редукций, некоторые backward для пулов/индексаций. Нужен общий режим только детерминированные алгоритмы.

  • Параллельная загрузка данных. Рабочие потоки и внешние библиотеки имеют свои генераторы случайных чисел. Их надо засеять синхронно и стабильно.

  • Хеш‑рандомизация Python. Порядок в dict/set, а заодно и псевдослучайные разбиения, зависят от переменной окружения.

  • Порядок файлов в каталоге. os.listdir не гарантирует сортировку. Сортируйте явно.

  • Разное железо и версии библиотек. Даже при всем по фэншую PyTorch предупреждает: абсолютная повторяемость не обещается между платформами и релизами.

PyTorch: чеклист, который действительно закрывает дыры

Начнём с окружения. Эти переменные нужно выставлять до старта процесса.

# CUDA/cuBLAS: фиксируем workspace, иначе часть CUDA-операций будет недетерминирована
export CUBLAS_WORKSPACE_CONFIG=":4096:8"   # или ":16:8" при малой памяти

# Хеши Python: фиксируем порядок в dict/set и всё, что на нём косвенно завязано
export PYTHONHASHSEED="0"

# Потоки BLAS/OpenMP: меньше гонок, проще повторяемость
export OMP_NUM_THREADS="1"
export MKL_NUM_THREADS="1"

При CUDA 10.2+ часть операций остаётся недетерминированной, если не выставлен CUBLAS_WORKSPACE_CONFIG.

Теперь код инициализации. Это минимальный, но практичный бутстрап для проекта.

# file: determinism.py
from __future__ import annotations
import os, random, warnings
import numpy as np

def set_seed(seed: int) -> None:
    # Базовые генераторы
    random.seed(seed)
    np.random.seed(seed)

    try:
        import torch

        torch.manual_seed(seed)
        # Один флажок включает глобальный режим детерминизма в PyTorch
        torch.use_deterministic_algorithms(True)
        # cuDNN: без бенчмаркинга и только детерминированные алгоритмы
        torch.backends.cudnn.benchmark = False
        torch.backends.cudnn.deterministic = True
        # Для безопасности: заполнять неинициализированную память известными значениями
        from torch.utils import deterministic as tdet
        tdet.fill_uninitialized_memory = True
    except Exception as e:
        warnings.warn(f"PyTorch determinism is partially configured: {e}")

def dataloader_seed_worker(worker_id: int) -> None:
    # Важно: синхронизируем Python и NumPy в воркере от базового состояния PyTorch
    import torch
    worker_seed = torch.initial_seed() % 2**32
    np.random.seed(worker_seed)
    random.seed(worker_seed)

def make_generator(seed: int):
    import torch
    g = torch.Generator()
    g.manual_seed(seed)
    return g

torch.use_deterministic_algorithms(True) не только включает детерминированные альтернативы, но и уронит рантайм, если операция физически не имеет такой реализации.

torch.utils.deterministic.fill_uninitialized_memory по умолчанию True при включенном deterministic‑режиме и закрывает класс багов, когда в граф случайно попадает мусор из неинициализированных тензоров.

Для cuBLAS нужна переменная окружения CUBLAS_WORKSPACE_CONFIG. Иначе даже при deterministic‑режиме споткнётесь на некоторых GEMM/редукциях.

Применение в тренировочном скрипте:

# train.py
import os
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
os.environ.setdefault("PYTHONHASHSEED", "0")

from determinism import set_seed, dataloader_seed_worker, make_generator
set_seed(2025)

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

tfm = transforms.Compose([
    transforms.ToTensor(),  # детерминированно
    # любые рандомные аугментации либо отключаем, либо снабжаем генератором
])

train = datasets.MNIST("./data", train=True, download=True, transform=tfm)
g = make_generator(2025)

loader = DataLoader(
    train,
    batch_size=256,
    shuffle=True,
    num_workers=4,                     # параллелим, но с правильной инициализацией
    worker_init_fn=dataloader_seed_worker,
    generator=g,
    persistent_workers=False,          # для простоты детерминизма
    prefetch_factor=2,                 # оставляем по умолчанию
)

model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(28*28, 256),
    nn.ReLU(),
    nn.Linear(256, 10),
).cuda()

opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
loss_fn = nn.CrossEntropyLoss()

model.train()
for xb, yb in loader:
    xb, yb = xb.cuda(non_blocking=True), yb.cuda(non_blocking=True)
    opt.zero_grad(set_to_none=True)
    logits = model(xb)
    loss = loss_fn(logits, yb)
    loss.backward()
    opt.step()

Отключили бенчмаркинг cuDNN и включили детерминированные алгоритмы, задали CUBLAS_WORKSPACE_CONFIG до старта процесса.

Три нюанса, из‑за которых часто дрейфует:

  • Полудетализация через float16 может давать микросдвиги из‑за неодинакового порядка округлений. Для строгого детерминизма используем float32 и одинаковые компиляционные пути.

  • Порядок чтения файлов. os.listdir и os.walk не гарантируют порядок.

  • Разные версии cudnn/cublas. Даже inference может немного расходиться на разных билдах библиотеки.

Сохранение и восстановление RNG-состояний из чекпоинта

Если нужно ровно продолжить тренировку с того же шага с теми же dropout‑масками и той же очередью данных, сохраняйте состояния генераторов:

# checkpointing_rng.py
import torch, random, numpy as np

def pack_rng_state():
    state = {
        "python": random.getstate(),
        "numpy": np.random.get_state(),
        "torch_cpu": torch.get_rng_state(),
        "torch_cuda_all": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
    }
    return state

def unpack_rng_state(state):
    random.setstate(state["python"])
    np.random.set_state(state["numpy"])
    torch.set_rng_state(state["torch_cpu"])
    if state["torch_cuda_all"] is not None:
        torch.cuda.set_rng_state_all(state["torch_cuda_all"])

TensorFlow: флаги и реальные границы

У TensorFlow давно появился прямой флажок на детерминизм операций. Его надо включать в коде в самом начале:

import os
os.environ.setdefault("TF_DETERMINISTIC_OPS", "1")  # старый путь через env
import tensorflow as tf
tf.config.experimental.enable_op_determinism()      # современный API

import numpy as np, random
seed = 2025
random.seed(seed)
np.random.seed(seed)
tf.random.set_seed(seed)

Включение determinism у TF приводит к выбору детерминированных реализаций, но с ожидаемым падением производительности. Если вы держите legacy‑окружение, его иногда приходится выставлять.

JAX: ключи, split и XLA

В JAX генерация случайных чисел изначально сделана прозрачной: вы всегда передаёте PRNG‑key и сплитите его в местах, где нужна случайность. Пример, который не ломается при переносе на многоядерные машины:

import jax
import jax.numpy as jnp
from jax import random, jit

key = random.key(2025)

@jit
def step(key, x):
    key, sub = random.split(key)
    w = random.normal(sub, shape=(x.shape[-1], 128))
    y = x @ w
    return key, jnp.tanh(y).sum()

x = jnp.ones((256, 1024), dtype=jnp.float32)
for _ in range(100):
    key, s = step(key, x)

Детерминированность генерации у JAX хорошая, но сам рантайм XLA может выбирать недетерминированные реализации для некоторых операций. На GPU у XLA есть флаг, вырезающий недетерминированные опции компилятора, и тогда часть графа компилироваться не будет, если детерминированной альтернативы нет.

Распределённое обучение: где именно гуляет результат

Даже при идеальном посеве и отключённом AMP результат может немного плыть в DDP/TPU‑конфигурациях. Основные причины:

  • Разный порядок суммирования при AllReduce. Сложение чисел с плавающей точкой не ассоциативно, и разные деревья редукции дают микроразброс.

  • Алгоритмы и транспорт NCCL. Между версиями и топологиями возможны разные пути редукции. Нельзя требовать от этого битовой идентичности на любых кластерах.

Что помогает:

  • Не смешивать DDP‑хуки для градиентов, пока вы добиваетесь строго детерминированной базы. Они меняют порядок и семантику редукций.

  • Фиксировать версии PyTorch, CUDA, cuDNN, NCCL, драйверов. И фиксировать топологию кластера, если вам нужна строгая проверка регрессий.

Тест, который ловит расхождения рано

Дешёвый тест на детерминизм: вычисляете хеш на фиксированном батче до и после backward для одинакового seed. Если хеш меняется между перезапусками — ищем источник.

# file: smoke_determinism.py
import hashlib, torch
from determinism import set_seed

def tensor_sha256(t: torch.Tensor) -> str:
    x = t.detach().cpu().contiguous().numpy().tobytes()
    return hashlib.sha256(x).hexdigest()

def run_once(seed=2025) -> tuple[str, str]:
    set_seed(seed)
    x = torch.randn(64, 128, device="cuda")
    w = torch.randn(128, 128, requires_grad=True, device="cuda")
    y = x @ w
    before = tensor_sha256(y)
    y.sum().backward()
    after = tensor_sha256(w.grad)
    return before, after

if __name__ == "__main__":
    b1, a1 = run_once()
    print("forward:", b1)
    print("backward:", a1)

Если эти строки совпадают между перезапусками на одной и той же версии софта и железа — базовые настройки у вас корректны. Если нет — включайте torch.use_deterministic_algorithms(True) и читайте исключения от конкретной операции.


Итог

Даже идеально настроенный проект не обещает битовую идентичность на другом драйвере или свежей сборке фреймворка. Поэтому к детерминизму относитесь как к режиму: включили, локализовали, задокументировали, а потом осознанно вернули быстрые пути.

Как видно, абсолютного детерминизма в машинном обучении добиться непросто: слишком много факторов вносят элемент случайности, начиная от библиотек и заканчивая железом. Но именно поэтому важно уметь разбираться в деталях работы алгоритмов и понимать, какие методы реально позволяют контролировать процесс. Записывайтесь на бесплатные уроки:

А если вы хотите системно и с нуля освоить ML, обратите внимание на Специализацию Machine Learning — она поможет перейти от стандартных аналитических подходов к сложным ML-алгоритмам для бизнес-прогнозирования.

Комментарии (3)


  1. BenderA
    06.09.2025 02:50

    Ничего не понятно, но очень интересно.


  1. aeder
    06.09.2025 02:50

    Извините за вопрос - а ЗАЧЕМ нужен абсолютный детерменизм в машинном обучении?

    Возможно, имеет некоторый смысл при тонкой оптимизации/отладки собственно алгоритмов разработчиками библиотек МЛ - но для обычных целей - зачем?


    1. imageman
      06.09.2025 02:50

      думаю в основном в целях отладки, изредка - убедиться, что твоя сборка не содержит ошибок (т.е. даёт результат "как у того чела из Бразилии").
      Для тонкой оптимизации я бы тоже не брал - жадный поиск не всегда даст оптимальный вариант (а жадный он потому что мы отключили разные оптимизации, которые потом будут включены).