LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

OpenMP: Threading Without Pthreads (And Without the Nightmares)

openmphpcparallel-computingccppfortrangpumultithreadingperformance

If you have ever written pthreads code — spinning up threads, juggling mutexes, reasoning about memory ordering, and forgetting to join one of them — you already know why OpenMP exists. For the large class of problems where you just want to parallelize a loop, fan out independent tasks, or offload a kernel to a GPU, OpenMP lets you do it with a pragma instead of a plumbing project.

OpenMP is not magic. It is a layer of compiler-understood directives plus a small runtime that does the thread management you would have written yourself. The directives are declarative (“this loop is parallel, these variables are private”), which means the compiler and runtime can vary the implementation — number of threads, scheduling, placement — without you rewriting your code. That is exactly the trade you want for compute-heavy numeric code.

This post covers how OpenMP actually works, how to think about the memory model, the constructs that matter in practice (parallel regions, work-sharing, tasks, reductions, SIMD, affinity, and GPU offload with target), and the failure modes that will bite you — data races, false sharing, and NUMA effects — along with tools to diagnose them.

The Mental Model

OpenMP follows a fork-join model. A program starts as one thread (the initial thread). When execution hits a parallel region, the runtime forks a team of threads; they execute the region in parallel; at the closing brace they join and the initial thread continues alone. That is the whole execution structure. Everything else — loops, tasks, sections, SIMD — is a way of describing what the team of threads should do inside a parallel region.

Three pieces of state every OpenMP programmer needs to have in their head:

  1. Who is executing this code right now? Inside a parallel region, every thread in the team runs every statement that is not inside a work-sharing construct. Outside a work-sharing construct, a print statement fires N times.
  2. What is shared and what is private? By default, variables declared outside the parallel region are shared; variables declared inside the region are private. shared, private, firstprivate, and lastprivate clauses override the defaults.
  3. When do writes become visible to other threads? OpenMP’s relaxed memory model means a thread can keep writes in a register or cache until a flush point — which happens implicitly at barriers, at the end of parallel regions, and around atomic, critical, and locks. If you need visibility sooner, #pragma omp flush forces it.

If you internalize those three things, most OpenMP bugs become obvious instead of mysterious.

Your First Parallel Region

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <omp.h>
#include <stdio.h>

int main(void) {
    #pragma omp parallel
    {
        int tid = omp_get_thread_num();
        int n   = omp_get_num_threads();
        printf("Hello from thread %d of %d\n", tid, n);
    }
    return 0;
}

Compile with gcc -fopenmp hello.c -o hello (GCC), clang -fopenmp (Clang), icx -qopenmp (Intel), or nvc -mp (NVIDIA HPC SDK). Set OMP_NUM_THREADS=8 and run — you will see eight lines in arbitrary order. That arbitrary order is your first lesson: stdout interleaving in a parallel region is nondeterministic. If you see neat output, it is coincidence.

Parallelizing a Loop

The workhorse directive is parallel for (C/C++) or parallel do (Fortran):

1
2
3
4
5
double sum = 0.0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < N; i++) {
    sum += a[i] * b[i];
}

Two things are happening here:

  • parallel for creates a team and splits the loop iterations across threads.
  • reduction(+:sum) tells the compiler “each thread gets a private copy of sum initialized to 0; at the end of the loop, combine them all with + and store the result in the shared sum.”

Without the reduction clause, every thread would race on sum and you would get garbage — possibly the right answer when you run it on the small test case, and wrong answers in production. This is the single most common OpenMP bug.

OpenMP supports reductions for +, -, *, &, |, ^, &&, ||, min, max, and user-defined reductions via declare reduction.

Scheduling Loops

The schedule clause controls how iterations are distributed:

Schedule Behavior Use when
static (default) Iterations split into equal-sized chunks, assigned round-robin Uniform work per iteration, no load imbalance
static, chunk Same but with a specified chunk size Cache-friendliness matters, tile-based access
dynamic, chunk Threads grab chunks as they finish the previous one Work varies per iteration (sparse data, early exits)
guided, chunk Like dynamic but chunks shrink as the loop progresses Work is roughly uniform but you want load balancing as a safety net
auto Compiler/runtime decides You genuinely do not know
runtime Decided at runtime from OMP_SCHEDULE Benchmarking different schedules without recompiling

