Testing, Debugging and Profiling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Fortran 2023 (ISO/IEC 1539-1:2023, J3/23-007r1) and was checked on 2026-10-18. Every Fortran example was compiled with gfortran -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -fcheck=all (GNU Fortran 16.2) without warnings and run, and, where the feature is portable, also built and run with LLVM Flang 23. The tools were run in a container (Debian, aarch64): fpm 0.13.0, test-drive 0.6.1, pFUnit 4.20.1, CMake 3.31.6 with CTest, gdb 16.3, Valgrind 3.24.0, GNU gprof and gcov from GCC 16.2, and perf 6.12 (only the software cpu-clock event was available inside the container). The output shown is real output. Two things were not run and are labelled as such wherever they appear: Intel ifx (-check all, -qopt-report, VTune) and the Vegetables test framework. The standard says nothing about any of the tools on this page, with one exception: it defines which conditions a program must not create (the error and exception rules in Error Handling and Program Termination), and a runtime check, a sanitizer or a debugger is how you find out that you created one. Everything else here is processor and tool dependent.

The examples use the heatflow project from the rest of the section (modules heatflow_kinds, heatflow_grid, heatflow_kernels, heatflow_solver, and so on). Their sources, with the shell commands, are reproduced in full so that a reader can follow along: a test suite in test/, small programs in example/, and the project built with fpm or CMake. The sibling pages cover the surrounding topics: Fortran Package Manager (fpm) and Building with Make, CMake and Meson for the build systems, Compilers and Compiler Options for the compiler flags, Common Gotchas and Pitfalls for the bugs that these tools catch, and Performance and Memory Layout for what to do with a profile.

Which tool for which symptom

Fortran gives no exception, no stack trace by default and no unit test framework in the language. The tools are a compiler flag, a library or an external program, and each one finds a different kind of fault. Choose by symptom.

flowchart TD A["The program is wrong or slow"] --> B{"What do you see?"} B -- "wrong numbers" --> C["Unit tests with test-drive or pFUnit, then gdb, then -fcheck=all"] B -- "crash or garbage" --> D["-fcheck=all, then AddressSanitizer or Valgrind"] B -- "NaN or Infinity appears" --> E["-ffpe-trap and signalling NaN, then gdb"] B -- "depends on uninitialised data" --> F["-finit-real=snan, Valgrind --track-origins"] B -- "too slow" --> G["gprof or perf, then -fopt-info and the performance page"] B -- "unsure what the tests cover" --> H["gcov coverage"]
Need gfortran 16.2 LLVM Flang 23 Intel ifx (not run)

Unit tests

test-drive, pFUnit, Vegetables (compiler independent)

test-drive passes through fpm test --compiler flang-23

the frameworks are compiler independent

Runtime checks

-fcheck=all

no -fcheck option (rejected by the driver)

-check all

Uninitialised values

-finit-real=snan with -ffpe-trap=invalid, -Wuninitialized

no -finit-* options; ieee_value works

an -init option (Intel’s -init reference page)

Sanitizers

-fsanitize=address,undefined,leak,thread

-fsanitize rejected

not covered here

Debugger

gdb, with -g

gdb, with -g

gdb

Call-count profiler

-pg with gprof

-pg rejected

see Intel’s documentation

Sampling profiler

perf, any build with -g

perf, any build with -g

VTune, perf

Coverage

--coverage with gcov

no --coverage; LLVM source-based coverage not tested

not covered here

Optimization reports

-fopt-info

-Rpass, -Rpass-missed, -Rpass-analysis

-qopt-report

The Flang column is the result of trying each option with flang-23 in the container: a rejected option prints error: unknown argument (for example flang-23: error: unknown argument: '-fcheck=bounds') and the compiler stops. The ifx column only names options from Intel’s documentation, which was not run here (see the references).

Unit testing

A unit test calls one procedure with known inputs and compares the result with an answer worked out by hand. The test passes or fails without anybody reading numbers, so it can run on every commit. For numerical code the comparison is never == on reals: use a tolerance, absolute for values near zero and relative for large values, and choose it from the algorithm’s expected accuracy rather than from machine epsilon.

Fortran has several community frameworks. None is part of the standard or of any compiler.

Framework Style Notes

test-drive

plain Fortran, no preprocessor; a test is a subroutine with an allocatable error_type argument

a small library maintained under the fortran-lang GitHub organisation (fpm new does not use it: its generated test/check.f90 is a placeholder that prints a message); builds with fpm, CMake and Meson (run here: 0.6.1)

pFUnit

tests are written in .pf files with @test and @assertEqual annotations that a Python preprocessor turns into Fortran

supports MPI and OpenMP tests; richer assertions and fixtures; needs CMake and the preprocessor (run here: 4.20.1)

Vegetables

Fortran sources processed by a generator; described in its repository; not checked here

community project hosted on GitLab; not built or run for this page

test-drive with fpm

fpm treats everything in test/ as tests. A test is an ordinary program, so a test-drive suite is a main program that collects test suites (one module per unit under test) and runs them. The dependency is declared once:

[dev-dependencies]
test-drive = { git = "https://github.com/fortran-lang/test-drive", tag = "v0.6.1" }

and the project layout is

heatflow/
  fpm.toml
  src/                    the library (heatflow_*.f90)
  app/main.f90
  test/
    check.f90             the test runner (program)
    test_grid.f90         suite 1: module test_grid, collect_grid
    test_kernels.f90      suite 2: module test_kernels, collect_kernels

A suite is a module with a collect subroutine that returns an array of unittest_type values made by new_unittest(name, procedure). Each test procedure has one dummy argument, type(error_type), allocatable, intent(out) :: error; the test passes when error is left unallocated and fails when a check allocates it. The checks are the generic check and the procedure test_failed; the check must be followed by if (allocated(error)) return because the first failed check ends the test. This is the second suite of heatflow:

module test_kernels
  use testdrive, only: new_unittest, unittest_type, error_type, check, skip_test, test_failed
  use heatflow_kinds, only: dp
  use heatflow_kernels, only: jacobi_sweep, ftcs_sweep
  implicit none
  private
  public :: collect_kernels

contains

  subroutine collect_kernels(testsuite)
    type(unittest_type), allocatable, intent(out) :: testsuite(:)
    testsuite = [ &
                new_unittest('jacobi-fixed-point', test_jacobi_fixed_point), &
                new_unittest('ftcs-linear-profile', test_ftcs_linear), &
                new_unittest('ftcs-bad-expectation', test_bad_expectation, should_fail=.true.), &
                new_unittest('gpu-only', test_skipped)]
  end subroutine collect_kernels

  !> A constant field is a fixed point of the Jacobi sweep: no change, zero residual.
  subroutine test_jacobi_fixed_point(error)
    type(error_type), allocatable, intent(out) :: error
    real(dp) :: u(6, 6), unew(6, 6), res
    u = 3.0_dp
    unew = u
    call jacobi_sweep(u, unew, res)
    call check(error, res, 0.0_dp, thr=1.0e-14_dp)
    if (allocated(error)) return
    call check(error, all(unew == 3.0_dp), message='interior changed')
  end subroutine test_jacobi_fixed_point

  !> A linear temperature profile has zero second derivative, so FTCS leaves it unchanged.
  subroutine test_ftcs_linear(error)
    type(error_type), allocatable, intent(out) :: error
    real(dp) :: u(7, 7), unew(7, 7), res
    integer :: i
    do i = 1, 7
      u(i, :) = real(i, dp)
    end do
    unew = u
    call ftcs_sweep(u, unew, 1.0_dp, 1.0e-3_dp, 0.1_dp, 0.1_dp, res)
    call check(error, res, 0.0_dp, thr=1.0e-10_dp)
  end subroutine test_ftcs_linear

  !> Declared should_fail: the test passes when this check fails.
  subroutine test_bad_expectation(error)
    type(error_type), allocatable, intent(out) :: error
    call check(error, 1 + 1, 3)
  end subroutine test_bad_expectation

  subroutine test_skipped(error)
    type(error_type), allocatable, intent(out) :: error
    call skip_test(error, 'needs a GPU')
  end subroutine test_skipped
