Profiling C and C++ code#
scope-profiler ships a C region API with the same model as the Python
one. A C 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 C run gets the same summaries, charts, exporters and
plot workflow as a Python one.
It is one C99 file plus a header: no dependencies beyond libc, no HDF5,
no MPI, nothing to link. The header is extern "C", so C++ codes can
include it directly.
The trace format is shared with the Fortran API, so a program built from both lands in a single profile.
Getting the source#
It ships inside the installed package:
cc -c $(python -c "import scope_profiler.native_trace as t; print(t.c_source_path())") \
-I$(python -c "import scope_profiler.native_trace as t; print(t.c_include_dir())")
or from a checkout, src/scope_profiler/c/. A Makefile and a runnable
example.c sit next to it.
Marking regions#
#include "scope_profiler.h"
int main(void)
{
int step, solve, assemble;
sp_init("profile", 0); /* prefix, MPI rank */
solve = sp_region("solve"); /* resolve names once... */
assemble = sp_region("assemble");
for (step = 0; step < nsteps; ++step) {
sp_begin(assemble); /* ...then use the handles */
assemble_matrix();
sp_end(assemble);
sp_begin(solve);
solve_system();
sp_end(solve);
}
sp_finalize(); /* writes profile_rank00000.spt */
return 0;
}
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. Returns non-zero if no monotonic clock is available. |
|
Handle for a region name, created on first use. |
|
Enter and leave a region. |
|
How often a region was entered; readable after |
|
The raw clock, in nanoseconds. |
|
Whether profiling is running. |
|
Write |
Calls made before sp_init() are harmless: sp_region() returns
SP_INVALID_REGION and sp_begin/sp_end ignore it. Instrumentation
can stay in a build that never profiles, with no #ifdef at the call
sites.
Under MPI#
Pass each rank’s id so the traces do not collide:
int rank;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
sp_init("profile", 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:
mpirun -n 128 ./simulation
scope-profiler import-native . -o profiling_data.h5
scope-profiler plot default profiling_data.h5 -o figures
Python calling C#
The case this is really for. Compile the kernels and the profiler into
one shared library and load it with ctypes — no binding generator, no
build backend:
cc -std=c99 -O2 -fPIC -shared kernels.c $(python -c \
"import scope_profiler.native_trace as t; print(t.c_source_path())") \
-I$(python -c "import scope_profiler.native_trace as t; print(t.c_include_dir())") \
-o libkernels.so
import ctypes
from scope_profiler import ProfileManager
kernels = ctypes.CDLL("./libkernels.so")
kernels.kernels_jacobi_solve.argtypes = [ctypes.c_int, ctypes.c_int]
kernels.kernels_jacobi_solve.restype = ctypes.c_double # see the trap below
ProfileManager.setup(file_path="profiling_data.h5")
kernels.kernels_start_profiling(b"trace", 0)
for step in range(nsteps):
with ProfileManager.profile_region("python:step"):
kernels.kernels_jacobi_solve(n, sweeps) # records its own regions
kernels.kernels_stop_profiling() # writes the trace
ProfileManager.finalize(native_traces=".") # ...and folds it in
Because both APIs read the same clock, the C regions nest inside the
Python region that called them and call_stack() sees a single tree:
python:setup 11.970 ms [python]
python:timestep 0.331 ms [python]
python:call_solver 0.320 ms [python]
c:stencil 0.023 ms [c]
c:residual 0.040 ms [c]
Two rules:
Call the C
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:/c:prefix is the simplest convention.
One ctypes trap#
ctypes assumes a function returns int unless told otherwise, so a
double return comes back as garbage without restype. The regions are
still recorded correctly, which makes it easy to miss. Declare
argtypes and restype for everything you call.
One timeline with Python#
The API reads the same OS clock CPython’s time.perf_counter_ns() uses
— CLOCK_MONOTONIC on Linux, CLOCK_UPTIME_RAW on macOS — resolved by
probing at run time.
The macOS case is worth knowing about if you ever vendor this file:
defining _POSIX_C_SOURCE there hides CLOCK_UPTIME_RAW, and the
fallback CLOCK_MONOTONIC is both microsecond-granular and on a
different epoch. The implementation therefore defines _DARWIN_C_SOURCE
on Apple platforms and _POSIX_C_SOURCE elsewhere — for opposite
reasons on each.
The trace format#
Identical to the Fortran API’s; see Profiling Fortran code for the layout. 16 bytes per recorded call, the same as the Python side.
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 native trace carries timings alone.
Recursion depth per region is capped at
SP_MAX_DEPTH(64); deeper nesting is reported on stderr and left untimed rather than corrupting the buffer.A region still open at
sp_finalize()is reported on stderr and dropped, rather than written with a missing end time.