AWS Trainium Profiling: A Practical PyTorch Guide14 min read2,723 words

AWS Trainium Profiling: A Practical PyTorch Guide

Learn how to profile PyTorch inference on AWS Trainium, read Neuron traces, and find fusion opportunities. Follow a practical workflow from capture to action.

AWS Trainium profilingPyTorch performance profilingTorchNeuronNeuron ExplorerPyTorch kernel fusion
AWS Trainium Profiling: A Practical PyTorch Guide

How to Profile PyTorch Inference on AWS Trainium

Inference can feel slow for a few very different reasons: the host might be spending time launching work, the accelerator might be running inefficient kernels, or you might be losing time moving data around. The annoying part is that until you profile, you're mostly guessing.

This is especially true when you move PyTorch inference workloads onto AWS Trainium. A familiar PyTorch call won't tell you how many device executions it triggers, how long those executions actually take, or whether the delay is happening on the host or on the accelerator. What you need is a profiling workflow that ties framework-level work to runtime scheduling and hardware activity.

This guide adapts a walkthrough from Amazon's TorchNeuron profiling article. It shows how to capture a clean trace with torch.profiler, interpret device-time measurements, and use Neuron Explorer to see what happens to a single operation as it moves through the system. A small RMSNorm example compares eager PyTorch, torch.rms_norm(), and torch.compile. The goal isn't to crown a permanent winner - it's to make the evidence visible so you can choose what to change in your own workload.

Why profile at the system level?

Profiling isn't only about producing a pretty timeline for a report. The bigger value is that it helps you separate causes that can look the same from the outside. If inference is slow, you want to know whether the time comes from Python/framework overhead, runtime scheduling, device execution, data movement - or some mix of those.

A system-level profile gives you a first useful breakdown. PyTorch's key_averages() table summarizes operations and their CPU time and accelerator time. Neuron Explorer then links those measurements to runtime and hardware events. The table helps you spot the hotspot; the trace helps you understand what the hotspot is actually doing.

TorchNeuron's place in the stack

TorchNeuron connects Trainium to PyTorch using PyTorch's PrivateUse1 extension point. That's how an accelerator backend can participate in PyTorch without requiring a dedicated dispatch key like cuda. In practice, you can keep using standard PyTorch APIs and common execution patterns (eager mode and torch.compile included). The Neuron backend handles the execution on the accelerator.

That compatibility is convenient, but it can also hide details that matter for performance. Two bits of PyTorch code that "do the same math" may still end up producing different backend operations and different hardware dispatches. So the Python side alone isn't a trustworthy performance model.

What system-level profiling can answer

A good first profile should help you answer questions like:

  • Which operations take the most Neuron device time?
  • How many times does each operation run?
  • Is a big device-time total caused by one expensive kernel, or by several smaller executions?
  • How much CPU time does the framework spend around those operations?
  • Does the timeline show repeated dispatches or idle gaps that fusion could potentially reduce?

One thing to keep straight: a system trace shows where time is spent and how work moves through the stack, but it won't automatically explain every hardware-level mechanism. If you need that, you move on to a device-level investigation (engine activity, memory behavior, and so on).

A practical flow is: use the table to pick a candidate, use the trace to confirm how it runs, and only then test an optimization. This avoids a common mistake - optimizing something that looks complicated instead of something that measurably costs time.

How to capture a useful Neuron profile

For a Neuron workload, you need the profiler to collect both regular PyTorch activity and Neuron-specific data. The key pieces are NeuronConfig (selects the Neuron data streams and where output goes) and NeuronProfiler (provides a callback to export the trace).

A representative configuration looks like this:

nc = NeuronConfig(

modes=[ProfileMode.DEVICE, ProfileMode.RUNTIME],

profile_output_dir=OUTPUT_DIR,

capture_enabled_for_nc='0',

correlate_device_time=True,

)

exporter = NeuronProfiler(nc)

Those modes control which data streams are collected. The NeuronCore choice determines which core is traced. With correlate_device_time=True, device time is included in the profiler's aggregated results, so you can compare CPU and Neuron time in key_averages().

Exact options depend on the Neuron tooling version and the type of profile you need. Check the NeuronConfig profile modes documentation for the current parameters instead of copying an old config unchanged.

Include the Neuron activity and export callback

To get Neuron device activity, the profiler needs CPU activity plus ProfilerActivity.PrivateUse1. Pass the Neuron configuration as experimental_config, and register the exporter so the framework trace gets written alongside the Neuron system traces.

with torch.profiler.profile(

activities=[ProfilerActivity.CPU, ProfilerActivity.PrivateUse1],

experimental_config=nc,

record_shapes=False,

on_trace_ready=exporter.export_trace,

schedule=torch.profiler.schedule(wait=2, warmup=1, active=1),

) as prof:

