View on GitHub

PEREGRINE's Homepage

Bookmark this to keep an eye on my project updates!

Executable Mode (Running real cases)

The directory structure is as follows:

.myCase
├── runPeregrine.py        # symlink to runPeregrine.py
├── Archive                # Folder to write archive results (*.h5, *.xmf)
├── Restart                # Folder to read/write restarts (q.h5, q.xmf)
├── Grid                   # Folder to read/write grid (g.h5, g.xmf)
├── Input                  # Folder for auxiliary inputs (profiles/, coproc.py, ...)
└── peregrine.yaml         # PEREGRINE config file (see /src/peregrinepy/files/configFile.py)

The grid file g.h5 is self-describing: it carries the BASE grid as it came from the mesher (coordinates as (nk, nj, ni) datasets, block connectivity in GridPro notation under /connectivity) and any number of decompositions of it (under /decompositions/{nProcs}x{ranksPerNode}, one per machine layout). A decomposition is a cut table (base block + node index ranges per piece), the pieces’ connectivity, and a rank per piece; pieces read their coordinates as hyperslabs of the base blocks, and the base grid never changes. g.xmf is only a ParaView view of the grid.

Restart and archive files are numbered by BASE block and written as (nk-1, nj-1, ni-1) cell datasets by hyperslab, so a run under one decomposition restarts under any other (different rank count, different cuts) without interpolation. Trace points (Input/tracePoints.npy) are (baseNblki, i, j, k) in base block cell indices.

Boundary Conditions

Boundary condition families live in peregrine.yaml under a top-level bcFams key, each family being {bcType, bcVals}. See templates.

Decomposition (cutting + load balancing)

Decompose the base grid for a machine layout (ranks and ranks per node). A cut plane must run through every block of its sheet (the family of parallel planes a conforming topology links together; a GridPro grid has a few dozen), cuts on different sheets commute, and a block’s piece count is the product over its three sheets of (cuts + 1). So the cheapest set of sheet cuts that brings every piece under the cap is solved exactly as a small integer program (-objective pieces for fewest new blocks, faces for least new halo area). The pieces are then placed over nodes first (METIS, minimizing network traffic) and ranks within each node (shared memory), and polished until every rank is within -tol of the mean cell count. Communication between blocks on the same rank is free. -granularity is the cap: pieces per rank the cutter aims for (omit it to place the base blocks uncut; 1 cuts only what cannot fit a rank; several values print the frontier). The result is verified for conformance and stored in the grid file:

utilities/decompose.py ./Grid -numProcs 384 -ranksPerNode 4 -granularity 1 1.5 2
utilities/decompose.py ./Grid -numProcs 384 -ranksPerNode 4 -granularity 1.5
utilities/verifyGrid.py ./Grid     # base grid + every stored decomposition

At startup, PEREGRINE detects the ranks per node from the MPI shared-memory split (override with io.ranksPerNode), picks the stored decomposition for the launch size (any layout for that rank count if the exact one is missing), and assumes one whole block per rank if none exists.

The step graph

A time step is a graph of kernel launches and halo exchanges (src/peregrinepy/_graph.py): every node declares the arrays it reads and writes and how far its stencil reaches, exchanges leave their variable valid only in the interior until they finish, and the executor runs each consumer on the core (interior at the needed depth) while the exchange is in flight and on the skin after, bit-identically to the sequential order. The integrators are whole-step graphs, so the state exchange of one stage overlaps the advective fluxes of the next and the gradient exchange overlaps the diffusive fluxes.

Kernel launches

Every cell kernel runs as one launch over all the rank’s blocks (a blockTable, see src/compute/blockTable_.hpp), so the launch count does not depend on the decomposition; only boundary conditions run per face. Tables cover a region of every block (all, core, or skin), and core plus skin tile every kernel range, which is how the interior cell state is computed while halos are in flight. Each table carries the execution-space instance it launches on (pgkokkos.partitionSpace(n)), so on asynchronous backends separate tables overlap. Bind OpenMP threads (OMP_PROC_BIND=spread OMP_PLACES=threads, OMP_WAIT_POLICY=active for many small blocks); runPeregrine.py warns when they are unset.

Chemistry

The Cantera mechanism yaml is the chemistry input: thermochem.mechanism names it (input dir, working dir, the PEREGRINE database, or a path) and thermochem.chemistry: true turns its reactions on. PEREGRINE parses the file itself (species composition, NASA7 thermo, Lennard-Jones transport; elementary, three-body, Lindemann and Troe falloff reactions with efficiencies, orders and unit conversion) into flat kinetics tables on thtrdat, and on the generic build one table-driven kernel evaluates any mechanism from them. Under the jit the mechanism is instead unrolled into straight-line source (caseKinetics.hpp in the cache: literal rate constants, explicit products, one block per reaction) and compiled with the case, which is about twice as fast as the table lookups. Nothing is checked in or run by hand per mechanism; a new mechanism is a new yaml file. thermochem.mechanism may still be a list of species names drawn from the PEREGRINE species library for non-reacting cases.

Finite-rate chemistry stays fully explicit. At every Runge-Kutta stage the source term is subcycled from the stage state with forward-Euler substeps, each the largest that keeps every mass fraction and the temperature non-negative, over a horizon fixed by the tableau (the stage’s largest Euler stretch: the full step for SSP RK2/RK3, half of it for the four-stage SSP scheme). The stage receives the reconstructed slope, the substep-weighted average of the rates, which conserves mass and elements exactly and is bounded regardless of stiffness; when one substep is feasible it is the base scheme’s slope exactly. There is no substep count to set. The implicit (Strang) path keeps its Backward Euler solver with its own step halving.

JIT specialization

runPeregrine.py recompiles the compute kernels for your case before solving: the species count, ghost-layer count, every thermo/transport coefficient and the mechanism’s kinetics tables are baked in as compile-time constants (per-thread scratch becomes stack arrays, loops get constant bounds). Builds are cached by content hash in ~/.cache/peregrine (override with PEREGRINE_CACHE), so only the first run of a new case/code combination pays the compile (~10 s + mechanism size). simulation.precision: single selects fp32 storage and kernel arithmetic (jit-only; the generic build is double). Set PEREGRINE_NO_JIT=1 to run on the generic build. If the toolchain or Kokkos install recorded at install time is unavailable, the run falls back to the generic build with a warning. Tests and utilities always use the generic build.

Config options

Every config key, its default, and the valid (registry-discovered) values:

utilities/configOptions.py

Old cases

Convert a pre-consolidation case (per-block grid files, loose conn.yaml, blocksForProcs.inp, bcFams.yaml):

utilities/convertCase.py ./myCase