Host-Device архитектура AI на нескольких GPU: полное руководство 2026 | AiManual
AiManual Logo Ai / Manual.
15 Фев 2026 Гайд

Host-Device для AI на нескольких GPU: почему ваш код тормозит и как это исправить

Объясняем архитектуру Host-Device для AI на нескольких GPU простыми словами. Основы распределённых вычислений, типичные ошибки и практические примеры для PyTorc

Ваш код на 8 GPU работает медленнее, чем на одной карте. Знакомо?

Вы купили вторую RTX 4090, подключили её, запустили обучение модели — и ничего. Скорость не удвоилась. Добавили третью — стало ещё хуже. Четвёртая GPU вообще простаивает. Знакомая ситуация? 90% разработчиков AI сталкиваются с этой проблемой, потому что не понимают фундаментальное различие между хостом (CPU) и девайсом (GPU).

Сегодня разберём архитектуру Host-Device на костях. Без воды, без академических определений. Только то, что влияет на скорость вашего кода прямо сейчас.

Важно: Эта статья актуальна на 15 февраля 2026 года. Все примеры используют PyTorch 3.1+, CUDA 13.5+ и учитывают изменения в архитектуре NVIDIA Hopper (H200) и Blackwell. Если вы работаете с устаревшими версиями — обновитесь или готовьтесь к несовместимости.

CPU vs GPU: не братья, а начальник и рабочий

Представьте строительную площадку. CPU — это прораб. Умный, может делать всё: читать чертежи, звонить поставщикам, считать смету. Но у него только две руки. GPU — это бригада из 10,000 рабочих. Каждый тупой как пробка (умеет только складывать и умножать), но работает синхронно.

Когда вы пишете tensor.to('cuda'), вы говорите прорабу: "Отнеси эти кирпичи рабочим". Прораб идёт через мост (PCIe), несёт кирпичи, возвращается. Пока он ходит — стройка стоит.

Операция Где выполняется Скорость (примерная) Что происходит в этот момент
Загрузка данных CPU (RAM → CPU cache) ~50 GB/s GPU простаивает
Копирование CPU→GPU PCIe bus ~16 GB/s (PCIe 4.0) Обе стороны заняты передачей
Матричное умножение GPU cores ~3000 GB/s (RTX 4090) CPU свободен

Видите проблему? GPU в 200 раз быстрее CPU в вычислениях, но данные к нему ползут как черепаха. Добавьте сюда задержки ядра Linux, аллокацию памяти, синхронизацию — и получите ситуацию, когда GPU занят только 30% времени.

Типичная ошибка новичка: синхронный ад

Вот как НЕ надо делать (видел этот код в десятках проектов):

# АНТИПАТТЕРН - НЕ КОПИРУЙТЕ ЭТОТ КОД
import torch

# 1. Загружаем данные на CPU
data = load_huge_dataset()  # 10GB в RAM

# 2. Копируем ВСЁ на GPU
for batch in data:
    gpu_data = batch.to('cuda')  # Прораб таскает кирпичи 10 секунд
    result = model(gpu_data)     # Рабочие работают 0.1 секунду
    cpu_result = result.cpu()    # Прораб таскает обратно 10 секунд
    save(cpu_result)

Что здесь происходит? Прораб 99% времени таскает кирпичи, рабочие стоят без дела. GPU utilization покажет смешные 5%.

💡
Проверьте утилизацию GPU прямо сейчас: nvidia-smi -l 1. Если видите скачки от 0% до 100% и обратно — у вас проблема с pipeline. GPU работает рывками, потому что ждёт данных от CPU.

Правильный паттерн: конвейер вместо очереди

Решение — overlap compute and data transfer. Пока GPU обрабатывает batch N, CPU готовит batch N+1. В PyTorch это делается через DataLoader с pin_memory и non_blocking:

import torch
from torch.utils.data import DataLoader, Dataset

class FastDataset(Dataset):
    def __init__(self):
        self.data = torch.randn(10000, 512, 512)
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        return self.data[idx]

