Project: Fluid dynamics

Fluid Dynamics Simulation

Fluid dynamics is the study of the movement of fluids, including air and water. In this project, we will use the Lattice Boltzmann Method (LBM) to simulate fluid dynamics, which is a mesoscopic method based on the kinetic theory of gases.

micromesomacro
Scale$10^{-9}$m$10^{-9} -10^{-6}$m$>10^{6}$m
Physicsmolecularprobabilisticcontinuous
Gov. Eq.NewtonBoltzmannNavier-Stokes equations
MethodMolecular DynamicsLattice BoltzmannComputational Fluid Dynamics

This book does not aim to provide a comprehensive understanding of fluid dynamics. If you are interested in learning more about fluid dynamics, you can refer to the following resources:

Lattice Boltzmann Method (LBM)

The general idea of LBM is to simulate the fluid dynamics by modeling the movement of particles in a lattice, a grid of cells, without keeping track of the individual particles. The state of a cell in the lattice is defined by the density of particles moving in different directions, i.e.

\[{\rm state}(i, j) \equiv \rho_{ij}(\mathbf{v})\]

where $(i, j)$ is the position of the cell in the lattice and $\mathbf{v}$ is the velocity of the particles.

The particles move with different velocities $\mathbf v$ and collide with each other, driving the fluid to reach an equilibrium state, where the energy of the particles is governed by the Boltzmann distribution

\[\rho_{\rm eq}(E) \sim e^{-\frac{E}{k_BT}} ({\rm or }\; e^{- {\rm const.} \times |\mathbf{v}|^2}),\]

where $k_B$ is the Boltzmann constant, $T$ is the temperature, and $E = \frac{1}{2}m|\mathbf{v}|^2$ is the energy of the particles.

D2Q9 model

The lattice Boltzmann method uses a discrete set of velocities, which is a simplification of the continuous velocity space. One of the simplest models is the D2Q9 model, which contains

  • a 2D lattice, and
  • 9 discrete velocities: $(0,0)$, $(1,0), (0,1), (-1,0), (0,-1)$, $(1,1), (-1,1), (-1,-1), (1,-1)$.

Lattice Boltzmann Method (LBM) contains two steps:

  1. Streaming - particles move to neighboring cells
  2. Collision - particles collide and exchange momentum

Streaming

Collision - Bhatnagar-Gross-Krook (BGK) model.

The collision step is based on the Bhatnagar-Gross-Krook (BGK) model, which is a simplified version of the Boltzmann equation. The collision step is defined as

\[\rho\leftarrow(1-\omega)\rho_0+\omega\rho_\mathrm{\rm eq}\]

where $\rho$ is the updated density $\rho_0$ is the density before collision, and $\rho_{\text{eq}}$ is the equilibrium density $\omega = \Delta t/\tau$, where $\tau$ is the (relative) relaxation time

The BGK model has a nice property that it conserves:

  • total density $\rho$
  • momentum $\rho\mathbf{u}$

Julia implementation

The following code is a part of the package MyFirstPackage that we created in the previous section: My First Package.

File: src/fluid.jl (Step 1-6)

Step 1. Define the lattice Boltzmann configuration

Let us start by defining an abstract type for lattice Boltzmann configurations and a concrete type that implements the D2Q9 lattice.

"""
    AbstractLBConfig{D, N}

An abstract type for lattice Boltzmann configurations.
"""
abstract type AbstractLBConfig{D, N} end

The D2Q9 lattice Boltzman configuration is defined as follows:

"""
    D2Q9 <: AbstractLBConfig{2, 9}

A lattice Boltzmann configuration for 2D, 9-velocity model.
"""
struct D2Q9 <: AbstractLBConfig{2, 9} end
directions(::D2Q9) = (
        Point(1, 1), Point(-1, 1),
        Point(1, 0), Point(0, -1),
        Point(0, 0), Point(0, 1),
        Point(-1, 0), Point(1, -1),
        Point(-1, -1),
    )

The directions function returns the 9 discrete velocities in the D2Q9 model. The velocities are ordered in a specific way, which enables us to define a function to flip the velocity vector. This is useful for handling the boundaries and barriers in the lattice.

# directions[k] is the opposite of directions[flip_direction_index(k)
function flip_direction_index(::D2Q9, i::Int)
    return 10 - i
end

Step 2: Define the Cell type for storing the state