for step in range(4):

with torch.no_grad():

_ = rms_norm_fn(x)

torch.neuron.synchronize()

prof.step()

The schedule above skips two steps, warms up for one, then records one active step. That extra loop iteration is what actually advances the schedule far enough to capture the active window. For your own model, pick a schedule that matches the phase you care about.

Synchronization matters. Accelerator work can run asynchronously: the host may enqueue operations and keep going before the device is done. Calling torch.neuron.synchronize() inside the profiling loop ensures the work finishes before the trace is finalized. If you skip it, the device-time numbers can look oddly incomplete or host-heavy, and you'll chase the wrong explanation.

Keep the capture focused

A profile is only useful if it answers a specific question. Start with the smallest representative workload that exercises the behavior you want to understand. When you compare implementations, keep inputs, shapes, data types, profiler settings, and the execution schedule consistent.

Also, don't confuse profiling overhead with normal execution. Think of the trace as an inspection tool, not a substitute for an end-to-end benchmark. Locate the cause with the trace, then measure the real workload with a repeatable benchmark setup. If your model compiles, keep compile time separate from steady-state inference time - mixing them makes it easy to misread runtime performance.

How to read the trace and find the bottleneck

Start with key_averages() and sort by self_privateuse1_time_total. That puts the operations with the highest Neuron device time at the top. The table usually includes the operation name, call count, self CPU time, self device time, and each row's share of total device time.

For the naive RMSNorm example, the reported operations were:

Operation Calls Self CPU Self Neuron Share of Neuron time
aten::mul 3 60.9 μs 1,164.5 μs 49.5%
aten::add 1 26.6 μs 434.6 μs 18.5%
aten::rsqrt 1 15.6 μs 415.0 μs 17.6%
aten::mean 1 21.1 μs 340.8 μs 14.5%

The total Neuron time in that run was 2.355 ms. In this case, mul dominates because the function multiplies to square the input, multiplies by the normalization factor, and then scales by the weight. The point isn't magic - it's that the count plus the role of the operation explains why the aggregate cost is high.

The table isn't the whole story. A full trace can include CPU-side work like allocations and transfers. In this example, a Python scalar can trigger a host-to-device copy. So it's worth looking at surrounding events instead of trusting a single row in isolation.

Follow one operation through Neuron Explorer

Neuron Explorer is a browser-based viewer for system traces, device timelines, and per-engine information. The system trace lays out events across lanes. Time goes left to right, while the lifecycle of each event spans framework, runtime, and hardware lanes.

A typical eager operation usually maps to three stages:

  1. Framework dispatch: PyTorch dispatches the operation on a framework thread.
  2. Runtime scheduling: the Neuron runtime schedules execution (shown in the walkthrough as nrta_execute_schedule).
  3. Hardware execution: the operation runs on a NeuronCore (often shown as a hardware event like nc_exec_running).

Lane labels tell you which subsystem and grouping you're looking at. Framework lanes can be grouped by thread or stream; runtime and hardware lanes are organized by NeuronCore grouping. The exact label names can vary across tooling and configuration, so don't memorize them - use the lane context and event relationships instead.

The dependency-chain view is particularly helpful when you're not sure which hardware event corresponds to a framework operation. Follow one operation from dispatch to scheduling to execution. Then check the timeline for long gaps, repeated small executions, or patterns that line up with the call counts you saw in the table.

Neuron Explorer shows an operation moving from framework dispatch through runtime scheduling to execution on Neuron hardware.

Know what files you are opening

A captured profile can include a system trace and, when device data is collected, matching .neff and .ntff files. NEFF is the compiled Neuron executable format; NTFF contains the corresponding hardware instruction trace. A shared numeric identifier links the files.

For a system or combined profile, upload the full output directory to Explorer. For a device-only profile, provide the matching NEFF and NTFF files. If you upload only part of the output, correlation gets harder and you may lose context needed to follow the execution path.

What RMSNorm reveals about fusion

RMSNorm works well as a teaching example because the computation is small and easy to describe: square values, compute a mean, add epsilon, take a reciprocal square root, then multiply by the input and a weight. You can express the same math in different ways: separate PyTorch operations, a built-in function, or a compiled graph.

The numbers below come from the source walkthrough and describe one specific run, with specific shapes and setup. Treat them as example evidence, not a guarantee for every model, input size, or Trainium configuration.

Variant 1: separate eager operations

The naive implementation uses individual operations that create intermediate tensors:

def rms_norm_fn(t):

x_sq = t * t

mean_sq = x_sq.mean(dim=-1, keepdim=True)

rms = torch.rsqrt(mean_sq + 1e-6)

return t * rms * weight