A classic mistake: using dynamic, 1 on a tight loop. The overhead of handing out one iteration at a time exceeds the work. Use dynamic, 64 or dynamic, 256 — big enough to amortize the scheduler cost.

Shared vs Private: The Rules You Must Know

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int i, j, n = 1000;
double x, y;
double a[n];

#pragma omp parallel for private(j, y) firstprivate(x) shared(a, n)
for (i = 0; i < n; i++) {
    y = some_function(i);
    for (j = 0; j < 10; j++) {
        a[i] += x * y + j;
    }
}
  • private(j, y): each thread gets its own uninitialized j and y. Using the outer value is undefined.
  • firstprivate(x): each thread gets its own x, initialized to the outer value.
  • lastprivate(k): each thread has its own k; after the loop, k in the enclosing scope gets the value from the sequentially last iteration. Useful when you need the final loop state.
  • shared(a, n): all threads see the same a and n. Reads are fine; writes need synchronization.
  • default(none): forces you to explicitly classify every variable. Use this. It catches bugs where you forgot to mark a loop-local variable as private.
1
#pragma omp parallel for default(none) private(j, y) firstprivate(x) shared(a, n)

When a compiler yells about an unclassified variable, fix it. Do not remove default(none).

Atomics, Critical Sections, and Locks

When threads need to update shared state, OpenMP offers three levels of synchronization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Atomic: fastest, hardware-level, only for simple read-modify-write ops
#pragma omp atomic
counter++;

#pragma omp atomic update
histogram[bin]++;

// Critical: protects a named section; only one thread at a time
#pragma omp critical(update_tree)
{
    tree_insert(root, value);
}

// Locks: explicit, for when you need finer control
omp_lock_t lock;
omp_init_lock(&lock);
// ... inside parallel region:
omp_set_lock(&lock);
// critical section
omp_unset_lock(&lock);
// ...
omp_destroy_lock(&lock);

Rules of thumb:

  • Prefer atomic over critical when the operation is a simple update (+=, ++, --, *=, /=, swap). Atomic maps to a hardware instruction; critical is a general-purpose mutex.
  • Prefer critical with a name over an unnamed critical. All unnamed criticals share one global lock — which means unrelated critical sections serialize against each other.
  • Prefer reduction over atomic in loops. The compiler can use thread-local accumulators and merge once at the end; atomic adds a hardware serialization on every iteration.
  • Use explicit locks when you need conditional locking (omp_test_lock), hierarchical locking, or when you are implementing a data structure like a concurrent hash map.

Sections, Single, Master, and Barriers

Not all parallelism is loop parallelism:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#pragma omp parallel
{
    #pragma omp sections
    {
        #pragma omp section
        task_a();
        #pragma omp section
        task_b();
        #pragma omp section
        task_c();
    }
    // implicit barrier here

    #pragma omp single
    {
        // exactly one thread runs this; others wait
        write_checkpoint();
    }
    // implicit barrier here

    #pragma omp master
    {
        // only the master thread runs this; NO implicit barrier
        log_progress();
    }

    #pragma omp barrier
    // all threads synchronize here

    do_parallel_thing();

    #pragma omp single nowait
    {
        print_summary();
    }
    // no barrier — threads proceed past
}

The implicit barriers are a frequent performance trap. If you have back-to-back work-sharing constructs and the intermediate barrier is not load-bearing, add nowait:

1
2
3
4
5
#pragma omp for nowait
for (...) { ... }

#pragma omp for
for (...) { ... }

This lets threads that finish the first loop start on the second without waiting for stragglers.

Tasks: When Loops Are Not Enough

OpenMP 3.0 introduced tasks for irregular parallelism — recursion, graph traversal, producer-consumer patterns. Tasks are deferred units of work that the runtime schedules onto the team.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
long fib(int n) {
    if (n < 2) return n;
    long x, y;
    #pragma omp task shared(x)
    x = fib(n - 1);
    #pragma omp task shared(y)
    y = fib(n - 2);
    #pragma omp taskwait
    return x + y;
}

