How a computer executes a program and stores numbers, how Linux and the shell work, and how compiled code is written for numerical work.
0 of 7 read
Computer architecture
The fetch–decode–execute cycle, the memory hierarchy, integers and IEEE 754 floating point.
1Inside a computer
What a processor actually does, one clock tick at a time: the stored-program idea, fetch–decode–execute, registers and memory, why caches exist, how fast a modern CPU or GPU can compute, and what limits real scientific codes.
about 14 min read
A machine that follows instructions
Every simulation, every neural network and every plot you will make runs on the same kind of machine, and it is simpler than it looks. A computer does exactly one thing, billions of times a second: it reads an instruction from memory, carries it out, and moves on to the next one. Everything else (operating systems, Python, GROMACS, PyTorch) is built from that.
The design almost every computer still follows was written down by John von Neumann in 1945, in a report on the EDVAC machine. Its central idea is the stored program: instructions are just numbers, kept in the same memory as the data they work on. A computer that can hold its own program can load a different program in seconds, which is what made general-purpose computing possible. The design has four parts:
Memory holds numbers in numbered cells called addresses: both the program and its data.
The processor (CPU) contains a control unit that steps through the program, an arithmetic logic unit (ALU) that adds, subtracts and compares, and a handful of very fast storage cells called registers.
Buses carry addresses and numbers between them.
Input and output connect it to disks, networks, screens and keyboards.
Watch one run
The machine below is a working computer with sixteen memory cells and six instructions. Its program, already in memory, adds up the numbers 1+2+⋯+n. Step through it one phase at a time and watch the three registers and the bus.
A toy stored-program computer
Loading the interactive figure…
Violet cells hold instructions, orange cells hold data. The orange dashed line is the bus carrying a number. Each instruction takes three phases; count how many clock cycles the whole sum needs, then change n.
Every instruction goes through the same three-phase cycle.
Fetch. The program counter (PC) holds the address of the next instruction. The control unit reads that memory cell into the instruction register and adds one to the PC.
Decode. The control unit works out what the instruction asks for and which address it refers to.
Execute. The ALU does the arithmetic, or a number moves between memory and the accumulator register, or (for a jump) the PC is overwritten so that the next fetch comes from somewhere else.
That last trick, the conditional jump, is what makes loops and if statements possible. In the program above, JNZ 0 sends the machine back to address 0 as long as the counter is not zero. Every for loop in Python or do loop in Fortran becomes exactly this pattern after compilation.
The toy machine needs 3 cycles per instruction. A modern core finishes several instructions per cycle, by overlapping their phases like an assembly line (pipelining), running several at once (superscalar execution) and reordering them to avoid waiting (out-of-order execution). Its instructions are also wider: one SIMD (vector) instruction can multiply eight double-precision numbers at once.
From Python to machine code
Nobody writes these instructions by hand. A compiler (gfortran, gcc, nvcc) translates a whole program written in a high-level language into machine code before it runs; the statement sum = sum + n becomes something very like LOAD, ADD, STORE. An interpreter such as Python instead reads your program and acts on it statement by statement while it runs, which is flexible but far slower for arithmetic loops. That is why numerical Python hands its heavy work to compiled libraries such as NumPy, which you will see in the Python chapter.
How fast can a computer calculate?
Scientific computing counts floating-point operations per second (FLOP/s): additions and multiplications of real numbers. The peak rate of a processor is a simple product:
A 64-core server processor at 2.5 GHz doing 16 double-precision operations per cycle peaks at 64×2.5×109×16≈2.6×1012 FLOP/s, 2.6 teraflops. A data-centre GPU, with thousands of simpler cores, reaches tens of teraflops in double precision and hundreds in the lower precisions used for machine learning. That is why both molecular dynamics and neural-network training moved to GPUs.
The memory wall
Here is the catch. Arithmetic is cheap; getting the numbers to the processor is not. Over the last forty years processors became far faster than memory, so a modern core can wait hundreds of cycles for a single number from main memory. Hardware hides this with a hierarchy of ever larger, ever slower memories:
How long it takes to fetch one piece of data
Loading the interactive figure…
Typical access times on a current machine (orders of magnitude, after the widely quoted figures popularised by Jeff Dean and Peter Norvig). Switch to the human scale: if one clock cycle lasted one second, a trip to main memory would take minutes and a hard-disk seek would take months.
Caches work because programs are predictable. They tend to reuse what they used recently (temporal locality) and to use neighbouring addresses next (spatial locality). Memory therefore moves in cache lines of 64 bytes, eight doubles at a time. A loop that walks through an array in storage order gets seven of every eight numbers for free; a loop that jumps around wastes most of each line. The Fortran chapter measures this: the same arithmetic runs 4.5 times slower with the wrong loop order.
Whether a calculation is limited by arithmetic or by memory depends on its arithmetic intensity, the number of operations it does per byte it moves. The roofline model puts it in one line:
Adding two long vectors (ci=ai+bi) does 1 operation per 24 bytes moved, so it runs at a few percent of peak whatever the processor. Multiplying two large matrices reuses each number many times, has high intensity, and runs near peak. This one idea explains a great deal about scientific software: why libraries such as BLAS reorganise loops into blocks that fit in cache, why neighbour lists in molecular dynamics are sorted by space, and why neural networks are built from large matrix multiplications.
Many processors at once
Clock frequencies stopped rising around 2005 because of heat, so performance now comes from parallelism at every level:
Level
Example
How you use it
Vector (SIMD)
one instruction on 8 doubles
the compiler, with -O2 -march=native
Cores in a chip
8–128 cores sharing memory
threads: OpenMP, NumPy’s BLAS
GPU
thousands of cores, own memory
CUDA, PyTorch, GPU builds of GROMACS and LAMMPS
Nodes in a cluster
hundreds of machines on a network
processes: MPI, job schedulers
Each level has a cost. Threads must not write to the same data at once; MPI processes must send messages over a network that is thousands of times slower than cache. Good parallel codes divide the work so that each processor mostly uses data it already has, a theme that returns in the chapters on clusters and molecular dynamics.
Check yourself
In the toy computer, what makes the loop repeat?
A jump changes the program counter, so the next fetch comes from address 0 again. Conditional jumps are how every loop and if-statement is implemented.
A 32-core chip runs at 3 GHz and each core can do 16 double-precision FLOP per cycle. What is its peak?
32 × 3×10⁹ × 16 = 1.536×10¹² FLOP/s.
Adding two large arrays element by element runs at a few percent of the CPU’s peak. Why?
Its arithmetic intensity is about 1/24 FLOP per byte. The roofline model says the attainable rate is intensity times bandwidth, far below peak.
Why does the cache load 64 bytes when a program asks for one 8-byte number?
Spatial locality makes it likely that the next accesses fall in the same line, which then costs almost nothing.
References and further reading
J. von Neumann, First Draft of a Report on the EDVAC, Moore School of Electrical Engineering, University of Pennsylvania (1945).
D. A. Patterson and J. L. Hennessy, Computer Organization and Design: The Hardware/Software Interface, RISC-V edition, 2nd ed., Morgan Kaufmann (2020).
R. E. Bryant and D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., Pearson (2016).
N. Nisan and S. Schocken, The Elements of Computing Systems: Building a Modern Computer from First Principles, 2nd ed., MIT Press (2021), the “Nand to Tetris” course.
C. Petzold, Code: The Hidden Language of Computer Hardware and Software, 2nd ed., Microsoft Press (2022).
S. Williams, A. Waterman and D. Patterson, “Roofline: an insightful visual performance model for multicore architectures,” Communications of the ACM52(4), 65–76 (2009).
U. Drepper, “What every programmer should know about memory” (2007).
2Numbers in a computer
Bits and bytes, integers and their overflow, and floating-point numbers bit by bit: why 0.1 + 0.2 is not 0.3, what machine epsilon means, and the rounding traps that bite real simulations and neural networks.
Memory stores only two states per cell, written 0 and 1: a bit. Eight bits make a byte. Every number, letter, image and instruction is a pattern of bits, and the meaning of a pattern depends entirely on how a program agrees to read it. The same 32 bits can be an integer, a floating-point number, four letters of text or an instruction. Most of the numerical surprises in scientific computing come from forgetting how these patterns work.
Integers
Bits read as an ordinary binary number weigh powers of two, exactly as decimal digits weigh powers of ten:
To represent negative numbers, computers use two’s complement: the leftmost bit counts as −2N−1 instead of +2N−1. In 8 bits it is worth −128, so the range runs from −128 to +127. The beauty of the scheme is that the hardware adds signed and unsigned numbers with exactly the same circuit. The price is that arithmetic wraps around silently when it leaves the range: 127+1 gives −128.
Bits, integers and floating-point numbers
Loading the interactive figure…
Click any bit to flip it, or type a number. In integer mode, try 127 and 128, and 100 + 100. In floating-point mode, try 0.1, 16777217 and the smallest number.
Real programs use 32-bit integers (up to about 2.1×109) or 64-bit ones (up to 9.2×1018). The 32-bit limit is not academic: a simulation of more than two billion atoms, or a counter of steps × atoms, overflows it, and codes such as LAMMPS have build options for 64-bit indices for exactly this reason.
Floating-point numbers
Scientific quantities span enormous ranges, from 10−23 J for kB to 1023 for Avogadro’s number, so computers store real numbers in a binary form of scientific notation: a sign, a few significant digits, and an exponent. The universal standard is IEEE 754 (first published 1985). A 32-bit single-precision number is three fields:
Two patterns of the exponent are reserved: all zeros encode zero and the subnormal numbers that fill the gap near zero, and all ones encode ±∞ and NaN (not a number, the result of 0/0 or −1). The 64-bit double precision used for most scientific work has the same structure with 11 exponent bits and 52 fraction bits:
Format
Bits (sign, exponent, fraction)
Significant decimal digits
Largest value
Typical use
half (FP16)
1, 5, 10
about 3
65504
neural-network training and inference
bfloat16
1, 8, 7
about 2
3×1038
neural-network training
single (FP32)
1, 8, 23
about 7
3.4×1038
GPU molecular dynamics (mixed with double), graphics
double (FP64)
1, 11, 52
about 16
1.8×10308
most scientific computing
Why 0.1 is not exactly 0.1
Only fractions whose denominator is a power of two can be written exactly in binary. One tenth is 0.000110011001100…2, repeating forever, so it is rounded to the nearest representable number. In the lab, type 0.1 in floating-point mode: the stored value is 0.100000001490116…, a relative error of about 1.5×10−8.
The spacing between neighbouring floats grows with their size. The key number is machine epsilon, the gap between 1 and the next larger number:
So above 224=16777216, single precision cannot even represent every integer: type 16777217 in the lab and it becomes 16777216.
Where rounding bites
Each single error is tiny; the trouble is how they combine. Run this in the Python chapter or on your machine:
importnumpyasnpprint(0.1+0.2==0.3,0.1+0.2)# the most famous surpriseprint(f"{0.1:.20f}")# what is really storedprint(np.finfo(np.float64).eps,np.finfo(np.float32).eps)# machine epsilonbig,small=1.0e16,1.0print((big+small)-big)# small is lost entirely: catastrophic cancellationx=np.full(10_000_000,0.1,dtype=np.float32)print("float32 running sum:",np.cumsum(x)[-1]," pairwise sum:",x.sum()," exact: 1000000")print(np.isclose(0.1+0.2,0.3))# compare floats with a toleranceprint(np.int32(2**31-1)+np.int32(1))# integer overflow wraps around
Never test floats for equality. Compare with a tolerance: abs(a - b) < tol or np.isclose.
Subtracting nearly equal numbers destroys digits (catastrophic cancellation). 1016+1−1016 gives 0 because the 1 was rounded away before the subtraction. Rewrite formulas to avoid it: for example, compute ln(1+x) for small x with log1p, not log(1 + x).
Summation order matters. Adding ten million single-precision 0.1s one after another gives 1,087,937, an error of 9%: once the running total is large, each small addition is rounded badly. NumPy’s sum adds in pairs, a tree, and is accurate to 10−7. Kahan’s compensated summation does better still. This is also why parallel sums give slightly different answers from serial ones (see the Fortran chapter): addition in floating point is not associative, (a+b)+c=a+(b+c).
Integers overflow silently, and the sign can flip.
Check yourself
What is 127 + 1 in a signed 8-bit integer?
127 is 01111111; adding 1 gives 10000000, whose leftmost bit counts −128. The hardware does not signal the overflow.
Why does 0.1 + 0.2 == 0.3 evaluate to False?
Each decimal fraction is rounded to the nearest binary fraction. The two rounding errors in 0.1 and 0.2 do not cancel the one in 0.3.
Single precision has about 7 significant digits. What happens when you add 1 to 100,000,000 stored as float32?
At 10⁸ the gap between adjacent float32 values is 8, so adding 1 rounds back to the same number.
Which change makes a long sum of small numbers more accurate?
Keeping partial sums of similar size, or carrying the lost low-order part, prevents small terms from being swamped by a large running total.
References and further reading
D. Goldberg, “What every computer scientist should know about floating-point arithmetic,” ACM Computing Surveys23(1), 5–48 (1991).
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM (2002), chapters 1–4.
IEEE Computer Society, IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019 (2019).
W. Kahan, “Further remarks on reducing truncation errors,” Communications of the ACM8(1), 40 (1965).
P. Micikevicius et al., “Mixed precision training,” International Conference on Learning Representations (2018).
R. E. Bryant and D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., Pearson (2016), chapter 2 (representing and manipulating information).
Linux and the shell
Kernel, system calls and processes; how bash parses a command; files, pipes, scripting and cluster job scheduling.
3The Linux system
What an operating system is for, how Linux is built from hardware up to your programs, what a process, a system call, virtual memory and a file really are, and how the directory tree is organised.
The previous chapters showed a processor executing one program. A real computer runs hundreds at once: your shell, an editor, a simulation on 64 cores, a network daemon, someone else’s job. Each believes it has the machine to itself. Something must share out the processor time, keep the programs from overwriting each other’s memory, and give them a simple way to use disks, networks and GPUs without knowing how each device works. That something is the operating system, and its core, the part that is always running with full control of the hardware, is the kernel.
An operating system gives programs three illusions, its three great abstractions:
a process: each program seems to have a processor of its own;
virtual memory: each process seems to have a large, private memory of its own;
files: storage, devices and even other programs look like named streams of bytes.
Unix, Linux and GNU
Unix was written at Bell Labs from 1969 by Ken Thompson and Dennis Ritchie, who also created the C language to rewrite it portably. Its ideas (a hierarchical file system, small programs connected by pipes, everything as a file) shaped every system since. In 1991 Linus Torvalds, then a student in Helsinki, wrote a new Unix-like kernel, Linux, and released it freely. Combined with the GNU project’s compiler, C library and command-line tools, it became a complete free operating system. Today Linux runs Android phones, most web servers, and every one of the world’s 500 fastest supercomputers. A distribution (Ubuntu, Rocky, Debian, SUSE) packages the kernel with tools and an installer; clusters usually run an enterprise distribution such as Rocky Linux or RHEL.
The layers
Linux is built in layers, each using only the one below it. Click any block to read what it does, then pick a command and step through what happens when you press Enter.
The layers of a Linux system
Loading the interactive figure…
From the physical hardware at the bottom to your programs at the top. The dashed system-call layer is the only door between ordinary programs and the kernel.
User mode and kernel mode. The processor itself enforces the division. Ordinary programs run in user mode, in which instructions that touch hardware or other programs’ memory are forbidden; if a program tries, the processor stops it and the kernel kills it (the familiar segmentation fault). Only the kernel runs in kernel mode. A program that needs something done, such as reading a file, asks the kernel through a system call: a special instruction that switches the processor into kernel mode at a fixed entry point, where the kernel checks the request, does the work and switches back. Linux has about 350 system calls; a handful (open, read, write, close, fork, execve, exit, mmap) do most of the work. You can watch them: on Linux, strace -c ls counts every system call ls makes.
Processes
A process is a running program: its code, its memory, its open files and the processor state (registers, program counter) needed to resume it. Each has a number, the PID. New processes are created in a two-step dance that is pure Unix:
fork() makes an almost exact copy of the calling process;
the copy calls execve() to replace its program with a different one.
That is how your shell starts every command, and why processes form a family tree (pstree shows it), all descended from process 1, systemd, which the kernel starts at boot. The scheduler decides which process runs on which core, switching between them many times a second, so that hundreds share a few cores fairly. A process can be running, waiting (for disk, network or a child), or stopped. Processes are controlled with signals: Ctrl-C sends SIGINT, kill PID sends SIGTERM (please stop), kill -9 PID sends SIGKILL (stop now, no clean-up).
Virtual memory
Every process sees its own private address space, as if it had the machine’s whole memory to itself. The kernel and the processor’s memory management unit translate these virtual addresses to physical ones, in blocks called pages (usually 4 KB), using a page table per process.
The consequences are everywhere in practice. Processes cannot read each other’s memory, so a crashing program cannot corrupt another. Memory is assigned lazily: a program that allocates 100 GB but touches only 1 GB uses only 1 GB of RAM. Pages not used for a while can be moved to disk (swap), which is why an overloaded machine suddenly becomes thousands of times slower; clusters usually disable swap and kill jobs that exceed their memory request instead. And the kernel uses spare RAM as a page cache for files, which is why reading the same trajectory a second time is much faster than the first.
Files, and everything else that looks like one
To Linux, a file is a named sequence of bytes. Directories are files listing names; so are devices (/dev/nvidia0), pipes between processes, network sockets, and live information about the system (/proc/cpuinfo, /proc/meminfo, /proc/PID/status). One set of system calls (open, read, write, close) works on all of them. This is the famous Unix idea that everything is a file.
A running process refers to its open files by small integers, file descriptors. Three are open from the start: 0 standard input, 1 standard output, 2 standard error. Redirection (>) and pipes (|), which the next chapter dissects, are nothing more than the shell rearranging these three numbers before it starts a program.
Each file’s metadata (owner, permissions, size, times, where its data lives on disk) is kept in an inode; the name you see is just a directory entry pointing to it. Permissions are three sets of read, write and execute bits for the owner, the group and everyone else, and the special user root (the administrator) bypasses them all. On a shared cluster you are never root, which is why software comes from modules or from environments you install in your own directories.
The directory tree
Linux has a single tree rooted at /; disks and network file systems are mounted at points inside it rather than having drive letters. The standard layout:
Directory
What lives there
/bin, /usr/bin
programs (commands) for everyone
/lib, /usr/lib
shared libraries those programs use
/etc
system-wide configuration files
/home/you (~)
your files and personal settings (hidden “dot files” such as .bashrc)
/tmp
temporary files, often cleared on reboot
/var
changing data: logs (/var/log), spool files
/opt, /usr/local
software installed by hand
/proc, /sys
windows into the running kernel, not real files on disk
/dev
devices: disks, terminals, GPUs, and /dev/null, which swallows anything written to it
/scratch, /work, /project
cluster-specific: large, fast storage for jobs
Looking inside your own machine
These commands work on any Linux system (macOS has some under different names):
uname -a # kernel name and versionlscpu # processor: cores, threads, caches, vector extensionsfree -h # memory in use, free and used as page cachecat /proc/meminfo | head # the kernel's own memory accountingdf -h # mounted file systems and free spaceps -ef --forest | head # the process family treetop # live view of processes (q to quit)strace -c python3 -c 'print(1)' 2>&1| tail -15 # system calls of a tiny Python runldd $(which python3)# the shared libraries a program depends on
Check yourself
Your program reads an array out of bounds and is killed with “Segmentation fault”. Who stopped it?
The address was not mapped for the process, so the memory management unit raised a fault in hardware; the kernel handled it by sending SIGSEGV, which terminates the program.
How does bash start the program cat?
fork followed by exec is the Unix way of creating every new process.
Why can two processes both use virtual address 0x400000 without conflict?
Virtual memory gives every process a private address space; the hardware translates each process’s addresses separately.
What are file descriptors 0, 1 and 2?
Every process starts with these three open. Redirection and pipes work by changing what they point to.
References and further reading
R. H. Arpaci-Dusseau and A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Arpaci-Dusseau Books (2018), free online at ostep.org: virtualisation, concurrency and persistence.
M. Kerrisk, The Linux Programming Interface, No Starch Press (2010), the definitive guide to Linux system calls.
D. M. Ritchie and K. Thompson, “The UNIX time-sharing system,” Communications of the ACM17(7), 365–375 (1974).
A. S. Tanenbaum and H. Bos, Modern Operating Systems, 4th ed., Pearson (2014).
R. Love, Linux Kernel Development, 3rd ed., Addison-Wesley (2010).
R. E. Bryant and D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., Pearson (2016), chapters 8 (exceptional control flow) and 9 (virtual memory).
4The Linux shell
The command line from zero: the file-system tree, paths, the anatomy of a command, wildcards, pipes and redirection, permissions and your first scripts, practised in a terminal that runs in this page.
about 14 min read
Why the shell, in 2026
Nearly every scientific computer you will work on, from a cluster with ten thousand cores to a GPU workstation or a cloud machine, runs Linux and is reached through a text shell. Simulation codes are launched from it, job schedulers are driven from it, and the files they produce, often thousands of them, are far quicker to inspect, filter and move with a few commands than with any graphical tool. The shell also makes work reproducible: a command you typed can be saved in a script and run again next year, on another machine, with identical results.
A few terms. The terminal is the window; the shell is the program inside it that reads your commands and runs them. The common shells are bash (the default on most Linux systems) and zsh (the default on macOS); everything in this lesson works in both. macOS’s tools are BSD versions that occasionally take slightly different options from the GNU versions on Linux, which is noted where it matters.
The file system is a tree
Every file lives in one tree that starts at the root, written /. Your own files live in your home directory, written ~ (for example /home/student). A path names a location in the tree:
an absolute path starts at the root: /home/student/project/input/md.mdp;
a relative path starts at your current directory: input/md.mdp;
. means “this directory” and .. means “the directory above”;
names starting with a dot, such as .bashrc, are hidden from a plain ls.
Avoid spaces in file names (run 1/ must then be quoted everywhere); use run_1/ or run-1/.
The anatomy of a command
ls -l -h runs/T300
A command is a program name (ls), then options that change its behaviour (-l long format, -h human-readable sizes; single-letter options can be combined as -lh), then arguments, usually files or directories (runs/T300). Long options start with two dashes (--human-readable). Every command documents itself: man ls opens its manual (press q to leave, /word to search), and most accept --help.
Practise here
The terminal below runs entirely in this page, on a pretend file system containing a small simulation project: three GROMACS runs at different temperatures, one of which crashed. Nothing you type can affect your computer. Work through the missions; each turns green when you have done it, and each has a solution if you are stuck. Type help to see what the practice shell understands, use Tab to complete names and ↑ to recall previous commands.
A practice shell
Loading the interactive figure…
A safe sandbox for the commands in this lesson. Reset the file system at any time.
Moving around and looking
Command
What it does
pwd
print the working directory
ls, ls -l, ls -a, ls -lh
list files; long format; include hidden files; readable sizes
cd dir, cd .., cd ~, cd -
change directory; up one level; home; back to the previous one
cat file
print a whole file
less file
page through a file (space, b, /search, q to quit)
head -n 20 file, tail -n 20 file
first or last 20 lines
tail -f md.log
follow a file as it grows: ideal for a running simulation
wc -l file
count lines
Reading ls -l:
-rw-r--r-- 1 student lab 412 Oct 14 09:30 md.mdp
drwxr-xr-x 1 student lab 4096 Oct 14 09:30 runs/
The first character is - for a file and d for a directory. The next nine are permissions in three groups of three, for the owner, the group and everyone else: r read, w write, x execute (for a directory, x means you may enter it). Then the owner, group, size in bytes, modification time and name. chmod +x script.sh makes a script executable; chmod 600 ~/.ssh/id_ed25519 makes a private key readable only by you, which SSH insists on.
Creating, copying, moving and deleting
mkdir -p analysis/rdf # -p creates parent directories as neededcp input/md.mdp md_new.mdp # copy a filecp -r input/ input_backup/ # copy a directory: -r means recursivemv md_new.mdp md_T320.mdp # rename (or move to another directory)rm md_T320.mdp # delete a filerm -r old_runs/ # delete a directory and everything in it
Wildcards
The shell expands wildcards into matching file names before the command runs:
* matches any sequence of characters: runs/*/md.log is every md.log one level below runs/;
? matches exactly one character: T3?0 matches T300, T320 and T340;
[ab] matches one of the listed characters; {a,b} expands to both words (bash and zsh).
Because the shell does the expansion, grep never sees the *; it receives a list of file names. Put a pattern in quotes (find . -name '*.py') when you want the command to see it instead.
Searching
grep 'Fatal error' runs/T340/md.log # lines containing the textgrep -n -i 'lincs' runs/*/md.log # with line numbers, ignoring case, across filesgrep -l 'Fatal error' runs/*/md.log # only the names of matching filesgrep -r 'ref_t' . # recursively through all files below .grep -v '^[#@]' energy.xvg # invert: lines NOT starting with # or @find . -name '*.xtc' -size +1G # files by name and sizefind . -type d -name 'T3*'# directories by name
grep patterns are regular expressions: ^ anchors the start of a line, $ the end, . matches any character, [0-9] any digit, * repeats the previous item. They are worth learning; they appear in every programming language.
Pipes and redirection: the real power
Every command reads from standard input and writes to standard output (and error messages to standard error). You can redirect them:
grep -v '^[#@]' energy.xvg > potential.dat # > writes output to a file (overwriting it)echo"T300 done" >> progress.log # >> appendsgmx mdrun -deffnm md > run.out 2>&1# 2>&1 sends errors to the same file
and connect commands with a pipe, |, so that the output of one becomes the input of the next:
grep -v '^[#@]' energy.xvg | wc -l # how many data lines?grep -h 'Performance' runs/*/md.log | sort -k2 -n | head -1 # slowest runcut -c18-20 protein.pdb | sort | uniq -c | sort -rn # residue counts in a PDB fileawk '!/^[#@]/ {print $1, $2}' energy.xvg > energy.dat # strip headers, keep two columnsawk '!/^[#@]/ {s += $2; n++} END {print s / n}' energy.xvg # mean of column 2
This is the Unix philosophy, set out by Doug McIlroy: small programs that each do one thing well, combined through a universal interface, text. A pipeline of four small commands often replaces a page of code.
Your first script
A shell script is a file of commands. This one prepares three simulations at different temperatures from one template:
#!/bin/bash
# make_runs.sh: one directory and one parameter file per temperatureset -euo pipefail # stop on errors, unset variables and failed pipesfor T in 300320 340;dodir="T${T}" mkdir -p "$dir"# replace the reference temperature, writing a new file in each directory sed "s/^ref_t .*/ref_t = ${T}/" md.mdp > "$dir/md.mdp"echo"prepared $dir: $(grep ref_t "$dir/md.mdp")"done
Points to notice: the first line (the shebang) says which program runs the script; variables are set with T=300 (no spaces) and used as $T or ${T}; double quotes around "$dir" keep the value as one word even if it contains spaces; $(…) inserts the output of a command; and set -euo pipefail makes the script stop at the first error instead of carrying on with broken state. sed "s/old/new/" substitutes text; on Linux sed -i edits a file in place, while macOS’s sed needs sed -i ''.
Check yourself
You are in /home/student/project/runs. Which path refers to /home/student/project/input/md.mdp?
.. goes up from runs to project, then input/md.mdp goes down. /input/md.mdp is an absolute path from the root of the file system.
What does grep -v '^#' data.txt | wc -l print?
-v inverts the match, keeping lines that do not start with #, and the pipe sends them to wc -l, which counts them.
Who expands *.log in ls *.log?
Wildcard expansion is done by the shell; ls receives the list of matching names. Quoting the pattern (’*.log’) passes it unexpanded.
A job script ran gmx mdrun ... > run.out, but error messages still appeared on the screen and are not in run.out. Why?
Standard output and standard error are separate streams. > run.out 2>&1 sends both to the file.
References and further reading
W. Shotts, The Linux Command Line, 2nd ed., No Starch Press (2019), free online at linuxcommand.org.
Software Carpentry, “The Unix Shell” lesson, swcarpentry.github.io/shell-novice, a tested introduction for researchers.
B. W. Kernighan and R. Pike, The UNIX Programming Environment, Prentice Hall (1984), the classic account of the pipes-and-small-tools philosophy.
J. Janssens, Data Science at the Command Line, 2nd ed., O’Reilly (2021), free online.
G. Wilson et al., “Best practices for scientific computing,” PLoS Biology12, e1001745 (2014).
G. Wilson, J. Bryan, K. Cranston, J. Kitzes, L. Nederbragt and T. K. Teal, “Good enough practices in scientific computing,” PLoS Computational Biology13, e1005510 (2017).
5How the shell reads a command
The shell's read–expand–execute cycle, step by step: tokens and quoting, the order of expansions, redirections and pipes as rewired file descriptors, built-ins versus programs, environment variables and exit status, and the classic traps.
bash is an ordinary user program (it lives at /bin/bash) whose whole job is a loop: print a prompt, read a line, work out what it means, start the programs it names, wait for them, and repeat. Understanding the middle step, working out what a line means, removes almost all of the mystery from the command line: why spaces in file names break scripts, why * sometimes stays a star, why cd cannot be a separate program, and why 2>&1 must come after >.
Four stages for every line
One command line, taken apart
Loading the interactive figure…
Edit the line or pick an example. Stage 1 splits it into tokens, stage 2 performs the expansions, stage 3 shows the processes that would be started and how their inputs and outputs are wired. Nothing is actually run.
1. Splitting into tokens
bash first cuts the line into words and operators. Operators are the characters with special meaning: |, &, ;, &&, ||, <, >, >>, parentheses. Quotes stop splitting: "my run" is one word, my run is two. Single quotes switch off every special meaning inside them; double quotes switch off splitting and wildcards but still allow $ expansions.
2. Expansions, in a fixed order
Each word is then rewritten, always in the same order:
Step
Example
Becomes
brace expansion
T{300,320,340}
T300 T320 T340
tilde expansion
~/project
/home/student/project
parameter (variable) expansion
md_$T.mdp
md_320.mdp
command substitution
$(date +%A)
the output of the command, e.g. Friday
arithmetic expansion
$((2*T))
640
word splitting
an unquoted $VAR holding a b
two words a and b
pathname expansion (globbing)
*.log
md.log run1.log run2.log
quote removal
"Hello"
Hello
Two consequences are worth remembering. Word splitting and globbing happen after variable expansion, and only to unquoted results, which is why variables should almost always be quoted: "$file". And a glob that matches nothing is passed on unchanged, so ls *.xyz complains about a file literally called *.xyz.
3. Redirections, pipes and starting programs
Before running a command, bash sets up its file descriptors (the chapter on the Linux system): 0 for input, 1 for output, 2 for errors.
Redirections are processed left to right. cmd > log 2>&1 first points 1 at log, then makes 2 point wherever 1 now points: both go to the file. cmd 2>&1 > log makes 2 point at the terminal (where 1 pointed at that moment) and only then moves 1 to the file, so errors still appear on screen.
A pipea | b asks the kernel for a small buffer with two ends, connects a’s descriptor 1 to the writing end and b’s descriptor 0 to the reading end, and starts both programs at once. Data streams through while both run: grep can be filtering the first lines of a huge file before cat has finished reading it, and the file never has to fit in memory. When b stops reading (as head does after ten lines), a is told to stop.
Then bash runs the command. If the first word is a built-in such as cd, export, echo or source, bash does the work itself. cdmust be a built-in: it changes the current directory of the shell process itself, and a separate program could only change its own. Otherwise bash searches the directories listed in $PATH, in order, for an executable with that name (type gmx or which gmx shows which one wins) and starts it with fork and exec.
4. Exit status
Every program ends by returning a small number, its exit status: 0 for success, anything from 1 to 255 for failure. bash keeps the last one in $?. The operators && and || act on it: make && ./run only runs if the build succeeded; grep -q Fatal md.log || echo "no errors" prints only if grep found nothing.
Variables and the environment
T=320 sets a shell variable, visible only to this shell. export T makes it an environment variable, copied into every program the shell starts; that is how settings such as OMP_NUM_THREADS, CUDA_VISIBLE_DEVICES and PATH reach your programs. Prefixing a single command, OMP_NUM_THREADS=8 ./md, sets the variable for that command alone.
At start-up an interactive bash reads ~/.bashrc (login shells on some systems read ~/.bash_profile instead). That is where aliases, module load lines and PATH additions go, which also means a broken .bashrc can break every job you submit.
Traps that catch everyone
Spaces around =.T = 320 runs a command called T with arguments = and 320. Assignments have no spaces: T=320.
Single against double quotes.echo '$HOME' prints $HOME; echo "$HOME" prints /home/student.
A pipeline’s status is its last command’s.false | true succeeds. set -o pipefail makes a pipeline fail if any part fails.
ls output is not for parsing. Loop over a glob instead: for f in *.log; do …; done.
Windows line endings. A script edited on Windows may end lines with \r\n; bash then reports $'\r': command not found. Convert with dos2unix.
Check yourself
Why must cd be built into the shell rather than be a program in /usr/bin?
The current directory belongs to each process. Only code running inside the shell process can change the shell’s directory.
With dir="old runs", what does ls $dir do?
Word splitting happens after variable expansion for unquoted words. Write ls "$dir".
Which command sends both normal output and errors to run.log?
Redirections are applied left to right. 1 must point at the file before 2 is duplicated from it.
In cat big.xvg | grep -v '^#' | head -5, when does grep start running?
bash starts every process of a pipeline at once; data flows through the kernel’s pipe buffers as it is produced.
References and further reading
C. Ramey and B. Fox, Bash Reference Manual, Free Software Foundation, gnu.org/software/bash/manual: §3.5 (shell expansions) and §3.6 (redirections) are the authority on everything above.
W. Shotts, The Linux Command Line, 2nd ed., No Starch Press (2019), chapters 7 (seeing the world as the shell sees it) and 24–36 (writing shell scripts).
G. Wooledge et al., “Bash Pitfalls” and “BashFAQ,” mywiki.wooledge.org, the best catalogue of shell traps.
B. W. Kernighan and R. Pike, The UNIX Programming Environment, Prentice Hall (1984), chapter 3 (using the shell).
M. Kerrisk, The Linux Programming Interface, No Starch Press (2010), chapters 24–27 (process creation and program execution) and 44 (pipes).
6Linux for simulation work
Working on clusters: SSH keys and configuration, moving data with rsync, sessions that survive disconnection, software modules and conda, the Slurm scheduler and job scripts, file systems and quotas. With a command explainer, a job-script builder and a searchable reference of about 75 commands.
A high-performance computing (HPC) cluster is many computers (nodes) connected by a fast network and sharing file systems. You log in to a login node, which is shared by everyone and meant for editing, compiling and submitting work. The calculations run on compute nodes, which you reach only through the scheduler (usually Slurm, sometimes PBS or LSF): you describe the resources you need in a job script, submit it, and the scheduler starts it when the resources are free.
Logging in with SSH, properly
ssh user@cluster.university.edu opens a remote shell. Typing a password every time is slow and less secure than a key pair: a private key that never leaves your computer and a public key that you install on the server.
ssh-keygen -t ed25519 -C "laptop key"# creates ~/.ssh/id_ed25519 and id_ed25519.pubssh-copy-id user@cluster.university.edu # appends the public key to the server's authorized_keys
Then give the cluster a short name in ~/.ssh/config:
Host cluster
HostName login.cluster.university.edu
User yourname
IdentityFile ~/.ssh/id_ed25519
ProxyJump gateway.university.edu # only if you must go through a gateway first
Now ssh cluster, scp file cluster: and rsync … cluster: all work, and so does the remote mode of editors such as VS Code. Protect the private key with a passphrase and keep it readable only by you (chmod 600).
Port forwarding lets you use a remote service in your local browser, for example a Jupyter notebook running on the cluster:
ssh -L 8888:localhost:8888 cluster # then open http://localhost:8888 on your laptop
Moving data
scp copies files over SSH; rsync synchronises directories and transfers only what has changed, which makes it the right tool for simulation data:
A trailing slash on the source (runs/) copies the contents of the directory; without it (runs) it copies the directory itself. Pack many small files before moving or archiving them, and check that the copy is intact:
tar -czf runs_2026-10.tar.gz runs/ # c create, z gzip, f file namesha256sum runs_2026-10.tar.gz > runs_2026-10.sha256 # on macOS: shasum -a 256sha256sum -c runs_2026-10.sha256 # verify after transfertar -xzf runs_2026-10.tar.gz # x extract
Sessions that survive a dropped connection
If your SSH connection drops, programs started in it are killed. tmux (or the older screen) keeps a session running on the server:
tmux new -s analysis # start a named session# ... work; press Ctrl-b then d to detach ...tmux ls # list sessions after logging back intmux attach -t analysis # reattach
For a single long command, nohup command > out.log 2>&1 & also survives logout, but on a cluster long runs should be batch jobs.
Software: modules and environments
Clusters provide many versions of compilers, MPI libraries and simulation codes through environment modules:
module avail gromacs # which versions exist?module load gromacs/2023.3 # put one on your PATHmodule list # what is loadedmodule purge # unload everything (start job scripts with this)
For Python, keep each project in its own environment so that package versions do not collide:
conda create -n mlip python=3.11 numpy scipy matplotlib
conda activate mlip
pip install mdanalysis ase
conda env export > environment.yml # record exact versions for reproducibility
Job scripts
A Slurm job script is a shell script whose #SBATCH comment lines request resources. The builder below writes one for common cases and explains every line; adjust the module names and partition to your cluster (sinfo -s lists partitions, module avail the software).
Build a Slurm job script
Loading the interactive figure…
Choose a program and the resources; the script updates as you type. Core-hours are what your allocation is charged when the job runs to its time limit.
The essentials of resource requests:
Nodes, tasks and cores.--ntasks is the number of MPI processes; --cpus-per-task is the number of threads each one runs (OpenMP); their product per node should not exceed the cores in a node. Set OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK so programs use exactly what was allocated.
GPUs. Request them explicitly (--gres=gpu:1 or --gpus-per-node=1, depending on the site). GPU codes usually want one to a few MPI ranks per GPU.
Time. The job is killed at the limit, so ask for a little more than you need, and write checkpoints: GROMACS -cpi, LAMMPS write_restart. Shorter jobs start sooner because the scheduler can fit them into gaps (backfilling).
Memory. Too little and the job is killed; far too much and it waits longer. After a job, sacct -j JOBID --format=JobID,Elapsed,MaxRSS,State shows what it actually used.
Monitoring and controlling jobs:
sbatch job.sh # submit; prints the job IDsqueue -u $USER# your jobs: PD pending, R runningsqueue -u $USER --start # estimated start timesscontrol show job 123456# full details, including why it is pendingscancel 123456# cancelsbatch --dependency=afterok:123456 analyse.sh # start only after job 123456 succeedssacct -u $USER -S 2026-10-01 --format=JobID,JobName,Elapsed,State,ExitCode # history
Job arrays run the same script many times with different inputs, with the index in $SLURM_ARRAY_TASK_ID: the natural way to run a temperature series or a set of independent replicas (tick the option in the builder).
Where to keep files
Clusters usually have several file systems with different purposes:
Location
Typical use
Notes
$HOME
scripts, source code, small inputs
small quota; usually backed up
scratch (/scratch/$USER)
running simulations, large temporary output
fast, large, not backed up, often purged after weeks of inactivity
project or work space
shared group data, results to keep
larger quota; check the backup policy
node-local disk ($TMPDIR)
very I/O-heavy temporary files during a job
erased when the job ends
Check your usage with quota, du -sh * or the site’s own tool, and copy anything you need to keep off scratch promptly.
Explain a command
When you meet an unfamiliar command line in a tutorial or a colleague’s script, paste it here to see what every part does.
Command explainer
Loading the interactive figure…
Covers about 60 common commands and their options, pipes, redirections and wrappers such as nohup and mpirun. It explains; it does not run anything.
Reference
Command reference
Loading the interactive figure…
Search or filter by topic. Click an example to copy it.
Check yourself
Where should a two-hour test simulation run on a shared cluster?
Login nodes are shared by all users for light work. Anything computationally heavy, including tests, belongs on compute nodes through the scheduler.
What is the safest way to try an rsync … --delete command for the first time?
--delete removes destination files absent from the source. -n (dry run) shows the transfers and deletions without performing them.
A job requests --ntasks-per-node=4 and --cpus-per-task=8. How many cores per node does it use, and what should OMP_NUM_THREADS be?
Four MPI ranks with eight threads each use 32 cores; each rank should run 8 OpenMP threads, i.e. OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK.
Your results live only in /scratch/$USER. What is the risk?
Scratch is designed for speed and size, not safety. Copy results you need to project storage or off the cluster.
References and further reading
Slurm documentation, slurm.schedmd.com: the sbatch, srun, squeue and sacct manual pages and the “Quick Start User Guide”.
HPC Carpentry, “Introduction to High-Performance Computing,” carpentries-incubator.github.io/hpc-intro.
M. W. Lucas, SSH Mastery, 2nd ed., Tilted Windmill Press (2018).
W. Shotts, The Linux Command Line, 2nd ed., No Starch Press (2019), chapters on networking, archiving and shell scripts.
B. P. Hogan, tmux 3: Productive Mouse-Free Development, Pragmatic Bookshelf (2023).
GROMACS documentation, “Getting good performance from mdrun,” manual.gromacs.org, for choosing ranks, threads and GPU offload.
Compiled languages
Fortran for numerical kernels: types, arrays, modules, compilation and parallelism.
7Fortran
Modern Fortran from a first program to modules, array syntax, memory layout and performance, compiler flags for debugging and speed, Makefiles, OpenMP, MPI, LAPACK, and calling Fortran from Python. Every program here was compiled and run.
Fortran (FORmula TRANslation, 1957) is the oldest language still in heavy scientific use, and for a good reason: it was designed for one job, fast numerical work on arrays, and compilers have had seventy years to get good at it. Much of the code a computational materials scientist runs every day is written in it: VASP, Quantum ESPRESSO, CP2K, CASTEP, SIESTA and ABINIT for electronic structure, and many weather, climate and engineering codes. Reading, modifying and compiling Fortran is part of the job, even if your own analysis is in Python.
The language you will meet is modern Fortran (Fortran 90 and later; the current standard is Fortran 2023). It has modules, dynamic arrays, whole-array arithmetic, derived types and built-in parallelism, and it bears little resemblance to the fixed-column FORTRAN 77 of punched cards, which you will still find in older libraries (files ending .f or .F instead of .f90).
A first program
Save this as hello.f90, then compile and run it:
programhelloimplicitnoneprint*,"Hello from Fortran"endprogramhello
gfortran hello.f90 -o hello # compile and link into an executable called hello./hello
Hello from Fortran
implicit none belongs in every program and module. Without it, Fortran silently declares any undeclared variable whose name starts with i–n as an integer and anything else as a real, so a typo creates a new variable instead of an error.
Types, kinds and the precision trap
Numerical code needs to say how many bits a number has. The portable way is to take kind parameters from the standard module iso_fortran_env:
programbasicsuse,intrinsic::iso_fortran_env,only:dp=>real64implicitnonereal(dp),parameter::kB=1.380649e-23_dp! J/K, exact since 2019
real(dp)::T,E,a,binteger::ndofcharacter(len=*),parameter::name="argon"T=300.0_dpndof=3E=0.5_dp*ndof*kB*T! equipartition: (3/2) kB T
print'(a, es12.5, a)',"mean kinetic energy of one "//name//" atom: ",E," J"a=0.1! a single-precision literal...
b=0.1_dp! ...and a double-precision one
print'(a, f20.17)',"0.1 stored in a real(dp): ",aprint'(a, f20.17)',"0.1_dp stored in a real(dp): ",bprint'(a, i0)',"integer division 7/2 = ",7/2endprogrambasics
basics.f90:18:48:
18 | print '(a, i0)', "integer division 7/2 = ", 7 / 2
| 1
Warning: Integer division truncated to constant '3' at (1) [-Winteger-division]
mean kinetic energy of one argon atom: 6.21292E-21 J
0.1 stored in a real(dp): 0.10000000149011612
0.1_dp stored in a real(dp): 0.10000000000000001
integer division 7/2 = 3
Two classic bugs are visible here.
Literal precision.0.1 is a single-precision constant, accurate to about 7 digits, even when assigned to a double-precision variable. Write 0.1_dp. A force field parameter typed without its kind suffix silently loses half its digits.
Integer division.7/2 is 3. Write 7.0_dp/2 when you mean 3.5. The compiler warned us because -Wall was on, one of many reasons to always compile with warnings.
Arrays: the reason Fortran exists
Arrays are first-class. Whole-array expressions, slices and a large library of intrinsic functions replace most explicit loops:
programarraysuse,intrinsic::iso_fortran_env,only:dp=>real64implicitnonereal(dp),allocatable::x(:),y(:),A(:,:)integer::i,nn=5allocate(x(n),y(n),A(3,3))x=[(real(i,dp),i=1,n)]! array constructor: 1, 2, ..., 5
y=x**2! element-wise: no loop needed
print'(a, 5f8.2)',"x =",xprint'(a, 5f8.2)',"x**2 =",yprint'(a, f8.2)',"sum(x**2) =",sum(y)print'(a, f8.2)',"dot(x, x) =",dot_product(x,x)print'(a, 3f8.2)',"x(2:4) =",x(2:4)! a slice
A=reshape([(real(i,dp),i=1,9)],[3,3])! filled column by column
print'(a)',"A, printed row by row:"doi=1,3print'(3f6.1)',A(i,:)enddoprint'(a, 3f8.2)',"A @ [1,1,1] =",matmul(A,[1.0_dp,1.0_dp,1.0_dp])where(y>10.0_dp)y=10.0_dp! masked assignment
print'(a, 5f8.2)',"capped at 10 =",ydeallocate(x,y,A)endprogramarrays
Notice two things about A. Indices start at 1 by default (you can declare other bounds, such as real :: psi(-10:10)). And reshape filled it column by column: the numbers 1, 2, 3 went down the first column. That is not a quirk of reshape; it is how Fortran stores every multidimensional array.
Memory layout and loop order
Memory is one-dimensional, so a 2D array must be flattened. Fortran stores arrays in column-major order: A(1,1), A(2,1), A(3,1), …, A(1,2), …, so the first index varies fastest. C, C++ and NumPy (by default) use row-major order, where the last index varies fastest.
This matters for speed. The processor never fetches a single number; it loads a whole cache line (64 bytes, eight doubles) at once, and it is fastest when consecutive iterations touch consecutive addresses. The lab shows which loop order does that.
Column-major against row-major, and why loop order matters
Loading the interactive figure…
Top: a 4 × 6 array. Bottom: the same elements in memory order, grouped into cache lines of four elements. Play each loop order: red arcs mark jumps in memory, and the counter shows how many cache lines had to be loaded with a two-line cache.
The rule: in Fortran, make the innermost loop run over the first index. On a real machine the effect is large. This program performs exactly the same arithmetic twice, on a 4000 × 4000 array:
programloop_orderuse,intrinsic::iso_fortran_env,only:dp=>real64,int64implicitnoneinteger,parameter::n=4000real(dp),allocatable::a(:,:)integer::i,j,repinteger(int64)::t0,t1,rateallocate(a(n,n))a=1.0_dpcallsystem_clock(t0,rate)dorep=1,5doj=1,n! outer loop over columns
doi=1,n! inner loop over rows: consecutive addresses
a(i,j)=a(i,j)*1.000001_dpenddoenddoenddocallsystem_clock(t1)print'(a, f7.3, a)',"inner loop over i (contiguous): ",real(t1-t0,dp)/rate," s"callsystem_clock(t0)dorep=1,5doi=1,n! outer loop over rows
doj=1,n! inner loop over columns: jumps of n*8 bytes
a(i,j)=a(i,j)*1.000001_dpenddoenddoenddocallsystem_clock(t1)print'(a, f7.3, a)',"inner loop over j (strided): ",real(t1-t0,dp)/rate," s"print'(a, f12.6)',"checksum: ",sum(a)/n**2endprogramloop_order
inner loop over i (contiguous): 0.083 s
inner loop over j (strided): 0.390 s
checksum: 1.000010
The strided version is about 4.5 times slower on this laptop (an Apple M1), only because of the order in which memory is touched. Whole-array syntax (a = a * 1.000001_dp) lets the compiler pick the right order for you.
Procedures and modules
Real programs are split into subroutines (which act through their arguments) and functions (which return a value), placed inside modules. A module gives the compiler an explicit interface: it can check every call for the right number, type and shape of arguments. The intent attribute documents and enforces which arguments are inputs and outputs.
Here is a Lennard-Jones energy and force routine with periodic boundaries, in a module, the same physics as the molecular dynamics lesson:
modulelj_moduse,intrinsic::iso_fortran_env,only:dp=>real64implicitnoneprivatepublic::dp,lj_energy_forcescontainssubroutinelj_energy_forces(x,box,rc,energy,f)real(dp),intent(in)::x(:,:)! positions, shape (3, n), reduced units
real(dp),intent(in)::box,rc! cubic box length and cut-off
real(dp),intent(out)::energyreal(dp),intent(out)::f(:,:)! forces, shape (3, n)
integer::i,j,nreal(dp)::d(3),r2,ir6,ff,ecn=size(x,2)energy=0.0_dpf=0.0_dpec=4.0_dp*(rc**(-12)-rc**(-6))! shift so that the energy is zero at rc
doi=1,n-1doj=i+1,nd=x(:,i)-x(:,j)d=d-box*anint(d/box)! minimum-image convention
r2=sum(d*d)if(r2<rc*rc)thenir6=1.0_dp/r2**3energy=energy+4.0_dp*ir6*(ir6-1.0_dp)-ecff=24.0_dp*ir6*(2.0_dp*ir6-1.0_dp)/r2f(:,i)=f(:,i)+ff*df(:,j)=f(:,j)-ff*dendifenddoenddoendsubroutinelj_energy_forcesendmodulelj_mod
and a main program that builds a 256-atom fcc crystal and calls it:
programljuselj_mod,only:dp,lj_energy_forcesimplicitnoneinteger,parameter::m=4,n=4*m**3! 4 x 4 x 4 fcc cells: 256 atoms
real(dp),parameter::rho=0.8442_dpreal(dp),parameter::basis(3,4)=reshape([0.0_dp,0.0_dp,0.0_dp,0.5_dp,0.5_dp,0.0_dp,&0.5_dp,0.0_dp,0.5_dp,0.0_dp,0.5_dp,0.5_dp],[3,4])real(dp)::x(3,n),f(3,n),a,box,einteger::i,j,k,b,pa=(4.0_dp/rho)**(1.0_dp/3.0_dp)! lattice constant for this density
box=m*ap=0dok=0,m-1doj=0,m-1doi=0,m-1dob=1,4p=p+1x(:,p)=a*([real(i,dp),real(j,dp),real(k,dp)]+basis(:,b))enddoenddoenddoenddocalllj_energy_forces(x,box,2.5_dp,e,f)print'(a, i0, a, f6.4)',"atoms: ",n,", fcc lattice at density ",rhoprint'(a, f10.5)',"potential energy per atom: ",e/nprint'(a, es10.2)',"largest force component (zero by symmetry): ",maxval(abs(f))endprogramlj
gfortran -O2 -Wall -c lj_mod.f90 # produces lj_mod.o and lj_mod.modgfortran -O2 -Wall -c lj_main.f90 # needs lj_mod.mod, so compile the module firstgfortran -o lj lj_mod.o lj_main.o # link./lj
atoms: 256, fcc lattice at density 0.8442
potential energy per atom: -6.33281
largest force component (zero by symmetry): 2.42E-14
On a perfect lattice every atom’s force cancels by symmetry, so the largest force is rounding noise: a free correctness test of the force code.
Compiling: the flags that matter
Purpose
gfortran
Intel (ifx)
Warnings
-Wall -Wextra
-warn all
Standard conformance
-std=f2018
-stand f18
Debugging symbols
-g
-g
Array bounds and other runtime checks
-fcheck=all (or =bounds)
-check all
Stack trace on a crash
-fbacktrace
-traceback
Stop at NaN, division by zero, overflow
-ffpe-trap=invalid,zero,overflow
-fpe0
Optimisation
-O2, -O3
-O2, -O3
Use every instruction of this CPU
-march=native
-xHost
OpenMP
-fopenmp
-qopenmp
Double-precision default reals (legacy codes)
-fdefault-real-8
-r8
Develop with the debug set and run production with the optimised set. Most “the code gives garbage” problems in scientific Fortran are out-of-bounds array accesses that -fcheck=all turns into an immediate, located error message.
A Makefile records how to build a program, rebuilds only what changed, and handles the module order. This one builds the Lennard-Jones example (the indented lines must start with a tab):
# Makefile for the Lennard-Jones example. Usage: make, make debug, make clean
FC= gfortran
FFLAGS= -O2 -march=native -Wall
DEBUG= -O0 -g -fcheck=all -fbacktrace -ffpe-trap=invalid,zero,overflow -Wall -Wextra
OBJ= lj_mod.o lj_main.o
lj:$(OBJ)$(FC)$(FFLAGS) -o $@$(OBJ)# a module must be compiled before any file that uses it
lj_main.o:lj_mod.o%.o: %.f90
$(FC)$(FFLAGS) -c $<
debug:FFLAGS = $(DEBUG)debug:cleanljclean: rm -f *.o *.mod lj
.PHONY:debugclean
make builds an optimised lj; make debug rebuilds it with all runtime checks; make clean removes the build files. Larger projects use CMake or the Fortran Package Manager (fpm), which find dependencies and module order automatically.
Parallel Fortran
OpenMP parallelises loops over the cores of one node with compiler directives, comment lines that start with !$omp:
programomp_sumuse,intrinsic::iso_fortran_env,only:dp=>real64useomp_libimplicitnoneinteger,parameter::n=200000000integer::ireal(dp)::s,t0s=0.0_dpt0=omp_get_wtime()!$omp parallel do reduction(+:s)
doi=1,ns=s+1.0_dp/real(i,dp)**2! each thread sums its share; reduction combines them
enddo!$omp end parallel do
print'(a, i0, a, f6.3, a)',"threads: ",omp_get_max_threads(),", time: ",omp_get_wtime()-t0," s"print'(a, f12.9, a, f12.9)',"sum 1/i^2 = ",s," pi^2/6 = ",acos(-1.0_dp)**2/6endprogramomp_sum
threads: 1, time: 0.569 s
sum 1/i^2 = 1.644934058 pi^2/6 = 1.644934067
threads: 8, time: 0.091 s
sum 1/i^2 = 1.644934062 pi^2/6 = 1.644934067
Eight threads give a 6.2-fold speed-up. Look closely at the sums: they differ in the ninth digit. Each thread adds its own share and the partial sums are combined at the end, so the order of additions changes, and floating-point addition is not associative. Parallel results differing in the last digits from serial ones is normal; differing in the third digit is a bug, usually a variable that should have been private or a missing reduction.
MPI (message passing) parallelises across many nodes. Each process owns part of the data and exchanges messages explicitly; this is how VASP, Quantum ESPRESSO and CP2K run on thousands of cores. Programs are compiled with a wrapper and launched with mpirun or, inside a Slurm job, srun:
BLAS (vector and matrix operations) and LAPACK (linear systems, eigenvalues, factorisations) are decades-tested and heavily optimised (OpenBLAS, Intel MKL, Apple Accelerate). Calling them is easy:
programsolveuse,intrinsic::iso_fortran_env,only:dp=>real64implicitnoneinteger,parameter::n=3real(dp)::A(n,n),b(n)integer::ipiv(n),infoexternal::dgesv! from LAPACK
A=reshape([4.0_dp,1.0_dp,2.0_dp,1.0_dp,5.0_dp,1.0_dp,2.0_dp,1.0_dp,6.0_dp],[n,n])b=[1.0_dp,2.0_dp,3.0_dp]calldgesv(n,1,A,n,ipiv,b,n,info)! solves A x = b; b is overwritten by x
if(info/=0)error stop"dgesv failed"print'(a, 3f10.6)',"x = ",bendprogramsolve
gfortran -O2 solve.f90 -o solve -llapack # on HPC systems often -lopenblas, or MKL flags./solve
x = -0.063830 0.319149 0.468085
The same answer as numpy.linalg.solve, which calls the same LAPACK routine underneath.
Calling Fortran from Python
f2py, part of NumPy, wraps Fortran routines as a Python module, the best of both worlds: Python for workflow and analysis, Fortran for the hot loop.
! ljf.f90: a Fortran routine to call from Python
subroutinelj_energy(x,n,box,rc,energy)implicitnoneinteger,intent(in)::ndouble precision,intent(in)::x(3,n),box,rcdouble precision,intent(out)::energyinteger::i,jdouble precision::d(3),r2,ir6energy=0.0d0doi=1,n-1doj=i+1,nd=x(:,i)-x(:,j)d=d-box*anint(d/box)r2=sum(d*d)if(r2<rc*rc)thenir6=1.0d0/r2**3energy=energy+4.0d0*ir6*(ir6-1.0d0)endifenddoenddoendsubroutinelj_energy
importnumpyasnpimportljf# the extension module built by f2py# 216 atoms on a simple cubic lattice (spacing 1.12) in a periodic boxg=np.arange(6)*1.12x=np.array(np.meshgrid(g,g,g,indexing="ij")).reshape(3,-1)# shape (3, n), as Fortran expectsbox,rc=6*1.12,2.5e=ljf.lj_energy(np.asfortranarray(x),box,rc)# n is inferred from the array shaped=x[:,:,None]-x[:,None,:]# the same sum in NumPy, for comparisond-=box*np.round(d/box)r2=(d**2).sum(0)[np.triu_indices(x.shape[1],1)]r2=r2[r2<rc**2]print(f"Fortran: {e:.8f} NumPy: {np.sum(4*(r2**-6-r2**-3)):.8f}")print(ljf.lj_energy.__doc__.splitlines()[0])
Fortran: -1039.38039581 NumPy: -1039.38039581
energy = lj_energy(x,box,rc,[n])
f2py inferred that n can be taken from the shape of x, so the Python call has one fewer argument; the docstring line shows the generated signature.
Check yourself
A Fortran loop nest updates a(i, j) with the outer loop over i and the inner loop over j. Why is it slow for large arrays?
In column-major storage, a(i, j) and a(i, j+1) are n elements apart. Swapping the loops makes consecutive iterations touch consecutive memory.
real(dp) :: x = 0.1 prints 0.10000000149011612. Why?
Literals without a kind are default (single-precision) reals. Write 0.1_dp to get the double-precision value.
Your program sometimes crashes with a segmentation fault and sometimes gives garbage. What should you try first?
Out-of-bounds array accesses are the most common cause of such symptoms in Fortran. Runtime checks turn them into a precise error message.
The OpenMP sum with 8 threads differs from the serial sum in the 9th significant digit. What does that indicate?
Reductions combine partial sums in a different order. Differences at the level of rounding error are expected; large differences would indicate a bug.
References and further reading
M. Metcalf, J. Reid and M. Cohen, Modern Fortran Explained: Incorporating Fortran 2018, Oxford University Press (2018).
M. Curcic, Modern Fortran: Building Efficient Parallel Applications, Manning (2020).
S. J. Chapman, Fortran for Scientists and Engineers, 4th ed., McGraw-Hill (2018).
fortran-lang.org: tutorials, the Fortran Package Manager (fpm) and the standard library project.
W. H. Press, S. A. Teukolsky, W. T. Vetterling and B. P. Flannery, Numerical Recipes in Fortran 90, Cambridge University Press (1996).
E. Anderson et al., LAPACK Users’ Guide, 3rd ed., SIAM (1999).
R. Chandra, L. Dagum, D. Kohr, D. Maydan, J. McDonald and R. Menon, Parallel Programming in OpenMP, Morgan Kaufmann (2001).
W. Gropp, E. Lusk and A. Skjellum, Using MPI, 3rd ed., MIT Press (2014).
U. Drepper, “What every programmer should know about memory” (2007), the classic account of caches and memory access patterns.
P. Peterson, “F2PY: a tool for connecting Fortran and Python programs,” International Journal of Computational Science and Engineering4, 296–305 (2009).