Profiling Fortran code#
Install for this page:
pip install scope-profilersupplies the Fortran source and requires a Fortran 2008 compiler. For the Python/F2PY example, usepip install "scope-profiler[fortran]"to add Meson and Ninja.
scope-profiler ships a Fortran module with the same region model as the
Python API. A Fortran program marks regions, writes a small trace file
per rank, and scope-profiler import-native turns those traces into the
usual HDF5 output — so a Fortran run gets the same summaries, charts,
exporters and plot workflow as a Python one.
The module is deliberately undemanding: plain Fortran 2008 with
iso_c_binding, one file, no preprocessor flags, no HDF5, no MPI,
nothing to link beyond libc.
The trace format is shared with the C API, so a program built from both lands in a single profile.
Build and run a first profile#
The commands below use GNU Fortran on Linux or macOS. Install the Python package in an activated environment; the compiled Fortran executable itself needs no Python interpreter. Python is used to locate the module and to convert and inspect the recorded trace afterwards.
python -m pip install scope-profiler
sp_source="$(python -c 'from scope_profiler.native_trace import fortran_source_path; print(fortran_source_path())')"
mkdir -p build traces
When working from a checkout, use python -m pip install -e . instead
to ensure the compiler picks up the local API. You can also copy
src/scope_profiler/fortran/scope_profiler.f90 into your own project.
Save this complete program as simulation.f90. It excludes five warm-up
iterations, measures 100 production iterations, and saves a snapshot
every 25 iterations. Replace solve_system with your application’s
work.
program simulation
use scope_profiler
use, intrinsic :: iso_fortran_env, only: real64, error_unit
implicit none
integer :: step, solve, status
character(len=512) :: message
type(sp_region_stats) :: stats
real(real64) :: checksum, total_seconds, mean_seconds
call sp_init("traces/profile", stat=status, errmsg=message)
call check_status()
solve = sp_region("solve", file="simulation.f90", stat=status, errmsg=message)
call check_status()
checksum = 0.0_real64
do step = 1, 5
call sp_begin(solve)
call solve_system(checksum)
call sp_end(solve)
end do
call sp_reset(stat=status, errmsg=message) ! same handle, fresh measurements
call check_status()
do step = 1, 100
call sp_begin(solve)
call solve_system(checksum)
call sp_end(solve)
if (mod(step, 25) == 0) then
call sp_flush(stat=status, errmsg=message)
call check_status()
end if
end do
call sp_get_region_stats(solve, stats, stat=status, errmsg=message)
call check_status()
total_seconds = real(stats%total_ns, real64)*1.0e-9_real64
mean_seconds = 0.0_real64
if (stats%calls > 0) mean_seconds = total_seconds/real(stats%calls, real64)
print *, "completed calls:", stats%calls
print *, "total / mean seconds:", total_seconds, mean_seconds
print *, "checksum:", checksum
call sp_finalize(stat=status, errmsg=message)
call check_status()
contains
subroutine solve_system(value)
real(real64), intent(inout) :: value
integer :: i
do i = 1, 10000
value = value + sqrt(real(i, real64))
end do
end subroutine solve_system
subroutine check_status()
if (status == SP_OK) return
write (error_unit, '(a)') trim(message)
error stop 1
end subroutine check_status
end program simulation
Compile the profiler module first, then compile and link the application:
gfortran -std=f2008 -O2 -Jbuild -c "$sp_source" -o build/scope_profiler.o
gfortran -std=f2008 -O2 -Ibuild simulation.f90 build/scope_profiler.o -o build/simulation
./build/simulation
python -m scope_profiler import-native traces -o build/profiling_data.h5
python -m scope_profiler inspect build/profiling_data.h5
python -m scope_profiler plot default build/profiling_data.h5 -o build/figures
Run these commands from the directory containing simulation.f90. The
program writes traces/profile_rank00000.spt; its solve region should
have exactly 100 calls. The checksum includes warm-up work, but the
profiling measurements do not. Keep traces from different runs in
different directories so the importer does not find multiple files
claiming the same rank.
Integrate the module into an existing build#
Compile
scope_profiler.f90before every source that uses its module. With GNU Fortran,-Jbuildselects where to writescope_profiler.modand-Ibuildlets dependent sources find it.Link
scope_profiler.ointo the final executable as well as making the.modfile available during compilation. The.modfile alone does not contain the implementation.Use the same Fortran compiler and compatible options for the profiler and its callers. Rebuild the
.modfile when switching compilers. Other compilers may use different module-directory flags.Create the output directory before running.
sp_initstores the output prefix; it does not create directories or test whether the file is writable. Those failures are reported bysp_flushorsp_finalize.
The repository also contains ready-made examples:
# From the repository root, with the package installed in this environment:
make -C examples/fortran run-standalone FC=gfortran PYTHON=python
That target compiles the example, runs it, and converts the trace into
examples/fortran/build/profiling_data.h5. A smaller Makefile and
example.f90 are also shipped beside the installed profiler module.
Marking regions#
For an existing simulation, place calls around the operations you want
to measure. In this outline, assemble_matrix and solve_system are
your application routines:
program simulation
use scope_profiler
implicit none
integer :: step, solve, assemble
call sp_init("profile") ! output prefix
solve = sp_region("solve") ! resolve names once...
assemble = sp_region("assemble")
do step = 1, 1000
call sp_begin(assemble) ! ...then use the handles
call assemble_matrix()
call sp_end(assemble)
call sp_begin(solve)
call solve_system()
call sp_end(solve)
end do
call sp_finalize() ! writes profile_rank00000.spt
end program simulation
sp_begin_name("solve") / sp_end_name("solve") take the name
directly, which reads better in cold code; they look the name up on
every call, so prefer the handle form in hot loops.
Regions may nest, and a region may re-enter itself recursively — each entry reserves its own slot, exactly as in the Python API.
Nesting must be proper: a region entered inside another has to be left
before it. Post-processing reconstructs the call graph from the recorded
intervals, so a sp_begin/sp_end pair that crosses another region’s
boundary — rather than sitting inside or outside it — has no call stack
to recover, and reading the trace back raises NestingError. The
timings themselves are still recorded and readable; it is the nesting,
the flame chart and the exclusive times that cannot be derived.
The API#
Call |
Purpose |
|---|---|
|
Start profiling. |
|
Handle for a region name, with optional source location. |
|
Enter and leave a region. |
|
The same, looking the name up each time. |
|
How often a region was entered; readable after |
|
The raw clock, in nanoseconds. |
|
Whether profiling is running. |
|
Completed-call count and total/minimum/maximum duration in nanoseconds. |
|
Write completed calls without stopping or discarding measurements. |
|
Clear measurements while preserving handles and source metadata. |
|
Write |
Session lifecycle#
Use one initialization per measurement session and resolve handles afterwards. There is one global profiler per process; independently instrumented libraries share its region registry and output prefix.
Operation |
What happens to handles and measurements |
|---|---|
|
A new session replaces the previous recording. Register handles again. Save the old session first if needed. |
|
Handles, measurements, and open calls remain valid. Only completed calls appear in the snapshot. |
|
Handles and source metadata survive; call counts and recorded timings are cleared. All calls must be closed. |
|
Recording stops after successful output. Entry counts and completed-call statistics remain readable. |
Before initialization or after finalization, begin/end calls do not
record. Do not cache a handle returned before initialization:
sp_region returns 0 when inactive. Likewise, a handle from a previous
session must be resolved again after another sp_init. Use
sp_is_active() to check session state.
Error handling#
sp_init, sp_region, sp_begin, sp_end, their name-based variants,
sp_flush, sp_reset, sp_get_region_stats, and sp_finalize accept
optional stat= (default integer) and errmsg= (character) outputs.
Success sets stat to SP_OK and clears errmsg. Supplying either
output suppresses stderr diagnostics; without them, errors are reported
on stderr. Instrumentation calls on an inactive profiler remain quiet
no-ops, with SP_ERR_INACTIVE returned when requested. Repeated
finalization succeeds.
integer :: status
character(len=512) :: message
call sp_init("profile", stat=status, errmsg=message)
if (status /= SP_OK) then
print *, trim(message)
end if
Choose a sufficiently long message buffer, such as character(len=512);
Fortran truncates the diagnostic to fit a shorter buffer. Read the
outputs after each call, because the next call overwrites them.
Status |
Meaning and next step |
|---|---|
|
The operation succeeded; |
|
Initialize before recording, flushing, or resetting. Statistics can still be read after finalization. |
|
A supported monotonic clock could not be read. Check platform support; a failed timing call is not a valid measurement. |
|
Allocation failed. Existing recordings survive failed growth/flush; reduce recording volume or free memory before retrying. A failed initialization may have replaced the previous session. |
|
Check the output directory, permissions, quota, and free space. A failed snapshot may be incomplete; correct the cause and retry output. |
|
Check for an extra end call or a return path that did not enter the region. |
|
Reset was refused, or finalization saved completed calls while dropping unfinished ones. Close regions before these operations. |
|
Check the region handle, prefix length, and rank. |
|
The per-region recursion limit was exceeded, or an enclosing entry failed. Its matching end must still be called. |
A failed sp_region returns handle 0. Prefixes must contain 1–512
non-trailing-blank characters and ranks must be nonnegative.
sp_finalize reports SP_ERR_OPEN_SCOPES when it saves completed calls
but drops unfinished calls, including retaining completed recursive
children. On allocation or output failure, flush/finalize retain the
measurements and leave the session active so the application can correct
the cause and retry. Output errors take precedence over unfinished-call
diagnostics.
The quick-start program treats profiling failures as fatal for clarity.
In a long-running simulation you may instead log the failure and
continue the calculation. On output failure, retry sp_flush or
sp_finalize after fixing the cause; calling sp_init would replace
the measurements you want to save.
Keep begin/end calls paired even when an entry fails for lack of memory, a clock error, or the recursion limit. A valid region’s rejected entry tracks its matching end so that it cannot accidentally close an outer invocation. If a region was never registered successfully (handle 0), skip its instrumentation or register it again before beginning work.
Snapshots, measurement windows, and statistics#
sp_flush() writes all completed calls so far to the usual trace path
and continues recording. Open calls are omitted until they finish. Each
flush replaces the previous snapshot; it does not append, clear buffers,
or provide an atomic checkpoint. A failed write may leave an incomplete
file. Flush uses temporary storage of 16 bytes per completed call in the
largest region, in addition to the recording buffers.
sp_reset() clears measurements and entry counts while preserving
handles, source locations, and allocated recording capacity. It refuses
with SP_ERR_OPEN_SCOPES if any region is open, leaving all
measurements intact. Use it after warm-up or between measurement
windows. Reset does not remove an existing snapshot; the next
flush/finalize replaces it.
The complete program at the start of this guide demonstrates this sequence: reset after warm-up, query statistics, flush periodically, and finalize.
sp_region_stats has four integer(int64) fields: calls, total_ns,
min_ns, and max_ns. These describe completed calls only, use
inclusive durations in nanoseconds, and are all zero when no calls have
completed. Statistics are computed on request by scanning the recorded
calls, adding no running statistics updates to sp_begin/sp_end. They
remain readable after finalization. In contrast, sp_num_calls counts
successful entries, including calls that are still open. Recording
buffers are retained after finalization and released when initialization
replaces the session’s storage.
For seconds, convert with real(stats%total_ns, real64)*1.0e-9_real64
after importing real64 from iso_fortran_env. Compute the mean only
when stats%calls > 0, as in the complete example above. Inclusive time
contains work in nested regions, so adding an outer region’s total to
its children’s totals double-counts that work. Use imported results for
call-tree and exclusive-time analysis.
Choose snapshot and reset boundaries#
For a long run, flush at completed timestep or checkpoint boundaries. Calling flush while an outer region is open is allowed, but the snapshot omits that outer call and may show completed children as roots until it closes. Wait until a flush returns successfully before importing its file, and avoid reading the path while another flush is replacing it.
Flush retains all measurements, so repeated snapshots are cumulative and do not bound memory use. Recording uses two growing timestamp arrays per region (16 bytes per allocated call slot); growth can reserve unused capacity. Reset lets later calls reuse that capacity but does not shrink it.
For separate measurement windows:
Close all regions and call
sp_flush, checking its status.Copy or convert the snapshot to a separate archive location if you need to retain that window. Do this before the next flush replaces it.
Call
sp_reset, checking that it succeeded, then continue using the same handles.
Keep archived windows outside the directory passed to import-native:
files from separate windows can claim the same rank, and the importer
rejects that ambiguity. For statistics-only progress reports, call
sp_get_region_stats at reporting boundaries rather than every
iteration; each query scans the recorded calls.
Source locations#
sp_region(name, file="solver.f90", line=42) stores optional source
metadata in the trace and imported HDF5 output. An existing region
registered without a source file can acquire one later. The first
nonempty source file wins; later registrations do not replace its file
or line. A missing or negative line means unknown; a line without a file
is ignored. No preprocessing is required, and cached-handle begin/end
calls do not process source metadata.
The file and line are supplied by you; registration does not inspect the calling source automatically. For example, place this beside the code whose location you want the profile to identify, and update the line when that code moves:
solve = sp_region("solve", file="src/solver.f90", line=42)
Source locations do not distinguish regions with the same name. Use
distinct names such as fluid:solve and particles:solve when they
should have separate measurements. A relative source path is usually
sufficient for identifying code without embedding a machine-specific
absolute path.
Under MPI#
Pass each rank’s id so the traces do not collide:
call MPI_Comm_rank(MPI_COMM_WORLD, my_rank, ierr)
call sp_init("profile", rank=my_rank)
Every rank writes its own file; nothing is communicated, so
sp_finalize() is not collective and a rank that dies takes only its
own trace with it. The importer merges whatever it finds. Create the
shared output directory before launching, and initialize the profiler
after MPI_Init/MPI_Comm_rank. Finalize the profiler on each rank
before MPI_Finalize. The profiler itself makes no MPI calls, and
flushing one rank does not wait for others.
Use your MPI compiler wrapper to build the application and the profiler
with the same underlying compiler. After compiling your MPI-aware
simulation, run it from a fresh trace directory:
mkdir -p mpi-run
(cd mpi-run && mpirun -n 128 ../simulation)
python -m scope_profiler import-native mpi-run -o mpi-run/profiling_data.h5
python -m scope_profiler plot default mpi-run/profiling_data.h5 -o mpi-run/figures
From Python#
from scope_profiler.native_trace import load_traces, convert_traces
results = load_traces("run_dir") # -> ProfilingResults, as usual
results.print_summary()
results.to_dataframe()
convert_traces("run_dir", "profiling_data.h5", label="128 ranks")
load_traces returns the very same ProfilingResults a Python run
produces, so every method, plot_* function and exporter works on it
unchanged.
One timeline with Python#
The module reads the same OS clock CPython’s time.perf_counter_ns()
uses — CLOCK_MONOTONIC on Linux, CLOCK_UPTIME_RAW on macOS — and
picks it by probing at run time rather than by compile-time platform
macros, which gfortran does not reliably define.
That means Fortran and Python regions recorded in the same process tree share an epoch: a Python driver’s timestamps and its Fortran kernels’ timestamps are directly comparable, and can be read on one timeline.
Python calling Fortran#
The case this is really for: a Python driver over Fortran kernels, where
both sides mark regions and you want one profile. Because the two
APIs read the same clock, their timestamps are directly comparable — the
Fortran regions nest inside the Python region that called them, and
call_stack() sees a single tree spanning both languages.
Compile scope_profiler.f90 alongside the kernels using f2py, ctypes,
Cython, or your existing extension build. With f2py, restrict Python
wrappers using only: to your kernel entry points. The profiler’s
derived types and named integer kinds are intended for Fortran callers,
not direct f2py wrapping. Expose wrappers for initialization and
finalization alongside the computational routines.
For example, this complete kernel module can be saved as kernels.f90:
module kernels
use scope_profiler
implicit none
integer :: solve_id = 0
contains
subroutine start_profiling(prefix, rank)
character(len=*), intent(in) :: prefix
integer, intent(in) :: rank
call sp_init(prefix, rank=rank)
solve_id = sp_region("fortran:solve", file="kernels.f90")
end subroutine start_profiling
subroutine stop_profiling()
call sp_finalize()
end subroutine stop_profiling
subroutine solve(n, value)
integer, intent(in) :: n
double precision, intent(out) :: value
integer :: i
call sp_begin(solve_id)
value = 0.0d0
do i = 1, n
value = value + sqrt(dble(i))
end do
call sp_end(solve_id)
end subroutine solve
end module kernels
Build it from the directory containing kernels.f90:
python -m pip install "scope-profiler[fortran]"
sp_source="$(python -c 'from scope_profiler.native_trace import fortran_source_path; print(fortran_source_path())')"
python -m numpy.f2py -c "$sp_source" kernels.f90 -m kernels --backend meson \
only: start_profiling stop_profiling solve :
Keep meson and ninja on PATH by activating the environment where
the Fortran extra is installed. Then save this as run_mixed.py beside
the built extension and run it with python run_mixed.py. finalize()
folds the Fortran trace into the Python result:
from pathlib import Path
import kernels
from scope_profiler import ProfileManager
Path("mixed-traces").mkdir(exist_ok=True)
ProfileManager.setup(file_path="mixed_profile.h5")
kernels.kernels.start_profiling("mixed-traces/trace", 0) # serial rank
for step in range(3):
with ProfileManager.region("python:step"):
value = kernels.kernels.solve(10000)
kernels.kernels.stop_profiling()
ProfileManager.finalize(native_traces="mixed-traces")
The resulting mixed_profile.h5 contains three python:step calls and
three fortran:solve calls nested inside them. Inspect it with
python -m scope_profiler inspect mixed_profile.h5. The repository’s
examples/fortran directory has a larger example; run it with
make -C examples/fortran run-mixed PYTHON=python from the repository
root.
Two rules:
Call the Fortran
sp_finalize()first. Its trace has to exist by the timefinalize()reads it.Give the two sides distinct region names. A name recorded by both raises, rather than silently double-counting a wrapper and the region inside it. A
python:/fortran:prefix is the simplest convention.
Under MPI each rank folds in the trace matching its own rank, so pass
the rank to sp_init and nothing else changes — the usual single output
file comes out the other end.
Combining afterwards#
If the two halves were profiled separately, merge them after the fact:
scope-profiler import-native traces/ --merge python_only.h5 -o combined.h5
or in Python:
from scope_profiler import merge_results, read_h5
from scope_profiler.native_trace import load_traces
combined = merge_results(read_h5("python_only.h5"), load_traces("traces/"))
Troubleshooting#
Symptom |
What to check |
|---|---|
Compiler cannot find |
Compile the profiler first; point the caller’s module search path at its output directory. |
Linker reports undefined profiler symbols |
Link the profiler object, built with the same compiler as the caller. |
No trace exists after initialization |
Output is written by flush/finalize, not init. Check their status and the process’s working directory. |
Trace file exists but is truncated |
Output may have failed. Check status, fix the cause, and retry from the still-active session. |
Snapshot has fewer calls than |
Entry counts include open calls; snapshots and statistics include completed calls only. |
Reset reports open regions |
Match every begin with an end, including on early returns, before resetting. |
Imported call graph raises |
Check properly nested begin/end pairs and avoid simultaneous instrumentation from multiple OpenMP threads. |
Import reports duplicate ranks |
Use one trace per rank per run directory. Keep archived snapshots and previous runs elsewhere. |
f2py build cannot find Meson/Ninja |
Activate the environment containing |
f2py tries to wrap profiler types or kinds |
Restrict wrappers with |
The trace format#
sp_finalize() writes <prefix>_rank<NNNNN>.spt, a small binary file:
char[8] "SCOPEPRF"
int32 format version (2)
int32 rank
int64 number of regions
per region:
int32 length of the name in bytes
char[] name
int32 length of source file in bytes (0 if unknown)
char[] source file
int32 source line (-1 if unknown)
int64 number of calls
int64[] start timestamps, nanoseconds
int64[] end timestamps, nanoseconds
Native endianness; the reader detects and handles both. 16 bytes per recorded call, the same as the Python side.
The reader also accepts legacy version-1 traces without source locations.
Limitations#
Not thread safe. A region must be entered and left by the same thread. OpenMP threads inside a region are fine — wrap the whole parallel construct in one
sp_begin/sp_endfrom the master thread.No LIKWID counters. Hardware counters are collected only by the Python API; a Fortran trace carries timings alone.
Names are truncated at
SP_MAX_NAME(128) characters, including for lookup. Names sharing those first 128 characters refer to the same region.Recursion depth per region is capped at
SP_MAX_DEPTH(64); deeper entries returnSP_ERR_DEPTHand are not timed. Their matching ends are consumed without closing an outer call.A region still open at
sp_finalize()is omitted from the trace and reported withSP_ERR_OPEN_SCOPES. Completed recursive children are kept.