int main(void) {
    long result;
    #pragma omp parallel
    #pragma omp single
    result = fib(40);
    printf("%ld\n", result);
}

Two things to notice:

  1. Tasks are spawned from a single region inside a parallel region — one thread creates tasks, the team executes them. Spawning tasks from every thread is usually wrong.
  2. Bare recursion with OpenMP tasks explodes into millions of tiny tasks at deep recursion levels. Use the final(n < threshold) clause to stop creating tasks past a cutoff and run sequentially instead. Also use if(n > threshold) to avoid task overhead for trivial work.
1
2
#pragma omp task shared(x) if(n > 20) final(n < 10)
x = fib(n - 1);

Task Dependencies

OpenMP 4.0 added dependency clauses that let you express a task DAG:

1
2
3
4
5
6
7
8
#pragma omp task depend(out: A)
produce(A);

#pragma omp task depend(in: A) depend(out: B)
transform(A, B);

#pragma omp task depend(in: A, B)
consume(A, B);

The runtime schedules tasks such that dependencies are respected. This lets you describe pipelines, wavefront algorithms, and anything with a producer-consumer structure without managing queues and condition variables yourself.

Taskloop

taskloop is what parallel for would look like if loops were expressed as tasks:

1
2
3
4
#pragma omp taskloop grainsize(1000) nogroup
for (int i = 0; i < N; i++) {
    heavy_work(i);
}

Use it when you want loop parallelism inside a task-based structure, or when you want grain control that is easier to reason about than chunk sizes.

SIMD: Vectorizing Inner Loops

Modern CPUs have SIMD instructions (AVX-512, NEON, SVE). The compiler’s auto-vectorizer often fails when it cannot prove the loop is safe to vectorize. OpenMP’s simd directive asserts that it is:

1
2
3
4
#pragma omp simd reduction(+:sum) aligned(a, b: 64)
for (int i = 0; i < n; i++) {
    sum += a[i] * b[i];
}

Combine thread parallelism and vectorization with parallel for simd:

1
2
3
4
#pragma omp parallel for simd reduction(+:sum)
for (int i = 0; i < n; i++) {
    sum += a[i] * b[i];
}

declare simd makes a function vectorizable so it can be called inside a SIMD loop:

1
2
#pragma omp declare simd
double myfunc(double x) { return x * x + 1.0; }

Without declare simd, calling myfunc inside a simd loop breaks vectorization — the compiler must fall back to scalar calls.

Affinity: Where Do Your Threads Actually Run?

On a multi-socket NUMA system, which core a thread runs on matters enormously. A thread running on socket 0 accessing memory allocated on socket 1 pays a 2–4× latency penalty on every miss. OpenMP exposes thread affinity through OMP_PLACES and OMP_PROC_BIND.

1
2
3
4
5
6
7
8
export OMP_NUM_THREADS=16
export OMP_PLACES=cores           # one place per core (SMT siblings grouped)
export OMP_PROC_BIND=close        # pack threads onto the same socket
# or:
export OMP_PROC_BIND=spread       # spread threads across sockets

# Verify:
export OMP_DISPLAY_AFFINITY=TRUE

close is right when your threads share data heavily (cache reuse matters more than memory bandwidth). spread is right when each thread streams lots of independent memory (you want bandwidth from all memory controllers).

For nested parallelism, you can specify per-level binding: OMP_PROC_BIND=spread,close — outer team spreads across sockets, inner teams pack onto cores of the same socket.

On Linux, verify with:

1
2
3
hwloc-ls                          # show system topology
hwloc-bind --get                  # show current binding
OMP_DISPLAY_AFFINITY=TRUE ./app   # OpenMP runtime prints actual placement

If you are running under SLURM, use --cpu-bind and --cpus-per-task to align SLURM’s view with OpenMP’s.

NUMA and First-Touch

Linux’s default memory allocation policy is first-touch: a physical page is allocated on the NUMA node of the thread that first writes to it. The trap:

1
2
3
4
5
double *a = malloc(N * sizeof(double));
// ... (optionally initialize sequentially)

#pragma omp parallel for
for (int i = 0; i < N; i++) a[i] = compute(i);

