A header-only C++17 library for Lattice Boltzmann Method (LBM) fluid simulations, with an OpenMP and a CUDA backend, 2D and 3D velocity sets, pluggable collision operators and boundary conditions, and built-in error estimation against analytical solutions and the Ghia et al. (1982) benchmark.
- Introduction
- Properties and features
- Installation and configuration
- Usage and simulation model
- Architecture Overview
- Embedding the library in a CMake project
- Design and performance
- Error estimation
- Simulation results
- Flow Around Immersed Obstacles
- Validation of Obstacles Flow
- Documentation and report
- References
- License
- Authors
The Lattice Boltzmann equation describes the dynamics of a gas at the mesoscopic scale; through a Chapman-Enskog expansion its solutions recover the incompressible Navier-Stokes equations in the low-Mach limit. The Boltzmann equation is analytically harder than the NSE, but its numerical scheme is far simpler and highly local, which is why it is a popular choice for CFD codes -- and an excellent target for parallelization.
Project Proposal
lbm-simulation-lib grew out of an AMSC hands-on on the 2D lid-driven cavity
and was extended into a modular and extensible simulation library:
- 2D and 3D problems (
D2Q9,D3Q19,D3Q27) ✅ ; - two collision operators (BGK, TRT) ✅;
- two execution backends (OpenMP, CUDA) behind a common solver interface ✅;
- domain-face and immersed-obstacle boundary conditions 🔶;
- error estimation against analytical solutions and reference benchmarks 🔶.
🔶: features not included in our project proposal, but which stem from the architectural choices.
We strongly recommend running 3D simulations only with CUDA as the bulk of the computation grows extremely fast as the grid becomes large and OpenMP cannot guarantee fast enough computation on CPUs.
This is an HW limit more than a software and parallelization limit.
The Library
The core library itself is header-only (include/lbm-sim/); the executables under
simulations/ are examples of how to drive it -- lid-driven cavity, Couette
flow, Poiseuille flow, flow past an obstacle.
There are two more modules that come bundled with it:
- a configuration module that uses toml as backend;
- a logging module that uses either quill
or
ostreamas backends.
Both modules are completely opt-out (see below 👇 and do not alter the core library's functionalities.
⚠️ When logging is DISABLED actual logging macros are replaced by no-ops that are then optimized out if using any optimization flag (as in happens Release mode).
This library also offers:
- asynchronous output (raw binary, or a VTK series for ParaView).
⚠️ For 3D even though AsynchronousBinaryWriter can generate an output, we have no post-processing scripts in place to parse and visualize it.
Therefore for 3D simulations stick with .vtk outputs.
| Area | What is available |
|---|---|
| Dimensions | 2D and 3D, selected by the dim template parameter (checked against the velocity set) |
| Velocity sets | D2Q9, D3Q19, D3Q27 (core/velocity-sets.hpp) |
| Collision operators | CollisionModel::BGK, CollisionModel::TRT (MRT is reserved, currently a static_assert) |
| Backends | ExecutionBackend::OPEN_MP (OpenMPSolver), ExecutionBackend::CUDA (CUDASolver, opt-in) |
| Boundary conditions | Rigid-wall and moving-wall bounce-back, periodic, pressure-periodic inlet/outlet -- per domain face (Solid::DomainBC) or per immersed obstacle (Solid::ObstacleData) |
| Obstacles | Rasterized from analytic shapes: Segment, Circle, Parallelogram, Airfoil (2D only), CylindricalShell (3D only) through CollisionDetection::CollisionArea |
| Output | AsyncBinaryWriter (raw binary, background thread) and VtkWriter (.vti frames + a .pvd series for ParaView) |
| Configuration | TOML files parsed with toml++, validated against the binary's own dimension, operator and backend |
| Logging | Three interchangeable backends selected by LBM_LOG_BACKEND: Quill, ostream (no dependency), or none (macros expand away); the active level is fixed at compile time |
| Profiling | Optional chrono instrumentation (LBM_ENABLE_PROFILING), dumped as CSV next to the profile output |
| Error estimation | L1, L2, L2^2, Linf norms against a user-supplied functional::Function<dim>, or against the Ghia et al. tables |
| Post-processing | Python scripts for animations, velocity profiles and scaling plots |
- CMake >= 3.18
- A C++17 compiler with OpenMP support (GCC, Clang, or MSVC -- MSVC
builds use
/openmp:experimental) - Git and network access at configure time, unless both optional
dependencies are disabled: Quill (
v12.1.0) is fetched only whenLBM_LOG_BACKEND=quill(the default) and toml++ (v3.4.0) only whenLBM_ENABLE_CONFIG=ON(the default) - Optional: CUDA Toolkit, for the CUDA backend
- Optional: Doxygen, for the documentation targets
- Optional: Python 3 with
numpyandmatplotlib, for post-processing
git clone https://github.com/Gab-San/lbm-simulation-lib.gitcmake -S . -B build && cmake --build build -jEvery .cpp under simulations/ becomes an executable named after its file,
built into build/simulations/. With the CUDA backend enabled, every .cu
becomes an executable prefixed with cuda_:
cmake -S . -B build -DLBM_ENABLE_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=75 && cmake --build build -jCMAKE_CUDA_ARCHITECTURES defaults to 75 -- set it to match your GPU. The
CUDA build deliberately aligns CMAKE_CUDA_HOST_COMPILER with
CMAKE_CXX_COMPILER (except under the Visual Studio generators), so that host
code compiled by nvcc and the rest of the project agree on one OpenMP
runtime.
| Option | Default | Meaning |
|---|---|---|
LBM_ENABLE_CONFIG |
ON |
Build the TOML configuration support (fetches toml++) |
LBM_ENABLE_PROFILING |
OFF |
Compile the chrono-based profiling instrumentation (LBM_PROFILING); required for the CSVs the scaling plots are built from |
LBM_ENABLE_LOGGING |
ON |
Emit log output; OFF forces LBM_LOG_BACKEND=none |
LBM_LOG_BACKEND |
quill |
Logging backend: quill (fetched), ostream (no dependency), none |
LBM_LOG_LEVEL |
INFO |
Compile-time active level: TRACE_L3, TRACE_L2, TRACE_L1, DEBUG, INFO, WARNING, ERROR, CRITICAL |
LBM_BUILD_SIMULATIONS |
top-level | Build the executables under simulations/; off by default when the project is embedded |
LBM_BUILD_DOCS |
top-level | Configure the docs/ Doxygen targets; off by default when the project is embedded |
LBM_SANITIZE |
(empty) | Sanitizers to build with: address, undefined, thread, leak (comma- or semicolon-separated) |
LBM_ENABLE_CUDA |
OFF |
Enable the CUDA language, build lbm-sim-cuda and the cuda_* executables |
CMAKE_CUDA_ARCHITECTURES |
75 |
Target GPU architecture(s) |
CMAKE_CUDA_HOST_COMPILER |
CMAKE_CXX_COMPILER |
Host compiler driven by nvcc; override only for host compilers nvcc rejects |
⚠️ Enabling profiling automatically disables the writers; the output files and folders might be created but they will be empty.
Log statements below LBM_LOG_LEVEL are compiled out entirely, so a release
run pays nothing for them:
cmake -S . -B build -DLBM_LOG_LEVEL=DEBUGThere are also some presets that one can use. To list them:
cmake --preset list
and then to run them:
cmake --preset <preset-name>
Default Generator: Ninja for its compatibility.
To change generator use the flag:
-DCMAKE_GENERATOR=<generator>for example:
cmake --preset release -DCMAKE_GENERATOR="Unix Makefiles"will use make as backend.
| Options x Preset | debug | release | bench | profiling |
|---|---|---|---|---|
CMAKE_BUILD_TYPE |
Debug |
Release |
Release |
Release |
LBM_ENABLE_CONFIG |
ON |
ON |
ON |
ON |
LBM_ENABLE_PROFILING |
OFF |
OFF |
ON |
ON |
LBM_ENABLE_LOGGING |
ON |
ON |
ON |
ON |
LBM_LOG_BACKEND |
quill |
quill |
none |
quill |
LBM_ENABLE_CUDA |
OFF |
OFF |
ON |
OFF |
Presets cuda-debug and cuda are the respective of **debug and release with CUDA activated.
python -m venv .venv && pip install -r scripts/py/requirements.txtPython scripts live in scripts/py folder; the two useful ones are:
scripts/py/visualize_profile.pyto visualize.datprofile outputs;scripts/py/visualize_frames.pythat generates an animation from the binary writer output.
See below 👇
A simulation main is short and always follows the same steps. Condensed from
simulations/openmp/couette_d2q9_bgk.cpp:
using namespace lbm;
using types::DimPoint;
// --- 1. READ THE CONFIGURATION FILE (OPTIONAL) ---------------------------
if (argc < 2) {
config::print_usage(argv[0]);
return 1;
}
// --- 2. SET UP THE LOGGER ------------------------------------------------
logging::setup();
logging::Logger *main_logger = logging::create_or_get_logger("main");
// If the logger is not set up nothing will show on screen.
// This does not mean that logging functions will be
// optimized out or skipped!
std::vector<config::SimulationConfig<DIM>> configs;
try {
configs = config::parse_config<DIM>(argv[1]);
} catch (const config::ConfigError &err) {
LBM_LOG_ERROR(main_logger, "Config Error {}", err.what());
return 1;
}
// --- 3. RUN ONE SIMULATION PER CONFIGURATION -----------------------------
for (const auto &cfg : configs) {
const DimPoint<DIM> grid_size(cfg.grid_size);
utils::Vector<double, DIM> u0(cfg.u0);
// --- 4. DESCRIBE THE DOMAIN FACES --------------------------------------
// Couette: rigid bottom wall, moving top wall, periodic left and right
// sides. Corners still belong to the horizontal faces: the wrap on x
// happens first, then the y face claims the link.
Solid::DomainBC<DIM> dbc{};
dbc.low(0) = Solid::PERIODIC; // x = 0
dbc.high(0) = Solid::PERIODIC; // x = nx-1
dbc.low(1) = Solid::BB_RIGID_WALL; // y = 0
dbc.high(1) = Solid::BB_MOVING_WALL; // y = ny-1
// --- 5. BUILD THE SOLID MASK -------------------------------------------
// No obstacle immersed in the fluid: the mask is entirely types::FLUID.
types::solid_mask_t solid_mask =
Solid::compute_solid_mask<DIM>({}, grid_size);
// --- 6. RUN THE SIMULATION ---------------------------------------------
// frames_out is the DIRECTORY; the file basename comes from the config
// name, so different runs in the same directory do not overwrite each
// other.
std::shared_ptr<AsyncBinaryWriter> writer =
std::make_shared<AsyncBinaryWriter>(cfg.frames_out);
LBMSimulation<DIM, D2Q9, COLLISION> simulation(
grid_size, std::move(solid_mask), {}, dbc,
CollisionParams<DIM, COLLISION>(cfg.reynolds, grid_size, u0));
simulation.attachListener(writer);
OpenMPSolver<DIM, D2Q9, COLLISION> solver(cfg.niters, cfg.nframes);
solver.attachListener(writer);
simulation.solve(solver);
simulation.output(cfg.profile_out.c_str(),
functional::extract_dx_profile_along_y_center);
// --- 7. ESTIMATE THE ERROR (see the dedicated section) -----------------
const double H = static_cast<double>(grid_size.y - 1);
const auto exact_solution = analysis::CouetteSolution2D(H, u0.dx);
const double err_l2 =
simulation.compute_error(analysis::NormType::L2, exact_solution);
LBM_LOG_NOTICE(main_logger, "{} error: {}",
analysis::to_string(analysis::NormType::L2), err_l2);
simulation.detachListener(writer);
solver.detachListener(writer);
}The writer must be attached to both objects: LBMSimulation emits the
grid-size header, the solver emits the velocity-norm frames, and they are two
distinct DataObservables. Detach it from both at the end of a run, so the
next configuration in the loop starts from a clean listener list.
Moving to 3D means changing three things: DIM, the velocity set (D3Q19 or D3Q27).
See lid_cavity_d3q19_bgk.cu.
Configurations are TOML files holding an array of [[conf]] tables -- one run
each -- parsed by lbm::config::parse_config<dim>(), which returns a
std::vector<config::SimulationConfig<dim>>:
[[conf]]
[conf.lattice]
size = [129, 129] # [nx, ny] in 2D, [nx, ny, nz] in 3D
[conf.physics]
reynolds = 100.0 # > 0
init_vel = [0.1, 0.0] # reference velocity, one component per dimension
[conf.solver]
niters = 100000 # > 0
nframes = 200 # must not exceed niters
[conf.output]
frames = "out/frames.out"
profile = "out/profile.dat"
[conf.backend]
n_threads = 8 # optional; 0 means "let the backend decide"Every field is mandatory except nframes and n_threads, which both default
to 0. The dimension, the collision operator and the backend are not
fields: they are compile-time parameters of the binary, so a lattice.size of
the wrong length is rejected instead of being reinterpreted. Failures raise
config::ConfigError with a message naming the offending field, so a main can
distinguish a bad configuration (print, exit 1) from an error raised during the
solve. Repeat the [[conf]] block to describe several runs in one file --
configs/profiling.toml is a thread-count sweep written that way.
Every executable under simulations/ takes the path of a .toml as its only
argument, and prints a usage line when called without one:
./build/simulations/couette_d2q9_bgk configs/example.toml./build/simulations/cuda_lid_cavity_d2q9_bgk configs/lid_cavity_cuda.tomlThe two obstacle_flow_2d_bgk mains are the exception: their geometry is
hardcoded, so they take no configuration file.
Benchmark tables and the configs/ directory are passed to the executables as
absolute paths at configure time (LBM_BENCHMARKS_DIR, LBM_CONFIGS_DIR), so
a binary finds its input data from any working directory and editing a .toml
takes effect without recompiling. Output paths, on the other hand, are relative
to the working directory, and the files under configs/ write to out/...:
launch from build/simulations/ to collect the output under
build/simulations/out/, or point [conf.output] somewhere else.
CUDA executables
CUDA executables mainly purpose is to relieve the Colab Notebooks of long chains of commands.
The CUDA executables can be run in sequence, one log per run, with:
./run_cuda_simulations.sh./run_cuda_simulations.sh lid_cavity_d2q9_bgkWith no argument it runs every cuda_* executable found in
build/simulations; a name is accepted with or without the cuda_ prefix, and
BIN_DIR and LOG_DIR override the defaults.
There are two output products, both written off the simulation thread:
- Frames -- the velocity magnitude at every node, emitted every
niters / nframessteps.AsyncBinaryWriterwrites them as a raw binary stream (nx,ny[,nz] asint32, then onefloat32per node per frame);VtkWriterturns the same stream into one.vtifile per frame plus a.pvdseries that ParaView can open while the run is still going. - Profile -- a 1D velocity profile along a centerline, written by
LBMSimulation::output()with an extractor fromlbm::functional. The file starts with a%%profile <operator> <n> <u_ref>ASCII header, followed byfloat64samples.
Python tooling
python scripts/py/visualize_frames.py out/norms_lid_cavity.bin -o cavity.gifpython scripts/py/visualize_profile.py output/lid_cavity_bgk_profile.dat benchmarks/ghia/data_y_100.txt --title "Re = 100" -o profile.pngpython scripts/py/validate_outputs.pyvisualize_frames.pyanimates the raw binary frames;visualize_profile.pyoverlays one or more profiles (including the Ghia tables, which are already normalized by the lid velocity);validate_outputs.pychecks the produced files structurally and numerically -- headers, payload lengths, finite values, and duplicate output paths.
The library is built around compile-time polymorphism, so that the dimension, the velocity set and the collision operator cost nothing at run time:
LBMSimulation<dim, VelocitySet, CollisionModel> owns the Lattice + CollisionParams
| solve(solver)
v
SolverBase<dim, VelocitySet, CollisionModel, Backend>
|-- OpenMPSolver<dim, VelocitySet, cm>
`-- CUDASolver<dim, VelocitySet, cm>
uses CollisionStrategy<dim, VelocitySet, cm> (BGK / TRT)
DataObservable ---notify---> IDataListener
^ |-- AsyncBinaryWriter
|-- LBMSimulation `-- VtkWriter
`-- SolverBase
Three deliberate choices shape the code:
- Strategy for collision.
CollisionStrategydispatches on theCollisionModeltemplate parameter withif constexpr, so the collision kernel is inlined into the fused stream-collide loop; an unimplemented operator is a compile error, not a run-time branch. - Observer for output. Solvers know nothing about file formats: they push raw byte chunks to listeners. Both writers drain a queue on a dedicated thread, so disk I/O overlaps with computation and the format can change without touching the solver.
- O(1) boundary description. Domain faces are at most six bytes regardless of resolution, and the per-node solid mask is 2 bytes per node -- about 1.4% of the population arrays on a 1000^2 grid, which keeps obstacle support essentially free.
A UML view of the project lives in
docs/report/LBM_UML.html
(editable source: LBM_UML.drawio).
Each iteration of OpenMPSolver::solve performs:
- streaming, with boundary conditions resolved link by link;
- computation of the macroscopic moments
rho(x,t)andu(x,t); - computation of the equilibrium
f^{eq}_i(x,t); - collision;
- every
niters / nframessteps, emission of a velocity-norm frame to the attached listeners.
Steps 1-4 are fused into a single update_stream_collide pass over two
population buffers that are swapped at the end of the step, so populations are
read from one array and written to the other with no aliasing and no extra
copy. The macroscopic fields are materialized only on the steps that need them
(a frame step, or the last iteration).
lbm-sim is an INTERFACE (header-only) target that already carries its
include directory, OpenMP, the logging helper and toml++. The top-level
CMakeLists.txt adds simulations/ only when the project is top level, so
consuming it as a subproject builds the library and nothing else.
With FetchContent:
include(FetchContent)
FetchContent_Declare(
lbm-simulation-lib
GIT_REPOSITORY https://github.com/Gab-San/lbm-simulation-lib.git
GIT_TAG main
)
FetchContent_MakeAvailable(lbm-simulation-lib)
add_executable(my_sim my_sim.cpp)
target_link_libraries(my_sim PRIVATE lbm-sim)Or with a vendored copy:
Directory Structure
my_project/
├── CMakeLists.txt
├── lbm-simulation-lib
└── main.cpp
Minimal CMakeLists.txt
cmake(VERSION 3.21)
project(my_project)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_subdirectory(lbm-simulation-lib)
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE lbm::sim)
# If LBM_ENABLE_CUDA=ON link CUDA with lbm::sim-cuda
For the CUDA backend, configure the consuming project with
-DLBM_ENABLE_CUDA=ON and link lbm::sim-cuda instead: it pulls lbm-sim in
and adds relocatable device code plus the OpenMP flag for nvcc's host pass.
LBM parallelizes well because its operations are local: at each time step almost every computation touches a single node and its immediate neighbours. The collision step is purely local, and the macroscopic moments at a node depend only on the populations stored there, so the node loop is embarrassingly parallel:
#pragma omp parallel for shared(lattice, part_stream) schedule(static)
for (int cell = 0; cell < area; ++cell) { /* ... */ }The loops iterate over a flattened cell index and recover the coordinate with
lbm::iteration::unflatten, which keeps one parallel for for both 2D and 3D
instead of a collapse(2) / collapse(3) pair. The schedule is static:
every node costs the same, so a static split is both the cheapest and the one
that keeps a thread on the same slice of the lattice across iterations, which
is what makes the per-thread working set stay in cache.
The thread count comes from the OpenMP runtime unless a main sets it
explicitly. Only profiling_lid_cavity_d2q9 does -- through
BackendProperties<OPEN_MP>::setNumThreads(), fed by [conf.backend] n_threads -- so for every other executable it is the environment that decides:
OMP_NUM_THREADS=8 ./build/simulations/lid_cavity_d2q9_bgk configs/example.tomlThe CUDA backend maps the same node loop onto the device, with the velocity set
uploaded to __constant__ memory and the domain BC passed by value as a kernel
argument (DomainBC is trivially copyable by design).
The timings come from the solver's own instrumentation, not from time: a
build configured with -DLBM_ENABLE_PROFILING=ON makes OpenMPSolver write
one row per run into a CSV, and the row kept here is the solve_total timer --
the wall time of the whole iteration loop, with initialization, allocation and
output excluded. The profiling main also sets setBenchmarkMode(true), which
suppresses frame emission, so the writer thread is not part of the measurement.
The sweeps are the [[conf]] lists in:
configs/profiling.toml(strong)configs/profiling_couette.toml(strong)configs/weak_scaling.toml(weak)
the thread count is dynamically set through the configuration entry
[conf.backend].n_threads, applied through BackendProperties<OPEN_MP>::scopedApply().
For the strong scaling we have set up two different 2D problems:
- a lid cavity problem with:
- grid size: 129x129;
- Reynolds number: 100;
- number of iterations: 100000.
- a couette problem with:
- grid size: 300x300;
- Reynolds number: 200;
- number of iterations: 100000.
For the weak scaling we have set up one configuration on a 2D lid cavity simulation.
NOTE: the configuration results is set up to 64 threads but the simulations with 32 and 64 threads were lost and we did not manage to re-run them yet.
Each repetition is a separate PBS job (scripts/cpu_job.pbs, one node,
ncpus=64, 30 minutes of walltime), submitted N times:
./scripts/submit_simulations.sh build/simulations/profiling_lid_cavity_d2q9 configs/profiling.toml 5python scripts/py/plot_strong.py results -o docs/report/assets/strong_scaling.pngplot_strong.py and plot_weak.py walk results/<JOBID>/prof/ recursively,
average the repetitions, and write both the figure and the summary table as
images. Speedup and efficiency are always measured against the smallest thread
count present in that series, never against a hardcoded 1.
Lid Cavity D2Q9
| threads | mean [s] | std [s] | speedup | efficiency | MLUPS |
|---|---|---|---|---|---|
| 1 | 140.459 | 0.792 | 1.00 | 1.00 | 11.8 |
| 2 | 72.565 | 1.086 | 1.94 | 0.97 | 22.9 |
| 4 | 37.437 | 0.256 | 3.75 | 0.94 | 44.5 |
| 8 | 19.560 | 0.252 | 7.18 | 0.90 | 85.1 |
| 16 | 10.567 | 0.271 | 13.29 | 0.83 | 157.5 |
| 32 | 7.207 | 0.523 | 19.49 | 0.61 | 230.9 |
| 64 | 14.092 | 5.891 | 9.97 | 0.16 | 118.1 |
Couette D2Q9
| threads | mean [s] | std [s] | speedup | efficiency | MLUPS |
|---|---|---|---|---|---|
| 1 | 960.569 | 1.853 | 1.00 | 1.00 | 9.37 |
| 2 | 482.318 | 3.188 | 1.991 | 0.996 | 18.66 |
| 4 | 246.033 | 0.198 | 3.904 | 0.976 | 36.58 |
| 8 | 126.055 | 0.449 | 7.620 | 0.953 | 71.4 |
| 16 | 66.827 | 2.367 | 14.374 | 0.898 | 134.68 |
| 32 | 41.643 | 2.005 | 23.067 | 0.721 | 216.122 |
| 64 | 40.523 | 1.657 | 23.704 | 0.370 | 222.1 |
MLUPS (million lattice updates per second) is
nx * ny * niters / time; it is the number to compare across grids, since
speedup alone hides the fact that the two rows below are not the same amount of
work.
What the curve says, in order:
- Up to 16 threads the solver behaves as the algorithm promises. 90% at 8
threads and 83% at 16 for an embarrassingly parallel node loop is what an
OpenMP
parallel forover a fused stream-collide pass should give: there is no reduction, no critical section, and the only synchronization is the implicit barrier at the end of the loop. - 32 threads still pays off, but the efficiency drops to 0.61. Each thread is left with 16641 / 32 = 520 nodes, i.e. about 75 kB of populations -- small enough that the barrier at every one of the 100 000 iterations starts to weigh against the useful work per iteration.
- 64 threads is a regression, not a plateau: the time goes back up from
7.2 s to 14.1 s, and the standard deviation explodes from 0.5 s to 5.9 s
(40% of the mean). A slower mean with an unstable spread is contention, not
saturation. With 260 nodes per thread the loop body is shorter than the
barrier that follows it, and if the 64 logical CPUs of the node are SMT
siblings on fewer physical cores, two threads then fight for one set of
execution units and one L1. The fix is not in the solver: bind the threads
(
OMP_PROC_BIND=close,OMP_PLACES=cores) and re-measure, and use a grid large enough that 64 threads have real work -- the weak-scaling sweep below shows the same node handling 4 million nodes without any of this.
The 300x300 series in the figure did not scale at all, and it is worth saying explicitly why it should not be read as a result of the solver: its wall time is 78.0 s on 1 thread and 75.5 s on 64, i.e. flat to within the noise, for a speedup of 1.03. A parallel loop that is genuinely limited by memory bandwidth still improves by a factor of several before flattening; a curve that is flat from the first doubling means the extra threads never ran. Two facts pin it down:
- the single-thread throughput of the two series is the same (11.8 MLUPS at 129x129, 11.5 MLUPS at 300x300 once the iteration counts are taken into account), so the serial code path is identical;
- the 64-thread 300x300 run has the same throughput as its own serial run.
The 300×300 series in the figure scales cleanly up to 32 threads and then stalls, and it's worth spelling out why the last data point should be read as a bandwidth ceiling rather than a property of the solver:
- wall time drops from 41.643 s at 32 threads to 40.523 s at 64, a 2.8% improvement for a doubling of thread count, pushing the speedup from 23.067 to just 23.704;
- efficiency makes the picture unambiguous — it holds above 0.72 through 32 threads (0.996, 0.976, 0.953, 0.898, 0.721 at 2, 4, 8, 16, 32) and then collapses to 0.370 at 64;
A genuinely compute-bound loop would keep gaining share of the extra cores; a curve that flattens sharply on the last doubling after tracking near-ideal scaling up to that point is the signature of memory bandwidth saturating rather than the solver failing to parallelize. This is corroborated by throughput: 216.1 MLUPS at 32 threads versus 222.1 MLUPS at 64 — essentially the same delivered bandwidth despite twice the threads, meaning the additional 32 threads bought almost nothing.
Since the n_threads column of the CSV is the value requested through
BackendProperties::getNumThreads() and not one measured inside the parallel
region with omp_get_num_threads(), a job that was granted fewer CPUs than it
asked for -- or one whose threads were confined to a single core by the queue
-- is recorded as a 64-thread run regardless. Before that series is quoted
anywhere, re-run it on a job whose select=...:ncpus= matches the sweep, and
log omp_get_num_threads() from inside the region so the CSV records what was
actually used rather than what was asked for.
| threads | grid | cells/thread | mean [s] | MLUPS | MLUPS per thread |
|---|---|---|---|---|---|
| 1 | 129x129 | 16641 | 141.774 | 11.7 | 11.7 |
| 2 | 183x183 | 16744 | 146.129 | 22.9 | 11.5 |
| 4 | 257x257 | 16512 | 146.945 | 45.0 | 11.2 |
| 8 | 365x365 | 16653 | 151.584 | 87.9 | 11.0 |
| 16 | 515x515 | 16577 | 151.768 | 174.8 | 10.9 |
The first five rows are a textbook weak-scaling sweep: the grid grows so that every thread keeps about 16 600 nodes, and the ideal is a flat time curve. It is flat to within 7% -- 141.8 s on one thread against 151.8 s on sixteen, efficiency 0.934 -- which is the same story the strong-scaling curve tells from the other side: the node loop itself parallelizes cleanly, and what little slope there is comes from the growing footprint rather than from synchronization.
Two complementary facilities, both reachable from LBMSimulation.
Modelled on dealii::VectorTools: pass an exact solution and a norm type; the
discrete field and the grid are taken from the simulation itself.
const analysis::PoiseuilleSolution2D exact(channel_height, u_max);
const double err = simulation.compute_error(analysis::NormType::L2, exact);analysis::NormType covers L1, L2, L2_squared and Linfty.
ErrorEvaluator<dim>::integrate_difference() produces the per-cell error and
compute_global_error() reduces it in the requested norm; compute_error()
returns the absolute global error. Ready-made solutions are
analysis::CouetteSolution2D and analysis::PoiseuilleSolution2D; any other
case is a subclass of functional::Function<dim> overriding
value(const Coordinate<dim> &).
For the 2D lid-driven cavity, results are validated against Ghia, Ghia & Shin
(1982). The tables in benchmarks/ghia/ hold the reference
centerline profiles for Re = 100, 1000 and 7500, already normalized by
the lid velocity.
const auto ghia_y = simulation.compute_ghia_error("benchmarks/ghia/data_y_100.txt");
// ghia_y.relative, ghia_y.absolute, ghia_y.norm_typecompute_ghia_error() compares lattice.u along the two centerlines with the
tabulated points, reduces them to a scalar in the requested norm (L2 by
default) and returns both the absolute and the relative error in an
analysis::NormErrorResult. It is defined only for dim == 2 -- the 3D
cavity has no tabulated equivalent here, so 3D runs are validated by exporting
the centerline profile and comparing it against external reference data.
Measured errors for both centerlines -- Re = 1000 on a 200x200 grid, and the
Couette and Poiseuille cases against their analytical solutions -- are tabulated
in docs/report/error_results.md.
Every profile below is produced the same way: the run writes a centerline with
LBMSimulation::output() and an extractor from lbm::functional, and
scripts/py/visualize_profile.py plots it. The script divides the samples by
the u_ref recorded in the %%profile header, which is the same normalization
the Ghia tables already use, so a curve that sits below the reference is
genuinely slower and not just scaled differently.
2D cavity, D2Q9, 2000x2000 nodes, TRT and BGK, compared against
benchmarks/ghia/data_y_7500.txt -- the vertical velocity along the horizontal
centerline, v(x, ny/2), which is what
functional::extract_dy_profile_along_x_center extracts.
frames_lid_cavity_d2q9_2000_2000_7500_02_trt.mp4
The shape is right: the profile leaves the left wall, peaks just inside it, crosses zero at mid-cavity and reaches a deeper negative extremum near the right wall -- the signature of the primary vortex, asymmetric in exactly the direction Ghia's table is. Point by point:
| simulation | Ghia et al. (1982) | difference | |
|---|---|---|---|
| positive peak (x ~ 0.06) | +0.38 | +0.4403 | -14% |
| zero crossing | ~0.50 | 0.50 (+0.008) | -- |
| negative extremum (x ~ 0.96) | -0.54 | -0.5522 | -2% |
Three things are worth reading off these two figures, in decreasing order of how easy they are to get wrong:
- BGK and TRT are indistinguishable at this scale. In the comparison plot the green BGK curve is drawn over the blue TRT one and hides it completely -- the legend entry is the only evidence TRT is there at all. That is the expected outcome, not a bug: the two operators differ in how they relax the antisymmetric moments, which shows up in the placement of curved or obstacle walls, and a square cavity resolved by 2000 nodes gives that difference nothing to act on. TRT earns its keep elsewhere -- on the pipe below, or at coarser resolutions where the wall position matters.
- The straight segment of the orange curve between x = 0.23 and x = 0.80 is
interpolation, not data. Ghia's table has 17 points and only one of them
(x = 0.5) lies in that interval;
visualize_profile.pyjoins consecutive points with a straight line. Most of the apparent gap in the middle of the plot is therefore an artifact of the reference being drawn as a polyline. The number to quote is the onecompute_ghia_error()returns: it interpolates the simulation onto the 17 tabulated abscissas and reduces the differences inL2, which is a comparison between 17 pairs of numbers rather than between two drawn curves. - The 14% deficit on the positive peak is real, and resolution is not the
explanation. At 2000 nodes across the cavity, one lattice spacing is 0.05%
of the side: the near-wall extremum is resolved by tens of nodes, so this is
not an under-resolved boundary layer. The likelier cause is that the run has
not reached the steady state Ghia's tables describe. One flow-through time
for a lid at
u = 0.1on a 2000-node cavity isL/u = 20000steps, and a cavity atRe = 7500-- where the secondary corner vortices are still growing long after the primary one has formed -- needs tens of flow-through times to settle. The asymmetry of the error supports this: the extremum on the side the lid drives into (-2%) is already converged, while the one on the returning side, which is fed by the slower recirculation, is the one lagging.
The configuration that produced these two figures is not in
configs/: it was run with an ad-hoc grid and iteration count. Commit it asconfigs/lid_cavity_7500.toml-- withoutnitersthe convergence argument above cannot be checked, and the figures cannot be reproduced.
D3Q19 on the CUDA backend
(simulations/cuda/pipe_poiseuille_d3q19_bgk.cu,
configs/pipe_config*.toml): a CollisionDetection::CylindricalShell
rasterized as the pipe wall inside a box, pressure-periodic inlet and outlet on
the x faces, rigid walls elsewhere. The profile is u_x along the z
centerline, from functional::extract_dx_profile_along_z_center.
- The profile is the parabola Hagen-Poiseuille predicts across a diameter, and
it is symmetric about the axis to within the line width -- which is the real
test here, because nothing in the setup enforces symmetry: the wall is a
rasterized cylinder, so a lopsided profile would have exposed a bias in how
compute_solid_mask()walks the shell. - The flat zero shoulders at both ends are not stagnant fluid, they are solid nodes. The extractor samples the full width of the box, and the pipe is inscribed in it: the two or three nodes at each end of the line sit between the box face and the cylinder wall. The fluid region is about 92% of the plotted line, and the profile does start from zero exactly at the shell, so bounce-back is placing the wall where the geometry says it is.
- The peak reaches 0.93 of the reference velocity that sets the viscosity, and
the two obvious suspects are already ruled out by the main itself: it builds
the effective radius as
r_eff = radius + 0.5, so the half-cell wall offset is accounted for, and it rescales the Reynolds number byny / d_effbefore handing it toCollisionParams, whose viscosity is otherwise derived from the box side rather than the pipe diameter. What is left is the wall itself: a rasterized cylinder is a staircase, and bounce-back on a staircase is only first-order accurate for a curved boundary, so a few percent on a radius of about 30 nodes is the expected price. That is also the one place where TRT and an interpolated bounce-back would earn their keep.
profile_pipe_poiseuille_64_65_65_21_bgk.pngdoes not carry its parameters in the filename, and three pipe configurations are committed (Re = 21andRe = 100on 64x65x65), so which one it is cannot be recovered from the file. Renaming it to the convention the other figures use --profile_pipe_d3q19_<nx>_<ny>_<nz>_<Re>_<operator>.png-- costs nothing now and saves the next reader a guess.
D3Q19, 200x200x200 nodes, Re = 1000, lid on the z = nz-1 face
(configs/lid_cavity_3d.toml). The animation is
the velocity magnitude on the exported frames, rendered with
scripts/py/visualize_frames.py.
There is no tabulated 3D counterpart to Ghia in benchmarks/, and
compute_ghia_error() is dim == 2 only, so this case is validated
qualitatively -- the primary vortex forms under the lid and the corner
recirculations appear where they should -- and quantitatively only through the
exported centerline, which has to be compared against external reference data.
The same run is available as lid_cavity_3d.mp4
(the GIF is ten times larger than the MP4, so prefer the MP4 when linking it
from anywhere that plays video).
Three cases using the same domain structure — BB_MOVING_WALL on the left boundary as the source of motion, OPEN_OUTFLOW on the other three boundaries, and an immersed obstacle handled through compute_solid_mask() — are used to qualitatively validate the open boundary condition pipeline together with the sponge layer, as well as the geometry of the shapes themselves.
None of the three cases has a reference dataset comparable to Ghia et al. that can be used for point-by-point validation. The validation here is therefore visual, following the same approach used for the 3D cavity described above: the goal is to verify that the flow behaves as expected around the obstacle, rather than to match a tabulated reference profile.
circle_simulation.mp4
The simplest of the three cases: a CollisionDetection::Circle is immersed in the channel. The video shows the formation of the wake behind the obstacle and the shedding of vortices downstream, with no evidence of flow passing through the circle (i.e., no "leakage" through the solid). This indirectly confirms that compute_solid_mask() correctly marks the entire body rather than an incomplete shell.
This case is also the most direct reference for evaluating the absorption performance of OPEN_OUTFLOW. Since the geometry is symmetric, any spurious reflection from the right boundary would be clearly visible as a disturbance propagating back along the wake axis.
NACA_2412_simulation.mp4
The airfoil is generated through CollisionDetection::Airfoil, which implements the four-digit NACA convention (NACA MPXX): the first digit (M) represents the maximum camber as a percentage of the chord; the second digit (P) represents the location of the maximum camber along the chord, expressed in tenths of the chord; and the final two digits (XX) represent the maximum thickness as a percentage of the chord.
For example, a NACA 2412 airfoil has a maximum camber of 2% located at 4/10 of the chord and a maximum thickness of 12%. These correspond directly to the three parameters exposed by the Airfoil constructor — max_camber, camber_pos, and thickness — together with the angle of attack.
The airfoil boundary is generated analytically using the standard NACA thickness equation and a piecewise camber-line formulation. The resulting geometry is then tested point-by-point using a ray-casting point-in-polygon algorithm, rather than being manually rasterized as with the other shapes.
The video shows asymmetric flow separation between the upper and lower surfaces, consistently with the imposed angle of attack. The wake develops downstream of the trailing edge without visible artifacts at the airfoil attachment, where the local curvature is higher and rasterization errors would be more apparent.
obstacle_simulation.mp4
The same domain setup is applied to an arbitrary geometry through CollisionArea, providing a test case for the open boundary conditions independently of the specific obstacle shape.
Only one OPEN_OUTFLOW boundary was used for the right exiting flux, while on the left boundary BB_MOVING_WALL was used as the source of motion and two BB_RIGID_WALL where used as the orizontal walls. This is also the case used to tune the sponge layer placed before the OPEN_OUTFLOW boundary. Without the sponge layer, the impulsive wave generated when the moving wall starts was visibly reflected by the right boundary. With the sponge layer enabled — relaxing rho and u toward the quiescent state over the last cells before the boundary — the initial transient leaves the domain without producing a perceptible reflected wave.
NOTE: None of the three configurations is currently present in
configs/: they were run using ad-hoc parameters during the tuning of the boundary conditions and currentrly do not present a.tomlfile.
The Strouhal number is a dimensionless quantity used to characterize the unsteady nature of a flow and, in particular, the frequency of periodic vortex shedding behind bluff bodies such as a circular cylinder. It is defined as
where (f) is the dominant vortex-shedding frequency, (D) is the characteristic length (the cylinder diameter in this case), and (U_\infty) is the free-stream velocity.
For the present work, the Strouhal number is used as a validation quantity for the implementation of solid-fluid interaction in our simulation. Since the vortex-shedding frequency is a macroscopic flow quantity that depends on the Reynolds number and on the correct representation of the unsteady wake, obtaining physically consistent Strouhal numbers provides an effective check that the simulation, boundary conditions, collision model, and numerical discretization are working correctly.
The first validation consists of evaluating the Strouhal number for a range of Reynolds numbers and comparing the resulting values with the expected behavior of the flow around a circular cylinder.
For each Reynolds number included in the final analysis, the simulation parameters are manually set in Circle_flow_2d_bgk.cu, in particular the desired Reynolds number and the spatial resolution factor (scale). The LBM BGK simulation is then executed until a sufficiently long time history of the flow is obtained.
The dominant vortex-shedding frequency is extracted from the temporal evolution of the flow field using the following procedure:
- The LBM simulation is run for the selected Reynolds number and spatial
scale. - The resulting flow field is loaded into ParaView.
- A
Probe Locationis placed downstream of the cylinder at
- A
Plot Data Over Timefilter is applied to obtain the temporal signal at the selected probe location. - The resulting time history is exported from ParaView as a
.csvfile.
This procedure is repeated for every Reynolds number considered in the study. All the CSV signals obtained then are saved in the folder benchmarks\strouhal using the name convention Eddy_Generation_RE{RaynoldNumber}_S{ScaleFactor}.csv.
The simplified post-processing workflow used to obtain the dominant shedding frequency is illustrated below.
Workflow used to obtain the dominant vortex-shedding frequency from the LBM simulation, from the probe signal in ParaView to the FFT.
The final plot is then made executing into the folder scripts/py the script analysis_strouhal_multiRE.py where:
- A Fast Fourier Transform (FFT) is applied to the temporal signal in order to identify its dominant frequency (f), corresponding to the main periodic vortex-shedding mode.
- The extracted peak frequency is then used to calculate the Strouhal number according to the already seen fomula
The resulting (St)-versus-(Re) curve is reported in the following plot with respesct to the standard Lienhard 1966 / Roshko St-Re diagram for a circular cylinder based on aggregated experimental data:
Strouhal number obtained from the LBM BGK simulations as a function of the Reynolds number.
The consistency of the resulting curve with the expected behavior for flow past a circular cylinder is used as a validation of the implementation. In particular, the appearance of a well-defined dominant frequency in the wake and its systematic evolution with Reynolds number demonstrate that the simulation is correctly capturing the periodic vortex-shedding dynamics.
A second validation was performed to verify that the computed Strouhal number is not significantly affected by the spatial resolution of the lattice.
For this purpose, a reference case at
was simulated using different spatial scaling factors, with
Increasing the scale parameter increases the number of lattice nodes used to represent the same physical geometry while preserving the geometric proportions of the computational domain. Therefore, if the numerical implementation and the chosen simulation parameters are behaving consistently, the resulting dimensionless Strouhal number should remain approximately unchanged as the spatial resolution is increased.
For each scale factor, the previous 5 post-processing steps are applied. All the CSV signals obtained then are saved in the folder benchmarks\strouhal using the name convention Eddy_Generation_Signal{ScaleFactor}.csv.
The final plot is then made executing into the folder scripts/py the script analysis_strouhal_scale_1000.py where the usal FFT computation and peak detection are performed to obtain the Strouhal numbers w.r.t. the scaling factor. The resulting values are reported in the following plot with respesct to the expected Lienhard 1966 / Roshko St-Re diagram experimental data (St. between 0.210 and 0.218 for Re = 1000):
Strouhal number obtained at (Re=1000) for spatial scaling factors from 1 to 7.
The results show that the computed Strouhal number remains consistent across the tested spatial resolutions. This demonstrates that the main dimensionless characteristic of the vortex-shedding process is preserved when the lattice resolution is increased.
Therefore, the scaling study provides an additional validation of the LBM BGK implementation: the observed invariance of (St) with respect to the scale parameter indicates that the vortex-shedding dynamics are not being significantly altered by the selected spatial discretization.
Overall, these two validation tests provide a solid complementary evidence that the LBM BGK simulation is capable of reproducing correctly the complex and unsteady flow behavior around the circular cylinder.
API documentation is generated with Doxygen when it is found at configure time:
cmake --build build --target lbm-docs-fulllbm-sim-docs-- the library headers alone;lbm-docs-full-- the whole portal, usingdocs/mainpage.mdas the main page.
Output lands in docs/doxygen/.
The report material lives under docs/report/:
Lattice_Boltzmann_Theory.pdf for the method and the boundary conditions,
profiling.md for the scaling study,
benchmarks.md and
error_results.md for the validation, and
flow_animations.md for the rendered runs.
The figures and animations they reference live under
- Architecture -- how lattice, solver, boundaries, collision and listeners fit together;
- Configuration files -- the TOML schema the parser actually reads, field by field;
- Output formats -- the frame stream, the profile file, the VTK series and the profiling CSV, byte by byte;
- Validation -- the benchmark cases, the error norms, the pitfalls behind a converged run that still scores badly, and the profiles the runs produced;
- Performance -- timing a run with the profiler, driving a scaling sweep on the cluster, and the strong and weak scaling results;
- Extending the library -- adding a velocity set, a collision operator, an obstacle shape or an output listener.
- T. Kruger et al., The Lattice Boltzmann Method: Principles and Practice, Graduate Texts in Physics, Springer, 2017.
- U. Ghia, K. N. Ghia, C. T. Shin, High-Re solutions for incompressible flow using the Navier-Stokes equations and a multigrid method, Journal of Computational Physics 48 (1982) 387-411.
Released under the MIT License. Copyright (c) 2026 Gabriele Santandrea.
Alessandro Frisone | Corrado Sciancalepore | Chiara Nonino | Gabriele Santandrea