# Ключевые параметры:
dataloader = DataLoader(
    dataset=FastDataset(),
    batch_size=32,
    num_workers=4,           # 4 процесса загружают данные параллельно
    pin_memory=True,         # Фиксируем память для быстрого DMA
    prefetch_factor=2        # Готовим 2 батча заранее
)

model = torch.nn.Linear(512, 256).cuda()

for batch in dataloader:
    # non_blocking=True позволяет CPU не ждать завершения копирования
    batch_gpu = batch.cuda(non_blocking=True)
    
    # Пока GPU вычисляет, CPU может готовить следующий batch
    output = model(batch_gpu)
    
    # Если результат не нужен на CPU сразу - оставляем на GPU
    loss = output.sum()
    loss.backward()
    
# Только в конце синхронизируем
print("Готово, все вычисления завершены")

1 Почему pin_memory работает быстрее?

Обычная RAM может перемещаться операционной системой (page swapping). Когда вы копируете данные из такой памяти в GPU, драйвер сначала должен "зафиксировать" страницы, потом скопировать. pin_memory сразу выделяет "неподвижную" память, которую GPU может читать напрямую через DMA (Direct Memory Access).

2 Сколько workers ставить?

Золотое правило: num_workers = количество CPU ядер - 1. Но есть нюансы:

  • На 8-ядерном процессоре ставьте 6-7 workers
  • Если загрузка данных простая (читаем из RAM) — workers могут мешать друг другу
  • Если данные на диске (HDD) — добавьте больше workers, потому что ждём IO
  • Слишком много workers (>16) создадут contention на GIL и замедлят всё

Несколько GPU: когда одна проблема становится восемью

Добавляем вторую GPU. Теперь у нас два отряда рабочих (GPU 0 и GPU 1) и один прораб (CPU). Прораб должен кормить обе бригады. Если он будет бегать от одной к другой — обе будут простаивать.

Популярная ошибка — копировать все данные на GPU 0, потом на GPU 1:

# ЕЩЁ ОДИН АНТИПАТТЕРН
batch = next(dataloader)
gpu0_batch = batch.to('cuda:0')  # Ждём...
gpu1_batch = batch.to('cuda:1')  # Снова ждём...
# GPU 1 простаивает пока данные идут на GPU 0

Правильный подход — использовать асинхронные потоки CUDA:

import torch

# Создаём отдельные потоки для каждой GPU
stream0 = torch.cuda.Stream(device='cuda:0')
stream1 = torch.cuda.Stream(device='cuda:1')

with torch.cuda.stream(stream0):
    data0 = batch[:half].to('cuda:0', non_blocking=True)
    output0 = model0(data0)

with torch.cuda.stream(stream1):
    data1 = batch[half:].to('cuda:1', non_blocking=True)
    output1 = model1(data1)

# Синхронизируем оба потока
torch.cuda.synchronize('cuda:0')
torch.cuda.synchronize('cuda:1')

Внимание на архитектуру: На 15 февраля 2026 года актуальны GPU с NVLink 4.0 (до 900 GB/s между картами) и PCIe 6.0 (256 GB/s). Если у вас старые карты без NVLink — меж-GPU коммуникация будет через PCIe, что в 30 раз медленнее. Проверьте nvidia-smi topo -m. Если видите "PHB" вместо "NV#" — готовьтесь к bottleneck.

Реальная архитектура: от 2 до 8 GPU

Рассмотрим типичную конфигурацию для AI-фермы на 8x RTX 3090:

Компонент Роль Типичная проблема Решение
CPU (Threadripper) Дирижёр оркестра Не хватает PCIe линий AMD Threadripper Pro с 128 линиями
RAM (256GB) Буфер обмена Медленная DDR4 DDR5 или HBM (если бюджет)
GPU 0-7 Вычислители Перегрев, троттлинг Прямой обдув, жидкостное охлаждение
NVLink/PCIe Дороги между GPU Узкое место (bottleneck) Группировка GPU по 4 с NVLink

