bagnold
Granular matter on the GPU: MLS-MPM with Drucker-Prager sand plasticity
(Klar et al., Drucker-Prager Elastoplasticity for Sand Animation, SIGGRAPH
2016), loadable through kernels. Unlike DEM, which stores per-contact
friction history (order 200 bytes per grain), MPM takes friction from the
constitutive model's yield surface: state is per-particle, the grid is
scratch, and nothing is stored per contact pair.
Named for Ralph Bagnold (The Physics of Blown Sand and Desert Dunes, 1941).
A 390k-particle column released and slumping to its repose angle. Simulated
with this kernel (media/make_collapse.py), each particle path-traced as a
sand grain in Cycles (media/render_blender.py).
Usage
from kernels import get_kernel
bag = get_kernel("phanerozoic/bagnold", version=1, trust_remote_code=True)
sim = bag.Sim(grid=(512, 512, 256), dx=1.25e-3,
material=bag.sand(phi_deg=35.0))
sim.add(positions, ppc=8) # particles per cell fixes their volume
sim.run(1000) # timestep from the elastic CFL
rho = sim.density() # kg/m3, per particle
Sim owns the grid scratch, picks a stable step, and keeps particles sorted
for the shared-memory scatter. Boundaries come from a signed distance field:
col = bag.Collider.from_sdf(phi, normals, dx, mu=0.3) # phi < 0 inside solid
sim = bag.Sim(grid=g, dx=dx, material=bag.sand(), collider=col)
mu=Noneis a stick boundary (infinite wall friction), under which a tall column hangs on its walls rather than loading the outlet;mu ~ 0.3is a finite wall.Esets the elastic wave speed and therefore the timestep. Plastic response is governed by the friction angle, soEcan be reduced for a larger step as long as elastic strains stay small.mpm_stepexposes every array and parameter directly;Simwraps it.- The published binary is Linux x86_64. On Windows,
load_local.pyJIT-builds the same source and exposes the identical API.
Performance
Throughput on one RTX 6000 Ada (48 GB), sand cube, dense grid:
| grid | particles | VRAM | ms/step | Mparticle/s |
|---|---|---|---|---|
| 256x256x128 | 16.4M | 3.0 GB | 11.9 | 1377 |
| 384x384x192 | 65.4M | 12.0 GB | 48.8 | 1341 |
| 512x512x256 | 110.1M | 20.5 GB | 84.3 | 1305 |
The per-particle rate is flat, so cost scales with particle count and VRAM is the limit. Past the point where the grid fills, sort and grid work start to dominate (221M particles run at 164 Mparticle/s) but nothing wraps: indices are 64-bit.
Head-to-head, same scene, same material, same GPU in each row:
| reference | scene | them | bagnold | margin |
|---|---|---|---|---|
| taichi_elements (Taichi team's engine) | 9.9M sand, 256^3, RTX 6000 Ada | 72.9 ms | 7.1 ms | 10.3x |
hand-tuned Taichi MLS-MPM (bench/vs_taichi.py) |
same | 13.3 ms | 7.1 ms | 1.86x |
| warp-mpm (NVIDIA Warp, the PhysGaussian solver) | 364k sand, 128^3, RTX 3070 Ti | 1.74 ms | 1.51 ms | 1.17x |
taichi_elements loses most of its step rebuilding particle-in-block lists
every substep (46 of 73 ms); bagnold amortizes its sort over 32 steps and
tolerates drift by falling back to global atomics, and still wins with
per-step sorting (~17 ms). Against Warp both pay the same dense cubic grid,
and the margin is bagnold's sorted shared-memory scatter against warp-mpm's
global atomics (bench/bench_warp.py; warp-mpm pins Warp 0.10 and needed six
source fixes to run on Warp 1.15 -- the published bagnold binary loaded
unmodified).
Where the time goes: p2g is ~85% of a step and its scatter is 93% of that -- 1.3G global atomics per step at 69 Gatomic/s; the SVD is 1%. Sorting particles by grid block so one CUDA block owns a 4^3 node patch, accumulates into a 6^3 shared tile, and flushes once (Gao et al. 2018) cuts p2g from 22.5 to 7.1 ms/step, 2.85x. Tile size 4^3 beats 2^3 and 8^3 by more than 2x, one warp per block beats every wider launch, and re-sorting every 32 steps beats both per-step and drift-adaptive sorting -- the interval is a performance knob, not a correctness one, since drifted particles fall back to global atomics. g2p stays on the global path: its 27 reads per particle are local enough that L2 serves 97%, and it runs at 83% of the card's bandwidth on particle state.
Validation
The constitutive model is exact. bench/constitutive_probe.py replicates
the kernel's return map in principal log-strain space and drives a single
material element to critical state in triaxial compression:
| fed phi | mobilised (Hencky) | mobilised (fixed-corotated) |
|---|---|---|
| 25 deg | 25.00 | 25.00 |
| 35 deg | 35.00 | 35.10 |
| 40 deg | 40.00 | 40.22 |
alpha matches Drucker-Prager to Mohr-Coulomb in triaxial compression and the
model returns the fed angle there to machine precision. bagnold's p2g uses a
fixed-corotated stress for force while the return map is derived for a Hencky
law; the probe bounds that inconsistency at 0.1 deg.
Angle of repose converges to the fed angle. A released cylinder slumps and the flank is measured against the fed 35 deg, four grids each halving dx:
| grid | dx | particles | repose |
|---|---|---|---|
| 128 | 5.00 mm | 174k | 27.9 deg |
| 256 | 2.50 mm | 1.4M | 30.2 deg |
| 512 | 1.25 mm | 11.1M | 31.7 deg |
| 1024 | 0.625 mm | 89.0M | 33.1 deg |
Monotone across the 16x refinement. The constitutive probe proves the material
yields at exactly 35, so the deficit is discretization, still converging:
fitting theta(dx) brackets the limit to ~33-37, consistent with 35 and not
claimed as converged. Four surface estimators differ by 0.3-0.6 deg and share
the trend (it is the simulation, not the ruler), and a plane-strain ridge
tracks the axisymmetric pile at the same dx. Basal friction does not move it:
runout is unchanged for floor mu >= 0.70 = tan(35 deg) -- above the internal
friction the sand shears internally instead of sliding -- and grows only below
that; floor_mu=None (stick) under-predicts runout against a real surface.
Collapse runout vs experiment. Axisymmetric column collapse has a
material-insensitive empirical law (Lube et al. 2004; Lajeunesse et al. 2004):
normalized runout (Rf - R0)/R0 is linear in aspect ratio a = H0/R0 below
a ~ 1.7 and grows as a^1/2 above. Sweeping a (bench/runout_fine.py,
dx 1.67 mm, phi 33, floor mu 0.6):
| a | 0.5 | 1.2 | 1.7 | 2.5 | 3.5 | 5.0 | 7.0 |
|---|---|---|---|---|---|---|---|
| sim dR/R0 | 0.42 | 0.92 | 1.25 | 1.76 | 2.30 | 3.05 | 3.84 |
| Lube 2004 | 0.62 | 1.49 | 2.09 | 2.53 | 2.99 | 3.58 | 4.23 |
The phenomenology is reproduced -- two regimes breaking at a ~ 1.7,
summit-preserving deposits at small a, conical-to-sombrero morphology at
large -- but the magnitude runs 60-90% of experiment (fitted 0.76a and
0.84 a^0.79 against 1.24a and 1.6 a^0.5). bench/runout_conv.py rules
out resolution (converged across three grids at a = 0.5) and the base
(floor mu 0.3-1.0 moves runout ~10%); at phi 23, matching Lajeunesse's glass
beads, a = 1.2 reaches 1.34 against their ~1.6. That pattern is the known
signature of rate-independent Drucker-Prager: no rate weakening, so the thin
flowing front refreezes early. Treat absolute runout as conservative by tens
of percent; the scaling structure, transition point, and morphology are right.
Silo discharge is head-independent and Beverloo-scaled. A granular
discharge, unlike a liquid, does not slow as the head falls: an arch over the
orifice carries the column's weight (Janssen saturation, needing a tall
narrow silo, H/R ~ 5). On a 4.2M-particle silo the rate is flat to a few
percent while the head drops:
| orifice D | steady W | spread |
|---|---|---|
| 12 mm | 18.9 g/s | 3.3% |
| 16 mm | 52.7 g/s | 8.6% |
| 20 mm | 103.1 g/s | 8.7% |
| 24 mm | 190.8 g/s | 9.5% |
The rates fit Beverloo's form W = C rho sqrt(g) (D - k d)^2.5 at rms 2.5%
with C = 0.514 against a literature 0.55-0.65 -- but with a 4.44 mm aperture
deficit that a continuum should not have. examples/orifice_profile.py shows
it is a dead annulus of ~2.5 grid cells at the rim, shrinking in millimetres
under refinement while the core exit speed stays put: the flow is
resolution-converged, the effective aperture is not. Measured separately, exit
velocity, effective aperture, and discharge density multiply to the observed
W ~ D^3.31, Beverloo scaling in the effective aperture. The discharge
density matters: sand dilates where it accelerates (down to 38% packing at the
centreline), and integrating the measured local density reproduces the silo's
103.1 g/s to 0.1.
Density is recoverable from particle state. det(F) alone reports rho0
even in free fall -- the return map projects F onto the yield surface each
step. The projection's discarded volume ratio accumulates in Jp, and
bagnold.density(pF, pJp) = rho0 / (det(F) Jp) reads a dropped block at a
mean 2155 kg/m3 over [1423, 2294]; grid mass and summed particle volumes agree
to 5.4% as independent checks.
Hardening (sand(hardening=True), Klar et al. sec. 4) makes the friction
angle track accumulated plastic strain, phi(q) = h0 + (h1 q - h3) exp(-h2 q)
-- non-monotonic by design: the paper's sand starts at 25 deg, peaks near 48,
and relaxes toward 35. Off by default; the fed phi_deg then governs.
Demos
The sim is a torch op on the same device as everything else, so it drops into
a loop with a search or a network around it. Each demo is one file in
demos/, runs in seconds to minutes on one card, and checks itself.
calibrate_friction.py-- recover an unknown friction angle from a pile: golden-section search, each step a full 3D collapse matched on runout. Recovers a hidden 31.7 deg to 0.04 deg in 34 sims.design_hopper.py-- size a hopper orifice for a target discharge: the wall is an SDF, so the orifice diameter is a search variable (21.1 mm for 134 g/s in 8 sims). Beverloo run backwards.learn_surrogate.py-- train a network on sand: the sim generates (friction angle -> pile height) pairs, a small MLP learns the map on the same GPU and inverts it instantly (32.4 deg recovered). The kernel has no analytic backward, so the network trains on sim outputs, not through the sim.
Scope and limits
- float32, CUDA, compute capability 8.0+, Linux x86_64 (Windows via JIT).
- Dense background grid: memory is
nx*ny*nz*4floats regardless of how much holds material. - Explicit;
dt <= ~0.3 dx / sqrt(E/rho). The step is set by stiffness, not flow speed, so a second of hourglass is tens of thousands of steps. - Discharged material must be removed or it piles at its repose angle beneath
the outlet and blocks it (
examples/hourglass.pyparks it outside the grid). - Absolute collapse runout is conservative by tens of percent (see validation); scaling structure and morphology are quantitative.
- Particle indices are 64-bit; a test allocates 250M slots to prove it.
License
Apache-2.0.
- Downloads last month
- -
- OS
- linux
- Arch
- x86_64
- Kernel Builder
- 19aaa64





