5. Custom analysis with the Python API#

Notebook 2 covered the aggregates: how long each region took in total, on average, per rank. This notebook goes one level down, to the individual calls, which is where you go when the question you have is not one the built-in summaries answer.

Three tools do most of the work:

Method

Gives you

results.events()

one entry per recorded call

results.to_events_dataframe()

the same, as a pandas DataFrame

results.call_stack()

the calls with their nesting reconstructed

As everywhere in this API, times are in seconds.

[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():
    time.sleep(0.002)


with ProfileManager.profile_region("setup"):
    time.sleep(0.01)

for step in range(6):
    with ProfileManager.profile_region("timestep"):
        assemble()
        with ProfileManager.profile_region("solve"):
            # The solve gets slower as the run progresses.
            time.sleep(0.002 + 0.001 * step)
    if step % 3 == 2:
        with ProfileManager.profile_region("checkpoint"):
            time.sleep(0.004)

ProfileManager.finalize(verbose=False)

Reading the results back in the same script#

ProfileManager.read_results() opens the file the current configuration just wrote, so you do not have to repeat the path. Under MPI only rank 0 writes the merged file, so guard the call with a rank check there.

[2]:
results = ProfileManager.read_results()
print(results)
print("same file:", results.file_path == DATA_FILE)
<ProfilingResults 'profiling_data.h5': 5 region(s), 1 rank(s)>
same file: True

One entry per call#

events() returns the long-form (“tidy”) view of the run: every recorded call, in region order, then rank, then call order.

[3]:
events = results.events()

print(f"{len(events)} calls recorded\n")
for event in events[:4]:
    print(event)
21 calls recorded

{'name': 'setup', 'rank': 0, 'call_index': 0, 'start': 0.001959806000002118, 'end': 0.012039456000024984, 'duration': 0.010079650000022866, 'call_id': 0, 'parent_id': -1}
{'name': 'timestep', 'rank': 0, 'call_index': 0, 'start': 0.012221035999999685, 'end': 0.016385643000006667, 'duration': 0.004164607000006981, 'call_id': 1, 'parent_id': -1}
{'name': 'timestep', 'rank': 0, 'call_index': 1, 'start': 0.016389048000007733, 'end': 0.021536933000021463, 'duration': 0.00514788500001373, 'call_id': 4, 'parent_id': -1}
{'name': 'timestep', 'rank': 0, 'call_index': 2, 'start': 0.021540118000018538, 'end': 0.02768315900001994, 'duration': 0.0061430410000014035, 'call_id': 7, 'parent_id': -1}

Each entry carries the region name, the rank that recorded it, its call_index within that rank, and start / end / duration in seconds.

The timeline starts at zero#

By default the timestamps are measured from the start of the runProfileManager.setup() records that instant, and ProfilingResults exposes it as run_start_time. The raw values come from perf_counter_ns(), a monotonic clock with an arbitrary origin that is comparable only within one run, and you can still get them with relative=False.

[4]:
print("relative :", [round(e["start"], 4) for e in results.events(include="timestep")])
print(
    "absolute :",
    [round(e["start"], 4) for e in results.events(include="timestep", relative=False)],
)

print("\nrun start    :", results.run_start_time)
print("first entry  :", results.minimum_start_time)
print("last exit    :", results.maximum_end_time)
print("wall clock   :", results.time_span, "s")
relative : [0.0122, 0.0164, 0.0215, 0.0318, 0.0389, 0.0471]
absolute : [163.4414, 163.4456, 163.4508, 163.461, 163.4681, 163.4763]

run start    : 163.429213445
first entry  : 163.431173251
last exit    : 163.489485019
wall clock   : 0.058311768000010034 s

The time before the first region#

setup() runs before any region is entered, so the gap between the two is work the instrumentation never saw — imports, reading input, building a mesh. It is often the first surprise a profile delivers. The run’s origin is the moment setup() is called, so calling it as early as possible is what makes that gap visible.

[5]:
print(
    f"{results.startup_time * 1e3:.2f} ms elapsed before the first region was entered"
)
1.96 ms elapsed before the first region was entered

Choosing a different origin#

time_origin is the zero point in use: the registered start time, or the first region entry for files that have none. Override it per call with origin.

The plot_* functions are the one exception — they frame their x axis on the first region entry, so a long startup does not fill a chart with empty space. The last line below reproduces exactly what a chart’s axis shows.

None of this is required reading for older files: one written before setup() recorded a start time reports run_start_time as None, falls back to the first region entry, and behaves exactly as it always did.

[6]:
print("origin       :", results.time_origin)
print("is run start :", results.time_origin == results.run_start_time)

print(
    "\nfrom run start   :",
    [round(e["start"], 4) for e in results.events(include="solve")],
)
print(
    "from first entry :",
    [
        round(e["start"], 4)
        for e in results.events(include="solve", origin=results.minimum_start_time)
    ],
)
origin       : 163.429213445
is run start : True

from run start   : [0.0143, 0.0185, 0.0236, 0.0339, 0.041, 0.0491]
from first entry : [0.0124, 0.0165, 0.0217, 0.0319, 0.039, 0.0472]

time_span is the width of the profiled window, which is what you want when asking what fraction of the run went into this region — the sum of all region durations double-counts nested regions and can exceed the wall clock.

[7]:
for region in results.get_regions():
    share = region.total_duration / results.time_span
    print(f"  {region.name:<11} {share:6.1%} of the run")
  setup        17.3% of the run
  timestep     68.4% of the run
  assemble     21.3% of the run
  solve        47.0% of the run
  checkpoint   14.0% of the run

Selecting what you get#

events() takes the same include / exclude regexes as the rest of the API, plus ranks:

results.events(include="solve")             # one region
results.events(include=["solve", "assemble"])
results.events(exclude="checkpoint")
results.events(ranks=0)                     # one rank, or ranks=[0, 2]
[8]:
solve_events = results.events(include="solve")
print("calls  :", len(solve_events))
print("indices:", [e["call_index"] for e in solve_events])
print("slowest:", max(solve_events, key=lambda e: e["duration"]))
calls  : 6
indices: [0, 1, 2, 3, 4, 5]
slowest: {'name': 'solve', 'rank': 0, 'call_index': 5, 'start': 0.04913167200001567, 'end': 0.05619917399999963, 'duration': 0.0070675019999839606, 'call_id': 19, 'parent_id': 17}

As a DataFrame#

to_events_dataframe() returns the same data with one row per call, which is usually the shortest path from a question to an answer. It needs pandas (part of the plot extra).

[9]:
frame = results.to_events_dataframe()
frame.head()
[9]:
name rank call_index start end duration
0 setup 0 0 0.001960 0.012039 0.010080
1 timestep 0 0 0.012221 0.016386 0.004165
2 timestep 0 1 0.016389 0.021537 0.005148
3 timestep 0 2 0.021540 0.027683 0.006143
4 timestep 0 3 0.031777 0.038923 0.007147

From here every question is a groupby. A few that come up constantly:

[10]:
# How consistent is each region, call to call?
frame.groupby("name")["duration"].agg(["count", "mean", "std", "max"]).sort_values(
    "mean", ascending=False
)
[10]:
count mean std max
name
setup 1 0.010080 NaN 0.010080
timestep 6 0.006646 0.001861 0.009136
solve 6 0.004568 0.001870 0.007068
checkpoint 2 0.004069 0.000003 0.004071
assemble 6 0.002067 0.000002 0.002071
[11]:
# The ten slowest individual calls in the whole run.
frame.nlargest(10, "duration")[["name", "rank", "call_index", "start", "duration"]]
[11]:
name rank call_index start duration
0 setup 0 0 0.001960 0.010080
6 timestep 0 5 0.047064 0.009136
5 timestep 0 4 0.038926 0.008135
4 timestep 0 3 0.031777 0.007147
18 solve 0 5 0.049132 0.007068
3 timestep 0 2 0.021540 0.006143
17 solve 0 4 0.040995 0.006065
2 timestep 0 1 0.016389 0.005148
16 solve 0 3 0.033850 0.005072
1 timestep 0 0 0.012221 0.004165
[12]:
# Time spent per region, per rank - the shape load-imbalance analysis wants.
frame.pivot_table(index="rank", columns="name", values="duration", aggfunc="sum")
[12]:
name assemble checkpoint setup solve timestep
rank
0 0.012401 0.008137 0.01008 0.027411 0.039874

Down to one region#

Region and MPIRegion expose the same view for a single region, so you can narrow before you widen. Region also hands back the stored integers directly, for the rare case where the nanosecond values matter more than the convenience of seconds.

[13]:
solve = results["solve"]  # an MPIRegion: the region across all ranks
print("all ranks   :", len(solve.events()), "calls")
print("rank 0 only :", len(solve.events(ranks=0)), "calls")

rank0 = solve[0]  # a Region: one rank
print("\nseconds     :", rank0.durations[:3])
print("nanoseconds :", rank0.durations_ns[:3])
print("start (ns)  :", rank0.start_times_ns[:3])
all ranks   : 6 calls
rank 0 only : 6 calls

seconds     : [0.00206877 0.00306926 0.00406826]
nanoseconds : [2068768 3069262 4068264]
start (ns)  : [163443529098 163447675037 163452827088]

The call stack#

Regions record no call graph of their own — each call is just a (start, end) pair. Nesting is therefore reconstructed from containment: a call that starts while another is still open is treated as its child. That is what the flame graph draws, and call_stack() hands you the same structure as plain dicts.

[14]:
calls = results.call_stack(rank=0)

for call in calls[:8]:
    indent = "  " * call["depth"]
    print(f"{indent}{call['name']:<12} {call['duration'] * 1e3:6.2f} ms")
setup         10.08 ms
timestep       4.16 ms
  assemble       2.07 ms
  solve          2.07 ms
timestep       5.15 ms
  assemble       2.07 ms
  solve          3.07 ms
timestep       6.14 ms

Every call carries its depth and the index of its parent in the returned list. Indices rather than names, because a region called repeatedly — or recursively — contributes several entries under one name.

call_stack_roots() and call_stack_children() turn that flat list into a tree you can walk:

[15]:
from scope_profiler import call_stack_children, call_stack_roots

children = call_stack_children(calls)

for root in call_stack_roots(calls):
    names = [calls[child]["name"] for child in children[root]]
    print(f"{calls[root]['name']:<12} contains {names}")
setup        contains []
timestep     contains ['assemble', 'solve']
timestep     contains ['assemble', 'solve']
timestep     contains ['assemble', 'solve']
checkpoint   contains []
timestep     contains ['assemble', 'solve']
timestep     contains ['assemble', 'solve']
timestep     contains ['assemble', 'solve']
checkpoint   contains []

Self time#

A useful thing the aggregates cannot give you: exclusive (self) time — how long a region took on its own, with the time spent inside its children removed. It falls straight out of the tree.

[16]:
from collections import defaultdict

total = defaultdict(float)
self_time = defaultdict(float)

for index, call in enumerate(calls):
    nested = sum(calls[child]["duration"] for child in children[index])
    total[call["name"]] += call["duration"]
    self_time[call["name"]] += call["duration"] - nested

print(f"{'region':<12}{'total [ms]':>12}{'self [ms]':>12}")
for name in sorted(total, key=total.get, reverse=True):
    print(f"{name:<12}{total[name] * 1e3:12.2f}{self_time[name] * 1e3:12.2f}")
region        total [ms]   self [ms]
timestep           39.87        0.06
solve              27.41       27.41
assemble           12.40       12.40
setup              10.08       10.08
checkpoint          8.14        8.14

timestep is nearly all children — it is a container. The regions with large self time are the ones actually doing work, and the ones worth optimising.

Several ranks#

Everything above is rank-aware; a serial run just happens to have one rank. To show the shape without launching mpirun, here is a two-rank file written by hand — it also documents the on-disk layout, which is plain HDF5:

[17]:
import h5py
import numpy as np

MPI_FILE = WORKDIR / "two_ranks.h5"
NS = 1_000_000_000  # timestamps are stored as int64 nanoseconds

with h5py.File(MPI_FILE, "w") as h5file:
    for rank, factor in enumerate([1.0, 1.6]):  # rank 1 is the slow one
        regions = h5file.create_group(f"rank{rank}").create_group("regions")
        starts = np.array([0.0, 0.5, 1.0]) * NS
        durations = np.array([0.2, 0.25, 0.2]) * factor * NS
        group = regions.create_group("solve")
        group.create_dataset("start_times", data=starts.astype(np.int64))
        group.create_dataset("end_times", data=(starts + durations).astype(np.int64))

mpi_reader = read_h5(MPI_FILE)
print(mpi_reader)

mpi_reader.to_events_dataframe()
<ProfilingResults 'two_ranks.h5': 1 region(s), 2 rank(s)>
[17]:
name rank call_index start end duration
0 solve 0 0 0.0 0.20 0.20
1 solve 0 1 0.5 0.75 0.25
2 solve 0 2 1.0 1.20 0.20
3 solve 1 0 0.0 0.32 0.32
4 solve 1 1 0.5 0.90 0.40
5 solve 1 2 1.0 1.32 0.32
[18]:
# Load imbalance: how much longer did the slowest rank take than the fastest?
per_rank = mpi_reader["solve"].total_durations()
print("total per rank:", per_rank)
print(f"imbalance     : {max(per_rank.values()) / min(per_rank.values()):.2f}x")
total per rank: {0: 0.65, 1: 1.04}
imbalance     : 1.60x

Next#

6. Building your own plots takes the same three tools and draws charts with them.