2. Post-processing profiling data#

Install for this tutorial: pip install "scope-profiler[pproc]"; pandas is used for the DataFrame examples.

This notebook is a tour of the analysis API: ProfilingResults and the MPIRegion / Region objects it hands out.

The one thing worth memorising: every duration and timestamp in this API is in seconds, converted from the nanoseconds stored on disk.

import tempfile
import time
from pathlib import Path

from scope_profiler import ProfileManager, read_h5

WORKDIR = Path(tempfile.mkdtemp(prefix="scope-profiler-tutorial-"))
DATA_FILE = WORKDIR / "profiling_data.h5"

ProfileManager.setup(file_path=str(DATA_FILE))


@ProfileManager.profile("assemble")
def assemble(scale):
    time.sleep(0.004 * scale)


with ProfileManager.region("setup"):
    time.sleep(0.02)

for step in range(5):
    with ProfileManager.region("timestep"):
        assemble(scale=1 + step % 3)
        with ProfileManager.region("solve"):
            time.sleep(0.003)

ProfileManager.finalize(verbose=False)
results = read_h5(DATA_FILE)
results
<ProfilingResults 'profiling_data.h5': 4 region(s), 1 rank(s)>

ProfilingResults is a mapping of regions#

It behaves like an ordered mapping from region name to region, so the usual Python idioms work.

print("regions:", results.region_names)
print("count:", len(results))
print("'solve' recorded?", "solve" in results)
print("ranks in file:", results.num_ranks)

for region in results:
    print(f"  {region.name:<10} {region.num_calls:>3} calls")
regions: ['assemble', 'setup', 'timestep', 'solve']
count: 4
'solve' recorded? True
ranks in file: 1
  assemble     5 calls
  setup        1 calls
  timestep     5 calls
  solve        5 calls

Asking for something that is not there tells you what is there:

try:
    results["sovle"]  # typo
except KeyError as exc:
    print(exc)
"No region named 'sovle' in /tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5. Available regions: ['assemble', 'setup', 'timestep', 'solve']"

Two levels: MPIRegion and Region#

results["solve"] returns an MPIRegion — the region across every rank that recorded it. Indexing it by rank gives a Region, the timings from that one rank.

For a serial run there is only rank 0, but the two levels are the same API you use for an MPI run.

timestep = results["timestep"]

print("aggregated over ranks")
print("  ranks       :", timestep.ranks)
print("  num_calls   :", timestep.num_calls)
print("  total   [s] :", timestep.total_duration)
print("  average [s] :", timestep.average_duration)
print("  min/max [s] :", timestep.min_duration, timestep.max_duration)
print("  std     [s] :", timestep.std_duration)

rank0 = timestep[0]
print("\nrank 0 only")
print("  durations [s]:", rank0.durations)
print("  start times [s]:", rank0.start_times)
print("  summary:", rank0.get_summary())
aggregated over ranks
  ranks       : [0]
  num_calls   : 5
  total   [s] : 0.051952459
  average [s] : 0.0103904918
  min/max [s] : 0.007170388 0.015161241
  std     [s] : 0.002962193788557623

rank 0 only
  durations [s]: [0.00729908 0.01116411 0.01516124 0.00717039 0.01115765]
  start times [s]: [151.80791315 151.81521668 151.82638527 151.84155175 151.84872659]
  summary: {'num_calls': 5, 'total_duration': 0.051952459, 'inclusive_duration': 0.051952459, 'exclusive_duration': 0.000186458, 'average_duration': 0.0103904918, 'min_duration': 0.007170388, 'max_duration': 0.015161241, 'first_duration': 0.007299078, 'last_duration': 0.011157645, 'std_duration': 0.0029621937885576225}

Per-rank breakdowns are available as dictionaries keyed by rank, which is where load imbalance shows up in an MPI run:

print("calls per rank :", timestep.num_calls_per_rank())
print("total per rank :", timestep.total_durations())
print("avg per rank   :", timestep.average_durations())
print("max per rank   :", timestep.max_durations())
calls per rank : {0: 5}
total per rank : {0: 0.051952459}
avg per rank   : {0: 0.0103904918}
max per rank   : {0: 0.015161241}

Summaries and DataFrames#

summary() returns one dict per region, aggregated over ranks. print_summary() formats the same data as a table.

results.print_summary()
  ╭─────────────────────────────────╮
  │ region              total [s]   │
  ├─────────────────────────────────┤
  │ setup               0.020085    │
  │ timestep (5x)       0.051952    │
  │ └─ (own)               0.000186 │
  │ └─ assemble (5x)       0.036391 │
  │ └─ solve (5x)          0.015375 │
  ╰─────────────────────────────────╯

  ╭─ Info ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
  │ Summary: ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5 (1 rank)                                      │
  │                                                                                                                                 │
  │ Explore:                                                                                                                        │
  │   Inspect: scope-profiler inspect ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5                      │
  │   TUI:     scope-profiler tui ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5                          │
  │                                                                                                                                 │
  │ Visualize and export:                                                                                                           │
  │   Plot:    scope-profiler plot default ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5 -o plots --show │
  │   Report:  scope-profiler report ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5 -o report.html        │
  │   Export:  scope-profiler export plot-data ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5 -o data     │
  │   Lines:   scope-profiler line-profile ../../../../../../tmp/scope-profiler-tutorial-d_d8_xni/profiling_data.h5                 │
  │                                                                                                                                 │
  │ Compare runs:                                                                                                                   │
  │   Diff:    scope-profiler diff BASE.h5 CANDIDATE.h5                                                                             │
  │   Check:   scope-profiler check BASE.h5 CANDIDATE.h5                                                                            │
  │                                                                                                                                 │
  │ Durations are in seconds.                                                                                                       │
  │ Regions may nest, so the summed total can exceed the wall-clock time.                                                           │
  ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
for row in results.summary():
    print(row)
{'name': 'setup', 'num_ranks': 1, 'num_calls': 1, 'total_duration': 0.020084566, 'inclusive_duration': 0.020084566, 'exclusive_duration': 0.020084566, 'average_duration': 0.020084566, 'min_duration': 0.020084566, 'max_duration': 0.020084566, 'first_duration': 0.020084566, 'last_duration': 0.020084566, 'std_duration': 0.0, 'tags': (), 'p50_duration': 0.020084566, 'p95_duration': 0.020084566, 'p99_duration': 0.020084566, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.020084566, 'p95': 0.020084566, 'p99': 0.020084566, 'imbalance': 0.0}
{'name': 'timestep', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.051952459, 'inclusive_duration': 0.051952459, 'exclusive_duration': 0.000186458, 'average_duration': 0.0103904918, 'min_duration': 0.007170388, 'max_duration': 0.015161241, 'first_duration': 0.007299078, 'last_duration': 0.011157645, 'std_duration': 0.002962193788557623, 'tags': (), 'p50_duration': 0.011157645, 'p95_duration': 0.0143618142, 'p99_duration': 0.01500135564, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.011157645, 'p95': 0.0143618142, 'p99': 0.01500135564, 'imbalance': 0.0}
{'name': 'assemble', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.036391272, 'inclusive_duration': 0.036391272, 'exclusive_duration': 0.036391272, 'average_duration': 0.007278254400000001, 'min_duration': 0.004080088, 'max_duration': 0.012074417, 'first_duration': 0.004085358, 'last_duration': 0.008074167, 'std_duration': 0.0029899126903858986, 'tags': (), 'p50_duration': 0.008074167, 'p95_duration': 0.011274982, 'p99_duration': 0.01191453, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.008074167, 'p95': 0.011274982, 'p99': 0.01191453, 'imbalance': 0.0}
{'name': 'solve', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.015374729, 'inclusive_duration': 0.015374729, 'exclusive_duration': 0.015374729, 'average_duration': 0.0030749458, 'min_duration': 0.003072538, 'max_duration': 0.003080052, 'first_duration': 0.003080052, 'last_duration': 0.003072538, 'std_duration': 2.622732956288094e-06, 'tags': (), 'p50_duration': 0.00307409, 'p95_duration': 0.0030788896, 'p99_duration': 0.00307981952, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.00307409, 'p95': 0.0030788896, 'p99': 0.00307981952, 'imbalance': 0.0}

With pandas installed (it comes with the plot extra), to_dataframe() gives you the same data ready for sorting, filtering and plotting.

frame = results.to_dataframe()
frame.sort_values("total_duration", ascending=False)

name

num_ranks

num_calls

total_duration

inclusive_duration

exclusive_duration

average_duration

min_duration

max_duration

first_duration

…

tags

p50_duration

p95_duration

p99_duration

rank_imbalance

rank_imbalance_pct

p50

p95

p99

imbalance

1

timestep

1

5

0.051952

0.051952

0.000186

0.010390

0.007170

0.015161

0.007299

…

()

0.011158

0.014362

0.015001

0.0

0.0

0.011158

0.014362

0.015001

0.0

2

assemble

1

5

0.036391

0.036391

0.036391

0.007278

0.004080

0.012074

0.004085

…

()

0.008074

0.011275

0.011915

0.0

0.0

0.008074

0.011275

0.011915

0.0

0

setup

1

1

0.020085

0.020085

0.020085

0.020085

0.020085

0.020085

0.020085

…

()

0.020085

0.020085

0.020085

0.0

0.0

0.020085

0.020085

0.020085

0.0

3

solve

1

5

0.015375

0.015375

0.015375

0.003075

0.003073

0.003080

0.003080

…

()

0.003074

0.003079

0.003080

0.0

0.0

0.003074

0.003079

0.003080

0.0

4 rows × 22 columns

per_rank=True emits one row per (region, rank) instead — the shape you want for load-balance analysis.

results.to_dataframe(per_rank=True)

name

rank

num_calls

total_duration

inclusive_duration

exclusive_duration

average_duration

min_duration

max_duration

first_duration

last_duration

std_duration

p50_duration

p95_duration

p99_duration

0

setup

0

1

0.020085

0.020085

0.020085

0.020085

0.020085

0.020085

0.020085

0.020085

0.000000

0.020085

0.020085

0.020085

1

timestep

0

5

0.051952

0.051952

0.000186

0.010390

0.007170

0.015161

0.007299

0.011158

0.002962

0.011158

0.014362

0.015001

2

assemble

0

5

0.036391

0.036391

0.036391

0.007278

0.004080

0.012074

0.004085

0.008074

0.002990

0.008074

0.011275

0.011915

3

solve

0

5

0.015375

0.015375

0.015375

0.003075

0.003073

0.003080

0.003080

0.003073

0.000003

0.003074

0.003079

0.003080

# Share of total recorded time per region.
frame = frame.assign(share=lambda df: df.total_duration / df.total_duration.sum())
frame[["name", "num_calls", "total_duration", "share"]].sort_values(
    "share", ascending=False
)

name

num_calls

total_duration

share

1

timestep

5

0.051952

0.419638

2

assemble

5

0.036391

0.293945

0

setup

1

0.020085

0.162230

3

solve

5

0.015375

0.124187

Selecting regions#

get_regions(), summary(), to_dataframe() and the plotting functions all take include / exclude, which are regular expressions matched against the region name (via re.match, so they anchor at the start).

print([region.name for region in results.get_regions(include="s")])
print([region.name for region in results.get_regions(exclude=["setup", "assemble"])])
print([row["name"] for row in results.summary(include=["solve", "timestep"])])
['setup', 'solve']
['timestep', 'solve']
['timestep', 'solve']

Run metadata#

Each file carries the environment it was recorded in: the host and CPU, the loaded environment modules, the Slurm job it ran under, and selected environment variables such as PATH and VIRTUAL_ENV. This is what tells two otherwise identical runs apart months later.

for key, value in sorted(results.metadata.items()):
    # PATH and friends run to thousands of characters; clip them for display.
    text = str(value)
    if len(text) > 70:
        text = text[:70] + " […]"
    print(f"{key:>24}: {text}")
        chip_information: AMD EPYC 7763 64-Core Processor
        finalize_time_ns: 151859991735
                mpi_size: 1
               num_nodes: 1
         omp_num_threads: 4
                platform: Linux-6.17.0-1022-azure-x86_64-with-glibc2.39
          python_version: 3.10.22
  scope_profiler_version: 0.7.4
           start_time_ns: 151786002816
               timestamp: 2026-10-16T14:13:15.104525+00:00
             total_cores: 4

scope-profiler inspect prints the same metadata grouped and readable — together with per-region statistics — straight from the command line:

scope-profiler inspect profiling_data.h5
scope-profiler inspect profiling_data.h5 --metadata-only

write_metadata_json() exports it instead, with one entry per file and no clipping of long values:

from scope_profiler.inspection import write_metadata_json

payload = write_metadata_json(DATA_FILE, WORKDIR / "metadata.json")
print(sorted(payload["files"][0]["metadata"])[:8])
['chip_information', 'finalize_time_ns', 'mpi_size', 'num_nodes', 'omp_num_threads', 'platform', 'python_version', 'scope_profiler_version']

Exporting statistics#

collect_region_statistics() builds a JSON-ready dict (durations in seconds, with a units field spelling that out), and write_region_statistics_json() writes it to a file. Both accept several runs at once for run comparisons.

from scope_profiler import collect_region_statistics, write_region_statistics_json

stats = collect_region_statistics(results, include="solve")
print(stats["units"])
print(stats["files"][0]["region_statistics"]["solve"])

stats_path = WORKDIR / "region_statistics.json"
write_region_statistics_json(results, stats_path)
print("wrote", stats_path)
{'durations': 'seconds'}
{'count': 5, 'average_duration_seconds': 0.0030749458, 'min_duration_seconds': 0.003072538, 'max_duration_seconds': 0.003080052, 'first_duration_seconds': 0.003080052, 'last_duration_seconds': 0.003072538, 'std_duration_seconds': 2.622732956288094e-06, 'total_duration_seconds': 0.015374729, 'per_rank': {'0': {'count': 5, 'average_duration_seconds': 0.0030749458, 'min_duration_seconds': 0.003072538, 'max_duration_seconds': 0.003080052, 'first_duration_seconds': 0.003080052, 'last_duration_seconds': 0.003072538, 'std_duration_seconds': 2.622732956288094e-06, 'total_duration_seconds': 0.015374729}}}
wrote /tmp/scope-profiler-tutorial-d_d8_xni/region_statistics.json

Next#

3. Visualizing results turns these numbers into Gantt, flame and duration charts, and 5. Custom analysis goes below the summaries to the individual calls.