Relatórios

124x Mais Lento: O que o PyTorch DataLoader Realmente Faz no Nível do Kernel

mm
Adicione Unite.AI às suas fontes preferidas no Google
A conceptual widescreen illustration of an hourglass containing glowing digital data streams and circuit patterns, with the bottom half featuring a large data block and a GPU hardware component sitting idle to the side, representing data processing delays and GPU starvation.

Este artigo é baseado em descobertas de uma investigação de rastreamento de nível de kernel da GPU realizada em um problema real do PyTorch (#154318) usando eBPF uprobes. Os bancos de dados de rastreamento são publicados no repositório de código aberto do Ingero para verificação independente.

TL;DR

O DataLoader do PyTorch pode ser 50-124x mais lento do que o índice de tensor direto para cargas de trabalho de GPU em memória. Reproduzimos um problema real do PyTorch em um RTX 4090 e rastreamos cada chamada de API do CUDA e evento do kernel do Linux para encontrar a causa raiz. A GPU não estava lenta – estava com fome. Os trabalhadores do DataLoader geraram 200.000 mudanças de contexto de CPU e 300.000 alocações de página em 40 segundos, deixando a GPU esperando em média 301ms por transferência de dados que deveria levar microssegundos.

O Problema

Um usuário do PyTorch relatou que o DataLoader era 7-22x mais lento do que o índice de tensor direto para uma carga de trabalho de inferência de MLP simples. Mesmo com num_workers=12, pin_memory=True e prefetch_factor=12, a lacuna permaneceu massiva. A utilização da GPU estava em 10-20%.

Reproduzimos isso. A lacuna era ainda pior em nosso hardware:

Método Tempo vs Direto
Índice de tensor direto 0,39s 1x
DataLoader (shuffle=True) 48,49s 124x mais lento
DataLoader (otimizado, 4 trabalhadores, pin_memory) 43,29s 111x mais lento

A carga de trabalho é trivial: 7M amostras, 100 recursos, 2 camadas de MLP, tamanho de lote 1M. O modelo processa um lote em milissegundos. Então, onde vai o tempo?

O que o nvidia-smi Mostra

Nada útil. A utilização da GPU oscila entre 0% e 30%. O uso de memória é estável. A temperatura está boa. A GPU está claramente subutilizada, mas o nvidia-smi não pode dizer por quê.

O que o torch.profiler Mostra

O relator tentou o profiler integrado do PyTorch e “obteve nenhum dado de rastreamento significativo”. Isso é uma frustração comum – os perfiladores de nível de aplicativo podem mostrar quais kernels do CUDA estão executando, mas não podem ver a programação de lado do host, eventos de memória e ciclo de vida de processo que determinam se os dados chegam à GPU no momento certo.

O que o Rastreamento de Nível de Kernel Mostra

Executamos o benchmark enquanto rastreamos chamadas de API do CUDA (via eBPF uprobes no libcudart.so) e eventos do kernel do Linux (mudanças de contexto de programação, alocações de página de memória, bifurcações de processo) simultaneamente. Os resultados contam a história completa.

Full video walkthrough: https://asciinema.org/a/RGwhPeXAPJdhXqxp

No vídeo, conectamos um LLM de peso aberto (MiniMax-M2.7) ao banco de dados de rastreamento via MCP (Model Context Protocol):

ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json

O arquivo de configuração JSON diz ao cliente MCP onde encontrar o servidor Ingero e qual banco de dados de rastreamento carregar:

{
"mcpServers": {
"ingero": {
"command": "./bin/ingero",
"args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"]
}
}
}

Isso dá ao LLM acesso direto aos dados de rastreamento por meio de 7 ferramentas: get_trace_stats, get_causal_chains, get_per_process_breakdown e outras. O AI pode consultar o banco de dados, correlacionar eventos do CUDA com dados de programação do kernel e produzir uma explicação em linguagem simples sem análise manual.

4 Cadeias Causais de Alta Severidade

O mecanismo de cadeia causal detectou 4 padrões de alta severidade, todos com a mesma causa raiz:

