Skip to content

SC26 SCC: Parallelize the cell loops inside each IB patch's gang - #2097

Draft
aliencaocao wants to merge 3 commits into
MFlowCode:masterfrom
aliencaocao:perf/ib-patch-vector-1
Draft

aliencaocao wants to merge 3 commits into
MFlowCode:masterfrom
aliencaocao:perf/ib-patch-vector-1

Conversation

@aliencaocao

@aliencaocao aliencaocao commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Parallelize the cell loops inside each IB patch's gang

Problem

Currently, s_apply_ib_patches_ib_parallelism had patch_id as its only parallel loop. For cases with few immersed bodies whose bounding boxes cover many cells, they leave the GPU nearly idle, because the code uses one thread per patch and every cell of every box is being processed serially by that thread.

What this does

The bounding-box cell loops (k, j, i in 3D; j, i in 2D) become a collapsed vector loop inside each patch's gang.

grid_cell_parallelism (the default for static patches) already parallelizes the cell loops and is untouched.

Measurements

Benchmark: https://github.com/MFlowCode/MFC/blob/master/examples/2D_mibm_particle_cloud/case.py
(many_ib_patch_parallelism=T), scaled to 7000x7000 on 1xH200.

master this PR Δ
grind (ns/gp/eq/rhs) 1.939 1.443 -25.6%

Validation

On NVHPC 26.9 with OpenACC, taking the saved files on the 20th step and comparing against master:

  • conservative field dumps (741M words): bit-identical.
  • IB kinematic state (ib_state_20.dat): 74/400 words differ by one ULP due to floating-point ordering, rest bit-identical.

On AMD OpenMP and CPU this code change is a no-op. On NVHPC OpenMP, it just add a !$omp loop bind(thread) which is also a no-op compared to no directive (current).

Contribution Policy

All contributions are expected to demonstrate:

  • A clear understanding of the codebase
  • Alignment with product direction
  • Thoughtful reasoning behind changes
  • Evidence of real-world usage or hands-on experience with the problem

If these expectations are not met, we would prefer to implement the changes ourselves rather than spend time reviewing low-effort submissions.

Acknowledgment

This PR is cherry-picked from #2093 and is contributed by Nanyang Technological University Team Supernova for SC26 Student Cluster Competition.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude Code Review

Head SHA: 0290a49

Files changed:

  • 1
  • src/simulation/m_ib_patches.fpp

Findings:

  • medium src/simulation/m_ib_patches.fpp: In s_apply_ib_patches_ib_parallelism (3D and 2D branches), xyz_local, length, radius, airfoil_id, model_id, eta are now in the private list of both the outer GPU_PARALLEL_LOOP(parallelism='[gang]') and the inner GPU_LOOP(parallelism='[vector]'). The outer-level entries are redundant: they allocate one gang-shared copy that the inner clause then overrides. Duplicate privatization of the same variable on nested constructs can be rejected or miscompiled under OpenMP offload or Cray. Keep i, j, k, il..kr, patch_id, encoded_patch_id, center on the outer loop and put the per-cell temporaries only on the inner GPU_LOOP. Confidence: moderate; I could not compile on the GPU targets here.
  • medium src/simulation/m_ib_patches.fpp: The collapsed inner loops have per-patch bounds from s_get_bounding_indices and no ir < il guard, unlike s_apply_ib_patches_grid_cell_parallelism. Empty ranges are fine for a plain loop. The inner loop indices i, j, k and the bounds il..kr are privatized only on the outer loop, so check that the inner collapsed loops handle them correctly on NVHPC and Cray (see the Silent-Failure Traps in gpuParallelization.md). Confidence: low-moderate.
  • medium src/simulation/m_ib_patches.fpp: The new copyin='[xp, yp, zp]', parallelism='[gang]') continuation on the GPU_PARALLEL_LOOP line is about 160 columns, past the project's line-length limit, so ./mfc.sh precheck formatting may reflow or fail it. Wrap the arguments. Confidence: moderate.

@sbryngelson
sbryngelson marked this pull request as ready for review October 26, 2026 01:15
@sbryngelson
sbryngelson requested a review from a team as a code owner October 26, 2026 01:15
@sbryngelson
sbryngelson requested review from wilfonba and removed request for a team October 26, 2026 01:15
@github-actions

Copy link
Copy Markdown

Lines of Code