The cell state is defined by the density of the fluid in different directions, $\rho_{ij}(\mathbf{v})$.

# the density of the fluid, each component is the density of a velocity
struct Cell{N, T <: Real}
    density::NTuple{N, T}
end
# the total density of the fluid
density(cell::Cell) = sum(cell.density)
# the density of the fluid in a specific direction,
# where the direction is an integer
density(cell::Cell, direction::Int) = cell.density[direction]

Expect the total density, the momentum $\mathbf{u}$ is also conserved, which is defined as the momentum of the fluid.

"""
    momentum(lb::AbstractLBConfig, rho::Cell)

Compute the momentum of the fluid from the density of the fluid.
"""
function momentum(lb::AbstractLBConfig, rho::Cell)
    return mapreduce((r, d) -> r * d, +, rho.density, directions(lb)) / density(rho)
end

Let us also define the addition and multiplication operations for the Cell type.

Base.:+(x::Cell, y::Cell) = Cell(x.density .+ y.density)
Base.:*(x::Real, y::Cell) = Cell(x .* y.density)

Step 3. Implement the streaming step

# streaming step
function stream!(
        lb::AbstractLBConfig{2, N},  # lattice configuration
        newgrid::AbstractMatrix{D}, # the updated grid
        grid::AbstractMatrix{D}, # the original grid
        barrier::AbstractMatrix{Bool} # the barrier configuration
    ) where {N, T, D<:Cell{N, T}}
    ds = directions(lb)
    @inbounds for ci in CartesianIndices(newgrid)
        i, j = ci.I
        newgrid[ci] = Cell(ntuple(N) do k # collect the densities
            ei = ds[k]
            m, n = size(grid)
            i2, j2 = mod1(i - ei[1], m), mod1(j - ei[2], n)
            if barrier[i2, j2]
                # if the cell is a barrier, the fluid flows back
                density(grid[i, j], flip_direction_index(lb, k))
            else
                # otherwise, the fluid flows to the neighboring cell
                density(grid[i2, j2], k)
            end
        end)
    end
end

Step 4. Implement the collision step

By the Bhatnagar-Gross-Krook (BGK) model, the collision step drives the fluid towards an equilibrium state. The equilibrium density is completely determined by the total density and the momentum of the fluid:

\[\rho_{\rm eq}(\mathbf{v}_i) \approx \rho_{\rm tot} w_i \left(1 + 3\mathbf{v}_i\cdot\mathbf{u} + \frac{9}{2}(\mathbf{v}_i\cdot\mathbf{u})^2 - \frac{3}{2}\mathbf{u}\cdot\mathbf{u}\right)\]

where $\rho_{\rm tot}$ is the total density, $\mathbf{u}$ is the momentum, $w_i \sim e^{-|\mathbf{v}_i|^2}$ is the distribution of velocities with mean-velocity zero, and $v_i$ is the velocity vector.

The detailed derivation of the equilibrium density could be found in the first reference at the top of this page.

"""
    equilibrium_density(lb::AbstractLBConfig, ρ, u)

Compute the equilibrium density of the fluid from the total density and the momentum.
"""
function equilibrium_density(lb::AbstractLBConfig{D, N}, ρ, u) where {D, N}
    ws, ds = weights(lb), directions(lb)
    return Cell(
        ntuple(i-> ρ * ws[i] * _equilibrium_density(u, ds[i]), N)
    )
end

# the distribution of the 9 velocities at the equilibrium state
weights(::D2Q9) = (1/36, 1/36, 1/9, 1/9, 4/9, 1/9, 1/9, 1/36, 1/36)
function _equilibrium_density(u, ei)
    # the equilibrium density of the fluid with a specific mean momentum
    return (1 + 3 * dot(ei, u) + 9/2 * dot(ei, u)^2 - 3/2 * dot(u, u))
end

The collision step that implements the BGK model is defined as follows:

# collision step, applied on a single cell
function collide(lb::AbstractLBConfig{D, N}, rho; viscosity = 0.02) where {D, N}
    omega = 1 / (3 * viscosity + 0.5)   # "relaxation" parameter
    # Recompute macroscopic quantities:
    v = momentum(lb, rho)
    return (1 - omega) * rho + omega * equilibrium_density(lb, density(rho), v)
end

Step 5. Implement the lattice Boltzmann simulation

"""
    LatticeBoltzmann{D, N, T, CFG, MT, BT}

A lattice Boltzmann simulation with D dimensions, N velocities, and lattice configuration CFG.
"""