[ALTA] cudaStreamSync p99=42ms (1.638x p50=25us) - CPU 100% + 1.880 mudanças de contexto
Linha do tempo:
[SYSTEM] CPU 100%
[HOST ] 1.880 mudanças de contexto (21s fora da CPU)
[CUDA ] p99=42ms (1.638x p50=25us)
Causa raiz: Trabalhadores do DataLoader lutando por CPU, pressão de alocação de página maciça
[ALTA] cudaLaunchKernel p99=24,67ms (349x p50=70us) - CPU 100%
Raiz: 34 mudanças de contexto

[ALTA] cuMemAlloc p99=627us (4,0x p50) - CPU 100%
[ALTA] cuLaunchKernel p99=106us (4,0x p50) - CPU 100%

O cudaStreamSync p99 é 1.638 vezes o p50. Isso não é lentidão da GPU – é a GPU esperando por dados que nunca chegam no momento certo.

Figura 1: Análise gerada pelo AI após executar /investigate. O modelo usou as 7 ferramentas MCP do Ingero para consultar o banco de dados de rastreamento e produzir uma explicação em linguagem simples com recomendações derivadas diretamente dos dados de rastreamento.

A Quebra por Processo

Aqui é onde fica claro. O processo principal e seus 4 trabalhadores do DataLoader são visíveis como entidades separadas:
Processo principal:

- cudaMemcpyAsync (transferência host-para-dispositivo): média 301ms, máximo 2,9 segundos
- cudaStreamSync: p99 = 42ms (normalmente 25us)
- 1.567 mudanças de contexto, média 16ms fora da CPU, pior parada 5 segundos
- 799.018 alocações de página
Trabalhador do DataLoader 1: 52.863 mudanças de contexto, 89.338 alocações de página, pior parada 5s
Trabalhador do DataLoader 2: 50.638 mudanças de contexto, 83.509 alocações de página, pior parada 5s
Trabalhador do DataLoader 3: 49.361 mudanças de contexto, 70.035 alocações de página, pior parada 5s
Trabalhador do DataLoader 4: 38.862 mudanças de contexto, 56.354 alocações de página, pior parada 5s

Total nos trabalhadores: ~191.000 mudanças de contexto e ~299.000 alocações de página em 40 segundos.

O que Isso Significa

Os trabalhadores do DataLoader estão fazendo três coisas caras que o índice direto evita completamente:

  1. Embaralhamento e índice: O DataLoader com shuffle=True gera uma permutação aleatória de índices, então cada trabalhador seleciona seu pedaço. Isso requer acesso aleatório à memória ao longo do tensor de 7M amostras – terrível para a localidade do cache e dispara falhas de página.
  2. Coleta e cópia: Cada trabalhador reúne amostras espalhadas em um tensor de lote contíguo. Isso significa alocar nova memória (alocações de página), copiar dados de locais aleatórios (erros de cache) e serializar o resultado de volta ao processo principal via memória compartilhada ou fila.
  3. Concorrência por CPU: Quatro trabalhadores + o processo principal em uma máquina de 4 vCPUs significa preempção constante. Cada trabalhador é desprogramado 50.000 vezes. A pior parada é de 5 segundos – durante o qual a GPU não tem nada para processar.

Com índice direto: X[i:i+batch_size] é uma visão de zero cópia de um tensor contíguo já na memória. .to(device) dispara uma transferência de DMA de uma região contígua única. Nenhum trabalhador, nenhum embaralhamento, nenhuma coleta, nenhuma cópia entre processos, nenhuma mudança de contexto. A GPU obtém dados em microssegundos, não em centenas de milissegundos.

A Solução

Para cargas de trabalho de GPU em memória onde o conjunto de dados inteiro cabe na RAM:

  1. Não use o DataLoader. Índice direto com um array de índice pré-embaralhado é mais simples e 100x mais rápido:
    indices = torch.randperm(num_samples)
    for i in range(0, num_samples, batch_size):
    batch = X[indices[i:i+batch_size]].to(device)
    output = model(batch)
    
  2. Se você deve usar o DataLoader, faça com que num_workers corresponda aos núcleos de CPU reais menos 1. Em uma máquina de 4 núcleos, num_workers=2 reduz a concorrência. Adicione persistent_workers=True para evitar a sobrecarga de bifurcação.
  3. Para conjuntos de dados maiores que a memória onde o DataLoader é necessário, o verdadeiro gargalo muda para E/S de disco. Use prefetch_factor=2 (não mais alto – mais pré-busca significa mais pressão de memória) e certifique-se de que seu armazenamento possa acompanhar.