File Lines Diff
src/simulation/m_ib_patches.fpp 644 +4
Directory Lines Diff
simulation 28572 +4
total 47641 +4

@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.95%. Comparing base (3d454a7) to head (0290a49).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2097   +/-   ##
=======================================
  Coverage   61.95%   61.95%           
=======================================
  Files          86       86           
  Lines       22909    22909           
  Branches     3379     3379           
=======================================
  Hits        14193    14193           
  Misses       6207     6207           
  Partials     2509     2509           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wilfonba

Copy link
Copy Markdown
Collaborator

@danieljvickers can you comment on this before I/Spencer hit merge?

@sbryngelson

Copy link
Copy Markdown
Member

our benchmarks in CI @aliencaocao can't replicate any of your speedups. maybe it's a failure of the benchmarking toolchain. not sure. but just one delta on one compiler on one case isn't convincing. moving this to an unapproved draft PR.

@sbryngelson
sbryngelson self-requested a review October 26, 2026 15:26
@sbryngelson
sbryngelson marked this pull request as draft October 26, 2026 15:27
@danieljvickers

danieljvickers commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

@aliencaocao You ran this on a test with 20 IB particles. That parallelism was designed for tens of thousands of particles on the same device. Did you run any analysis on how this compares in the actual larger scale case. It was my experience that the speed up vs what you are sugesting is about 10x-50x using a vector parallelism for everything, which is the current default. My estimation is that this will be slower once your particle count saturates the number of streaming multi-processors on you device.

@sbryngelson I don't think any of the benchmarks CI cover the many-ib-parallelism case. This goes back to my previous comment about wanting to add a moving IBM case to the benchmark suite at some point soon.

@sbryngelson

Copy link
Copy Markdown
Member

@danieljvickers at that rate we may as well add 15 other tests to the benchmarks. perhaps we should think about how benchmarking can be better automated.

@danieljvickers

Copy link
Copy Markdown
Member

Getting off topic, but yes that is why I haven't done that yet. Relevant to the conversation, you should not expect this optimization to appear in the CI.

@aliencaocao

Copy link
Copy Markdown
Contributor Author

Hi @danieljvickers following your suggestion I ran some extra tests.

Environment: 1x H200 SXM, NVHPC 26.9 OpenACC
Test case (case.py is included below):
2D shock over a moving-IB particle bed, 5000x5000, 20 steps

particles radius / spacing master with many_ib_patch_parallelism=T this PR master with many_ib_patch_parallelism=F (grid-cell default)
20 0.15 / 0.05 1.856 1.403 (-24%) 1.855
2,000 0.02 / 0.005 1.487 1.470 1.477
20,000 0.005 / 0.002 1.536 1.528 1.525

So the gain shows up when there are few patches with large bounding boxes (20 particles), which is the case the PR targets. At 2,000 and 20,000 particles all three variants are within ~1%, and I see no evidence that it is "10-50x" slower as you mentioned, at 20k scale.

nsys on the 20,000-particle case:

variant IB-marking kernel (m_ib_patches_s_apply_ib_patches_45) calls total avg / call share of GPU time
master with many_ib_patch_parallelism = T 61 39.8 ms 0.652 ms 0.38%
PR 61 26.8 ms 0.439 ms 0.26%

At 20k particle size the marking is under 0.4% of GPU time whichever path is used, but even in this case, the PR saves ~30% for this specific kernel. The difference is simply not visible at 20k scale due to it being a smaller part of the total runtime. However data suggests that there is no harm to it, and it is strictly a positive for cases with less particles.

If we want CI to be able to see this path, add a benchmark that sets many_ib_patch_parallelism = T will do.

case.py (self-contained; derived from examples/2D_mibm_particle_cloud)

Run as NP=20000 RAD=0.005 SPC=0.002 GRID=5000 STEPS=20 MANY=T ./mfc.sh run case.py -t pre_process simulation -n 1 ...

#!/usr/bin/env python3
"""2D Mach-1.5 shock over a bed of free-floating circles (moving IBM), derived from
examples/2D_mibm_particle_cloud. Sized for an A/B of many_ib_patch_parallelism.

Env (defaults in parens): GRID cells per side (5000), STEPS (20), NP particles (20),
RAD particle radius (0.15), SPC min spacing (0.05), MANY many_ib_patch_parallelism (T).
"""
import json
import os

GRID = int(os.environ.get("GRID", 5000))
STEPS = int(os.environ.get("STEPS", 20))