Because those operations dispatch separately in eager mode, the device runs multiple executions. In the example run, the profile shows 2,355 μs of total Neuron time and six operator calls in the summary. Intermediates like x_sq and mean_sq are part of the execution path, and the profiler makes the cost of that path easy to see.

Variant 2: the built-in operation

If you replace the sequence with the PyTorch built-in, the backend can lower it as a fused kernel:

def rms_norm_fn(t):

return torch.rms_norm(t, [512], weight)

Here the profile shows one device operation, aten::_fused_rms_norm, with 403.3 μs of Neuron time. That's about one-sixth of the naive version's device time in this run. The change isn't just "less Python" - it's that the computation runs as a fused operation instead of dispatching each arithmetic step independently.

Variant 3: compile the naive function

You can also feed the same sequence into torch.compile using the Neuron backend:

@torch.compile(backend='neuron', fullgraph=True)

def naive_compiled(t):

x_sq = t * t

mean_sq = x_sq.mean(dim=-1, keepdim=True)

rms = torch.rsqrt(mean_sq + 1e-6)

return t * rms * weight

In that flow, TorchDynamo turns the Python function into an FX graph, and then the compilation path sends it to the Neuron backend. From there, the backend can fuse the operations and compile the graph into a single NEFF artifact. The profile shows one compiled region and 375.5 μs of Neuron time.

Implementation Neuron time Self CPU time Operator calls
Separate eager operations 2,355 μs ~125 μs 6
torch.rms_norm() 403 μs ~48 μs 1
Naive function with torch.compile 376 μs ~98 μs 1

All three approaches reduce the number of device executions to one in this example. The built-in version expresses a known operation directly; compilation finds the fusion opportunity from the original sequence. The measured device times are close, with the compiled RMSNorm slightly faster in this run. That difference isn't enough to declare a general winner.

What you choose depends on the real workload. Built-ins can be clean and convenient when they match the semantics and shapes you need. Compilation can be great when multiple ops can fuse into a larger region. But compiled graphs come with tradeoffs: graph breaks, unsupported behavior, compile time, and sensitivity to shape changes. Test the actual model instead of extrapolating from a micro-example.

The profile compares separate RMSNorm operations with fused built-in and compiled implementations.

A practical decision process

Use this workflow when you profile a larger inference workload:

  1. Find the hotspot. Sort the averages table by device time and note both call count and total contribution.
  2. Confirm the execution path. Use Explorer to connect the framework event to runtime scheduling and hardware execution.
  3. Form a specific hypothesis. For example: repeated dispatches or materialized intermediates might make fusion worth testing.
  4. Change one thing. Try a supported fused operator or compile the relevant region.
  5. Measure the full workload again. Re-check device time, CPU overhead, end-to-end latency, and correctness/constraint impact.

Fusion isn't automatically beneficial. It can reduce dispatch overhead and intermediate materialization, but the fused kernel might behave differently in terms of resources. A system-level trace tells you where to look; once you know the candidate, the device-level profile is the next step for engine utilization, memory traffic, and whether the kernel is compute-bound or memory-bound.

Conclusion: profile first, optimize second

System-level profiling gives you a disciplined way to move from "inference feels slow" to something you can test. key_averages() points to the operations that consume Neuron time, and Neuron Explorer shows how those operations travel through the framework, runtime, and hardware. The RMSNorm comparison makes the difference obvious: separate eager operations took 2.355 ms of device time in the reported run, while fused built-in and compiled versions landed at roughly 0.4 ms.

But treat these numbers as evidence from one workload, not a promise for yours. Reproduce the profile with your model, verify the trace, and benchmark the change under realistic conditions. If the system trace flags a device hotspot that fusion doesn't explain, move on to a device-level investigation of engine utilization and memory behavior.

For the original walkthrough and implementation details, see Amazon's TorchNeuron profiling article. The next step is practical: profile one representative inference path, identify the biggest measurable cost, and test one change at a time.

Frequently Asked Questions

It enables PyTorch profiling of activity from the Neuron backend. For a useful Neuron profile, pair it with CPU activity and pass the Neuron-specific configuration through `experimental_config`.
Neuron execution can be asynchronous relative to the host. Synchronization makes sure the device work has completed before the profiling step advances or the trace is exported, helping ensure device-time measurements are present.
No. Compilation may fuse operations and reduce dispatches, but the result depends on the model, shapes, graph coverage, and compilation behavior. Profile and benchmark the target workload; do not treat a small RMSNorm result as a universal prediction.
A system trace connects framework dispatch, runtime scheduling, and hardware execution over time. A device-level profile provides more detail about the compiled kernel and hardware activity, such as engine and memory behavior. Use the system trace to locate and follow work, then investigate the device when you need to explain kernel efficiency.

Related articles