Skill Tracer
Skills › Scientific Agent Skills › torch-geometric › scaling.md

scaling.md

265 lines · skills/torch-geometric/references/scaling.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Scaling GNNs — Full Reference
2
3Techniques for training GNNs on large graphs that don't fit in GPU memory, multi-GPU training, and performance optimization.
4

Scaling GNNs — Full Reference

Techniques for training GNNs on large graphs that don't fit in GPU memory, multi-GPU training, and performance optimization.

Raw lines 1 to 4
5## Table of Contents
61. Neighbor Sampling (NeighborLoader)
72. Other Sampling Strategies
83. Multi-GPU / Distributed Training
94. torch.compile Support
105. Performance Tips
12---

Table of Contents

  1. Neighbor Sampling (NeighborLoader)
  2. Other Sampling Strategies
  3. Multi-GPU / Distributed Training
  4. torch.compile Support
  5. Performance Tips

Raw lines 5 to 13
14## 1. Neighbor Sampling (NeighborLoader)
16The primary approach for large single-graph training. Recursively samples a fixed number of neighbors per hop, bounding fan-out per seed (not a global memory limit). Requires compatible `pyg-lib` or `torch-sparse`; backend support differs for induced, disjoint and temporal sampling. Start with `num_workers=0` for debugging.
18```python
19from torch_geometric.loader import NeighborLoader
21loader = NeighborLoader(
22 data,
23 num_neighbors=[15, 10], # Max neighbors per hop (hop 1: 15, hop 2: 10)
24 batch_size=1024, # Number of seed nodes per batch
25 input_nodes=data.train_mask, # Which nodes to sample from
26 shuffle=True,
27 num_workers=4, # Parallel data loading
28 replace=False, # Sample without replacement
30```
32**Key parameters:**
33- `num_neighbors`: List of max neighbors per hop. Length should match GNN depth. Use `-1` to sample all neighbors for a hop.
34- `input_nodes`: Seed nodes — can be a mask, tensor of indices, or `('node_type', mask)` tuple for hetero graphs.
35- `subgraph_type`: `"directional"` (default), `"bidirectional"` (add reverse edges), or `"induced"` (full induced subgraph).
36- `disjoint`: If `True`, don't fuse neighborhoods across seed nodes (uses more memory but can be needed).
38**Training pattern:**
39```python
40from torch_geometric.nn import GraphSAGE
41model = GraphSAGE(in_channels, hidden_channels, num_layers=2, out_channels=out_channels)
43for batch in loader:
44 batch = batch.to(device)
45 out = model(batch.x, batch.edge_index)
46 # CRITICAL: only first batch_size nodes are seed nodes
47 loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size])
48```
50**Important details:**
51- Nodes are sorted: first `batch.batch_size` nodes are the seed nodes
52- `batch.n_id` maps local indices back to original node IDs
53- Multiple hops can grow neighborhoods rapidly; fan-out and overlap determine the actual cost
54- Keep `len(num_neighbors) == num_gnn_layers` for efficiency
55- PyG 2.7 adds `BidirectionalSampler` and forward+reverse edge sampling on `NeighborSampler` for undirected graphs

1. Neighbor Sampling (NeighborLoader)

The primary approach for large single-graph training. Recursively samples a fixed number of neighbors per hop, bounding fan-out per seed (not a global memory limit). Requires compatible pyg-lib or torch-sparse; backend support differs for induced, disjoint and temporal sampling. Start with num_workers=0 for debugging.

from torch_geometric.loader import NeighborLoader

loader = NeighborLoader(
    data,
    num_neighbors=[15, 10],       # Max neighbors per hop (hop 1: 15, hop 2: 10)
    batch_size=1024,               # Number of seed nodes per batch
    input_nodes=data.train_mask,   # Which nodes to sample from
    shuffle=True,
    num_workers=4,                 # Parallel data loading
    replace=False,                 # Sample without replacement
)

Key parameters:

  • num_neighbors: List of max neighbors per hop. Length should match GNN depth. Use -1 to sample all neighbors for a hop.
  • input_nodes: Seed nodes — can be a mask, tensor of indices, or ('node_type', mask) tuple for hetero graphs.
  • subgraph_type: "directional" (default), "bidirectional" (add reverse edges), or "induced" (full induced subgraph).
  • disjoint: If True, don't fuse neighborhoods across seed nodes (uses more memory but can be needed).

Training pattern:

from torch_geometric.nn import GraphSAGE
model = GraphSAGE(in_channels, hidden_channels, num_layers=2, out_channels=out_channels)

