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
Section titled “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-profilersp_source="$(python -c 'from scope_profiler.native_trace import fortran_source_path; print(fortran_source_path())')"mkdir -p build tracesWhen 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_statusend program simulationCompile the profiler module first, then compile and link the application:
gfortran -std=f2008 -O2 -Jbuild -c "$sp_source" -o build/scope_profiler.ogfortran -std=f2008 -O2 -Ibuild simulation.f90 build/scope_profiler.o -o build/simulation./build/simulationpython -m scope_profiler import-native traces -o build/profiling_data.h5python -m scope_profiler inspect build/profiling_data.h5python -m scope_profiler plot default build/profiling_data.h5 -o build/figuresRun 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
Section titled “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=pythonThat 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
Section titled “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.sptend program simulationsp_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
Section titled “The API”| Call | Purpose |
| — | — |
| sp_init(prefix [, rank]) | Start profiling. rank (default 0) gives each MPI rank its own trace file. |
| sp_region(name [, file, line]) | Handle for a region name, with optional source location. |
| sp_begin(id) / sp_end(id) | Enter and leave a region. |
| sp_begin_name(name) / sp_end_name(name) | The same, looking the name up each time. |
| sp_num_calls(id) | How often a region was entered; readable after sp_finalize(). |
| sp_now_ns() | The raw clock, in nanoseconds. |
| sp_is_active() | Whether profiling is running. |
| sp_get_region_stats(id, stats) | Completed-call count and total/minimum/maximum duration in nanoseconds. |
| sp_flush() | Write completed calls without stopping or discarding measurements. |
| sp_reset() | Clear measurements while preserving handles and source metadata. |
| sp_finalize() | Write <prefix>_rank<NNNNN>.spt and stop. |
Session lifecycle
Section titled “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 |
| — | — |
| sp_init | A new session replaces the previous recording. Register handles again. Save the old session first if needed. |
| sp_flush | Handles, measurements, and open calls remain valid. Only completed calls appear in the snapshot. |
| sp_reset | Handles and source metadata survive; call counts and recorded timings are cleared. All calls must be closed. |
| sp_finalize | 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
Section titled “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 :: statuscharacter(len=512) :: message
call sp_init("profile", stat=status, errmsg=message)if (status /= SP_OK) then print *, trim(message)end ifChoose 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 |
| — | — |
| SP_OK | The operation succeeded; errmsg is empty. |
| SP_ERR_INACTIVE | Initialize before recording, flushing, or resetting. Statistics can still be read after finalization. |
| SP_ERR_NO_CLOCK | A supported monotonic clock could not be read. Check platform support; a failed timing call is not a valid measurement. |
| SP_ERR_NO_MEMORY | 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. |
| SP_ERR_IO | Check the output directory, permissions, quota, and free space. A failed snapshot may be incomplete; correct the cause and retry output. |
| SP_ERR_UNMATCHED_END | Check for an extra end call or a return path that did not enter the region. |
| SP_ERR_OPEN_SCOPES | Reset was refused, or finalization saved completed calls while dropping unfinished ones. Close regions before these operations. |
| SP_ERR_INVALID_ARGUMENT | Check the region handle, prefix length, and rank. |
| SP_ERR_DEPTH | 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
Section titled “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
Section titled “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
Section titled “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
Section titled “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.h5python -m scope_profiler plot default mpi-run/profiling_data.h5 -o mpi-run/figuresFrom Python
Section titled “From Python”from scope_profiler.native_trace import load_traces, convert_traces
results = load_traces("run_dir") # -> ProfilingResults, as usualresults.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
Section titled “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
Section titled “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 = 0contains 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 solveend module kernelsBuild 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 kernelsfrom 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
Section titled “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.h5or in Python:
from scope_profiler import merge_results, read_h5from scope_profiler.native_trace import load_traces
combined = merge_results(read_h5("python_only.h5"), load_traces("traces/"))Troubleshooting
Section titled “Troubleshooting”| Symptom | What to check |
| — | — |
| Compiler cannot find scope_profiler.mod | 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 sp_num_calls | 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 NestingError | 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 scope-profiler[fortran] so its tools are on PATH. |
| f2py tries to wrap profiler types or kinds | Restrict wrappers with only: to the kernel routines called by Python. |
The trace format
Section titled “The trace format”sp_finalize() writes <prefix>_rank<NNNNN>.spt, a small binary file:
char[8] "SCOPEPRF"int32 format version (2)int32 rankint64 number of regionsper 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, nanosecondsNative 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
Section titled “Limitations”- No line-level profiling. Regions are timed as a whole; see Line-by-line profiling for how to get line-level detail for native code.
- 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.