Profiling a TGraphX Workflow with PyTorch Tools
Before optimizing a graph learning workflow, you need to know where time
actually goes. Guessing is rarely productive — the bottleneck is often not
where intuition says it is. This tutorial covers profiling a TGraphX training
loop using PyTorch's built-in profiling tools, with specific attention to the
operations that matter most in graph learning.
The Profiling Mindset
Profiling answers: "Where does wall time go?" For a GNN training loop, the
candidates are:
- Data loading and graph construction — building
Graphobjects from raw data - Device transfer —
.to(device)calls - Message passing aggregation — scatter operations in layers
- Linear projections — the
nn.Linearlayers within each GNN layer - Loss computation and backward pass — autograd overhead
- Optimizer step — weight update
Without profiling, people typically over-optimize message passing (which is
often already fast due to vectorized scatter) and under-optimize data loading
(which is often the actual bottleneck).
Basic Timing with CUDA Synchronization
The simplest profiling approach is manual timing. But on GPU, timing without
synchronization gives misleading results because CUDA operations are
asynchronous — the Python timer may return before the GPU has finished.
import torch
import time
def timed_section(label, fn, device):
if device == "cuda":
torch.cuda.synchronize()
t0 = time.perf_counter()
result = fn()
if device == "cuda":
torch.cuda.synchronize()
t1 = time.perf_counter()
print(f"{label}: {(t1 - t0)*1000:.2f} ms")
return result
from tgraphx.performance import recommended_device
device = recommended_device()
# Time the forward pass
timed_section(
"forward pass",
lambda: model(g),
device,
)
This approach is useful for coarse timing but misses the breakdown between
operations within the forward pass.
Using torch.profiler for Detailed Tracing
PyTorch's torch.profiler provides operator-level profiling with CUDA event
tracking:
import torch
from torch.profiler import profile, record_function, ProfilerActivity
activities = [ProfilerActivity.CPU]
if device == "cuda":
activities.append(ProfilerActivity.CUDA)
with profile(
activities=activities,
record_shapes=True,
with_stack=False,
) as prof:
with record_function("full_training_step"):
with record_function("data_to_device"):
g = raw_graph.to(device)
with record_function("forward_pass"):
out = model(g)
with record_function("loss"):
loss = criterion(out, g.node_labels)
with record_function("backward"):
loss.backward()
with record_function("optimizer_step"):
optimizer.step()
optimizer.zero_grad()
# Print top operations by self CPU time
print(prof.key_averages().table(
sort_by="cpu_time_total",
row_limit=20,
))
The record_function context managers tag operations with human-readable
labels that appear in the profiler output.
Exporting a Chrome Trace
For visual exploration of the timeline:
prof.export_chrome_trace("trace.json")
# Open in Chrome at chrome://tracing, or use Perfetto UI
The Chrome trace view shows a timeline where each operation occupies a
horizontal block. For GNN workflows, you're looking for:
- Long blocks in the scatter aggregation (indicating the message passing
kernel is the bottleneck) - Long gaps between GPU operations (indicating CPU overhead or data transfer
bottlenecks) - Wide data loading blocks (indicating graph construction is the bottleneck)
What Operations to Look For
| Operation in profiler | What it represents in TGraphX |
|---|---|
aten::index_add_ |
Message aggregation in SAGE/GIN layers |
aten::scatter_reduce_ |
Alternative aggregation in scatter layers |
aten::mm / aten::addmm |
Linear projections in layer weights |
aten::softmax |
Attention coefficient normalization (GAT) |
aten::to |
Device transfer (.to(device)) |
DataLoader worker time |
Graph construction in workers |
aten::cat |
GraphBatch concatenation during collation |
Profiling Data Loading Separately
Data loading happens in background workers and is not captured by
torch.profiler in the main process. To profile the data loading pipeline
separately:
from tgraphx import GraphDataLoader
import time
loader = GraphDataLoader(
dataset=my_dataset,
batch_size=32,
num_workers=4,
)
# Time how long it takes to consume one batch
t0 = time.perf_counter()
batch = next(iter(loader))
t1 = time.perf_counter()
print(f"First batch load time: {(t1-t0)*1000:.1f} ms")
# Compare to construction in main process (num_workers=0)
loader_sync = GraphDataLoader(
dataset=my_dataset,
batch_size=32,
num_workers=0,
)
t0 = time.perf_counter()
batch = next(iter(loader_sync))
t1 = time.perf_counter()
print(f"Synchronous batch load time: {(t1-t0)*1000:.1f} ms")
If the synchronous time is much greater than the GPU forward pass time, more
workers will help. If they're similar, workers may not improve throughput.
Using record_function in a GNN Module
Add record_function markers inside your model for fine-grained layer
profiling:
import torch.nn as nn
from torch.profiler import record_function
class ProfiledGNN(nn.Module):
def __init__(self, layer1, layer2):
super().__init__()
self.layer1 = layer1
self.layer2 = layer2
def forward(self, g):
with record_function("layer1_message_pass"):
x = self.layer1(g)
with record_function("relu"):
x = torch.relu(x)
with record_function("layer2_message_pass"):
x = self.layer2(g)
return x
This gives you per-layer timing in the profiler output.
Memory Profiling with estimate_message_memory
Before running on GPU, use TGraphX's built-in estimator:
from tgraphx.performance import estimate_message_memory
import torch
# Estimate before construction, as a sanity check
est = estimate_message_memory(
num_edges=g.edge_index.shape[1],
node_feature_shape=tuple(g.node_features.shape[1:]),
dtype=torch.float32,
)
print(f"Estimated scatter buffer memory: {est}")
For runtime memory profiling, use PyTorch's CUDA memory APIs:
if device == "cuda":
torch.cuda.reset_peak_memory_stats()
out = model(g)
peak_mb = torch.cuda.max_memory_allocated() / 1024**2
print(f"Peak GPU memory: {peak_mb:.1f} MB")
Interpreting Results: Common Patterns
Pattern 1: Data loading dominates
If loading one batch takes longer than the GPU forward + backward, increase
num_workers. If already at 4+ workers, consider caching preprocessed graphs.
Pattern 2: Device transfer is expensive
If aten::to takes significant time, batch your .to(device) calls rather
than moving individual tensors. g.to(device) is already batched for the
Graph object.
Pattern 3: aten::cat appears large
This is GraphBatch concatenation during collation. It scales with the number
of graphs per batch multiplied by tensor sizes. If this dominates, consider
pre-batching your dataset or reducing the number of Graph objects per batch.
Pattern 4: aten::index_add_ is the bottleneck
The message passing aggregation itself dominates. This is expected for
large, dense graphs. Options: reduce graph density, reduce feature dimensions,
or accept the cost as inherent to the graph structure.
Reproducible Profiling
Always profile under controlled conditions:
from tgraphx.reproducibility import set_seed
from tgraphx.performance import env_report
set_seed(42, deterministic=True)
info = env_report(include_hardware=True)
print("Profiling environment:", info)
# Warm up (PyTorch JIT and CUDA have startup overhead)
for _ in range(3):
_ = model(g)
if device == "cuda":
torch.cuda.synchronize()
# Now profile
with profile(...) as prof:
for _ in range(10): # profile multiple iterations
out = model(g)
Running multiple iterations and averaging removes run-to-run noise from
profiling results.