for batch in loader:
    batch = batch.to(device)
    out = model(batch.x, batch.edge_index)
    # CRITICAL: only first batch_size nodes are seed nodes
    loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size])

Important details:

  • Nodes are sorted: first batch.batch_size nodes are the seed nodes
  • batch.n_id maps local indices back to original node IDs
  • Multiple hops can grow neighborhoods rapidly; fan-out and overlap determine the actual cost
  • Keep len(num_neighbors) == num_gnn_layers for efficiency
  • PyG 2.7 adds BidirectionalSampler and forward+reverse edge sampling on NeighborSampler for undirected graphs
Raw lines 14 to 56
57### LinkNeighborLoader (for link prediction)
59Samples subgraphs around supervision edges:
61```python
62from torch_geometric.loader import LinkNeighborLoader
64loader = LinkNeighborLoader(
65 train_data,
66 num_neighbors=[20, 10],
67 edge_label_index=train_data.edge_label_index,
68 edge_label=train_data.edge_label,
69 batch_size=256,
70 neg_sampling=None, # This example assumes fixed positive AND negative labels.
71 shuffle=True,
73```

LinkNeighborLoader (for link prediction)

Samples subgraphs around supervision edges:

from torch_geometric.loader import LinkNeighborLoader

loader = LinkNeighborLoader(
    train_data,
    num_neighbors=[20, 10],
    edge_label_index=train_data.edge_label_index,
    edge_label=train_data.edge_label,
    batch_size=256,
    neg_sampling=None,  # This example assumes fixed positive AND negative labels.
    shuffle=True,
)
Raw lines 57 to 74
75### HGTLoader (type-aware heterogeneous sampling)
77Samples a node budget per type per hop following the HGT paper; requires `torch-sparse` (illustrative):
79```python
80from torch_geometric.loader import HGTLoader
82loader = HGTLoader(
83 data,
84 num_samples=[512] * 2, # Nodes per type per hop
85 batch_size=128,
86 input_nodes=('paper', data['paper'].train_mask),
88```

HGTLoader (type-aware heterogeneous sampling)

Samples a node budget per type per hop following the HGT paper; requires torch-sparse (illustrative):

from torch_geometric.loader import HGTLoader

loader = HGTLoader(
    data,
    num_samples=[512] * 2,        # Nodes per type per hop
    batch_size=128,
    input_nodes=('paper', data['paper'].train_mask),
)
Raw lines 75 to 89
90## 2. Other Sampling Strategies

2. Other Sampling Strategies

Raw lines 90 to 91
92### ClusterLoader (ClusterGCN)
94Partitions the graph into clusters using METIS (`pyg-lib` with METIS or `torch-sparse`), then trains on batches of partitions. Boundary edges can be omitted; it is not full-graph message passing (illustrative):
96```python
97from torch_geometric.loader import ClusterData, ClusterLoader
99cluster_data = ClusterData(data, num_parts=1500)
100loader = ClusterLoader(cluster_data, batch_size=20, shuffle=True)
102for batch in loader:
103 # Mask supervised nodes; do not compute an empty-mask loss.
104 if not batch.train_mask.any():
105 continue
106 out = model(batch.x, batch.edge_index)
107 loss = F.cross_entropy(out[batch.train_mask], batch.y[batch.train_mask])
108```

ClusterLoader (ClusterGCN)

Partitions the graph into clusters using METIS (pyg-lib with METIS or torch-sparse), then trains on batches of partitions. Boundary edges can be omitted; it is not full-graph message passing (illustrative):

from torch_geometric.loader import ClusterData, ClusterLoader

cluster_data = ClusterData(data, num_parts=1500)
loader = ClusterLoader(cluster_data, batch_size=20, shuffle=True)

for batch in loader:
    # Mask supervised nodes; do not compute an empty-mask loss.
    if not batch.train_mask.any():
        continue
    out = model(batch.x, batch.edge_index)
    loss = F.cross_entropy(out[batch.train_mask], batch.y[batch.train_mask])
Raw lines 92 to 109
110### GraphSAINTSampler
112Samples subgraphs via random walks, nodes, or edges with importance-based normalization:
114```python
115from torch_geometric.loader import GraphSAINTRandomWalkSampler
117loader = GraphSAINTRandomWalkSampler(
118 data, batch_size=6000, walk_length=2, num_steps=5, sample_coverage=100,
120```
122`GraphSAINT` requires `torch-sparse`. With positive `sample_coverage`, use its `edge_norm` in the message operator and `node_norm` in the unreduced supervised loss as in the official GraphSAINT example; merely constructing the sampler does not apply normalization.