O Quadro Maior

Esta investigação ilustra um padrão que vemos constantemente em cargas de trabalho de GPU: a GPU é rápida, o host é o gargalo e as métricas da GPU não podem vê-lo. O nvidia-smi relatou baixa utilização, mas não pôde explicar por quê. O torch.profiler capturou kernels do CUDA, mas perdeu as 200.000 mudanças de contexto acontecendo no espaço do usuário.

A única maneira de ver a imagem completa foi rastrear ambos os lados simultaneamente – chamadas de API do CUDA no nível da biblioteca e eventos de programação do kernel do Linux – e correlacioná-los por tempo e ID de processo. A cadeia causal “CPU 100% -> 1.880 mudanças de contexto -> cudaMemcpyAsync 301ms -> cudaStreamSync 42ms” conta a história completa em uma linha. Sem rastreamento entre pilhas, isso teria permanecido um mistério – como foi para o relator original que passou semanas depurando.

Figura 2: Quando perguntado “qual é o problema principal?”, o modelo identifica a superinscrição da CPU causando atrasos de programação do lado do host. O cudaLaunchKernel passou de 73us para 25,8ms (356x mais lento) porque a CPU não pôde programar o lançamento no momento certo.

Tente Você Mesmo

Reproduza o benchmark:

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

X = torch.randn(7_000_000, 100) model = torch.nn.Sequential( torch.nn.Linear(100, 512), torch.nn.ReLU(), torch.nn.Linear(512, 512), torch.nn.ReLU(), torch.nn.Linear(512, 10) ).cuda()

# Caminho rápido start = time.time() with torch.no_grad(): for i in range(0, len(X), 1_048_576): model(X[i:i+1_048_576].cuda()) torch.cuda.synchronize() print(f'Direto: {time.time()-start:.3f}s')

# Caminho lento loader = DataLoader(X, batch_size=1_048_576, shuffle=True) start = time.time() with torch.no_grad(): for batch in loader: model(batch.cuda()) torch.cuda.synchronize() print(f'DataLoader: {time.time()-start:.3f}s')

Rastreie com o Ingero para ver o que está acontecendo por baixo dos panos:

git clone https://github.com/ingero-io/ingero.git
cd ingero && make build
sudo ./bin/ingero trace --duration 60s # em um terminal
python3 benchmark.py # em outro terminal
./bin/ingero explain --since 60s # após o benchmark concluir

Ou pule a reprodução e explore os dados de rastreamento diretamente. O banco de dados de investigação (764KB) está no repositório:

# Exiba cadeias causais da investigação
./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --since 5m

# Quebra por processo (veja trabalhadores do DataLoader vs processo principal) ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --per-process --since 5m

# Conecte seu assistente de IA para investigação interativa ./bin/ingero mcp --db investigations/pytorch-dataloader-starvation.db

Investigue com a IA (recomendado). A maneira mais rápida de analisar o rastreamento é conectar qualquer IA compatível com o MCP diretamente ao banco de dados. Nenhuma análise manual é necessária.

Crie um arquivo de configuração:

cat > /tmp/ingero-mcp-dataloader.json << 'EOF'
{
"mcpServers": {
"ingero": {
"command": "./bin/ingero",
"args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"]
}
}
}
EOF

Em seguida, conecte seu modelo:

# Com Ollama + MiniMax (o que usamos no vídeo)
ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json

# Com Claude Code claude --mcp-config /tmp/ingero-mcp-dataloader.json

# Com qualquer cliente compatível com o MCP # Adicione a configuração acima às configurações do MCP do seu AI

Digite /investigate para disparar a análise guiada ou faça qualquer pergunta: “O que causou a fome da GPU?” A IA tem acesso a 7 ferramentas que consultam o banco de dados de rastreamento diretamente.

GitHub: github.com/ingero-io/ingero
Problema original: pytorch/pytorch#154318
Video walkthrough: https://asciinema.org/a/RGwhPeXAPJdhXqxp

Investigação realizada no TensorDock RTX 4090 (24GB), Ubuntu 22.04, PyTorch 2.10.0+cu128.

David Mail é coautor e mantenedor do Ingero, um agente de código aberto eBPF para observabilidade de GPU de nível CUDA. Ele se especializa em rastreamento de nível de kernel de cargas de trabalho de IA de produção.