Skip to content
Projects

HDF5 output & post-processing from Python

Download notebookView on GitHubRun when the site was built.

Install for this page: pip install "scope-profiler[pproc]" enables all examples. Reading HDF5 and inspecting regions need only the base install; pandas DataFrames and plotting need pproc.

Unless deactivate_file_output=True, finalize() writes the run’s timing data into a single HDF5 file. Under MPI, ranks write their own event arrays directly through parallel HDF5 or token-ordered access.

This page covers the file layout and the Python API for reading and plotting it. The same charts are available from the command line without writing any code — see Plotting with the CLI.

The examples below run on a small mock solver, profiled into a temporary directory (the setup cell is hidden), so every cell can be executed as is.

The merged output file (default: profiling_data.h5) has the following layout:

profiling_data.h5
├── metadata/ (attributes describing the run)
├── region_table/
│ └── names (region ID → UTF-8 name)
├── rank_region_index/
│ ├── region_ids, ranks
│ ├── event_offsets, event_counts
│ ├── summary_statistics (fixed-size per-rank/region statistics)
│ ├── exclusive_totals, event_metadata
│ └── source_files, source_lines, source_texts, tags
├── events/
│ ├── start_deltas (int64, nanoseconds; gap to the previous call's start)
│ ├── durations (int64, nanoseconds)
│ ├── call_ids, parent_ids (optional int64; the call tree, -1 means no parent)
│ ├── gpu_durations (optional int64, -1 means unavailable)
│ ├── thread_ids (optional int64, -1 means unknown)
│ ├── task_ids (optional int64, -1 means "outside any task")
│ └── await_ns (optional int64, nanoseconds suspended)
├── thread_table/ (optional; one row per thread, per rank)
├── task_table/ (optional; one row per asyncio task or greenlet)
└── rank<N>/ (only auxiliary line-profile/LIKWID records)

The root attribute scope_profiler_schema identifies the HDF5 layout version. New files currently use schema version 3. Files created before this attribute was introduced are treated as version 1 for backward compatibility; files from a newer, unsupported schema are rejected with an upgrade hint.

  • Region names are stored once. Each rank/region pair indexes a contiguous slice of the shared event columns, avoiding thousands of small HDF5 objects.
  • Times are stored as int64 nanoseconds from time.perf_counter_ns(). Schema 3 stores, per region, the gap between the starts of consecutive calls (start_deltas; the first element is the absolute start) and each call’s length (durations) instead of absolute start and end timestamps: the numbers are much smaller, so they compress far better. The reader rebuilds the absolute timestamps with a cumulative sum.
  • Runs with track_threads=True add the thread_ids event column and the thread_table group; track_async=True adds task_ids, await_ns and task_table. The two tables describe what those per-call columns index into. A run without them writes none of the four, which is how a reader tells “single-threaded” from “threads not recorded”. See Threads, tasks and coroutines.
  • Runs with use_likwid=True additionally get a rank<N>/likwid/ group holding the hardware counters and derived metrics of every marker region. See LIKWID hardware counters.

Every file records the environment it was produced in, as attributes on the metadata group. This is what lets you tell two otherwise identical runs apart months later.

Derived fields use lower-case names:

FieldDescription
timestampISO-8601 time the run started
labelthe run’s name, from setup(label=...); absent when none was given
start_time_nsrun start on the perf_counter_ns clock, the origin of the relative timeline
finalize_time_nsrun end (the call to finalize()) on the same clock
user, hostnamewho ran it, and where
platform, unameOS description, and the full uname tuple
chip_informationCPU model (from /proc/cpuinfo or sysctl)
python_versioninterpreter version
scope_profiler_versionversion of this package
working_directorydirectory the run started in
omp_num_threads, mpi_size, total_cores, num_nodesparallelism, usable as scaling-plot x-axes; num_nodes counts the distinct hosts the ranks ran on
modulesloaded environment modules, as a list of strings

Captured environment variables keep their own upper-case names and are present only when set:

  • PATH, LD_LIBRARY_PATH, VIRTUAL_ENV
  • LOADEDMODULES, MODULEPATH, MODULESHOME, MODULES_CMD, MODULES_RUN_QUARANTINE
  • PYTHON_HOME, PYTHON_INC, PYTHON_INCLUDE, PYTHON_LIB
  • every SLURM_* / SLURMD_* variable the batch system exported, so a run can be traced back to its job

user, hostname, uname, working_directory, modules and the environment variables are only recorded with metadata_detail="full"; see below. The run below uses the default, so only the fields of the minimal set are present.

metadata = read_h5("profiling_data.h5").metadata
print(metadata["chip_information"]) # the CPU model of this machine
print(metadata.get("modules")) # None: only recorded with metadata_detail="full"
print(metadata.get("SLURM_JOB_ID")) # a string inside a Slurm job, or None
AMD EPYC 9V45 96-Core Processor
None
None

Values longer than 60 000 characters are truncated with a trailing ...[truncated], since HDF5 attributes cannot exceed 64 KB.

Metadata is collected on every rank but only rank 0’s copy is stored, so it describes the run as a whole. Per-task values such as SLURM_PROCID reflect rank 0.

By default a profiling file records only what describes the run itself: timestamp, platform, chip_information, python_version, scope_profiler_version, omp_num_threads, mpi_size, total_cores, num_nodes (a count, which names no machine), your label, and the run’s start and finish times. Source files are stored relative to the working directory (examples/run.py); a file outside it is stored relative to the sys.path entry it was imported from (struphy/feec/basis_projection_ops.py), or by name alone if neither applies. So no home directory or user name ends up in the file, and a report can still find the source. Line profiles also store the source of each profiled function, so the report shows the code on any machine.

For runs that stay on the machine they were recorded on, more context can help explain why two runs behaved differently. Opt into it with metadata_detail="full":

simulate(file_path="run.h5", metadata_detail="full")
full = read_h5("run.h5").metadata
print(sorted(full))
['PATH', 'chip_information', 'finalize_time_ns', 'hostname', 'modules', 'mpi_size', 'num_nodes', 'omp_num_threads', 'platform', 'python_version', 'scope_profiler_version', 'start_time_ns', 'timestamp', 'total_cores', 'uname', 'user', 'working_directory']
"minimal" (default)"full"
timestamp, platform, chip_information, python_version, scope_profiler_versionrecordedrecorded
omp_num_threads, mpi_size, total_cores, num_nodesrecordedrecorded
label, start_time_ns, finalize_time_nsrecordedrecorded
user, hostname, uname, working_directory, modulesleft outrecorded
environment variables (PATH, LD_LIBRARY_PATH, VIRTUAL_ENV, module variables)left outrecorded
every SLURM_* / SLURMD_* variableleft outrecorded
memory_profile_path (with use_memray)left outrecorded
source file of a region or line profilerelative to the working directory or import rootabsolute path

Whatever the level, the file holds what you put there yourself: region names, label and metadata(...) values as you wrote them; the source text of each region when you turn on capture_region_source=True; and, when line profiling is on, the source lines of each line-profiled function, so a report can show the code without the source file. No other environment variable is read, so secrets held in variables such as AWS_SECRET_ACCESS_KEY are never stored.

The level is applied when the file is recorded: a "full" file cannot be reduced afterwards, and every export, report and the terminal UI show what the file holds. Files written by the C library’s own HDF5 writer also record the host name.

The same setting can go in a TOML settings file (metadata_detail = "full" under [profiling]) or in a shared ProfilingOptions.

from scope_profiler import read_h5
results = read_h5("profiling_data.h5")
# Number of MPI ranks in the file
print(results.num_ranks)
# Number of nodes (distinct hosts) the ranks ran on; None for a multi-rank
# file written before scope-profiler recorded it
print(results.num_nodes)
# Get all regions (sorted by first start time)
for region in results.get_regions():
r0 = region[0] # Region data for rank 0
print(f"{region.name}: {r0.num_calls} calls, avg {r0.average_duration:.6f} s")
Show output
1
1
timestep: 3 calls, avg 0.000085 s
rhs.assemble: 3 calls, avg 0.000014 s
solver.precondition: 3 calls, avg 0.000007 s
solve: 3 calls, avg 0.000030 s
exchange: 3 calls, avg 0.000003 s
io.write: 3 calls, avg 0.000002 s

Durations and timestamps on Region and MPIRegion are reported in seconds, converted from the nanoseconds stored in the file.

Large profiles can contain millions of events. Use read_h5_summary() when you only need metadata or scalar region statistics:

from scope_profiler import read_h5_summary
results = read_h5_summary("profiling_data.h5")
for region in results.get_regions():
print(region.name, region.num_calls, region.total_duration)
Show output
timestep 3 0.000253703
rhs.assemble 3 4.3456e-05
solver.precondition 3 2.0371e-05
solve 3 9.1357e-05
exchange 3 9.024e-06
io.write 3 4.607e-06

For large runs, restrict the compact read to selected region names or ranks, and skip optional profiler payloads when they are not needed. Ranks that the file does not have are simply absent (this file has rank 0 only):

results = read_h5_summary(
"profiling_data.h5",
regions=["solve", "exchange"],
ranks=[0, 1],
include_likwid=False,
include_line_profile=False,
)
print([region.name for region in results.get_regions()])
['solve', 'exchange']

This reads one fixed-size record per rank/region instead of the event-sized timestamp, call-ID, and GPU columns. Counts, totals, averages, min/max, first/last, standard deviation, rank imbalance, wall-clock span, GPU totals, sources, metadata, and exclusive totals remain exact. Per-call events, call trees, timelines, and percentiles require read_h5(); percentile properties are None on summary-only results.

Files created before summary records were added are accepted by default and fall back to read_h5(). Pass fallback=False to require the bounded-memory path instead. The diff, check, benchmark, metadata-only inspection, and MCP comparison paths select this reader automatically when their requested metric can be computed exactly from the stored statistics.

get_regions() accepts include and exclude patterns (Python regex):

results = read_h5("profiling_data.h5")
# Only regions whose name starts with "solver"
print([region.name for region in results.get_regions(include="solver.*")])
# Everything except IO regions
print([region.name for region in results.get_regions(exclude="io.*")])
['solver.precondition']
['timestep', 'rhs.assemble', 'solver.precondition', 'solve', 'exchange']

A region records its defining filename and line by default: the with block for the context-manager form, or the function for the decorator form. Full source text is opt-in with capture_region_source=True (see Configuration for what it costs and why it isn’t on unconditionally):

ProfileManager.setup(file_path="source.h5", capture_region_source=True)
with ProfileManager.region("solve"):
work(3000)
ProfileManager.finalize(verbose=False)
results = read_h5("source.h5")
region = results.get_region("solve")
print(f"line {region.source_lineno}, has_source={region.has_source}")
print(region.source_text)
line 2, has_source=True
with ProfileManager.region("solve"):
work(3000)

region.has_source is False when it was never enabled, for a file written before this feature existed, or for a region created only by the recursive tracer (recursive_profile=True) or scope-profiler run, none of which have one call site to point at. If the same region name is used at more than one call site, the source of whichever call created it first is kept - their timings are pooled together under that one name either way. The same information is available without writing any Python, via scope-profiler inspect --source; see CLI reference.

Post-processing in the script that produced the data

Section titled “Post-processing in the script that produced the data”

ProfileManager.read_results() opens the file the current configuration just wrote, so a run can analyse itself without repeating the path:

simulate(file_path="self.h5") # setup(), the regions and finalize()
results = ProfileManager.read_results()
results.print_summary()
results.print_summary(columns=["region", "ranks", "calls", "total", "avg"])
Show output
╭──────────────────────────────────────────────╮
│ region total [s] │
├──────────────────────────────────────────────┤
│ timestep (3x) 0.000251 │
│ └─ (own) 0.000084 │
│ └─ rhs.assemble (3x) 0.000046 │
│ └─ solver.precondition (3x) 0.000021 │
│ └─ solve (3x) 0.000095 │
│ │ └─ (own) 0.000086 │
│ │ └─ exchange (3x) 0.000009 │
│ └─ io.write (3x) 0.000004 │
╰──────────────────────────────────────────────╯
╭─ Info ────────────────────────────────────────────────────────────────╮
│ Summary: self.h5 (1 rank) │
│ │
│ Explore: │
│ Inspect: scope-profiler inspect self.h5 │
│ TUI: scope-profiler tui self.h5 │
│ │
│ Visualize and export: │
│ Plot: scope-profiler plot default self.h5 -o plots --show │
│ Report: scope-profiler report self.h5 -o report.html │
│ Export: scope-profiler export plot-data self.h5 -o data │
│ Lines: scope-profiler line-profile self.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. │
╰───────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────╮
│ region ranks n total [s] avg/call [s] │
├─────────────────────────────────────────────────────────────────────────────┤
│ timestep 1 3 0.000251 8.352e-05 │
│ └─ (own) 0.000084 2.798e-05 │
│ └─ rhs.assemble 1 3 0.000046 1.531e-05 │
│ └─ solver.precondition 1 3 0.000021 7.040e-06 │
│ └─ solve 1 3 0.000095 3.171e-05 │
│ │ └─ (own) 0.000086 2.865e-05 │
│ │ └─ exchange 1 3 0.000009 3.061e-06 │
│ └─ io.write 1 3 0.000004 1.469e-06 │
╰─────────────────────────────────────────────────────────────────────────────╯
╭─ Info ────────────────────────────────────────────────────────────────╮
│ Summary: self.h5 (1 rank) │
│ │
│ Explore: │
│ Inspect: scope-profiler inspect self.h5 │
│ TUI: scope-profiler tui self.h5 │
│ │
│ Visualize and export: │
│ Plot: scope-profiler plot default self.h5 -o plots --show │
│ Report: scope-profiler report self.h5 -o report.html │
│ Export: scope-profiler export plot-data self.h5 -o data │
│ Lines: scope-profiler line-profile self.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. │
╰───────────────────────────────────────────────────────────────────────╯

Under MPI only rank 0 writes the merged file, so guard the call accordingly.

finalize(return_results=True) hands back the run’s data directly from the in-memory buffers, with no file to write and read back:

from scope_profiler.plotting_scripts import plot_gantt
ProfileManager.setup(deactivate_file_output=True)
with ProfileManager.region("timestep"):
with ProfileManager.region("solve"):
work(3000)
results = ProfileManager.finalize(return_results=True)
results.print_summary()
df = results.to_dataframe()
plot_gantt(results, show=True)
Show output
╭─────────────────────────╮
│ region total [s] │
├─────────────────────────┤
│ timestep 0.000055 │
│ └─ (own) 0.000031 │
│ └─ solve 0.000025 │
╰─────────────────────────╯
╭─ Info ───────────────────────────────────────────────────────────────────╮
│ Summary: profiling_data (in memory, 1 rank) │
│ │
│ Explore: │
│ Inspect: scope-profiler inspect profiling_data.h5 │
│ TUI: scope-profiler tui profiling_data.h5 │
│ │
│ Visualize and export: │
│ Plot: scope-profiler plot default profiling_data.h5 -o plots --show │
│ Report: scope-profiler report profiling_data.h5 -o report.html │
│ Export: scope-profiler export plot-data profiling_data.h5 -o data │
│ Lines: scope-profiler line-profile 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. │
╰──────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────╮
│ region total [s] │
├─────────────────────────┤
│ timestep 0.000055 │
│ └─ (own) 0.000031 │
│ └─ solve 0.000025 │
╰─────────────────────────╯
╭─ Info ───────────────────────────────────────────────────────────────────╮
│ Summary: profiling_data.h5 (1 rank) │
│ │
│ Explore: │
│ Inspect: scope-profiler inspect profiling_data.h5 │
│ TUI: scope-profiler tui profiling_data.h5 │
│ │
│ Visualize and export: │
│ Plot: scope-profiler plot default profiling_data.h5 -o plots --show │
│ Report: scope-profiler report profiling_data.h5 -o report.html │
│ Export: scope-profiler export plot-data profiling_data.h5 -o data │
│ Lines: scope-profiler line-profile 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. │
╰──────────────────────────────────────────────────────────────────────────╯
Plotting Gantt chart for ranks: [0]
Notebook output

finalize() returns the very same ProfilingResults type that read_h5() gives back — the only difference is where the data came from — so every method on this page, every plot_* function and every exporter accepts it.

This works with deactivate_file_output=True, where no file is written at all (as above).

The gather is collective, so every rank must call finalize(return_results=True) — don’t hide the call behind a rank guard. Rank 0 then holds the whole run, mirroring the merged file; the other ranks get an empty result set.

You do not need a rank guard for anything downstream either. Everything that produces output — print_summary(), the plot_* functions, the exporters — does nothing for those empty result sets, so the script above prints its table once and writes each figure once, from rank 0, whether you run it on 1 rank or 1000:

Terminal window
python simulation.py
mpirun -n 64 python simulation.py # same script, same output, once

Analyses of your own need no guard as long as they iterate — get_regions() and events() simply come back empty off rank 0. When you do need the distinction (writing your own file, say), ask for it explicitly:

def my_report(results):
print("writing my own report for", len(results.get_regions()), "regions")
if results.is_root:
my_report(results)
writing my own report for 2 regions

See examples/ex_in_memory_results.py for a complete script that runs unchanged serially and under mpirun.

The built-in charts cover the common cases; when you want something else, work from the raw calls rather than the aggregates.

events() returns the long-form (“tidy”) view: one entry per recorded call, with name, rank, call_index, start, end and duration in seconds. Timestamps are measured from the first region entry in the file, so the timeline starts at zero and is directly plottable — pass relative=False for the raw monotonic-clock values.

results = read_h5("profiling_data.h5")
for event in results.events(include="solver.*", ranks=0):
plt.barh(event["name"], event["duration"], left=event["start"])
plt.xlabel("time since the first region entry (s)")
plt.show()
Notebook output

to_events_dataframe() returns the same data as a pandas DataFrame, which is usually the shortest path to a custom chart:

events = results.to_events_dataframe()
# Which region has the most variable calls?
events.groupby("name")["duration"].std().sort_values(ascending=False)
Show output
name
timestep 6.322224e-05
solve 1.077568e-05
rhs.assemble 9.345781e-07
solver.precondition 5.548336e-07
exchange 2.504137e-07
io.write 2.427145e-07
Name: duration, dtype: float64
# Per-rank load imbalance in one line
events.pivot_table(index="rank", columns="name", values="duration", aggfunc="sum")
Show output
name exchange io.write rhs.assemble solve solver.precondition timestep
rank
0 0.000009 0.000005 0.000043 0.000091 0.00002 0.000254
# Distribution of a single region's call durations
events.query("name == 'solve'")["duration"].hist(bins=50)
plt.show()
Notebook output

The same filters apply as everywhere else: include/exclude regexes and ranks.

Individual Region and MPIRegion objects expose the same view for a single region (results["solve"].events()), and Region also offers the stored integer nanoseconds via start_times_ns, end_times_ns and durations_ns for anyone who wants to avoid the float conversion.

One difference: the per-region events() accepts ranks and origin but leaves the timestamps raw (origin=0.0) rather than measuring from the timeline origin. Pass origin=results.time_origin to get the same zero as results.events().

print(results["solve"].events()[0])
print(results["solve"][0].durations_ns) # Region of rank 0
print(results["solve"].events(origin=results.time_origin)[0]["start"])
{'name': 'solve', 'rank': 0, 'call_index': 0, 'start': 203.63278228, 'end': 203.632825175, 'duration': 4.2895000007092676e-05, 'call_id': 3, 'parent_id': 0}
[42895 24246 24216]
0.0011882219999961308

results.minimum_start_time, results.maximum_end_time and results.time_span bound the profiled window in seconds — handy for normalising axes or computing what fraction of the run a region accounts for:

frame = results.to_dataframe()
frame["fraction_of_run"] = frame["total_duration"] / results.time_span
frame[["total_duration", "fraction_of_run"]]
Show output
total_duration fraction_of_run
0 0.000254 0.993842
1 0.000043 0.170232
2 0.000020 0.079800
3 0.000091 0.357877
4 0.000009 0.035350
5 0.000005 0.018047

results.run_start_time is when the run itself started, as registered by ProfileManager.setup(), and results.startup_time is the gap from there to the first region — the time the instrumentation never saw:

print(f"{results.startup_time:.3f} s elapsed before the first region was entered")
0.001 s elapsed before the first region was entered

events() and call_stack() measure from results.time_origin: the registered start time when the file has one, and the first region entry otherwise. Two ways to override it:

raw = results.events(relative=False) # raw clock timestamps
first = results.events(origin=results.minimum_start_time) # zero on the first region
print(raw[0]["start"] > first[0]["start"], first[0]["start"])
True 0.0

The plot_* functions are the exception: they frame the x axis on the first region entry, so that a long gap between setup() and the first region does not fill a chart with empty space. The second line above reproduces exactly what a chart’s axis shows.

Files that carry no start time — anything written before setup() began recording one — need no special handling anywhere: run_start_time is None, time_origin falls back to the first region entry, startup_time is 0.0, and every ProfilingResults method, export and chart behaves exactly as it did before.

call_stack() recovers the nesting the flame graph draws, as plain dicts you can render however you like. Each call carries its depth and the index of its parent in the returned list:

from scope_profiler import call_stack_children, call_stack_roots
calls = results.call_stack(rank=0)
for call in calls[:6]:
print(f"{' ' * call['depth']}{call['name']}")
# Or walk it as a tree
children = call_stack_children(calls)
for root in call_stack_roots(calls):
print(calls[root]["name"], "has", len(children[root]), "direct children")
Show output
timestep
rhs.assemble
solver.precondition
solve
exchange
io.write
timestep has 4 direct children
timestep has 4 direct children
timestep has 4 direct children

Calls are identified by position rather than by name, because a region that is called repeatedly — or recursively — contributes several entries under one name.

Nesting is reconstructed by containment, so the intervals have to be properly nested: any two calls on a rank either nest completely or do not overlap. Anything else raises NestingError. Python regions always satisfy this; a mismatched sp_begin/sp_end pair in native code is the way to break it.

On a long run, prefer build_call_arrays() to call_stack(). It is the same reconstruction returned as numpy columns rather than a dict per call, which is what makes it usable on millions of events:

from scope_profiler import build_call_arrays
calls = build_call_arrays(results.get_regions(), rank=0)
print(calls.depth, calls.parent, calls.exclusive_ns) # all indexed by call id
Show output
[0 1 1 1 2 1 0 1 1 1 2 1 0 1 1 1 2 1] [-1 0 0 0 3 0 -1 6 6 6 9 6 -1 12 12 12 15 12] [89875 15554 7431 39600 3295 1813 2354 14081 6470 21412 2834 1432
1683 13821 6470 21321 2895 1362]
from scope_profiler import read_h5
from scope_profiler.plotting_scripts import plot_gantt
results = read_h5("profiling_data.h5")
plot_gantt(
profiling_data=results,
include=["solver.*", "rhs.*"],
exclude=["io"],
ranks=[0], # ranks=[0, 1] on a file with at least two ranks
filepath="gantt.png",
show=True,
)
Plotting Gantt chart for ranks: [0]
Notebook output

The chart displays one horizontal lane per (region, rank) combination, with bars spanning each recorded start-to-end interval. When multiple files are provided, each file gets its own stacked subplot in the exported chart.

The comparison charts take several runs. The (hidden) cell below records three more runs of the mock solver, run_a and run_b with different workloads and run_1, run_2 and run_4 with OMP_NUM_THREADS set to 1, 2 and 4, so that the scaling charts further down have something to scale over.

from scope_profiler import read_h5
from scope_profiler.plotting_scripts import plot_durations
runs = [
read_h5("run_a.h5"),
read_h5("run_b.h5"),
]
saved_paths = plot_durations(
runs,
filepath="durations.png",
show=True,
)
Plotting duration comparison (total) for files: run_a, run_b
Notebook output

Each bar chart compares matching regions across files, with bars grouped by file when several files are provided. plot_durations renders one figure per requested statistic: total duration by default (the metric argument picks another one: avg, min, max, first or last). Use the metrics argument to render several at once, for example avg, min, max and total duration per call:

saved_paths = plot_durations(
runs,
metrics=["avg", "total"],
filepath="durations.png",
show=True,
)
Plotting duration comparison (avg, total) for files: run_a, run_b
Notebook output Notebook output

When filepath is given and more than one metric is plotted, the metric name is inserted before the file extension, e.g. durations_avg.png, durations_total.png. plot_durations returns the list of filepaths it wrote (empty if filepath is None).

print([Path(path).name for path in saved_paths])
['durations_avg.png', 'durations_total.png']
from scope_profiler.plotting_scripts import plot_flame_chart
results = read_h5("profiling_data.h5")
plot_flame_chart(results, ranks=[0], filepath="flame_chart.png", show=True)
Plotting flame chart for: mock solver
For interactive flame-chart hover details, use --backend plotly.
Notebook output

The call stack is reconstructed from timestamp containment: a region whose interval falls inside another’s becomes its child. Unlike the Gantt chart, the flame chart draws one panel per rank, defaulting to rank 0 only, with time on the horizontal axis. The aggregated flame graph is available as plot_flame_graph when the time axis is not needed. Frame labels use the full reconstructed call path (for example, timestep > assembly > solve), so same-named regions below different parents remain distinguishable. Colors still identify the underlying region. When source capture is enabled, the flame data export includes each frame’s source_file and source_lineno; Plotly hover cards show the same location.

from scope_profiler.plotting_scripts import plot_duration_timeseries
plot_duration_timeseries(results, filepath="duration_timeseries.png", show=True)
Plotting duration over time for files: mock solver
Notebook output

One line per region tracks the mean duration of each call over wall-clock time, shaded between the minimum and maximum across the selected ranks, so rank imbalance and drift over the run become visible.

from scope_profiler.plotting_scripts import (
collect_region_statistics,
write_region_statistics_json,
)
stats = collect_region_statistics(runs) # dict, nothing written
stats = write_region_statistics_json(runs, "stats.json") # same dict, and a file
print(list(stats))
['units', 'filters', 'common_regions', 'files']

Both return per-file, per-region aggregates (count, average, min, max, std, total, all in seconds), per-rank statistics for each region, and the region names common to all inputs. This is the same document scope-profiler plot default -o ... writes as region_statistics.json.

from scope_profiler import read_h5
from scope_profiler.plotting_scripts import plot_speedup
runs = [
read_h5("run_1.h5"),
read_h5("run_2.h5"),
read_h5("run_4.h5"),
]
plot_speedup(
runs,
filepath="speedup.png",
show=True,
)

This is the form for runs of 1, 2 and 4 MPI ranks, which needs an MPI launcher and is not executed here. The run_1, run_2 and run_4 files above are serial runs that differ in OMP_NUM_THREADS, so the executable version below scales over threads instead (x_field="omp_num_threads", one of the options described below):

from scope_profiler.plotting_scripts import plot_speedup
runs = [read_h5(f"run_{threads}.h5") for threads in (1, 2, 4)]
plot_speedup(
runs,
x_field="omp_num_threads",
filepath="speedup.png",
show=True,
)
Plotting speedup comparison using x_field='omp_num_threads', values: 4
Notebook output

The speedup plot shows one line per scope, with MPI rank count on the x-axis and speedup on the y-axis, derived from average per-call durations for each matching scope. The dashed reference line shows optimal scaling relative to the smallest rank count present in the inputs. x_field="num_nodes", "omp_num_threads" or "total_cores" scales over nodes, threads, or ranks × threads instead.

create_html_report(runs, "scaling.html", speedup_x="nodes") sets the x-axis of a comparison report’s speedup chart the same way: "auto" (default), "ranks", "nodes", "threads" or "cores". A named axis that some run did not record raises scope_profiler.html_report.SpeedupAxisError (a ValueError).

from scope_profiler import create_html_report
create_html_report(runs, "scaling.html", speedup_x="threads")
print(Path("scaling.html").exists())
True