GraphSAINTSampler

Samples subgraphs via random walks, nodes, or edges with importance-based normalization:

from torch_geometric.loader import GraphSAINTRandomWalkSampler

loader = GraphSAINTRandomWalkSampler(
    data, batch_size=6000, walk_length=2, num_steps=5, sample_coverage=100,
)

GraphSAINT requires torch-sparse. With positive sample_coverage, use its edge_norm in the message operator and node_norm in the unreduced supervised loss as in the official GraphSAINT example; merely constructing the sampler does not apply normalization.

Raw lines 110 to 123
124### ShaDowKHopSampler
126Extracts K-hop induced subgraphs around seed nodes — decouples depth from scope:
128```python
129from torch_geometric.loader import ShaDowKHopSampler
131loader = ShaDowKHopSampler(
132 data, depth=2, num_neighbors=5, batch_size=64,
133 node_idx=data.train_mask,
135```
137ShaDow requires `torch-sparse`. Seed outputs are at `batch.root_n_id`, not the NeighborLoader prefix; node labels are already indexed to roots in the returned batch.

ShaDowKHopSampler

Extracts K-hop induced subgraphs around seed nodes — decouples depth from scope:

from torch_geometric.loader import ShaDowKHopSampler

loader = ShaDowKHopSampler(
    data, depth=2, num_neighbors=5, batch_size=64,
    node_idx=data.train_mask,
)

ShaDow requires torch-sparse. Seed outputs are at batch.root_n_id, not the NeighborLoader prefix; node labels are already indexed to roots in the returned batch.

Raw lines 124 to 138
139## 3. Multi-GPU / Distributed Training
141`torch_geometric.distributed` is deprecated as of PyG 2.7 — use standard PyTorch DDP below.

3. Multi-GPU / Distributed Training

torch_geometric.distributed is deprecated as of PyG 2.7 — use standard PyTorch DDP below.

Raw lines 139 to 142
143### DistributedDataParallel (DDP)
145Standard DDP replicates model parameters and synchronizes gradients; it does not automatically partition/store a giant graph. The following single-machine CUDA recipe is illustrative. Launch it with `torchrun --standalone --nproc_per_node=4 train.py`, and supply your already prepared CPU `data` and class count. The distributed sampler pads to equal per-rank counts (possibly repeating a few seeds), preventing unequal backward-call counts.
147```python
148import os
149import torch
150import torch.nn.functional as F
151import torch.distributed as dist
152from torch.nn.parallel import DistributedDataParallel
153from torch.utils.data.distributed import DistributedSampler
154from torch_geometric.loader import NeighborLoader
155from torch_geometric.nn import GraphSAGE
157def run(data, num_classes):
158 local_rank = int(os.environ['LOCAL_RANK'])
159 torch.cuda.set_device(local_rank)
160 dist.init_process_group('nccl')
161 rank, world_size = dist.get_rank(), dist.get_world_size()
162 device = torch.device('cuda', local_rank)
163 train_idx = data.train_mask.nonzero(as_tuple=False).view(-1)
164 if train_idx.numel() == 0:
165 raise ValueError('Empty training seed set')
166 seed_sampler = DistributedSampler(train_idx, num_replicas=world_size,
167 rank=rank, shuffle=True, drop_last=False)
168 model = GraphSAGE(data.num_features, 64, num_layers=2,
169 out_channels=num_classes).to(device)
170 model = DistributedDataParallel(model, device_ids=[local_rank])
171 optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
172 for epoch in range(10):
173 seed_sampler.set_epoch(epoch)
174 rank_seeds = train_idx[torch.tensor(list(seed_sampler))]
175 loader = NeighborLoader(data, input_nodes=rank_seeds,
176 num_neighbors=[25, 10], batch_size=1024,
177 num_workers=0, shuffle=False)
178 model.train()
179 for batch in loader:
180 batch = batch.to(device)
181 optimizer.zero_grad()
182 out = model(batch.x, batch.edge_index)[:batch.batch_size]
183 loss = F.cross_entropy(out, batch.y[:batch.batch_size])
184 loss.backward()
185 optimizer.step()
186 # All ranks reach both barriers. If evaluating only on rank 0,
187 # use model.module under no_grad and aggregate metrics correctly;
188 # invoking only one rank's DDP wrapper can hang its collectives.
189 dist.barrier()
190 dist.barrier()
191 dist.destroy_process_group()
192```
194For multi-node launch, use the launcher's rendezvous and global rank environment; `LOCAL_RANK` only selects a device on its own host. Avoid hardcoded localhost rendezvous or floor-sized slicing that drops remaining seeds. The graph is still replicated in this recipe; estimate host/GPU memory before launching.