If the array was initialized in a sequential phase, all of it lives on one NUMA node. Your parallel loop on that array hits remote memory for N−1 threads. The fix: parallelize the initialization the same way you will parallelize the use.

1
2
3
4
5
#pragma omp parallel for
for (int i = 0; i < N; i++) a[i] = 0.0;   // first-touch

#pragma omp parallel for
for (int i = 0; i < N; i++) a[i] = compute(i);

This one trick often produces 1.5–2× speedups on NUMA machines with no algorithmic changes.

False Sharing: The Silent Killer

Two threads writing to different variables that happen to live on the same cache line will thrash that line between cores. Symptom: code that should scale linearly plateaus at 2–3 threads.

1
2
3
4
5
6
7
// BAD: counts[0] and counts[1] are on the same cache line
int counts[NTHREADS];
#pragma omp parallel
{
    int tid = omp_get_thread_num();
    for (int i = 0; i < M; i++) counts[tid]++;
}

Fixes:

  • Reductions: use reduction(+:total) — the runtime manages per-thread accumulators.
  • Pad: make each slot a full cache line.
1
2
typedef struct { int count; char pad[60]; } padded_int;
padded_int counts[NTHREADS];

Or with C11 alignas:

1
typedef struct { _Alignas(64) int count; } padded_int;

Diagnose with perf c2c on Linux:

1
2
perf c2c record ./app
perf c2c report

This pinpoints the exact cache lines suffering HITM events (hit-modified, meaning one core stole a dirty line from another).

GPU Offload with target

OpenMP 4.0 introduced GPU offload. It works on NVIDIA (via nvc, clang), AMD (via amdclang, rocmcc), and Intel GPUs (via icx).

1
2
3
4
#pragma omp target teams distribute parallel for map(to: a[0:n], b[0:n]) map(from: c[0:n])
for (int i = 0; i < n; i++) {
    c[i] = a[i] + b[i];
}

Decoding the directives:

  • target — run this on the device.
  • teams — create multiple teams (one per GPU Streaming Multiprocessor / Compute Unit).
  • distribute — split outer loop iterations across teams.
  • parallel for — further parallelize within each team.
  • map(to: ...) — copy data host → device before the region.
  • map(from: ...) — copy data device → host after the region.
  • map(tofrom: ...) — both directions.
  • map(alloc: ...) — allocate on device, no copy.

Managing Data Across Multiple Offloads

Copying the same array between host and device on every kernel call destroys performance. Use target data to keep data resident on the GPU across multiple regions:

1
2
3
4
5
6
7
8
9
#pragma omp target data map(to: a[0:n], b[0:n]) map(from: c[0:n])
{
    for (int iter = 0; iter < num_iters; iter++) {
        #pragma omp target teams distribute parallel for
        for (int i = 0; i < n; i++) {
            c[i] = a[i] + b[i] + iter;
        }
    }
}

For finer control, target enter data / target exit data let you manage the residency lifetime explicitly. target update copies specific ranges on demand.

Unified Memory

Modern GPUs support unified memory — host and device see the same virtual address space. OpenMP 5.0 exposes it via:

1
#pragma omp requires unified_shared_memory

This removes most map clauses but can be slower than explicit data management for well-understood access patterns. Use it when the ergonomic win outweighs the performance cost.

Compile and Run GPU Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# NVIDIA HPC SDK
nvc -mp=gpu -gpu=cc80 -target=gpu app.c -o app

# Clang (with CUDA)
clang -fopenmp -fopenmp-targets=nvptx64-nvidia-cuda app.c -o app

# AMD (ROCm)
amdclang -fopenmp -fopenmp-targets=amdgcn-amd-amdhsa -Xopenmp-target=amdgcn-amd-amdhsa -march=gfx90a app.c -o app

# Run with debug info:
LIBOMPTARGET_INFO=16 ./app        # Clang/LLVM
NVCOMPILER_ACC_NOTIFY=3 ./app     # NVIDIA

Use LIBOMPTARGET_DEBUG=1 or the vendor equivalent to see every data transfer. If your GPU code is slow, the output almost always reveals unnecessary copies.

Debugging Data Races