end module test_kernels

new_unittest takes an optional should_fail=.true., used above for a test that passes only if its check fails (it is how a test of the test framework itself, or of an error path, is written), and skip_test marks a test as skipped with a message. The runner is a program that lists the suites, runs them all, and turns the number of failures into a nonzero exit status; the version below also reads the first two command-line arguments to select one suite and one test:

program check
  use, intrinsic :: iso_fortran_env, only: error_unit
  use testdrive, only: run_testsuite, run_selected, new_testsuite, testsuite_type, &
                       select_suite, get_argument
  use test_grid, only: collect_grid
  use test_kernels, only: collect_kernels
  implicit none
  integer :: stat, is
  character(len=:), allocatable :: suite_name, test_name
  type(testsuite_type), allocatable :: testsuites(:)
  character(len=*), parameter :: fmt = '("#", *(1x, a))'

  stat = 0
  testsuites = [new_testsuite('grid', collect_grid), new_testsuite('kernels', collect_kernels)]

  call get_argument(1, suite_name)
  call get_argument(2, test_name)

  if (allocated(suite_name)) then
    is = select_suite(testsuites, suite_name)
    if (is < 1 .or. is > size(testsuites)) then
      write (error_unit, fmt) 'Available testsuites'
      do is = 1, size(testsuites)
        write (error_unit, fmt) '-', testsuites(is)%name
      end do
      error stop 1
    end if
    write (error_unit, fmt) 'Testing:', testsuites(is)%name
    if (allocated(test_name)) then
      call run_selected(testsuites(is)%collect, test_name, error_unit, stat)
      if (stat < 0) error stop 1
    else
      call run_testsuite(testsuites(is)%collect, error_unit, stat)
    end if
  else
    do is = 1, size(testsuites)
      write (error_unit, fmt) 'Testing:', testsuites(is)%name
      call run_testsuite(testsuites(is)%collect, error_unit, stat)
    end do
  end if

  if (stat > 0) then
    write (error_unit, '(i0, 1x, a)') stat, 'test(s) failed!'
    error stop 1
  end if
end program check

fpm test builds and runs it. Everything after -- goes to the test program, and --target selects one test executable (globbing is supported):

fpm test
# Testing: grid
  Starting spacing ... (1/2)
       ... spacing [PASSED]
  Starting dirichlet ... (2/2)
       ... dirichlet [PASSED]
# Testing: kernels
  Starting jacobi-fixed-point ... (1/4)
       ... jacobi-fixed-point [PASSED]
  Starting ftcs-linear-profile ... (2/4)
       ... ftcs-linear-profile [PASSED]
  Starting ftcs-bad-expectation ... (3/4)
       ... ftcs-bad-expectation [EXPECTED FAIL]
  Message: Integer value mismatch
           expected 3 but got 2
  Starting gpu-only ... (4/4)
       ... gpu-only [SKIPPED]
  Message: needs a GPU
fpm test -- kernels                       # one suite
fpm test -- kernels jacobi-fixed-point    # one test of one suite
fpm test --target check -- grid           # explicit test executable, then arguments

With kernels jacobi-fixed-point only ... jacobi-fixed-point [PASSED] is printed after the # Testing: kernels line, and a suite name that does not exist prints the available suites and stops with ERROR STOP 1:

# Available testsuites
# - grid
# - kernels
ERROR STOP 1

When a check fails, the message names the two values, and the run ends with a nonzero status that fpm reports. Changing the expected cell spacing in test_grid.f90 from 0.1_dp to 0.2_dp gives:

# Testing: grid
  Starting spacing ... (1/2)
       ... spacing [FAILED]
  Message: Floating point value mismatch
           expected 0.20000000000000001 but got 0.10000000000000001 (difference: 0.10000000000000001)
  Starting dirichlet ... (2/2)
       ... dirichlet [PASSED]
# Testing: kernels
...
1 test(s) failed!
<ERROR> Execution for object " check " returned exit code  1
<ERROR> *cmd_run*:stopping due to failed executions
STOP 1

The fpm test exit status was 1. (The ERROR STOP 1 backtrace that gfortran prints before the suite output is omitted, and the middle of the listing is shortened where it says ....)

The checks that check offers (from the test-drive README and the check generic interface in the source) are these. Two overloads of real comparison exist, with thr= for an absolute tolerance and rel=.true. for a relative one.

Call Meaning

check(error, logical_expr)

true means pass

check(error, stat) with an integer status

zero means pass; use it for iostat, stat= and similar

check(error, a, b) for integers, logicals, character strings

equal means pass

check(error, a, b, thr=t) for real and complex kinds sp and dp

abs(a - b) <= t; the default threshold is machine epsilon; rel=.true. makes it relative

check(error, x) for a real or complex

fails with "Exceptional value 'not a number' found" when x is a NaN (every real comparison also starts with this NaN test on actual)

check(error, ..., message='text', more='detail')

message replaces the generated message and more adds a second line

test_failed(error, message)

allocates error directly, for hand-written checks

The runner above does not use the extra features, but the library also writes JUnit XML (junit_output) and coloured output (init_color_output), which its own tester uses and which continuous integration services read.

fpm builds every test with the flags of the selected profile, so a test run with fpm test --profile debug includes -fcheck=bounds (see the runtime checks section) and is the cheapest extra safety net; fpm test --flag "-fcheck=all -g -fbacktrace" adds all the checks to whatever fpm would use. With Flang the same suite passes unchanged (fpm test --compiler flang-23): test-drive is standard Fortran. The output was identical to the gfortran listing.

test-drive with CTest

Projects that build with CMake register each suite as a CTest test. test-drive publishes a CMake package, so FetchContent can download it. Two details matter: the exported target is called test-drive (in version 0.6.1 the ALIAS in its CMakeLists.txt is written test-drive:test-drive with a single colon, so linking to the namespaced name test-drive::test-drive fails with "target was not found"), and test-drive adds its own self-tests to your CTest list unless TESTDRIVE_BUILD_TESTING is switched off. This addition to heatflow’s CMakeLists.txt runs one CTest test per suite, using the command-line selection built into the runner:

include(CTest)
if(BUILD_TESTING)
  include(FetchContent)
  FetchContent_Declare(test-drive
    GIT_REPOSITORY https://github.com/fortran-lang/test-drive
    GIT_TAG v0.6.1)
  # keep test-drive's own self-tests out of our ctest run
  set(TESTDRIVE_BUILD_TESTING OFF CACHE BOOL "" FORCE)
  FetchContent_MakeAvailable(test-drive)

  add_executable(heatflow-tests test/check.f90 test/test_grid.f90 test/test_kernels.f90)
  target_link_libraries(heatflow-tests PRIVATE heatflow test-drive)

  add_test(NAME grid COMMAND heatflow-tests grid)
  add_test(NAME kernels COMMAND heatflow-tests kernels)
endif()
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
cd build && ctest --output-on-failure
Test project /tmp/cb
    Start 1: grid
1/2 Test #1: grid .............................   Passed    0.00 sec
    Start 2: kernels
2/2 Test #2: kernels ..........................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 2

Total Test time (real) =   0.00 sec

ctest -R kernels -V shows the suite output, ctest --rerun-failed --output-on-failure repeats only the failures, ctest -j4 runs tests in parallel, and ctest --output-junit results.xml writes a JUnit report (all four options exist in CMake 3.31; the JUnit file was written and read here). CTest decides pass or fail from the exit status, so the runner’s error stop 1 on failure is essential: a runner that only printed failures would be reported as passing.

pFUnit

pFUnit is built from source with CMake (no package manager is used here) and then found with find_package(PFUNIT). The release built for this page was the v4.20.1 tag; the build took one cmake --build and the install tree contained PFUNIT-4.20, GFTL-1.17, GFTL_SHARED-1.12 and FARGPARSE-1.12:

git clone --depth 1 https://github.com/Goddard-Fortran-Ecosystem/pFUnit.git
cmake -S pFUnit -B pfunit-build -G Ninja -DCMAKE_INSTALL_PREFIX="$HOME/pfunit" -DSKIP_MPI=YES -DSKIP_OPENMP=YES
cmake --build pfunit-build
cmake --install pfunit-build