DistributedDataParallel (DDP)

Standard DDP replicates model parameters and synchronizes gradients; it does not automatically partition/store a giant graph. The following single-machine CUDA recipe is illustrative. Launch it with torchrun --standalone --nproc_per_node=4 train.py, and supply your already prepared CPU data and class count. The distributed sampler pads to equal per-rank counts (possibly repeating a few seeds), preventing unequal backward-call counts.

import os
import torch
import torch.nn.functional as F
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data.distributed import DistributedSampler
from torch_geometric.loader import NeighborLoader
from torch_geometric.nn import GraphSAGE

def run(data, num_classes):
    local_rank = int(os.environ['LOCAL_RANK'])
    torch.cuda.set_device(local_rank)
    dist.init_process_group('nccl')
    rank, world_size = dist.get_rank(), dist.get_world_size()
    device = torch.device('cuda', local_rank)
    train_idx = data.train_mask.nonzero(as_tuple=False).view(-1)
    if train_idx.numel() == 0:
        raise ValueError('Empty training seed set')
    seed_sampler = DistributedSampler(train_idx, num_replicas=world_size,
                                      rank=rank, shuffle=True, drop_last=False)
    model = GraphSAGE(data.num_features, 64, num_layers=2,
                      out_channels=num_classes).to(device)
    model = DistributedDataParallel(model, device_ids=[local_rank])
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    for epoch in range(10):
        seed_sampler.set_epoch(epoch)
        rank_seeds = train_idx[torch.tensor(list(seed_sampler))]
        loader = NeighborLoader(data, input_nodes=rank_seeds,
                                num_neighbors=[25, 10], batch_size=1024,
                                num_workers=0, shuffle=False)
        model.train()
        for batch in loader:
            batch = batch.to(device)
            optimizer.zero_grad()
            out = model(batch.x, batch.edge_index)[:batch.batch_size]
            loss = F.cross_entropy(out, batch.y[:batch.batch_size])
            loss.backward()
            optimizer.step()
        # All ranks reach both barriers. If evaluating only on rank 0,
        # use model.module under no_grad and aggregate metrics correctly;
        # invoking only one rank's DDP wrapper can hang its collectives.
        dist.barrier()
        dist.barrier()
    dist.destroy_process_group()

For multi-node launch, use the launcher's rendezvous and global rank environment; LOCAL_RANK only selects a device on its own host. Avoid hardcoded localhost rendezvous or floor-sized slicing that drops remaining seeds. The graph is still replicated in this recipe; estimate host/GPU memory before launching.

Raw lines 143 to 195
196### PyTorch Lightning Integration
198PyG provides wrappers under `torch_geometric.data.lightning`. Use a real `LightningModule` implementing training/validation steps and optimizers, not a bare GNN. The neighbor/GPU Trainer example is illustrative:
200```python
201import lightning as L
202from torch_geometric.data.lightning import LightningNodeData
204datamodule = LightningNodeData(
205 data,
206 input_train_nodes=data.train_mask,
207 input_val_nodes=data.val_mask,
208 input_test_nodes=data.test_mask,
209 loader='neighbor',
210 num_neighbors=[25, 10],
211 batch_size=1024,
214# Use with any Lightning Trainer
215trainer = L.Trainer(devices=4, accelerator='gpu', strategy='ddp')
216trainer.fit(lightning_model, datamodule=datamodule)
217```
219Also available: `LightningLinkData` for link prediction, `LightningDataset` for graph-level tasks.

PyTorch Lightning Integration

PyG provides wrappers under torch_geometric.data.lightning. Use a real LightningModule implementing training/validation steps and optimizers, not a bare GNN. The neighbor/GPU Trainer example is illustrative:

import lightning as L
from torch_geometric.data.lightning import LightningNodeData

datamodule = LightningNodeData(
    data,
    input_train_nodes=data.train_mask,
    input_val_nodes=data.val_mask,
    input_test_nodes=data.test_mask,
    loader='neighbor',
    num_neighbors=[25, 10],
    batch_size=1024,
)

# Use with any Lightning Trainer
trainer = L.Trainer(devices=4, accelerator='gpu', strategy='ddp')
trainer.fit(lightning_model, datamodule=datamodule)

Also available: LightningLinkData for link prediction, LightningDataset for graph-level tasks.