Самая частая ошибка при сборке такой системы — экономия на материнской плате. Вы покупаете 8 дорогих GPU, но вставляете их в плату с 48 PCIe линиями. Каждая карта получает только x4 вместо x16. Пропускная способность падает в 4 раза. Все ваши вычисления упрутся в передачу данных.

Практика: распределяем модель по 4 GPU

Допустим, у нас есть огромная модель, которая не помещается в память одной GPU. Разделим её слои между устройствами:

import torch
import torch.nn as nn

device_count = torch.cuda.device_count()
print(f"Доступно GPU: {device_count}")

class DistributedModel(nn.Module):
    def __init__(self):
        super().__init__()
        # Создаём части модели на разных GPU
        self.part1 = nn.Sequential(
            nn.Linear(1024, 4096),
            nn.ReLU(),
            nn.Dropout(0.1)
        ).to('cuda:0')
        
        self.part2 = nn.Sequential(
            nn.Linear(4096, 4096),
            nn.ReLU(),
            nn.Dropout(0.1)
        ).to('cuda:1')
        
        # И так далее для всех GPU
        
    def forward(self, x):
        # x начинается на CPU
        x = x.to('cuda:0')
        
        # Проходим через часть на GPU 0
        x = self.part1(x)
        
        # Перемещаем на GPU 1
        x = x.to('cuda:1')
        x = self.part2(x)
        
        # Возвращаем на CPU для лосса
        return x.cpu()

# Но это НЕПРАВИЛЬНО! Почему?
# Каждый .to() — это синхронная операция.
# GPU 1 ждёт, пока GPU 0 закончит вычисления.
# GPU 0 ждёт, пока данные переедут на GPU 1.
# Все стоят в очереди.

Правильный подход — pipeline parallelism. Пока GPU 0 обрабатывает batch N, GPU 1 обрабатывает batch N-1:

# Упрощённый пример pipeline
from torch.distributed.pipeline.sync import Pipe

model = DistributedModel()
# PyTorch сам разделит модель и создаст конвейер
model_pipe = Pipe(model, chunks=8)  # 8 микробатчей

# Теперь GPU работают параллельно, а не последовательно
output = model_pipe(input)

Когда всё летит в тартарары: отладка распределённого кода

Вы написали "идеальный" код, но он работает медленнее, чем на одной GPU. Куда смотреть?

  1. nvidia-smi dmon — показывает активность PCIe и NVLink в реальном времени. Если PCIe utilization постоянно 100% — у вас bottleneck в передаче данных.
  2. Nsight Systems — профилировщик от NVIDIA. Покажет timeline: где GPU ждёт, где CPU ждёт, какие операции блокируют конвейер.
  3. PyTorch Profiler — встроенный инструмент. Запустите с with torch.profiler.profile(...) и получите flame graph.

Типичные находки при профилировании:

  • cudaMemcpyAsync занимает 80% времени — вы слишком часто копируете данные туда-сюда. Оставляйте тензоры на GPU между итерациями.
  • cudaStreamSynchronize после каждой операции — вы вызываете .cpu() или .item() слишком часто. Эти операции блокируют весь конвейер.
  • GPU utilization скачет 0% → 100% → 0% — batch size слишком маленький. GPU быстро обрабатывает батч и ждёт следующего.

Специальный случай: инференс больших LLM

Для инференса (генерации текста) архитектура меняется. Здесь нельзя использовать pipeline parallelism в чистом виде, потому что каждый токен зависит от предыдущего. Но можно распределить слои модели по GPU с помощью tensor parallelism:

# Используем FullyShardedDataParallel (FSDP) из PyTorch
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import ShardingStrategy

# Модель остаётся целой логически, но тензоры шардятся по GPU
model = HugeTransformer()

fsdp_model = FSDP(
    model,
    sharding_strategy=ShardingStrategy.FULL_SHARD,  # Шардим параметры, градиенты и оптимизатор
    device_id=torch.cuda.current_device()
)