A pFUnit test is a .pf file. Lines that begin with @ are directives for the preprocessor, which generates a driver program and a Fortran module; the rest is ordinary Fortran. The assertion @assertEqual(expected, actual, tolerance=t) works for scalars and arrays of the supported kinds, and @test(ifdef=NAME) compiles a test only when the macro is defined (used below to switch a deliberately failing test on):

module test_kernels
  use funit
  use heatflow_kinds, only: dp
  use heatflow_kernels, only: jacobi_sweep
  implicit none

contains

  @test
  subroutine jacobi_fixed_point()
    real(dp) :: u(6, 6), unew(6, 6), res
    u = 3.0_dp
    unew = u
    call jacobi_sweep(u, unew, res)
    @assertEqual(0.0_dp, res, tolerance=1.0e-14_dp)
    @assertEqual(3.0_dp, unew, tolerance=1.0e-14_dp)
  end subroutine jacobi_fixed_point

  @test(ifdef=HEATFLOW_FAIL_DEMO)
  subroutine deliberately_wrong()
    @assertEqual(3, 1 + 1)
  end subroutine deliberately_wrong

  @test
  subroutine jacobi_averages_neighbours()
    real(dp) :: u(3, 3), unew(3, 3), res
    u = 0.0_dp
    u(1, 2) = 4.0_dp
    unew = u
    call jacobi_sweep(u, unew, res)
    @assertEqual(1.0_dp, unew(2, 2), tolerance=1.0e-14_dp)
    @assertEqual(1.0_dp, res, tolerance=1.0e-14_dp)
  end subroutine jacobi_averages_neighbours

end module test_kernels
cmake_minimum_required(VERSION 3.20)
project(heatflow_pfunit LANGUAGES Fortran)

enable_testing()
find_package(PFUNIT REQUIRED)