Raw lines 196 to 220
221## 4. torch.compile Support
223PyG supports `torch.compile`; speedups depend on shapes, backend and warmup. This optimizing-compiler example is illustrative; an eager-backend capture smoke does not establish optimized CPU/CUDA performance:
225```python
226from torch_geometric.nn import GCN
227model = GCN(in_channels, hidden_channels=64, num_layers=2, out_channels=out_channels)
228model = torch.compile(model, dynamic=True)
230# Works with standard training loops
231out = model(data.x, data.edge_index)
232```
234**What works:**
235- Most GNN layers (GCNConv, SAGEConv, GATConv, etc.)
236- Standard training/inference pipelines
237- Both CPU and CUDA backends
239**Limitations:**
240- Dynamic shapes (varying graph sizes per batch) may trigger recompilation
241- Some specialized layers or custom MessagePassing subclasses may not compile
242- Use `torch.compile(model, dynamic=True)` if batch graph sizes vary significantly

4. torch.compile Support

PyG supports torch.compile; speedups depend on shapes, backend and warmup. This optimizing-compiler example is illustrative; an eager-backend capture smoke does not establish optimized CPU/CUDA performance:

from torch_geometric.nn import GCN
model = GCN(in_channels, hidden_channels=64, num_layers=2, out_channels=out_channels)
model = torch.compile(model, dynamic=True)

# Works with standard training loops
out = model(data.x, data.edge_index)

What works:

  • Most GNN layers (GCNConv, SAGEConv, GATConv, etc.)
  • Standard training/inference pipelines
  • Both CPU and CUDA backends

Limitations:

  • Dynamic shapes (varying graph sizes per batch) may trigger recompilation
  • Some specialized layers or custom MessagePassing subclasses may not compile
  • Use torch.compile(model, dynamic=True) if batch graph sizes vary significantly
Raw lines 221 to 243
244## 5. Performance Tips
246- **num_workers**: Benchmark workers and memory use; start with zero, then increase. On spawn-based platforms use a main guard.
247- **pin_memory**: Use `pin_memory=True` in loaders for faster CPU-to-GPU transfer
248- **Sparse tensors**: Some layers benefit from `torch_sparse.SparseTensor`; pass transposed adjacency (`adj_t`, target rows/source columns) and verify numerical parity.
249- **Profiling**: Use `torch_geometric.profile` to measure time and memory of individual layers
250- **Mixed precision**: Standard PyTorch AMP works with PyG:
251 ```python
252 from torch.amp import autocast, GradScaler
253 scaler = GradScaler("cuda")
254 optimizer.zero_grad()
255 with autocast('cuda'):
256 out = model(batch.x, batch.edge_index)
257 loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size])
258 scaler.scale(loss).backward()
259 scaler.step(optimizer)
260 scaler.update()
261 ```
262- **Reduce sampling**: Fewer neighbors per hop = faster but noisier. Start with `[15, 10]` for 2-layer GNNs.
263- **Avoid unnecessary computation**: With NeighborLoader, only the first `batch_size` outputs matter — don't compute metrics on sampled-only nodes.
265Sources: [loaders](https://pytorch-geometric.readthedocs.io/en/latest/modules/loader.html), [DDP tutorial](https://pytorch-geometric.readthedocs.io/en/latest/tutorial/multi_gpu_vanilla.html), [compile guidance](https://pytorch-geometric.readthedocs.io/en/latest/advanced/compile.html), [LightningNodeData](https://pytorch-geometric.readthedocs.io/en/latest/generated/torch_geometric.data.lightning.LightningNodeData.html).

5. Performance Tips

  • num_workers: Benchmark workers and memory use; start with zero, then increase. On spawn-based platforms use a main guard.
  • pin_memory: Use pin_memory=True in loaders for faster CPU-to-GPU transfer
  • Sparse tensors: Some layers benefit from torch_sparse.SparseTensor; pass transposed adjacency (adj_t, target rows/source columns) and verify numerical parity.
  • Profiling: Use torch_geometric.profile to measure time and memory of individual layers
  • Mixed precision: Standard PyTorch AMP works with PyG: ``python from torch.amp import autocast, GradScaler scaler = GradScaler("cuda") optimizer.zero_grad() with autocast('cuda'): out = model(batch.x, batch.edge_index) loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size]) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()
  • Reduce sampling: Fewer neighbors per hop = faster but noisier. Start with [15, 10] for 2-layer GNNs.
  • Avoid unnecessary computation: With NeighborLoader, only the first batch_size outputs matter — don't compute metrics on sampled-only nodes.

Sources: loaders, DDP tutorial, compile guidance, LightningNodeData.

Raw lines 244 to 265
Starts an agent spawn :246