# Теперь модель распределена по всем доступным GPU
# Каждый GPU хранит только кусок параметров
output = fsdp_model(input_ids)

FSDP особенно эффективен для моделей типа Llama 3 405B (да, на 15 февраля 2026 года это уже не самая большая модель). Если у вас арендованные H200 с 141GB памяти каждая — можно уместить довольно крупные модели.

Гибридные системы: когда у вас разные GPU

Реальность такова, что редко у кого есть 8 одинаковых GPU. Чаще встречается зоопарк: RTX 4090 + A100 + старый V100. Или, что ещё хуже, смесь NVIDIA и AMD.

Правило простое: самая медленная GPU определяет скорость всей системы. Если у вас одна карта с PCIe 3.0 x4, а остальные с PCIe 4.0 x16 — система будет работать на скорости PCIe 3.0 x4.

💡
Проверьте совместимость: torch.cuda.get_device_capability(device_id) вернёт (compute capability major, minor). Карты с разной compute capability (например, 8.9 для Ada и 7.0 для Volta) могут не работать вместе в одном процессе.

Мифы и правда о Host-Device архитектуре

Миф Правда Что делать
"Больше GPU = быстрее обучение" Только если данные успевают за GPU. Иначе добавление карт бесполезно. Сначала оптимизируйте data pipeline, потом добавляйте GPU.
"NVLink решает все проблемы" NVLink ускоряет только GPU↔GPU обмен. CPU↔GPU всё равно идёт через PCIe. Используйте CUDA graphs для минимизации CPU участия.
"Можно использовать любую материнскую плату" Дешёвые платы делят PCIe линии. 4 карты × x4 = все карты на x1 скорости. Берите серверные платы или Threadripper с большим числом линий.
"Docker/K8s прозрачны для GPU" Контейнеры добавляют overhead на системные вызовы. Может быть 5-10% потерь. Используйте nvidia-container-toolkit и избегайте виртуализации GPU.

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

  1. Проверьте PCIe топологию: nvidia-smi topo -m. Идеально, если все GPU связаны NVLink.
  2. Убедитесь, что CUDA видит все карты: torch.cuda.device_count() должно показывать правильное число.
  3. Настройте окружение: export CUDA_VISIBLE_DEVICES="0,1,2,3" если хотите использовать только часть карт.
  4. Включите benchmark mode: torch.backends.cudnn.benchmark = True для автоматической оптимизации convolutions.
  5. Проверьте память: torch.cuda.empty_cache() перед началом, чтобы очистить кэш.
  6. Настройте DataLoader: pin_memory=True, num_workers=4, prefetch_factor=2.
  7. Используйте mixed precision: torch.autocast('cuda') для FP16/BF16 где возможно.
  8. Избегайте синхронных операций: Не вызывайте .cpu() или .item() в цикле.

Что будет дальше? Эволюция архитектур

К 2026 году тенденции ясны:

  • CPU становится узким местом — поэтому NVIDIA и AMD интегрируют CPU и GPU на одном чипе (Grace-Hopper, MI300X).
  • Память унифицируется — HBM на CPU и GPU позволяет обмениваться данными без копирования.
  • Программный стек упрощается — PyTorch 3.x обещает автоматическое распределение без ручных .to('cuda').

Но пока эти технологии не стали мейнстримом, вам придётся разбираться с ручным распределением. Хорошая новость: понимание Host-Device архитектуры — это 80% успеха в распределённом ML. Остальные 20% — это знание конкретных фреймворков (DDP, FSDP, DeepSpeed), о которых мы поговорим в следующих статьях.

Самый важный урок: GPU — не волшебные чёрные ящики. Они требуют правильного питания данными. Если вы кормите их с ложечки (синхронно, маленькими порциями), они будут голодать. Настройте конвейер — и они заработают на полную.

Проверьте себя: Запустите nvprof --print-gpu-trace python your_script.py. Если видите большие gaps между kernel launches — у вас bottleneck в Host-Device коммуникации. Пора оптимизировать.