The single most valuable tool in the OpenMP debugger’s kit is ThreadSanitizer (TSan):

1
2
3
# GCC / Clang
gcc -fopenmp -fsanitize=thread -g -O1 app.c -o app
./app

TSan instruments every memory access and flags races at runtime. It is accurate but slows execution 5–15×; use it on shrunken inputs, not production workloads. It catches:

  • Missing private / reduction clauses.
  • Unsynchronized writes to shared arrays.
  • Ordering bugs around flushes.

On Intel systems, Intel Inspector does the same thing and understands OpenMP semantics natively, so it produces fewer false positives on reductions and locks.

Helgrind

1
valgrind --tool=helgrind ./app

Slower than TSan, but works on binaries you cannot recompile. Good for dusty legacy code.

Archer

Archer is a TSan-based tool specifically aware of OpenMP semantics — it understands that iterations of a parallel for do not race with each other even though they access the same array. Strongly recommended over raw TSan if you have non-trivial OpenMP code.

Profiling OpenMP Code

Tool Best for
perf stat Quick check: CPU cycles, IPC, cache misses, branch mispredictions
perf c2c Finding false sharing
Intel VTune Hotspots, threading efficiency, imbalance, memory access patterns
NVIDIA Nsight Systems Timeline view of GPU offload: kernels, memory transfers, overlap
NVIDIA Nsight Compute Single-kernel profiling on GPU: occupancy, memory throughput
HPCToolkit Scalable, works at 1000s of threads; call-path profiling
Score-P + Scalasca Same league as HPCToolkit; integrates with MPI traces
OMP_DISPLAY_ENV=TRUE Dump every OpenMP environment variable at startup

For a first pass, perf stat -d ./app tells you whether your code is CPU-bound, memory-bound, or synchronization-bound. If IPC is 0.3 and LLC miss rate is 40%, you are memory-bound and no amount of OpenMP tuning will save you — you need better data layout.

Nested Parallelism

OpenMP supports nested parallel regions — a thread inside one parallel region can start another. It is useful for libraries that internally parallelize (BLAS, FFTW) being called from OpenMP code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
omp_set_max_active_levels(2);      // allow nesting 2 deep
// or export OMP_MAX_ACTIVE_LEVELS=2

#pragma omp parallel num_threads(4)
{
    #pragma omp parallel num_threads(4)
    {
        // 16 threads total
    }
}

The trap: nested parallelism oversubscribes your cores. Running 16 threads on 8 cores means context switching, cache thrashing, and worse performance than sequential code. Either (a) carefully size the outer and inner teams to match hardware, or (b) set OMP_MAX_ACTIVE_LEVELS=1 and let only the outer region parallelize.

A better alternative is often OMP_PROC_BIND=spread,close with two levels — outer teams spread across sockets, inner teams bind to cores of the same socket — so the nesting actually maps to NUMA structure.

Hybrid MPI + OpenMP

Large-scale HPC applications typically use MPI between nodes and OpenMP within a node. The pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <mpi.h>
#include <omp.h>

int main(int argc, char **argv) {
    int provided;
    MPI_Init_thread(&argc, &argv, MPI_THREAD_FUNNELED, &provided);

    int rank;
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);

    #pragma omp parallel for
    for (int i = 0; i < N; i++) {
        compute(i);
    }

    // MPI calls outside parallel regions: MPI_THREAD_FUNNELED is enough
    MPI_Allreduce(...);

    MPI_Finalize();
    return 0;
}

Guidance:

  • Start with MPI_THREAD_FUNNELED (only the master thread makes MPI calls). That is sufficient for most apps and avoids the performance cost of MPI_THREAD_MULTIPLE.
  • Under SLURM: srun --cpus-per-task=8 --ntasks-per-node=2 ./app with OMP_NUM_THREADS=8 gives you 2 MPI ranks per node, 8 OpenMP threads per rank. Alignment between SLURM, MPI, and OpenMP is the hardest part of getting this right.
  • Set OMP_PLACES=cores and OMP_PROC_BIND=close so OpenMP threads for a given MPI rank stay on the same NUMA domain.

If you have not read the companion MPI post yet, it covers the threading levels (MPI_THREAD_SINGLE, FUNNELED, SERIALIZED, MULTIPLE) in detail.