case = {
    "run_time_info": "F",
    "x_domain%beg": -2.0,
    "x_domain%end": 2.0,
    "y_domain%beg": -2.0,
    "y_domain%end": 2.0,
    "cyl_coord": "F",
    "p": 0,
    "dt": 2.742857142857143e-05,
    "t_step_start": 0,
    "num_patches": 2,
    "model_eqns": "5eq",
    "alt_soundspeed": "F",
    "num_fluids": 1,
    "mpp_lim": "F",
    "mixture_err": "T",
    "time_stepper": "rk3",
    "weno_order": 5,
    "weno_eps": 1e-16,
    "weno_Re_flux": "T",
    "weno_avg": "T",
    "avg_state": "arithmetic",
    "mapped_weno": "T",
    "null_weights": "F",
    "mp_weno": "T",
    "riemann_solver": "hllc",
    "wave_speeds": "direct",
    "fd_order": 2,
    "bc_x%beg": -17,
    "bc_x%end": -8,
    "bc_y%beg": -15,
    "bc_y%end": -15,
    "ib": "T",
    "num_ibs": 0,
    "viscous": "T",
    "collision_model": 1,
    "coefficient_of_restitution": 0.9,
    "collision_time": 0.015,
    "ib_coefficient_of_friction": 0.1,
    "num_particle_clouds": 1,
    "particle_cloud(1)%x_centroid": 0.5,
    "particle_cloud(1)%y_centroid": 0.0,
    "particle_cloud(1)%z_centroid": 0.0,
    "particle_cloud(1)%length_x": 2.0,
    "particle_cloud(1)%length_y": 3.5,
    "particle_cloud(1)%length_z": 0.0,
    "particle_cloud(1)%mass": 0.25,
    "particle_cloud(1)%moving_ibm": 2,
    "particle_cloud(1)%seed": 42,
    "particle_cloud(1)%packing_method": 1,
    "format": "silo",
    "precision": "double",
    "prim_vars_wrt": "T",
    "E_wrt": "T",
    "ib_state_wrt": "F",
    "parallel_io": "F",
    "patch_icpp(1)%geometry": 3,
    "patch_icpp(1)%x_centroid": -1.75,
    "patch_icpp(1)%y_centroid": 0.0,
    "patch_icpp(1)%length_x": 0.5,
    "patch_icpp(1)%length_y": 4.0,
    "patch_icpp(1)%vel(1)": 0.6944,
    "patch_icpp(1)%vel(2)": 0.0,
    "patch_icpp(1)%pres": 2.4583,
    "patch_icpp(1)%alpha_rho(1)": 2.6069,
    "patch_icpp(1)%alpha(1)": 1.0,
    "patch_icpp(2)%geometry": 3,
    "patch_icpp(2)%x_centroid": 0.25,
    "patch_icpp(2)%y_centroid": 0.0,
    "patch_icpp(2)%length_x": 3.5,
    "patch_icpp(2)%length_y": 4.0,
    "patch_icpp(2)%vel(1)": 0.0,
    "patch_icpp(2)%vel(2)": 0.0,
    "patch_icpp(2)%pres": 1,
    "patch_icpp(2)%alpha_rho(1)": 1.4,
    "patch_icpp(2)%alpha(1)": 1.0,
    "fluid_pp(1)%gamma": 2.5000000000000004,
    "fluid_pp(1)%eos": "ideal_gas",
    "fluid_pp(1)%Re(1)": 2500000
}

case.update(
    {
        "m": GRID,
        "n": GRID,
        "t_step_stop": STEPS,
        "t_step_save": STEPS,
        "many_ib_patch_parallelism": os.environ.get("MANY", "T"),
        "particle_cloud(1)%num_particles": int(os.environ.get("NP", 20)),
        "particle_cloud(1)%radius": float(os.environ.get("RAD", 0.15)),
        "particle_cloud(1)%min_spacing": float(os.environ.get("SPC", 0.05)),
    }
)
print(json.dumps(case))

@danieljvickers

danieljvickers commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

@aliencaocao I will refer you to this PR when I merged this feature #1549. In it, I presented this plot.
image

This was 8k particles on a single device with 10 grid cell diameters being placed in 3D. The performance difference was x150 when using many-ib parallelism. Your tables do not have units nor are they clear, so you are going to have to correct me if I interpret these incorrectly. Your results show that the grid cell parallelism is actually faster than many-ib, even with 20k particles on a device. Two separate subroutines with completely separate parallelism strategies that deviate this heavily should not produce identical results to 3 digits in all of these cases, and it makes your results appear questionable.

