Skip to main content
The Enigma profiler attaches every measurement to the chain of active user scopes (a call-path tree), so the same kernel launched from two places stays distinguishable. Scopes carry semantic work counts the hardware cannot know, and the profiler derives throughput (GFLOP/s, GB/s) from measured GPU time.
Profiling never changes generated MSL, and every API is a no-op when no profiler is active — the disabled path allocates nothing.

Quick start

How it works

  1. enigma.profile() installs a Profiler in a contextvar. The runtime checks it once per call; when empty, dispatch takes the untimed fast path.
  2. enigma.scope(name, metrics=...) pushes name onto a contextvar scope stack and records one event on exit. Scopes nest; the stack is the call path.
  3. When a profiler is active, dispatch goes through the Swift runtime’s timed path, which reads Metal command-buffer GPU timestamps — so gpu_time_us is GPU time, not CPU wall clock.
  4. Profiler.add_event stamps the current scope stack onto each event (call_path) and runs any registered kernel hook to attach metrics.
  5. Aggregation (key_averages), the call tree (tree), and the exporters all read the same event list.

Dispatch metadata

Profiled gpu_dispatch events carry extra metadata (visible in events() and Chrome traces):

One-shot vs prepared dispatch

execute() is one-shot: its table includes library load, pipeline creation, buffer creation, readback, and release. PreparedKernel.dispatch() reuses all of that.
Never compare an execute_total row against a prepared_dispatch row as if they measure the same thing.

Scopes, metrics, derived throughput

Attach semantic work counts to a scope and the profiler derives throughput from measured GPU time:
The metric names flops and bytes drive the derivation; any other keys pass through to exports untouched.

Kernel hooks

Register the work formula once; every profiled dispatch of that kernel gets metrics automatically:

Call-path analysis

The same kernel called from different contexts stays distinguishable:
export_hatchet("profile.json") writes the tree as Hatchet literal JSON — load it with hatchet.GraphFrame.from_literal(json.load(f)) to query hotspots or diff two profiles.

Unbiased benchmarking on unified memory

Apple Silicon shares one physical memory between CPU and GPU. That removes explicit transfers but creates two biases:
  • Cold-start bias — the first dispatches pay pipeline creation, driver work, and page-residency costs. Timing them overstates kernel cost.
  • Warm-cache bias — after a few iterations the working set is resident in the shared cache, so tight repeat loops on small buffers report bandwidth no cold-data workload will see. If you are measuring DRAM bandwidth, size the working set well beyond the chip’s last-level cache.
enigma.benchmark_kernel is built around these constraints:
  • Times come from Metal GPU timestamps only — no profiler events, no Python timing inside the measured region.
  • Warmup runs on the untimed fast path.
  • The full distribution is reported. Compare kernels by median (robust to thermal/scheduler outliers); min is best-case; p90 exposes tail variance.
Use benchmark_kernel to compare kernels; use profile_kernel / enigma.profile() to understand where time goes.

Xcode GPU capture (.gputrace)

In-shader clock intrinsics do not exist on Apple Silicon, so per-instruction timing is not available from Python. For intra-kernel analysis (instruction mix, memory traffic, occupancy timelines), hand the profiled region to Xcode’s GPU debugger instead:
Run the script with capture enabled:
  • The path must end in .gputrace (ValueError otherwise).
  • Capture starts when the profile() context enters and stops when it exits; everything dispatched inside is in the trace.
  • Without MTL_CAPTURE_ENABLED=1 (or before any MetalRuntime exists) entering the context raises RuntimeError — capture never fails silently.
See the Profiler API reference for full signatures.