file(GLOB HEATFLOW_SOURCES CONFIGURE_DEPENDS src/*.f90)
add_library(heatflow ${HEATFLOW_SOURCES})
set_target_properties(heatflow PROPERTIES Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/modules)
target_include_directories(heatflow PUBLIC ${CMAKE_BINARY_DIR}/modules)

add_pfunit_ctest(kernels_tests
  TEST_SOURCES test/test_kernels.pf
  LINK_LIBRARIES heatflow)
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$HOME/pfunit/PFUNIT-4.20"
cmake --build build
cd build && ctest --output-on-failure && ./kernels_tests
Test project /tmp/pb
    Start 1: kernels_tests
1/1 Test #1: kernels_tests ....................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.00 sec
..
Time:         0.000 seconds

 OK
 (2 tests)

Configuring with -DCMAKE_Fortran_FLAGS=-DHEATFLOW_FAIL_DEMO compiles the third test, and the driver then reports the assertion with its source line and a nonzero exit status (2):

..F.
Time:         0.000 seconds

Failure
 in:
test_kernels_suite.deliberately_wrong
  Location:
[test_kernels.pf:21]
AssertEqual failure:
      Expected: (3)
        Actual: (2)
    Difference: <-1>

 FAILURES!!!
Tests run: 3, Failures: 1, Errors: 0
, Disabled: 0
*** Encountered 1 or more failures/errors during testing ***
STOP 2

The generated driver accepts -h (usage), -d (verbose), -f (filter tests by pattern), -e (exclude), -o (output file), -x (XML output), -t (TAP output) and --shuffle/--seed (random order), as printed by ./kernels_tests -h. pFUnit also has MPI-parallel test cases and parameterized tests; those were not exercised here (the build used -DSKIP_MPI=YES -DSKIP_OPENMP=YES). The trade-off against test-drive is plain: pFUnit has much richer assertions and fixtures, but it adds a preprocessing step, a CMake-centred build and a longer installation, while test-drive is one dev-dependency line.

Vegetables and others

Vegetables is a third community framework. It was not built or run for this page, so nothing is claimed here about its syntax or commands; see its repository for the current instructions. Whichever framework you choose, the project structure is the same: the library in src/, the tests in test/ calling only the public module interface, and an exit status that CI can read.

What to test

For a numerical code the useful tests are those that need no reference output: invariants (a constant field is a fixed point of the Jacobi sweep; a linear profile has zero second difference, so the FTCS update leaves it alone: these are the two tests above), conservation and symmetry properties, convergence order (halve the grid spacing and check that the error falls by the expected factor), known analytic solutions (a single Fourier mode of the heat equation decays as an exponential), boundary and degenerate cases (the smallest grid the code accepts, nx = 3), and error paths (should_fail, or a status argument that must be nonzero; see Error Handling and Program Termination). Keep tests small and deterministic: seed random numbers, never depend on file paths or the clock, and make each test independent of the order of the others.

Let the compiler find it first

The cheapest debugging happens before the program runs. gfortran reports many faults at compile time when the information is available: with constant bounds, an index outside an array is a warning (Warning: Array reference at (1) out of bounds (6 > 5) in loop beginning at (2)), a loop variable passed to an intent(inout) argument inside its own loop is an error (Variable 'i' at (1) not definable inside loop beginning at (2) as INTENT(INOUT) argument to subroutine 'bump'), an array assignment between constant shapes that differ is an error (Different shape for array assignment at (1) on dimension 1 (3 and 4)), and a recursive call of a procedure that is not recursive is an error (Function 'countdown' at (1) cannot be called recursively, as it is not RECURSIVE). Those four messages were produced by an earlier, constant-size version of the program in the next section; the version printed below uses run-time sizes precisely so that the compiler cannot see the fault.

The warning options worth turning on in development, each accepted by gfortran 16.2:

Option What it adds

-Wall -Wextra

the baseline: unused variables, unused dummy arguments, possibly uninitialised variables (with optimization), truncated constants, and more

-std=f2023 -pedantic

rejects or warns about every extension; without it real*8 and similar spellings are accepted silently

-fimplicit-none

makes implicit none the default for every scope, which also catches misspelled names in old code

-Wuninitialized

warns about variables read before assignment; it needs an optimization level (-O1 or higher) to see through the control flow, and it is silenced for variables covered by any -finit-* option

-Wconversion

implicit conversions that can change a value (for example real(dp) assigned to real(sp))

-Wimplicit-interface

calls to external procedures without an interface block or module, where the compiler cannot check arguments

-Warray-temporaries

compile-time notice when an array temporary will be created for an argument

-Wrealloc-lhs, -Wrealloc-lhs-all

assignments that may reallocate the left-hand side

-Waliasing

actual arguments that alias (the standard forbids this when one is modified)

-fmax-errors=1

stop at the first error to avoid cascades

LLVM Flang 23 reports semantic and conformance errors it finds and accepts -Wall, -Wextra and -pedantic, but it does not accept -std=f2023 (error: Only -std=f2018 is allowed currently.; -std=f2018 is accepted) and its warning set is smaller than gfortran’s. A build that must be portable should therefore also be compiled with gfortran under the flags above in continuous integration. The full list of gfortran warnings is in its manual (Error and Warning Options).

Runtime checks

When the compiler cannot decide, it can insert tests that run with the program. They cost time (often a factor of two or more), so they belong in debug builds and in tests, not in production runs.

gfortran: -fcheck

-fcheck= takes a comma-separated list of keywords; a keyword prefixed with no- turns one off, so -fcheck=all,no-array-temps is everything except the temporary-array warning. The keywords, from the manual, with a description of what each tested here reports:

Keyword Checks

all

every check below

bounds

array subscripts against the declared bounds; for assumed-shape and deferred-shape arrays, against the actual bounds; array-shape conformance in assignments; substring ranges; the manual notes that some checks require the option when compiling the main program

array-temps

a run-time warning, once per location, when a temporary array had to be created for an actual argument (it points at copies that hurt performance)

bits

invalid arguments to bit intrinsics such as a shift larger than the bit size

do

modification of a do loop variable (k = k + 1 hidden behind a call)

mem

memory allocation; explicit allocate is always checked anyway

pointer

pointers and allocatables: a disassociated pointer passed where a value is required, and so on

recursion

a recursive call of a subroutine or function that is not marked recursive

-fcheck=none is not accepted (gfortran says Argument to '-fcheck' is not valid: none); omit the option to switch the checks off. -fbounds-check is a deprecated alias for -fcheck=bounds. The program below has one deliberate fault per keyword; the case number is the first command-line argument. The sizes are decided at run time so the compiler cannot reject the code up front.

! One failing case per -fcheck= option; the case number is the first command-line argument.
module checks_cases
  use, intrinsic :: iso_fortran_env, only: real64
  implicit none
  private
  public :: run_case
  integer, parameter :: dp = real64

  abstract interface
    subroutine walk_i(k)
      integer, intent(in) :: k
    end subroutine walk_i
  end interface

  procedure(walk_i), pointer :: hook => null()

contains

  subroutine run_case(n)
    integer, intent(in) :: n
    select case (n)
    case (1); call case_bounds()
    case (2); call case_shape_mismatch()
    case (3); call case_substring()
    case (4); call case_pointer()
    case (5); call case_do()
    case (6); call case_recursion()
    case (7); call case_array_temp()
    case (8); call case_bits()
    case default; print '(a)', 'no such case'
    end select
  end subroutine run_case

  subroutine case_bounds()
    real(dp), allocatable :: row(:)
    integer :: i
    allocate (row(5))
    do i = 1, 6
      row(i) = real(i, dp)
    end do
    print *, row(1)
  end subroutine case_bounds

  subroutine case_shape_mismatch()
    real(dp), allocatable :: a(:), b(:)
    allocate (a(3), b(4))
    b = 1.0_dp
    a(:) = b
    print *, a(1)
  end subroutine case_shape_mismatch

  subroutine case_substring()
    character(len=8) :: name
    integer :: k
    name = 'heatflow'
    k = 9
    print *, name(k:k)
  end subroutine case_substring

  subroutine case_pointer()
    real(dp), pointer :: p
    nullify (p)
    call show(p)
  end subroutine case_pointer

  subroutine show(x)
    real(dp), intent(in) :: x
    print *, x
  end subroutine show

  subroutine case_do()
    integer, target :: i
    integer, pointer :: alias
    alias => i
    do i = 1, 3
      call bump(alias)
    end do
  end subroutine case_do

  subroutine bump(k)
    integer, intent(inout) :: k
    k = k + 1
  end subroutine bump

  subroutine case_recursion()
    hook => walk
    call hook(2)
  end subroutine case_recursion

  subroutine walk(k)
    integer, intent(in) :: k
    if (k > 0) call hook(k - 1)
  end subroutine walk

  subroutine case_array_temp()
    real(dp) :: m(4, 4)
    m = 1.0_dp
    call total(m(2, :))
  end subroutine case_array_temp

  subroutine total(x)
    real(dp), intent(in) :: x(4)
    print *, sum(x)
  end subroutine total

  subroutine case_bits()
    integer :: i, s
    i = 1
    s = 40
    print *, shiftl(i, s)
  end subroutine case_bits
end module checks_cases

program checks_demo
  use checks_cases, only: run_case
  implicit none
  character(len=8) :: arg
  integer :: n
  call get_command_argument(1, arg)
  read (arg, *) n
  call run_case(n)
end program checks_demo

Compiled with gfortran -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -fcheck=all -g -fno-backtrace 02_checks_catalogue.f90 (no warnings), the eight cases print:

$ ./a.out 1
At line 39 of file 02_checks_catalogue.f90
Fortran runtime error: Index '6' of dimension 1 of array 'row' above upper bound of 5
$ ./a.out 2
At line 48 of file 02_checks_catalogue.f90
Fortran runtime error: Array bound mismatch for dimension 1 of array 'a' (3/4)
$ ./a.out 3
At line 57 of file 02_checks_catalogue.f90
Fortran runtime error: Substring out of bounds: upper bound (9) of 'name' exceeds string length (8)
$ ./a.out 4
At line 63 of file 02_checks_catalogue.f90
Fortran runtime error: Pointer actual argument 'p' is not associated
$ ./a.out 5
At line 75 of file 02_checks_catalogue.f90
Fortran runtime error: Loop variable has been modified
$ ./a.out 6
At line 90 of file 02_checks_catalogue.f90
Fortran runtime error: Recursive call to nonrecursive procedure 'walk'
$ ./a.out 7
At line 98 of file 02_checks_catalogue.f90
Fortran runtime warning: An array temporary was created for argument 'x' of procedure 'total'
   4.0000000000000000
$ ./a.out 8
At line 110 of file 02_checks_catalogue.f90
Fortran runtime error: SHIFT argument (40) out of range 0:32 in intrinsic SHIFTL

Every message names the source line, which is why -g is worth adding even though the line number appears without it. Errors end the program with exit status 2; the warning in case 7 lets it continue. Without -fno-backtrace an address backtrace (#0 0x... in ???, with source lines when -g is used) follows each error.

The same program built without any check (gfortran -O0 -g) behaves as follows, which is the reason the checks exist: cases 1, 2 and 7 print their result as if nothing were wrong (the out-of-range write in case 1 and the shorter-array assignment in case 2 silently succeed), case 3 prints a garbage character, case 4 dies with SIGSEGV, and cases 5, 6 and 8 run to the end (case 8 prints 0, because a shift by more than the bit size gives a processor-dependent value).

The same fault in heatflow shows the typical shape of a real bug. example/bounds_bug.f90 writes one cell too many into the first row of a grid:

! An off-by-one in a boundary loop: writes one element past the end of a row.
program bounds_bug
  use heatflow_kinds, only: dp
  use heatflow_grid, only: grid_t, make_grid
  implicit none
  type(grid_t) :: g
  integer :: i

  g = make_grid(5, 5, 1.0_dp, 1.0_dp)
  do i = 1, g%nx + 1
    g%u(i, 1) = 0.0_dp
  end do
  print '(a,f6.2)', 'corner = ', g%u(1, 1)
end program bounds_bug

Built with fpm’s debug profile, which includes -fcheck=bounds, the error names the component and the line:

fpm run --example bounds_bug --profile debug
At line 11 of file example/bounds_bug.f90
Fortran runtime error: Index '6' of dimension 1 of array 'g%u' above upper bound of 5

Error termination. Backtrace:
#0  0xffff9ddd196f in ???
#1  0xffff9ddd26b3 in ???
#2  0xffff9ddd2c13 in ???
#3  0x400d63 in bounds_bug
	at example/bounds_bug.f90:11
#4  0x400f7b in main
	at example/bounds_bug.f90:3
<ERROR> Execution for object " bounds_bug " returned exit code  2
<ERROR> *cmd_run*:stopping due to failed executions
STOP 2

A plain fpm run --example bounds_bug printed corner = 0.00: fpm 0.13.0 passed no flags at all in that run (its verbose output listed an empty COMPILER OPTIONS), so the error was not seen. With --profile debug fpm added -Wall -Wextra -Wimplicit-interface -Wno-external-argument-mismatch -fPIC -fmax-errors=1 -g -fcheck=bounds -fcheck=array-temps -fbacktrace -fcoarray=single, and --profile release added -O3 -Wimplicit-interface -fPIC -fmax-errors=1 -funroll-loops -fcoarray=single. fpm run --flag "-fcheck=all" replaces the choice with exactly the flags you list.

Other compilers

LLVM Flang 23 has no -fcheck option: flang-23 -fcheck=bounds stops with error: unknown argument: '-fcheck=bounds'. It does check some things on its own. The same eight cases built with flang-23 -g gave: case 2 aborts with fatal Fortran runtime error(...): Assign: mismatching element counts in array assignment (to 3, from 4) (exit status 134), case 4 is a segmentation fault (exit 139), and cases 1, 3, 5, 6, 7 and 8 run to the end without any message. So a Flang build is not a debugging substitute for a gfortran -fcheck=all build; test with both.

Intel ifx has the -check option with an all keyword (see the -check page of Intel’s reference); it was not run for this page, and its keyword set differs from gfortran’s. Check the vendor documentation for the release you use.

Debugging with gdb

gdb understands Fortran: it prints arrays with the Fortran subscript order, derived types with their components, shows allocatable arrays by their current bounds, and accepts Fortran expressions (array sections, size(u, 1), % for components). Build with -g and without optimization (-O0; at -O2 variables disappear into registers and lines reorder). The demo is a short program that builds a 5 by 4 grid, sets a hot spot and calls one Jacobi sweep:

program gdb_demo
  use heatflow_kinds, only: dp
  use heatflow_grid, only: grid_t, make_grid
  use heatflow_boundary, only: dirichlet
  use heatflow_kernels, only: jacobi_sweep
  implicit none
  type(grid_t) :: g
  real(dp), allocatable :: unew(:, :)
  real(dp) :: residual

  g = make_grid(5, 4, 1.0_dp, 1.0_dp)
  call g%apply_boundary(dirichlet, 0.0_dp)
  call g%set_hot_spot(100.0_dp)
  unew = g%u
  call jacobi_sweep(g%u, unew, residual)
  print '(a,f8.3)', 'residual = ', residual
end program gdb_demo

A batch script (gdb reads commands from a file) shows the usual session. set disable-randomization off is only needed inside a container that is not allowed to change the address-space layout; without it gdb prints a warning.

set pagination off
set disable-randomization off
break gdb_demo.f90:14
run
print g
ptype g
print g%u(3, 3)
print g%u(2:4, 2:3)
next
step
info args
print size(u, 1)
print u(2:4, 2:3)
next 3
info locals
watch residual
continue
continue
bt
delete
continue
fpm run --example gdb_demo --flag "-g -O0" --runner "gdb -q -batch -x gdb.cmds --args"
Breakpoint 1 at 0x400d50: file example/gdb_demo.f90, line 14.
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

Breakpoint 1, gdb_demo () at example/gdb_demo.f90:14
14	  unew = g%u
$1 = ( nx = 5, ny = 4, dx = 0.25, dy = 0.33333333333333331, u = ((0, 0, 0, 0, 0) (0, 0, 0, 0, 0) (0, 0, 100, 0, 0) (0, 0, 0, 0, 0)) )
type = Type grid_t
    integer(kind=4) :: nx
    integer(kind=4) :: ny
    real(kind=8) :: dx
    real(kind=8) :: dy
    real(kind=8), allocatable :: u(5,4)
End Type grid_t
$2 = 100
$3 = ((0, 0, 0) (0, 100, 0))
1	program gdb_demo
heatflow_kernels::jacobi_sweep (u=..., unew=..., residual=3.1151034035696666e-307) at ././src/heatflow_kernels.f90:11
11	  pure subroutine jacobi_sweep(u, unew, residual)
u = ((0, 0, 0, 0, 0) (0, 0, 0, 0, 0) (0, 0, 100, 0, 0) (0, 0, 0, 0, 0))
unew = ((0, 0, 0, 0, 0) (0, 0, 0, 0, 0) (0, 0, 100, 0, 0) (0, 0, 0, 0, 0))
residual = 3.1151034035696666e-307
$4 = 5
$5 = ((0, 0, 0) (0, 100, 0))
18	      do i = 2, size(u, 1) - 1
i = 65535
j = 2
Hardware watchpoint 2: residual

Hardware watchpoint 2: residual

Old value = 0
New value = 25
heatflow_kernels::jacobi_sweep (u=..., unew=..., residual=25) at ././src/heatflow_kernels.f90:18
18	      do i = 2, size(u, 1) - 1

Hardware watchpoint 2: residual

Old value = 25
New value = 100
heatflow_kernels::jacobi_sweep (u=..., unew=..., residual=100) at ././src/heatflow_kernels.f90:18
18	      do i = 2, size(u, 1) - 1
#0  heatflow_kernels::jacobi_sweep (u=..., unew=..., residual=100) at ././src/heatflow_kernels.f90:18
#1  0x00000000004010d4 in gdb_demo () at example/gdb_demo.f90:1
residual =  100.000

Things to read from this listing:

  • Array printing. u has 5 rows and 4 columns. gdb prints one parenthesised group per column, and inside a group the first subscript varies fastest, which is the column-major memory order. The hot spot 100 is in the third group (j = 3), third place (i = 3), and print g%u(3, 3) confirms it. print g%u(2:4, 2:3) prints a section with the same convention: two groups of three.

  • Derived types. print g shows every component, including the allocatable u, and ptype g shows the type with the current shape u(5,4) of the allocatable component.

  • Module procedures are named module::procedure (heatflow_kernels::jacobi_sweep), and break heatflow_kernels::jacobi_sweep works.

  • Assumed-shape dummies (u=...) are shown as ... in frame lines; info args or print u expands them.

  • The first stop is before the prologue. The pure subroutine line is reported with an unset residual (3.1151034035696666e-307 is leftover stack) and i = 65535 before the loop starts; real values appear after a few next commands. Treat values printed at the very first line of a procedure as undefined.

  • Watchpoints. watch residual stops each time the variable changes and prints the old and new value: here the residual grows from 0 to 25 and then to 100.

  • Quirks. The frame of the main program is reported at line 1 (program gdb_demo) in bt and when stepping, not at the line of the call: the line table gfortran emits for the main program is coarse. The watchpoints in this session were hardware watchpoints; where the machine or a virtual environment cannot provide them gdb falls back to much slower software watchpoints.

A program built with Flang (fpm run --example gdb_demo --compiler flang-23 --flag "-g -O0" --runner ...) can be debugged the same way. The only visible difference seen was the upper-case program name (GDB_DEMO () at example/gdb_demo.f90:14) and identical print g and print g%u(2:4, 2:3) output. The step that follows the assignment unew = g%u entered memcpy in Flang’s build instead of the Fortran call, which is a reminder that array assignments can hide library calls.

Other useful gdb commands for Fortran programs: info locals, info frame, up/down to move through the stack, tbreak for a one-time breakpoint, catch signal SIGFPE or simply run (a trapped floating-point exception stops at the faulting line, see the next section), print $_siginfo after a signal, call to invoke a procedure, and set var to change a value. Core dumps work the usual way (ulimit -c unlimited, then gdb ./a.out core). lldb was not tested here.

Uninitialised data and signalling NaNs

Reading a variable before it is assigned is undefined behaviour that usually does not crash: the garbage often looks like a plausible number, and the program gives the right answer on one machine and the wrong one on another. Fortran has no automatic initialisation of local variables, and allocate does not zero memory. Two techniques make the fault loud:

  • initialise every real to a signalling NaN and enable the invalid-operation trap, so the first use stops the program at the faulting line;

  • use a tool that tracks definedness (Valgrind with --track-origins=yes, next sections).

gfortran provides the first technique with -finit-real=snan -ffpe-trap=invalid. The family of options (from the manual) is -finit-local-zero, -finit-derived, -finit-integer=n, -finit-real=<zero|inf|-inf|nan|snan>, -finit-logical=<true|false> and -finit-character=n. They do not initialise objects with the pointer attribute, allocatable arrays or variables in an equivalence; and they silence -Wuninitialized for the variables they cover. The manual also warns that compile-time optimization may turn a signalling NaN into a quiet one, so use these options in -O0 builds.

For the objects the options do not cover, make the signalling NaN yourself with ieee_value from ieee_arithmetic (ieee_signaling_nan is a standard ieee_class_type value, see Floating-Point and IEEE Arithmetic). The test program uses both:

! A work array whose halo is never initialised, caught with signalling NaNs.
program snan_demo
  use, intrinsic :: iso_fortran_env, only: real64
  use, intrinsic :: ieee_arithmetic, only: ieee_value, ieee_signaling_nan
  implicit none
  integer, parameter :: dp = real64
  real(dp) :: local(4)
  real(dp), allocatable :: work(:)
  real(dp) :: poison, total
  integer :: i, mode
  character(len=8) :: arg

  call get_command_argument(1, arg)
  read (arg, *) mode

  select case (mode)
  case (1)
    ! uninitialised local: -finit-real=snan fills it
    local(1:3) = 1.0_dp
    total = sum(local)
    print '(a,f8.3)', 'total = ', total
  case (2)
    ! allocatable: -finit-real does NOT touch it, so poison it by hand
    allocate (work(0:5))
    poison = ieee_value(poison, ieee_signaling_nan)
    work = poison
    work(1:4) = 1.0_dp
    total = 0.0_dp
    do i = 0, 5
      total = total + work(i)
    end do
    print '(a,f8.3)', 'total = ', total
  end select
end program snan_demo

Built plainly, case 1 prints total = 3.000 (the fourth, unset element happened to be zero on this run) and case 2 prints total = NaN (the NaN poison propagates by itself without any trap). Built with -finit-real=snan -ffpe-trap=invalid, the first use of the unset value stops at the line that uses it:

case 1

Program received signal SIGFPE: Floating-point exception - erroneous arithmetic operation.

Backtrace for this error:
#0  0xffffbb2e196f in ???
#1  0xffffbb2e05df in ???
#2  0xffffbb4af7ff in ???
#3  0x400d98 in snan_demo
	at 04_snan.f90:20
Floating point exception
case 2

Program received signal SIGFPE: Floating-point exception - erroneous arithmetic operation.

Backtrace for this error:
#0  0xffffa8aa196f in ???
#1  0xffffa8aa05df in ???
#2  0xffffa8aa87ff in ???
#3  0x401058 in snan_demo
	at 04_snan.f90:30
Floating point exception

Line 20 is total = sum(local) and line 30 is total = total + work(i). Run the same binary under gdb and the faulting statement and every variable are in front of you (gdb -q -batch -ex run -ex bt -ex "info locals" --args ./a.out 2):

Program received signal SIGFPE, Arithmetic exception.
0x0000000000401058 in snan_demo () at 04_snan.f90:30
30	      total = total + work(i)
#0  0x0000000000401058 in snan_demo () at 04_snan.f90:30
arg = '2       '
i = 0
local = (nan(0x4000000000000), nan(0x4000000000000), nan(0x4000000000000), nan(0x4000000000000))
mode = 2
poison = nan(0x4000000000000)
total = 0
work = (nan(0x4000000000000), 1, 1, 1, 1, nan(0x4000000000000))
dp = 8

The array work shows exactly which elements are poisoned (the two halo cells at 0 and 5), and i = 0 shows the loop was at its first element. -finit-real=nan (quiet NaN) does not trap but makes the wrong answer obviously wrong (total = NaN). The other IEEE traps, -ffpe-trap=zero,overflow,invalid, and the -ffpe-summary option that prints which flags were raised at exit, are described in Error Handling and Program Termination. LLVM Flang 23 rejects -finit-real and -ffpe-trap; its build of the same program printed total = 3.000 for case 1 (again by luck) and NaN for case 2, because only the explicit ieee_value poisoning is portable. Intel’s ifx has an -init option for this purpose (not run here).

Sanitizers

-fcheck only knows about the arrays the compiler can see. Once an array is passed to an explicit-shape dummy, to C, or through a pointer, its extent is gone and -fcheck is silent. The sanitizers instrument every memory access of the compiled code and keep a shadow map of valid memory. gfortran supports the GCC sanitizers (-fsanitize=address, undefined, leak, thread, among others, from GCC’s instrumentation options); combine them with -g and -fno-omit-frame-pointer for readable stacks. This program holds the faults that -fcheck misses:

! Memory bugs that -fcheck=all does not see once the compiler has lost the array bounds.
module memory_bugs
  use, intrinsic :: iso_fortran_env, only: real64, int32
  implicit none
  private
  public :: run_case
  integer, parameter :: dp = real64

contains

  subroutine run_case(n)
    integer, intent(in) :: n
    select case (n)
    case (1); call heap_overflow()
    case (2); call use_after_free()
    case (3); call leak()
    case (4); call int_overflow()
    case (5); call uninitialized()
    case default; print '(a)', 'no such case'
    end select
  end subroutine run_case

  !> Writes past the end of a heap array through an explicit-shape dummy.
  subroutine heap_overflow()
    real(dp), allocatable :: u(:)
    allocate (u(10))
    u = 0.0_dp
    call fill(u, 11)
    print *, u(1)
  end subroutine heap_overflow

  subroutine fill(x, n)
    integer, intent(in) :: n
    real(dp), intent(inout) :: x(n)
    x(n) = 1.0_dp
  end subroutine fill

  !> Reads through a pointer after the target was deallocated.
  subroutine use_after_free()
    real(dp), pointer :: p(:), q(:)
    real(dp) :: v
    allocate (p(10))
    p = 2.0_dp
    q => p
    deallocate (p)
    v = q(3)
    print *, v
  end subroutine use_after_free

  !> The pointer goes out of scope without a deallocate.
  subroutine leak()
    real(dp), pointer :: work(:)
    allocate (work(1000))
    work = 1.0_dp
    print *, sum(work)
  end subroutine leak

  subroutine int_overflow()
    integer(int32) :: k
    integer :: i
    k = huge(k) - 2_int32
    do i = 1, 5
      k = k + 1_int32
    end do
    print *, k
  end subroutine int_overflow

  !> A local that is read before it is ever assigned.
  subroutine uninitialized()
    real(dp) :: total, x(4)
    integer :: i
    x = [1.0_dp, 2.0_dp, 3.0_dp, 4.0_dp]
    do i = 1, 4
      total = total + x(i)
    end do
    if (total > 5.0_dp) print *, 'big'
  end subroutine uninitialized
end module memory_bugs

program memory_bugs_demo
  use memory_bugs, only: run_case
  implicit none
  character(len=8) :: arg
  integer :: n
  call get_command_argument(1, arg)
  read (arg, *) n
  call run_case(n)
end program memory_bugs_demo

The five cases built plainly (gfortran -g -O0) all appear to work: case 1 prints 0.0000000000000000, case 2 2.0000000000000000, case 3 1000.0000000000000, case 4 -2147483646 and case 5 big. Built with gfortran -g -O0 -fsanitize=address,undefined -fno-omit-frame-pointer:

==29==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xfc6f9f7e0070 at pc 0x000000402e04 bp 0xffffea92e9a0 sp 0xffffea92e9b8
WRITE of size 8 at 0xfc6f9f7e0070 thread T0
    #0 0x000000402e00 in __memory_bugs_MOD_fill 03_memory_bugs.f90:35
    #1 0x000000403160 in __memory_bugs_MOD_heap_overflow 03_memory_bugs.f90:28
    #2 0x0000004035d8 in __memory_bugs_MOD_run_case 03_memory_bugs.f90:14
    #3 0x000000403b88 in memory_bugs_demo 03_memory_bugs.f90:85
    ...

0xfc6f9f7e0070 is located 0 bytes after 80-byte region [0xfc6f9f7e0020,0xfc6f9f7e0070)
allocated by thread T0 here:
    #0 0xffffacfd4428 in malloc (/usr/local/lib64/libasan.so.8+0xd4428)
    #1 0x000000403068 in __memory_bugs_MOD_heap_overflow 03_memory_bugs.f90:26
    ...
SUMMARY: AddressSanitizer: heap-buffer-overflow 03_memory_bugs.f90:35 in __memory_bugs_MOD_fill

(Case 1.) The report names the exact line of the write (35, in fill), the call chain, and where the 80-byte block was allocated (line 26). Case 2, the read of freed memory, is reported as heap-use-after-free with three stacks: the read (line 46), the deallocate that freed it (line 45) and the original allocate (line 42). Case 3, the forgotten deallocate, is found by the leak checker when the program ends (the output shortened):

=================================================================
==35==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 8000 byte(s) in 1 object(s) allocated from:
    #0 0xffff8c5c4428 in malloc (/usr/local/lib64/libasan.so.8+0xd4428)
    #1 0x000000401d74 in __memory_bugs_MOD_leak 03_memory_bugs.f90:51
    ...

and the exit status of the program changes to 1, which makes leaks fail a CI job. Case 4 is reported by the undefined behaviour sanitizer, and the run continues:

03_memory_bugs.f90:61:21: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'integer(kind=4)'
 -2147483646

Case 5 (an uninitialised local) is not found by AddressSanitizer: it checks addresses, not definedness. Use -finit-real=snan or Valgrind for that.

Notes:

  • Library calls are not instrumented. In an earlier version of the program the read in case 2 was written print *, q(3); AddressSanitizer did not report it, because the element is read inside the (uninstrumented) Fortran runtime library, not by compiled code. Copy the value to a local first (v = q(3)), as the listing does, when you test this kind of fault.

  • Leaks in the main program. Run through fpm (fpm test --flag "-g -O0 -fsanitize=address,undefined -fno-omit-frame-pointer" --link-flag "-fsanitize=address,undefined"), the test suite of this page passed all its tests and then LeakSanitizer ended the run with exit status 1 and SUMMARY: AddressSanitizer: 59 byte(s) leaked in 3 allocation(s), allocated at test/check.f90:14, the testsuites = [...] assignment in the runner. A variable declared in a main program has the save attribute (J3/23-007r1 8.5.16), so gfortran does not deallocate it at end program. Adding deallocate (testsuites) before the final check removed the report. Such leaks are harmless, but they make a leak-checked CI job fail; free the data or move it into a procedure.

  • Compile every file with the flag and link with it. A sanitizer-compiled object mixed with plain ones sees only part of the program. (The fpm command is above.)

  • Do not combine -fsanitize=address with -fsanitize=thread, and expect runs several times slower and memory use two to three times larger.

  • Flang. flang-23 -fsanitize=address stops with error: unknown argument: '-fsanitize=address'; so do undefined and thread. Flang-built binaries can still be run under Valgrind (below) and perf.

Valgrind

Valgrind runs an unmodified binary on a virtual CPU that checks every memory access and tracks which bits of every value are defined. It needs no special compile flag except -g for line numbers, is slower (typically 10 to 50 times), and finds what AddressSanitizer finds plus uninitialised uses. On the same program, built with plain gfortran -g -O0 and run as valgrind --leak-check=full --track-origins=yes -q ./a.out N:

==13== Invalid write of size 8
==13==    at 0x4011CC: __memory_bugs_MOD_fill (03_memory_bugs.f90:35)
==13==    by 0x401317: __memory_bugs_MOD_heap_overflow (03_memory_bugs.f90:28)
==13==    by 0x4013F3: __memory_bugs_MOD_run_case (03_memory_bugs.f90:14)
==13==    by 0x401517: MAIN__ (03_memory_bugs.f90:87)
==13==    by 0x401557: main (03_memory_bugs.f90:81)
==13==  Address 0x4d311a0 is 0 bytes after a block of size 80 alloc'd
==13==    at 0x488545C: malloc (vg_replace_malloc.c:446)
==13==    by 0x40127B: __memory_bugs_MOD_heap_overflow (03_memory_bugs.f90:26)

for case 1 (Invalid write of size 8). Case 2 gives Invalid read of size 8 with Address 0x4d31160 is 16 bytes inside a block of size 80 free'd with the stack of the free and of the malloc. Case 3 gives, after the program’s own output:

==15== 8,000 bytes in 1 blocks are definitely lost in loss record 1 of 1
==15==    at 0x488545C: malloc (vg_replace_malloc.c:446)
==15==    by 0x400E3F: __memory_bugs_MOD_leak (03_memory_bugs.f90:53)

and case 5, the read of the uninitialised accumulator, which neither -fcheck nor AddressSanitizer saw:

==17== Conditional jump or move depends on uninitialised value(s)
==17==    at 0x400CC8: __memory_bugs_MOD_uninitialized (03_memory_bugs.f90:76)
==17==    by 0x401413: __memory_bugs_MOD_run_case (03_memory_bugs.f90:18)
==17==    by 0x401517: MAIN__ (03_memory_bugs.f90:87)
==17==    by 0x401557: main (03_memory_bugs.f90:81)
==17==  Uninitialised value was created by a stack allocation
==17==    at 0x400C44: __memory_bugs_MOD_uninitialized (03_memory_bugs.f90:69)

The report points at the first use that changes control flow (the if), and --track-origins=yes says where the value came from (the stack frame of uninitialized, which is where total lives). Valgrind does not report an uninitialised value that is only copied or added, until it is tested, printed or used in a system call. Case 4 (integer overflow) produces no report, because the processor does not treat it as a fault. Use -q to drop the banner, --error-exitcode=1 to turn errors into a failing exit status in CI, and --suppressions=file for known library noise. Valgrind works on binaries from any compiler; on this aarch64 container it was run on gfortran-built executables only.

Profiling

A profile answers "where does the time go?" Measure the optimized program, not the debug build: bounds checks and -O0 change the balance. Use realistic sizes, keep -g for line information (it does not slow the code), and run long enough to collect samples. The demonstration program is a 500 by 500 FTCS run of 400 steps with a deliberately badly ordered diagnostic added; energy_by_rows sums the grid with the first subscript in the outer loop, so it strides through memory:

! A heatflow run with a deliberately slow diagnostic, used for profiling.
program profile_demo
  use heatflow_kinds, only: dp
  use heatflow_grid, only: grid_t, make_grid
  use heatflow_boundary, only: dirichlet
  use heatflow_solver, only: solver_t, make_solver
  implicit none
  type(grid_t) :: g
  class(solver_t), allocatable :: s
  real(dp) :: residual, total
  integer :: k

  g = make_grid(500, 500, 1.0_dp, 1.0_dp)
  call g%apply_boundary(dirichlet, 0.0_dp)
  call g%set_hot_spot(100.0_dp)
  s = make_solver('ftcs', g, dirichlet, 0.0_dp, 1.0_dp, 1.0e-6_dp)
  total = 0.0_dp
  do k = 1, 400
    call s%step(residual)
    total = total + energy_by_rows(s%grid%u)
  end do
  print '(a,es10.3,a,f12.3)', 'residual = ', residual, '  mean energy = ', total/400.0_dp

contains

  !> Sums u with the first index in the outer loop: strides through memory.
  function energy_by_rows(u) result(e)
    real(dp), intent(in) :: u(:, :)
    real(dp) :: e
    integer :: i, j
    e = 0.0_dp
    do i = 1, size(u, 1)
      do j = 1, size(u, 2)
        e = e + u(i, j)
      end do
    end do
  end function energy_by_rows
end program profile_demo

gprof

gprof is the classic instrumenting profiler: -pg makes the compiler insert a call counter at each procedure entry and a sampling timer, and the program writes gmon.out into the current directory when it ends. Both compile and link need the flag; -fno-inline keeps small procedures visible as separate entries.

fpm run --example profile_demo --flag "-O2 -g -pg -fno-inline" --link-flag "-pg"
gprof -b -p build/gfortran_*/example/profile_demo gmon.out | head
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 58.86      0.10     0.10      400     0.25     0.25  __heatflow_kernels_MOD_ftcs_sweep
 23.54      0.14     0.04      400     0.10     0.10  energy_by_rows.0
 17.66      0.17     0.03      400     0.08     0.33  __heatflow_solver_MOD_ftcs_step
  0.00      0.17     0.00      401     0.00     0.00  __heatflow_grid_MOD_grid_apply_boundary

(The glob in the second command assumes one fpm build directory; fpm names it with a hash.) The flat profile ranks procedures by self time: the kernel is the biggest, but the diagnostic takes a quarter of the run. -q prints the call graph, in which MAIN__ calls ftcs_step 400 times with children time 0.10 and energy_by_rows 400 times. Module procedures appear with their mangled names (__module_MOD_name). Limits: gprof’s sample interval is 10 ms, so short runs are noisy (this one lasted 0.17 s; read the percentages as rough); inlined procedures do not appear (hence -fno-inline, which changes the optimization); and multi-threaded programs are not profiled correctly. Flang rejects -pg.

perf

perf samples the program from outside, using the hardware performance counters or a software timer, with no instrumentation and no recompilation beyond -g. Frame pointers (-fno-omit-frame-pointer) make call graphs (perf record -g) reliable. On a normal Linux host the commands would be:

fpm run --example profile_demo --flag "-O2 -g -fno-omit-frame-pointer"
perf record -g build/gfortran_*/example/profile_demo
perf report --stdio --no-children

The container used for this page does not allow the default hardware cycles event (No permission to enable cycles:Pu event), so the run used the software clock and extra capabilities (docker run --cap-add SYS_ADMIN --cap-add PERFMON --security-opt seccomp=unconfined), with perf record -e cpu-clock -F 999 (without -g, because call-graph unwinding printed unwind: get_proc_name unsupported in this container). The report listed the same two procedures:

    46.01%  profile_demo  profile_demo      [.] __heatflow_kernels_MOD_ftcs_sweep
    39.88%  profile_demo  profile_demo      [.] energy_by_rows.0

perf stat summarises a run (166.17 msec cpu-clock, 0.970 CPUs utilized here); on hardware with counters, perf stat -e cycles,instructions,cache-misses shows instructions per cycle and cache behaviour, which is what tells the strided energy_by_rows from the contiguous kernel. perf was run on gfortran builds only; because it needs only symbols, it works on Flang and ifx binaries too. The sampled shares differ from gprof’s (46 and 40 against 59 and 24) because of the low sample count and because -fno-inline was used for gprof only: both numbers are samples, not measurements. Fixing energy_by_rows (loop over j outside, i inside, or sum(u)) is covered in Performance and Memory Layout.

Intel VTune

Intel VTune Profiler is Intel’s sampling profiler with hardware-event analyses (hotspots, memory access, threading). It was not installed or run for this page and no command is shown; see Intel’s documentation.

Coverage with gcov

gcov reports which lines a test run executed, which answers the question "what do the tests not test?". Compile and link with --coverage (which means -fprofile-arcs -ftest-coverage at compile time and -lgcov at link time), run the tests, then run gcov on the data files:

fpm test --flag "--coverage -O0" --link-flag "--coverage"
gcov build/gfortran_*/heatflow/src_heatflow_grid.f90.gcda
File 'src/heatflow_grid.f90'
Lines executed:43.59% of 39
Creating 'heatflow_grid.f90.gcov'

The .gcov file is the source annotated with execution counts, and ##### marks lines that never ran:

        2:   42:    select case (bc)
        -:   43:    case (dirichlet)
        6:   44:      self%u(1, :) = value
        6:   45:      self%u(self%nx, :) = value
        6:   46:      self%u(:, 1) = value
       6*:   47:      self%u(:, self%ny) = value
        -:   48:    case (neumann)
    #####:   49:      self%u(1, :) = self%u(2, :)
    #####:   50:      self%u(self%nx, :) = self%u(self%nx - 1, :)
    #####:   51:      self%u(:, 1) = self%u(:, 2)
    #####:   52:      self%u(:, self%ny) = self%u(:, self%ny - 1)
    #####:   54:      self%u(1, :) = self%u(self%nx - 1, :)

The Neumann and periodic branches of apply_boundary are never exercised by the suite, which is a concrete next test to write. The same run reported 100% of the 14 lines of heatflow_kernels.f90. The counts on the Dirichlet lines are per-line execution counts of the compiler’s generated loop code, not the number of calls (the function was called once); read the zero versus nonzero distinction, not the numbers. gcov -b adds branch probabilities. Other front ends exist for .gcda data (HTML reports from lcov or gcovr); they were not installed here. Flang lists -fprofile-generate and -fprofile-use for LLVM profile data, but the llvm-cov and llvm-profdata tools were not present in the container, so Flang coverage was not tested.

Compiler optimization reports

An optimization report asks the compiler what it did to a loop. For performance work this is often faster than profiling: it tells you directly that a loop was or was not vectorized, and why. gfortran uses -fopt-info, with a pass filter (vec, loop, inline, or all), a verbosity (optimized, missed, note, or all) and an optional output file (-fopt-info-vec=vec.txt). On the heatflow kernels at -O3:

gfortran -O3 -fopt-info-vec-optimized -c heatflow_kernels.f90
heatflow_kernels.f90:34:12: optimized: loop vectorized using 16 byte vectors and unroll factor 2
heatflow_kernels.f90:34:12: optimized: loop vectorized using 16 byte vectors and unroll factor 2
heatflow_kernels.f90:18:12: optimized: loop vectorized using 16 byte vectors and unroll factor 2

and with -fopt-info-vec-missed the loops that were not:

heatflow_kernels.f90:33:10: missed: couldn't vectorize loop
heatflow_kernels.f90:37:72: missed: not vectorized: complicated access pattern.
heatflow_kernels.f90:33:10: missed: couldn't vectorize loop
heatflow_kernels.f90:37:72: missed: not vectorized: not suitable for strided load _22 = *_21;
heatflow_kernels.f90:17:10: missed: couldn't vectorize loop
heatflow_kernels.f90:19:84: missed: not vectorized: complicated access pattern.

Lines 17 and 33 are the outer do j loops, 18 and 34 the inner do i loops. gfortran vectorized the inner loops of both sweeps (the report mentions lines 34 and 18, a loop appearing twice when the compiler made two versions of it) and declined the outer loops, which is the usual and desirable outcome: one level of vectorization, along the contiguous first subscript. The missed lines are the reasons for the outer loops. Adding the contiguous attribute to the two assumed-shape dummies did not change which loops were vectorized in this experiment. See Performance and Memory Layout for what to do when an inner loop is the one that is missed.

LLVM Flang uses the LLVM remark options -Rpass=<regex>, -Rpass-missed=<regex> and -Rpass-analysis=<regex> (the driver lists all three). The same kernel file compiled with flang-23 -O3 -Rpass-missed=loop-vectorize printed

heatflow_kernels.f90:18:7: remark: loop not vectorized [-Rpass-missed=loop-vectorize]
heatflow_kernels.f90:34:7: remark: loop not vectorized [-Rpass-missed=loop-vectorize]

(each remark appears twice; paths were shortened) and -Rpass-analysis=loop-vectorize gives the reason: loop not vectorized: value that could not be identified as reduction is used outside the loop. With -Rpass=loop-vectorize nothing was printed, because no loop was vectorized. The remarks refer to the inner loops (lines 18 and 34), which update residual with max and write unew, so on this kernel the two compilers differ at -O3: gfortran vectorized the inner loops and Flang 23 did not. That is worth knowing before comparing their speeds.

Intel ifx writes a report with -qopt-report (see the -qopt-report page of Intel’s reference); that option and its levels were not run here.

Where older books and tutorials are outdated

  • Test frameworks. Older texts recommend FUnit (the Ruby-based predecessor of pFUnit) or hand-written if (abs(x - y) > tol) stop checks. The maintained frameworks today are pFUnit 4 (rewritten, CMake, preprocessor) and the fortran-lang test-drive; a build system test (fpm test, CTest) replaces the shell-script runners.

  • Runtime checks. Old compilers called it -C or -check bounds; gfortran spells it -fcheck= with keyword lists, -fbounds-check is a deprecated alias, and the list has grown (do, mem, pointer, recursion, bits, array-temps).

  • Recursion. Older books say a procedure must be declared recursive to call itself. Since Fortran 2018 a procedure is recursive by default (the recursive keyword is advisory) and non_recursive states the opposite (J3/23-007r1 15.6.2.1 and Annex C.1). gfortran 16.2 still diagnoses recursion in a procedure that is not declared recursive, with an error at compile time or the -fcheck=recursion error at run time, so write recursive for portability across compilers.

  • Sanitizers. The AddressSanitizer and UndefinedBehaviorSanitizer family was added to GCC after most Fortran books were written, and works with gfortran without Fortran-specific support, but only reports what is visible to compiled code (not library-internal reads and not uninitialised values).

  • Profiling. gprof and -pg are all that older books mention; sampling profilers (perf, VTune) need no recompilation and work with optimized code, and optimization reports (-fopt-info, -Rpass, -qopt-report) are the modern first step for vectorization questions.

  • Debuggers. Debugger-specific sections in old books describe vendor debuggers; gdb prints Fortran arrays, sections and derived types directly.

See also Documentation and Editor Tooling for the language server and formatter, and Best Practices and Style for how tests and checks fit into project practice.