To check, I ran the case file your provided on an A100 for the 2k particle case and measured the performance of the projection subroutine on master for each parallelism. I get this table

Value MANY=T MANY=F (grid-cell)
APPLY-IB-PATCHES, avg per call 3.44 ms 271 ms
APPLY-IB-PATCHES, median / min / max 3.44 / 3.34 / 3.82 ms 252 / 229 / 788 ms
APPLY-IB-PATCHES, total (60 calls) 0.21 s 16.3 s
UPDATE-MIBM, avg (contains the projection) 11.1 ms 278 ms
TIMESTEP, avg 0.895 s 1.71 s

THis table shows that for your case, master with grid cell parallelism should be 78x slower. Since these numbers deviate by nearly two orders of magnitude from what you present, I don't really believe anything you are presenting here.

@aliencaocao

aliencaocao commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@danieljvickers thanks, you were right on your accounts and I apologise for the confusion.

In my previous comment the "master with many_ib_patch_parallelism=F" column was wrong: my testing script never passed the flag correctly, so those runs were a second =T run, which is why they matched the =T column (not faster, that is just run-to-run noise).

However, my mistake did not change the conclusion of the PR, as we are comparing actually between this PR and master with many_ib_patch_parallelism=T for both, not T vs F. I provided the F data just for completeness. My goal here is to demostrate that this PR improves existing code. I am not arguing to turn exisiting parallelsim off.

I reran everything with the flag verified in the generated simulation.inp for every variant. Corrected data below:

I ran on 2 cases, both 20 steps: the 2D case from my previous comment (5000x5000, 20 / 2,000 / 20,000 particles), and a 3D case in the regime of #1549: 8,000 spheres of ~300 grid cells each (radius 4.15 cells) in a periodic 320^3 box, same moving-IB/collision settings (case.py below).

grind, ns/gp/eq/rhs (lower is better):