OpenMP 5.x and Beyond: Worth Knowing

  • loop construct (OpenMP 5.0): a more abstract alternative to parallel for that lets the compiler decide between SIMD, threading, or GPU offload. Less control, more portability.
  • scan reduction (5.0): inclusive/exclusive prefix sums as a built-in construct.
  • declare variant (5.0): multiple implementations of a function, selected per target (CPU vs GPU, arch-specific tuning).
  • interop (5.1): passing CUDA streams, HIP queues, and other foreign objects through OpenMP.
  • Free-agent threads / task_reduction (5.x): more expressive task-based reductions for irregular parallelism.

Compiler support lags the specification. GCC supports most of 5.0; Clang 17+ covers 5.1 well; NVIDIA and Intel compilers are similar. Check specific features against your compiler’s OpenMP page before depending on them.

The Performance Checklist

Before you ship OpenMP code, walk through this:

  1. Thread scaling curve: plot runtime vs thread count from 1 to full machine. If it does not speed up near-linearly through at least half the cores, find the bottleneck before adding more threads.
  2. default(none): every parallel construct uses it. No implicit data sharing.
  3. Reductions instead of atomics: any thread-local accumulation uses reduction, not atomic updates to a shared variable.
  4. First-touch initialization: arrays are initialized in parallel with the same schedule as their use.
  5. Affinity set: OMP_PLACES and OMP_PROC_BIND are configured, verified with OMP_DISPLAY_AFFINITY.
  6. No unnamed critical sections: every critical section has a name, and you have justified why critical instead of atomic.
  7. False sharing checked: perf c2c or VTune ran clean.
  8. Nowait where barriers are unneeded: back-to-back work-sharing constructs do not pay for barriers you do not need.
  9. TSan/Archer ran clean: no race warnings on a representative workload.
  10. Profiled with realistic input: small inputs hide scaling problems. Profile with production-shaped data.

Writing to a shared index variable:

1
2
3
4
5
6
int k;
#pragma omp parallel for
for (int i = 0; i < n; i++) {
    k = i * 2;         // RACE: k is shared
    a[i] = k + 1;
}

Fix: declare k inside the loop, or add private(k).

Reductions that should be atomics, atomics that should be reductions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Slow: atomic on every iteration
#pragma omp parallel for
for (int i = 0; i < n; i++) {
    #pragma omp atomic
    sum += a[i];
}

// Fast: one merge at the end
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) sum += a[i];

Barrier inside a work-sharing construct:

1
2
3
4
5
#pragma omp parallel for
for (int i = 0; i < n; i++) {
    compute(i);
    #pragma omp barrier        // ILLEGAL — do not do this
}

Work-sharing constructs have their own synchronization. Putting a barrier inside is undefined behavior.

Calling omp_get_thread_num() outside a parallel region: Returns 0 unconditionally. Harmless but almost always not what you meant. If you see this in logging code, it is a sign the logger was never running in parallel.

Assuming thread 0 runs first: The order threads reach a region is unspecified. Do not write code that assumes thread 0 initializes state before others observe it — use single or explicit synchronization.

Recursing into a parallel region you already created:

1
2
3
4
5
6
void process(node *n) {
    #pragma omp parallel for
    for (int i = 0; i < n->nchildren; i++) {
        process(n->children[i]);        // oversubscribes
    }
}

Use tasks (#pragma omp task) for recursion, not nested parallel regions.

Wrapping Up

OpenMP is the single best tool for shared-memory parallelism in C, C++, and Fortran. Not because it is the most powerful — raw pthreads, TBB, or std::thread can express more — but because it lets you describe what should be parallel without writing the how. The compiler and runtime do the work you would have written yourself, and they do it on every target (x86, ARM, Power, GPU) with one source.

The catch is that OpenMP makes it easy to write parallel code and easy to write wrong parallel code. The gap is closed by a small set of disciplines: default(none), reductions over atomics, first-touch initialization, explicit affinity, and regular TSan runs. Adopt those, pair OpenMP with MPI when you scale beyond one node, and you have a durable model for compute-heavy software that will still be making sense ten years from now.

Comments