struct LatticeBoltzmann{D, N, T, CFG<:AbstractLBConfig{D, N}, MT<:AbstractMatrix{Cell{N, T}}, BT<:AbstractMatrix{Bool}}
    config::CFG # lattice configuration
    grid::MT    # density of the fluid
    gridcache::MT # cache for the density of the fluid
    barrier::BT # barrier configuration
end

function LatticeBoltzmann(config::AbstractLBConfig{D, N}, grid::AbstractMatrix{<:Cell}, barrier::AbstractMatrix{Bool}) where {D, N}
    @assert size(grid) == size(barrier)
    return LatticeBoltzmann(config, grid, similar(grid), barrier)
end

A single step of the lattice Boltzmann simulation is defined as follows, which directly modifies the grid field of the LatticeBoltzmann type.

"""
    step!(lb::LatticeBoltzmann)

Perform a single step of the lattice Boltzmann simulation.
"""
function step!(lb::LatticeBoltzmann)
    copyto!(lb.gridcache, lb.grid)
    stream!(lb.config, lb.grid, lb.gridcache, lb.barrier)
    lb.grid .= collide.(Ref(lb.config), lb.grid)
    return lb
end

For better visualization, we define a function to compute the curl of the momentum field in 2D, which is defined as:

"""
    curl(u::AbstractMatrix{Point2D{T}})

Compute the curl of the momentum field in 2D, which is defined as:
```math
∂u_y/∂x−∂u_x/∂y
```
"""
function curl(u::Matrix{Point2D{T}}) where T 
    return map(CartesianIndices(u)) do ci
        i, j = ci.I
        m, n = size(u)
        uy = u[mod1(i + 1, m), j][2] - u[mod1(i - 1, m), j][2]
        ux = u[i, mod1(j + 1, n)][1] - u[i, mod1(j - 1, n)][1]
        return uy - ux # a factor of 1/2 is missing here?
    end
end

Step 6. Find the example simulation

A D2Q9 lattice Boltzmann simulation example. A simple linear barrier is added to the lattice.

function example_d2q9(;
        height = 80, width = 200,
        u0 = Point(0.0, 0.1)) # initial and in-flow speed
    # Initialize all the arrays to steady rightward flow:
    rho = equilibrium_density(D2Q9(), 1.0, u0)
    rgrid = fill(rho, height, width)

    # Initialize barriers:
    barrier = falses(height, width)  # True wherever there's a barrier
    mid = div(height, 2)
    barrier[mid-8:mid+8, div(height,2)] .= true              # simple linear barrier

    return LatticeBoltzmann(D2Q9(), rgrid, barrier)
end

Step 7. Include the above file into the package module

File: src/MyFirstPackage.jl

module MyFirstPackage
# import packages
using LinearAlgebra

# export interfaces
export Lorenz, integrate_step
export Point, Point2D, Point3D
export RungeKutta, Euclidean
export D2Q9, LatticeBoltzmann, step!, equilibrium_density, momentum, curl, example_d2q9, density

include("lorenz.jl")
include("fluid.jl")

end

Using the package

File: examples/barrier.jl

using CairoMakie: RGBA # for visualization
using CairoMakie
using MyFirstPackage # our package

# Set up the visualization with Makie:
lb = example_d2q9()
vorticity = Observable(curl(momentum.(Ref(lb.config), lb.grid))')
fig, ax, plot = image(vorticity, colormap = :jet, colorrange = (-0.1, 0.1))

# Show barrier
barrier_img = map(x -> x ? RGBA(0, 0, 0, 1) : RGBA(0, 0, 0, 0), lb.barrier)
image!(ax, barrier_img')

# Recording the simulation
record(fig, joinpath(@__DIR__, "barrier.mp4"), 1:100; framerate = 10) do i
    for i=1:20
        step!(lb)
    end
    vorticity[] = curl(momentum.(Ref(lb.config), lb.grid))'
end

To ensure the reproducibility of the code, we need to create a local environment and install the required packages.

pkg> activate examples

pkg> dev .

pkg> add CairoMakie BenchmarkTools

Type Backspace to exit the package mode. To execute the code, type

julia> include("examples/barrier.jl")

You should see a new file barrier.mp4 in the examples directory, which is the recording of the simulation.

Benchmarking

julia> using BenchmarkTools

julia> @benchmark step!($(deepcopy(lb)))
BenchmarkTools.Trial: 3637 samples with 1 evaluation.
 Range (min … max):  1.323 ms …  1.899 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     1.363 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   1.374 ms ± 30.730 μs  ┊ GC (mean ± σ):  0.00% ± 0.00%

             ▁▇█▆▄▃▃▂▂▂▁▂▂▂▂▁▁▁▁▁▁                           ▁
  ▃▁▁▃▃▃▃▁▁▁▃█████████████████████████▆▆▆▆▅▆▆▆▆▅▅▆▆▃▅▅▄▆▆▅▃▆ █
  1.32 ms      Histogram: log(frequency) by time     1.49 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.

Profiling - identify the performance bottleneck

Premature optimization is the root of all evil – Donald Knuth

Performance optimization comes after the correctness of the code, and it should be based on the profiling result. Profiling is mainly used to identify the performance bottleneck of your code. The Profile module in Julia provides a set of tools to profile your code.

julia> using Profile

julia> Profile.init(n = 10^7, delay = 0.001) # set the number of samples and the delay between samples

Then you can profile your code by wrapping the code with @profile macro.

julia> @profile for i in 1:100
           step!(lb)
       end

In order to collect enough samples, we run the step! function for 100 times. To view the profile result, you can use the Profile.print function.

julia> Profile.print(; mincount=10) # only show functions with ≥10 counts
Overhead ╎ [+additional indent] Count File:Line; Function
=========================================================
            ... truncated message ...
  ╎    ╎    ╎    ╎ 88  …rc/fluid.jl:144; step!(lb::LatticeBoltzm…
  ╎    ╎    ╎    ╎  88  …rc/fluid.jl:76; stream!(lb::D2Q9, newgr…
33╎    ╎    ╎    ╎   88  …e/ntuple.jl:19; ntuple
  ╎    ╎    ╎    ╎    18  …rc/fluid.jl:79; (::MyFirstPackage.var…
  ╎    ╎    ╎    ╎     10  …perators.jl:830; mod1
 1╎    ╎    ╎    ╎    16  …rc/fluid.jl:80; (::MyFirstPackage.var…
  ╎    ╎    ╎    ╎     15  …actarray.jl:1291; getindex
  ╎    ╎    ╎    ╎    ╎ 13  …actarray.jl:1323; _getindex
 1╎    ╎    ╎    ╎    ╎  13  …actarray.jl:702; checkbounds
  ╎    ╎    ╎    ╎    ╎   12  …actarray.jl:681; checkbounds
  ╎    ╎    ╎    ╎ 13  …rc/fluid.jl:145; step!(lb::LatticeBoltzm…
  ╎    ╎    ╎    ╎  13  …roadcast.jl:911; materialize!
  ╎    ╎    ╎    ╎   13  …roadcast.jl:914; materialize!
  ╎    ╎    ╎    ╎    13  …roadcast.jl:956; copyto!
  ╎    ╎    ╎    ╎     13  …roadcast.jl:1003; copyto!
  ╎    ╎    ╎    ╎    ╎ 12  …simdloop.jl:77; macro expansion
  ╎    ╎    ╎    ╎    ╎  12  …roadcast.jl:1004; macro expansion
  ╎    ╎    ╎    ╎    ╎   12  …roadcast.jl:636; getindex
Total snapshots: 112. Utilization: 100% across all threads and tasks. Use the `groupby` kwarg to break down by thread and/or task.

Alternatively, you can use the format :flat to show the profile result in a flat view.

julia> Profile.print(; mincount=10, format=:flat)
 Count  Overhead File                                                        Line Function
 =====  ======== ====                                                        ==== ========
            ... truncated message ...
    10         0 @MyFirstPackage/src/fluid.jl                                  63 #6
    11        11 @MyFirstPackage/src/fluid.jl                                   ? (::MyFirstPackage.var"#8#9"{D2Q9, Matrix{MyFirstPackage.Cell{9, Float64}}, BitMatrix, N…
    20         0 @MyFirstPackage/src/fluid.jl                                  79 (::MyFirstPackage.var"#8#9"{D2Q9, Matrix{MyFirstPackage.Cell{9, Float64}}, BitMatrix, N…
    17         0 @MyFirstPackage/src/fluid.jl                                  80 (::MyFirstPackage.var"#8#9"{D2Q9, Matrix{MyFirstPackage.Cell{9, Float64}}, BitMatrix, N…
    15         3 @MyFirstPackage/src/fluid.jl                                  94 collide(lb::D2Q9, rho::MyFirstPackage.Cell{9, Float64}; viscosity::Float64)
    11         1 @MyFirstPackage/src/fluid.jl                                  62 equilibrium_density(lb::D2Q9, ρ::Float64, u::Point2D{Float64})
    97         0 @MyFirstPackage/src/fluid.jl                                 144 step!(lb::LatticeBoltzmann{2, 9, Float64, D2Q9, Matrix{MyFirstPackage.Cell{9, Float64}}…
    10         0 @MyFirstPackage/src/fluid.jl                                 145 step!(lb::LatticeBoltzmann{2, 9, Float64, D2Q9, Matrix{MyFirstPackage.Cell{9, Float64}}…
    97         0 @MyFirstPackage/src/fluid.jl                                  76 stream!(lb::D2Q9, newgrid::Matrix{MyFirstPackage.Cell{9, Float64}}, grid::Matrix{MyFirs…
Total snapshots: 120. Utilization: 100% across all threads and tasks. Use the `groupby` kwarg to break down by thread and/or task.

The printed result shows that the stream! step is more costly than the collide step, which should be the focus of optimization.

How profiling works

Profiling is a statistical method to measure the performance of your code. It works by sampling the function call stack of the running code at a certain frequency.

Let use look at the stream! function to see which part of the code is more costly.

julia> Profile.clear()   # clear the previous profile result

julia> @profile for i in 1:300
    MyFirstPackage.stream!(lb.config, lb.grid, lb.gridcache, lb.barrier)
end

Profile.print(format=:flat, mincount=30)  # show the profile result, only show the functions that are called more than 5 times
            ... truncated message ...
  ╎    ╎    ╎    ╎ 263 …rc/fluid.jl:76; stream!(lb::D2Q9, newgri…
77╎    ╎    ╎    ╎  259 …e/ntuple.jl:19; ntuple
  ╎    ╎    ╎    ╎   57  …rc/fluid.jl:79; (::MyFirstPackage.var"…
28╎    ╎    ╎    ╎    28  @Base/int.jl:86; -
  ╎    ╎    ╎    ╎    29  …perators.jl:830; mod1
  ╎    ╎    ╎    ╎     27  @Base/int.jl:287; mod
  ╎    ╎    ╎    ╎    ╎ 27  @Base/div.jl:319; fld
  ╎    ╎    ╎    ╎    ╎  27  @Base/div.jl:354; div
  ╎    ╎    ╎    ╎    ╎   25  @Base/int.jl:1068; -
25╎    ╎    ╎    ╎    ╎    25  @Base/int.jl:86; -
 3╎    ╎    ╎    ╎   51  …rc/fluid.jl:80; (::MyFirstPackage.var"…
  ╎    ╎    ╎    ╎    48  …actarray.jl:1291; getindex
  ╎    ╎    ╎    ╎     46  …actarray.jl:1323; _getindex
 3╎    ╎    ╎    ╎    ╎ 46  …actarray.jl:702; checkbounds
  ╎    ╎    ╎    ╎    ╎  43  …actarray.jl:681; checkbounds
  ╎    ╎    ╎    ╎    ╎   39  …actarray.jl:728; checkbounds_indi…
  ╎    ╎    ╎    ╎    ╎    39  …actarray.jl:728; checkbounds_ind…
  ╎    ╎    ╎    ╎    ╎     39  …actarray.jl:763; checkindex
38╎    ╎    ╎    ╎    ╎    ╎ 39  @Base/int.jl:86; -
  ╎    ╎    ╎    ╎   29  …rc/fluid.jl:83; (::MyFirstPackage.var"…
29╎    ╎    ╎    ╎    29  …sentials.jl:14; getindex
Total snapshots: 264. Utilization: 100% across all threads and tasks. Use the `groupby` kwarg to break down by thread and/or task.

The line 79 in file src/fluid.jl costs 57/264 samples, which is the following line of code:

i2, j2 = mod1(i - ei[1], m), mod1(j - ei[2], n)

The line 80 in file src/fluid.jl costs 51/264 samples, which is the following line of code:

if barrier[i2, j2]

and most of the time is spent on the checkbounds function.

Could we remove the boundary check? Please refer to the Julia Performance Tips for more information.