case grid-cell many-ib (#1549) this PR
2D, 20 particles (r=0.15) 1.418 1.863 1.419
2D, 2,000 particles (r=0.02) 2.323 1.492 1.471
2D, 20,000 particles (r=0.005) 9.975 1.536 1.528
3D, 8,000 spheres (~300 cells each) 3.535 1.366 1.380

I believe the 3D case is a measurement noise. To further prove this, I ran nsys which give a much clearer breakdown:

Marking cost per call of s_apply_ib_patches (nsys, 3 calls per step; the grid-cell path launches one kernel per patch per call, so its cost is host launch time, which I derive from the step-time difference to many-ib):

case grid-cell many-ib (#1549) this PR
2D, 2,000 particles ~105 ms (122,000 launches) 2.36 ms 0.29 ms
2D, 20,000 particles ~1.06 s (1,220,000 launches) 0.65 ms 0.44 ms
3D, 8,000 spheres ~430 ms (488,000 launches) 1.24 ms 0.94 ms
  • This reproduces your result: the grid-cell path is far slower once there are thousands of particles.
  • On top of 1454-projection-optimization  #1549 this PR makes the many-ib path faster in every case measured: 8x per call at 2,000 particles (2.36 -> 0.29 ms), ~33% at 20,000 (0.65 -> 0.44 ms), ~24% in 3D (1.24 -> 0.94 ms). With a few large patches (20 particles) it takes the many-ib path from 31% slower than grid-cell to parity with it (1.863 -> 1.419).
  • With many small particles the many-ib marking kernel is under ~1.5% of GPU time, so whole-run grind is unchanged between many-ib and this PR there.

Please try my PR on A100 and let me know if you cannot reproduce the PR's speedup.

3D case.py (self-contained)

Run as NP=8000 GRID=320 RC=4.15 STEPS=20 MANY=T ./mfc.sh run case.py -t pre_process simulation -n 1 ...

#!/usr/bin/env python3
"""3D uniform flow past a bed of free-floating spheres (moving IBM), to reproduce the regime of
MFC PR #1549: thousands of small spheres (~300 grid cells each), timing s_apply_ib_patches.

Env (defaults): GRID cells per side (320), STEPS (20), NP particles (8000),
RC sphere radius in grid cells (4.15 -> ~300 cells/sphere), MANY many_ib_patch_parallelism (T).
"""
import json
import math
import os

GRID = int(os.environ.get("GRID", 320))
STEPS = int(os.environ.get("STEPS", 20))
NP = int(os.environ.get("NP", 8000))
RC = float(os.environ.get("RC", 4.15))

L = 1.0  # domain [-L/2, L/2]^3
dx = L / GRID
radius = RC * dx
bed = 0.8 * L  # cubic bed, centred
dt = 5.0e-4 * (320.0 / GRID)

case = {
    "run_time_info": "F",
    "x_domain%beg": -0.5 * L,
    "x_domain%end": 0.5 * L,
    "y_domain%beg": -0.5 * L,
    "y_domain%end": 0.5 * L,
    "z_domain%beg": -0.5 * L,
    "z_domain%end": 0.5 * L,
    "cyl_coord": "F",
    "m": GRID,
    "n": GRID,
    "p": GRID,
    "dt": dt,
    "t_step_start": 0,
    "t_step_stop": STEPS,
    "t_step_save": STEPS,
    "num_patches": 1,
    "model_eqns": "5eq",
    "alt_soundspeed": "F",
    "num_fluids": 1,
    "mpp_lim": "F",
    "mixture_err": "T",
    "time_stepper": "rk3",
    "weno_order": 5,
    "weno_eps": 1e-16,
    "weno_avg": "T",
    "avg_state": "arithmetic",
    "mapped_weno": "T",
    "null_weights": "F",
    "mp_weno": "T",
    "riemann_solver": "hllc",
    "wave_speeds": "direct",
    "fd_order": 2,
    "bc_x%beg": -1,
    "bc_x%end": -1,
    "bc_y%beg": -1,
    "bc_y%end": -1,
    "bc_z%beg": -1,
    "bc_z%end": -1,
    "ib": "T",
    "num_ibs": 0,
    "many_ib_patch_parallelism": os.environ.get("MANY", "T"),
    "collision_model": 1,
    "coefficient_of_restitution": 0.9,
    "collision_time": 20.0 * dt,
    "ib_coefficient_of_friction": 0.1,
    "num_particle_clouds": 1,
    "particle_cloud(1)%x_centroid": 0.0,
    "particle_cloud(1)%y_centroid": 0.0,
    "particle_cloud(1)%z_centroid": 0.0,
    "particle_cloud(1)%length_x": bed,
    "particle_cloud(1)%length_y": bed,
    "particle_cloud(1)%length_z": bed,
    "particle_cloud(1)%num_particles": NP,
    "particle_cloud(1)%radius": radius,
    "particle_cloud(1)%mass": 0.25,
    "particle_cloud(1)%min_spacing": 0.5 * radius,
    "particle_cloud(1)%moving_ibm": 2,
    "particle_cloud(1)%seed": 42,
    "particle_cloud(1)%packing_method": 1,
    "format": "silo",
    "precision": "double",
    "prim_vars_wrt": "T",
    "E_wrt": "T",
    "ib_state_wrt": "F",
    "parallel_io": "F",
    "patch_icpp(1)%geometry": 9,
    "patch_icpp(1)%x_centroid": 0.0,
    "patch_icpp(1)%y_centroid": 0.0,
    "patch_icpp(1)%z_centroid": 0.0,
    "patch_icpp(1)%length_x": L,
    "patch_icpp(1)%length_y": L,
    "patch_icpp(1)%length_z": L,
    "patch_icpp(1)%vel(1)": 0.5,
    "patch_icpp(1)%vel(2)": 0.0,
    "patch_icpp(1)%vel(3)": 0.0,
    "patch_icpp(1)%pres": 1.0,
    "patch_icpp(1)%alpha_rho(1)": 1.4,
    "patch_icpp(1)%alpha(1)": 1.0,
    "fluid_pp(1)%gamma": 1.0 / (1.4 - 1.0),
    "fluid_pp(1)%pi_inf": 0.0,
}
print(json.dumps(case))

The 2D case is the same as from my previous comment.

@danieljvickers

danieljvickers commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

For your 20k particle case, I am not getting the same answers as you on an A100, I get that these methods are identical, which makes sense because it utilizes the same amount of the GPU. I think your measurement is noise at this level. But in the smaller cases I am seeing a speedup of the kernel that about aligns with the increased theoretical usage of the device.

I will run this on AMDFlang and make sure that it gives results that are not harmful to performance. If so I will return here and approve.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants