2. Post-processing profiling data#
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.
[1]:
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.profile_region("setup"):
time.sleep(0.02)
for step in range(5):
with ProfileManager.profile_region("timestep"):
assemble(scale=1 + step % 3)
with ProfileManager.profile_region("solve"):
time.sleep(0.003)
ProfileManager.finalize(verbose=False)
results = read_h5(DATA_FILE)
results
[1]:
<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.
[2]:
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:
[3]:
try:
results["sovle"] # typo
except KeyError as exc:
print(exc)
"No region named 'sovle' in /tmp/scope-profiler-tutorial-aphmwbar/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.
[4]:
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.051781265
average [s] : 0.010356253
min/max [s] : 0.007145383 0.015165983
std [s] : 0.002995423968500419
rank 0 only
durations [s]: [0.00716907 0.01115715 0.01516598 0.00714538 0.01114369]
start times [s]: [156.88244556 156.88961753 156.90077737 156.91594628 156.92309456]
summary: {'num_calls': 5, 'total_duration': 0.051781265, 'inclusive_duration': 0.051781265, 'exclusive_duration': 6.5455e-05, 'average_duration': 0.010356253, 'min_duration': 0.007145383, 'max_duration': 0.015165983, 'first_duration': 0.007169067, 'last_duration': 0.011143686, 'std_duration': 0.002995423968500419}
Per-rank breakdowns are available as dictionaries keyed by rank, which is where load imbalance shows up in an MPI run:
[5]:
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.051781265}
avg per rank : {0: 0.010356253}
max per rank : {0: 0.015165983}
Summaries and DataFrames#
summary() returns one dict per region, aggregated over ranks. print_summary() formats the same data as a table.
[6]:
results.print_summary()
╭─────────────┬─────┬─────────────┬───────────╮
│ region │ n │ total [s] │ avg [s] │
├─────────────┼─────┼─────────────┼───────────┤
│ setup │ 1 │ 2.0e-02 │ 2.0e-02 │
│ timestep │ 5 │ 5.2e-02 │ 1.0e-02 │
│ └─ assemble │ 5 │ 3.6e-02 │ 7.3e-03 │
│ └─ solve │ 5 │ 1.5e-02 │ 3.1e-03 │
│ TOTAL │ 16 │ 1.2e-01 │ │
╰─────────────┴─────┴─────────────┴───────────╯
╭─ Info ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Summary: ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 (1 rank) │
│ │
│ Explore: │
│ Inspect: scope-profiler inspect ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 │
│ TUI: scope-profiler tui ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 │
│ │
│ Visualize and export: │
│ Plot: scope-profiler plot default ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 -o plots --show │
│ Report: scope-profiler report ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 -o report.html │
│ Export: scope-profiler export plot-data ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/profiling_data.h5 -o data │
│ Lines: scope-profiler line-profile ../../../../../../../../tmp/scope-profiler-tutorial-aphmwbar/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. │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
[7]:
for row in results.summary():
print(row)
{'name': 'setup', 'num_ranks': 1, 'num_calls': 1, 'total_duration': 0.020079415, 'inclusive_duration': 0.020079415, 'exclusive_duration': 0.020079415, 'average_duration': 0.020079415, 'min_duration': 0.020079415, 'max_duration': 0.020079415, 'first_duration': 0.020079415, 'last_duration': 0.020079415, 'std_duration': 0.0, 'tags': (), 'p50_duration': 0.020079415, 'p95_duration': 0.020079415, 'p99_duration': 0.020079415, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.020079415, 'p95': 0.020079415, 'p99': 0.020079415, 'imbalance': 0.0}
{'name': 'timestep', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.051781265, 'inclusive_duration': 0.051781265, 'exclusive_duration': 6.5455e-05, 'average_duration': 0.010356253, 'min_duration': 0.007145383, 'max_duration': 0.015165983, 'first_duration': 0.007169067, 'last_duration': 0.011143686, 'std_duration': 0.002995423968500419, 'tags': (), 'p50_duration': 0.011143686, 'p95_duration': 0.014364215599999999, 'p99_duration': 0.01500562952, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.011143686, 'p95': 0.014364215599999999, 'p99': 0.01500562952, 'imbalance': 0.0}
{'name': 'assemble', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.036365912, 'inclusive_duration': 0.036365912, 'exclusive_duration': 0.036365912, 'average_duration': 0.0072731824, 'min_duration': 0.004069909, 'max_duration': 0.012082558, 'first_duration': 0.00407022, 'last_duration': 0.008068723, 'std_duration': 0.002997499632040985, 'tags': (), 'p50_duration': 0.008068723, 'p95_duration': 0.0112809468, 'p99_duration': 0.01192223576, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.008068723, 'p95': 0.0112809468, 'p99': 0.01192223576, 'imbalance': 0.0}
{'name': 'solve', 'num_ranks': 1, 'num_calls': 5, 'total_duration': 0.015349898, 'inclusive_duration': 0.015349898, 'exclusive_duration': 0.015349898, 'average_duration': 0.0030699796000000002, 'min_duration': 0.00306586, 'max_duration': 0.003074542, 'first_duration': 0.003074542, 'last_duration': 0.003066681, 'std_duration': 3.514645393208247e-06, 'tags': (), 'p50_duration': 0.003069284, 'p95_duration': 0.0030743398, 'p99_duration': 0.00307450156, 'rank_imbalance': 0.0, 'rank_imbalance_pct': 0.0, 'p50': 0.003069284, 'p95': 0.0030743398, 'p99': 0.00307450156, '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.
[8]:
frame = results.to_dataframe()
frame.sort_values("total_duration", ascending=False)
[8]:
| 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.051781 | 0.051781 | 0.000065 | 0.010356 | 0.007145 | 0.015166 | 0.007169 | ... | () | 0.011144 | 0.014364 | 0.015006 | 0.0 | 0.0 | 0.011144 | 0.014364 | 0.015006 | 0.0 |
| 2 | assemble | 1 | 5 | 0.036366 | 0.036366 | 0.036366 | 0.007273 | 0.004070 | 0.012083 | 0.004070 | ... | () | 0.008069 | 0.011281 | 0.011922 | 0.0 | 0.0 | 0.008069 | 0.011281 | 0.011922 | 0.0 |
| 0 | setup | 1 | 1 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | ... | () | 0.020079 | 0.020079 | 0.020079 | 0.0 | 0.0 | 0.020079 | 0.020079 | 0.020079 | 0.0 |
| 3 | solve | 1 | 5 | 0.015350 | 0.015350 | 0.015350 | 0.003070 | 0.003066 | 0.003075 | 0.003075 | ... | () | 0.003069 | 0.003074 | 0.003075 | 0.0 | 0.0 | 0.003069 | 0.003074 | 0.003075 | 0.0 |
4 rows × 22 columns
per_rank=True emits one row per (region, rank) instead — the shape you want for load-balance analysis.
[9]:
results.to_dataframe(per_rank=True)
[9]:
| 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.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.020079 | 0.000000 | 0.020079 | 0.020079 | 0.020079 |
| 1 | timestep | 0 | 5 | 0.051781 | 0.051781 | 0.000065 | 0.010356 | 0.007145 | 0.015166 | 0.007169 | 0.011144 | 0.002995 | 0.011144 | 0.014364 | 0.015006 |
| 2 | assemble | 0 | 5 | 0.036366 | 0.036366 | 0.036366 | 0.007273 | 0.004070 | 0.012083 | 0.004070 | 0.008069 | 0.002997 | 0.008069 | 0.011281 | 0.011922 |
| 3 | solve | 0 | 5 | 0.015350 | 0.015350 | 0.015350 | 0.003070 | 0.003066 | 0.003075 | 0.003075 | 0.003067 | 0.000004 | 0.003069 | 0.003074 | 0.003075 |
[10]:
# 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
)
[10]:
| name | num_calls | total_duration | share | |
|---|---|---|---|---|
| 1 | timestep | 5 | 0.051781 | 0.419022 |
| 2 | assemble | 5 | 0.036366 | 0.294279 |
| 0 | setup | 1 | 0.020079 | 0.162486 |
| 3 | solve | 5 | 0.015350 | 0.124214 |
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).
[11]:
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.
[12]:
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}")
LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.10.21/x64/lib
PATH: /opt/hostedtoolcache/Python/3.10.21/x64/bin:/opt/hostedtoolcache/Pytho […]
chip_information: AMD EPYC 9V74 80-Core Processor
finalize_time_ns: 156934348692
hostname: runnervmgx7h7
modules: []
mpi_size: 1
omp_num_threads: 4
platform: Linux-6.17.0-1022-azure-x86_64-with-glibc2.39
python_version: 3.10.21
scope_profiler_version: 0.5.0
start_time_ns: 156860262960
timestamp: 2026-09-01T07:52:04.381792+00:00
total_cores: 4
uname: Linux runnervmgx7h7 6.17.0-1022-azure #22-Ubuntu SMP Mon Jul 27 17:24: […]
user: runner
working_directory: /home/runner/work/scope-profiler/scope-profiler/docs/source/tutorials
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:
[13]:
from scope_profiler.inspection import write_metadata_json
payload = write_metadata_json(DATA_FILE, WORKDIR / "metadata.json")
print(sorted(payload["files"][0]["metadata"])[:8])
['LD_LIBRARY_PATH', 'PATH', 'chip_information', 'finalize_time_ns', 'hostname', 'modules', 'mpi_size', 'omp_num_threads']
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.
[14]:
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.0030699796000000002, 'min_duration_seconds': 0.00306586, 'max_duration_seconds': 0.003074542, 'first_duration_seconds': 0.003074542, 'last_duration_seconds': 0.003066681, 'std_duration_seconds': 3.514645393208247e-06, 'total_duration_seconds': 0.015349898, 'per_rank': {'0': {'count': 5, 'average_duration_seconds': 0.0030699796000000002, 'min_duration_seconds': 0.00306586, 'max_duration_seconds': 0.003074542, 'first_duration_seconds': 0.003074542, 'last_duration_seconds': 0.003066681, 'std_duration_seconds': 3.514645393208247e-06, 'total_duration_seconds': 0.015349898}}}
wrote /tmp/scope-profiler-tutorial-aphmwbar/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.