Skip to content

Large-Scale Startup Optimizations - #1686

Open
danieljvickers wants to merge 9 commits into
MFlowCode:masterfrom
danieljvickers:smarter-particle-bed-instantiation
Open

Large-Scale Startup Optimizations#1686
danieljvickers wants to merge 9 commits into
MFlowCode:masterfrom
danieljvickers:smarter-particle-bed-instantiation

Conversation

@danieljvickers

@danieljvickers danieljvickers commented Jul 27, 2026

Copy link
Copy Markdown
Member

Description

I performed a weak scaling test to the full of OLCF Frontier. To get the simulation to run correctly, the initialization process in simulation had to be modified to accommodate the number of IBs in the simulation as well as specific slowdowns I encountered at the full system size. This branch contains those modifications.

Some initialization optimizations are exclusive to the lattice packing method.

Type of change (delete unused ones)

  • Bug fix
  • Refactor

Testing

Ran 500 million immersed boundaries on 78k MPI ranks.

Checklist

Check these like this [x] to indicate which of the below applies.

  • I added or updated tests for new behavior
  • I updated documentation if user-facing behavior changed

See the developer guide for full coding standards.

GPU changes (expand if you modified src/simulation/)
  • GPU results match CPU results
  • Tested on NVIDIA GPU or AMD GPU

AI code reviews

Reviews are not retriggered automatically. To request a review, comment on the PR:

  • @claude full review — Claude full review (also triggers on PR open/reopen/ready)
  • Or add label claude-full-review — Claude full review via label

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude Code Review

Head SHA: f35421e

Files changed:

  • 8
  • src/common/m_boundary_io.fpp
  • src/common/m_constants.fpp
  • src/common/m_delay_file_access.f90
  • src/pre_process/m_data_output.fpp
  • src/simulation/m_collisions.fpp
  • src/simulation/m_data_output.fpp
  • src/simulation/m_particle_cloud.fpp
  • src/simulation/m_start_up.fpp

Findings:

  • src/simulation/m_collisions.fpp: the active IB collision routine is swapped from s_detect_ib_collisions_n2 to s_detect_ib_collisions (the previous call is commented out instead). This is a behavior/physics change unrelated to the rest of the PR's particle-cloud refactor, and it lands in the same commit as a 15x increase to num_local_ibs_max (2000 → 30000 in m_constants.fpp) with no explanation of why the alternate collision algorithm is now preferred at that scale. Please confirm this swap is intentional and not a leftover from local debugging before merging, since it silently changes which collision-detection path every IB simulation takes.
  • src/common/m_constants.fpp: num_ib_patches_max_namelist jumps 15x (54000 → 810000). Per its own docstring this is the "Fixed capacity of patch_ib ... patch_ib is never reallocated", i.e. a fixed-size ib_patch_parameters array allocated on every MPI rank regardless of how many IBs that rank actually holds (num_local_ibs_max is only raised to 30000, 27x smaller). If ib_patch_parameters embeds any of the STL/airfoil fixed-size fields sized off num_stl_models_max/num_ib_airfoils_max, this multiplies per-rank memory for patch_ib by ~15x across every rank in an exascale run. Worth confirming this capacity was deliberately sized (not just copied up alongside the particle-cloud limits) given the per-rank memory cost.

@danieljvickers
danieljvickers marked this pull request as ready for review July 30, 2026 02:58
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 45.00000% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.83%. Comparing base (8dfe8c7) to head (75c7c61).

Files with missing lines Patch % Lines
src/simulation/m_particle_cloud.fpp 43.39% 24 Missing and 6 partials ⚠️
src/simulation/m_start_up.fpp 29.41% 11 Missing and 1 partial ⚠️
src/simulation/m_collisions.fpp 0.00% 1 Missing ⚠️
src/simulation/m_data_output.fpp 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1686      +/-   ##
==========================================
+ Coverage   60.77%   60.83%   +0.05%     
==========================================
  Files          83       83              
  Lines       20872    20924      +52     
  Branches     3101     3108       +7     
==========================================
+ Hits        12685    12729      +44     
+ Misses       6121     6118       -3     
- Partials     2066     2077      +11     

☔ 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.

@sbryngelson sbryngelson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Three separable changes bundled together:

  1. Particle-cloud generation becomes neighborhood-aware (m_particle_cloud.fpp, m_start_up.fpp) — the real content. get_neighbor_bounds() moves ahead of generation; each packing method now allocates its own working array, filters to the rank's IB neighborhood, and returns a count. gbl_patch_id is assigned at placement time (glbl_idx) instead of being reassigned in s_reduce_ib_patch_array. For lattice packing this makes per-rank memory O(local) instead of O(global).
  2. Capacity constant bumps (m_constants.fpp) — 15× across the board.
  3. Two drive-bysDelayFileAccesss_delay_file_access rename, and re-enabling s_detect_ib_collisions over s_detect_ib_collisions_n2.

The core design is sound. The global-ID scheme is deterministic across ranks (glbl_idx advances for every candidate site whether kept or not, and both packers place exactly num_particles), so IDs stay dense and consistent. Moving the moving_ibm scan to particle_cloud(:) specs is required by the filtering and is correct. And moving the num_local_ibs > num_local_ibs_max PROHIBIT inside the loop fixes a genuine out-of-bounds write to local_ib_patch_ids that could previously happen before the check fired.

The problems are mostly in what the constant bumps drag along with them.

High priority

1. The constant bumps have large unconditional memory and per-timestep costs.

ib_patch_parameters is 60 reals + 6 ints + 1 logical ≈ 512 B. With num_ib_patches_max_namelist: 54000 → 810000:

Structure Before After
patch_ib (fixed-size, GPU_DECLARE) 27.6 MB 415 MB
collision_lookup (num_local_ibs_max*27*8, 4) 6.9 MB 104 MB
wall_overlap_distances 2.6 MB 39 MB
recv_bufs in s_handoff_ib_ownership 26.6 MB 399 MB

patch_ib is a fixed-size module array in the generated decls with the GPU flag set, so the 415 MB is resident on device for every simulation run, IB or not — and pre_process carries the same 415 MB in host BSS, which matters at high ranks-per-node on CPU partitions.

Worse, m_ibm.fpp:1429 and :1454 do $:GPU_UPDATE(host='[patch_ib]') / device='[patch_ib]' on the whole array, and s_handoff_ib_ownership runs every time step when moving_immersed_boundary_flag is set (m_time_steppers.fpp:591). That's 415 MB D2H + 415 MB H2D per step, up from 27.6 MB — in a PR whose purpose is performance at scale.

The cheap fix already exists elsewhere in the file: m_ibm.fpp:83/:94 and m_data_output.fpp:923 use patch_ib(1:num_ibs). Scoping lines 1429/1454/1541 the same way costs nothing and removes the regression. Separately, worth considering making patch_ib allocatable and sized from the actual case rather than paying 415 MB statically.

2. The collision-detection swap is an unexplained behavior change.

call s_detect_ib_collisions(ghost_points, ib_markers, num_gps, num_considered_collisions)
! call s_detect_ib_collisions_n2(num_considered_collisions)

git log shows master deliberately runs the _n2 variant and this PR flips it back, with no mention in the description. The motivation is obvious at scale — _n2 is O(num_ibs²) over local patches, hopeless at 810k — but these are not equivalent detectors: the ghost-point path only sees pairs whose IB markers are cell-adjacent, and it dedups serially on the host in O(num_raw × num_collisions), then pushes the full collision_lookup (now 104 MB) to device on every call. That's a different accuracy/cost tradeoff, not a bug fix.

Please state the reasoning in the PR body, and make it a case-file switch or delete the loser — leaving commented-out dead code contradicts #1699, which just landed.

3. The p == 0num_dims < 3 fix is only half applied.

Correct and valuable in s_particle_cloud_lattice: in simulation p is the local z-extent, so a rank owning a single z-cell has p == 0 in a 3D run and would silently take the 2D branch — very reachable at 78k ranks. But s_particle_cloud_random_box still has four p == 0 tests (m_particle_cloud.fpp:116, 147, 156, 169) with exactly the same latent bug. Fix both or neither.

4. ib_gbl_idx_lookup is still O(N_global).

m_start_up.fpp:1332 allocates ib_gbl_idx_lookup(1:num_gbl_ibs) on device unconditionally — 2 GB/rank at 500M particles — and s_update_ib_lookup() zeroes it on host and transfers it both directions inside every handoff. Not introduced here, but it's the next wall for exactly the workload this PR targets, and it means the memory win applies to static beds only. Worth a note in the PR so it doesn't get lost.

Medium

5. Argument shadowing. s_reduce_particle_cloud_ibs(cloud_ibs, num_ibs) — the dummy num_ibs shadows the module global num_ibs from m_global_parameters, which the surrounding code reads. Rename to num_cloud_ibs.

6. Fragile single-rank invariant. The num_procs == 1 and no-MPI paths copy num_particle_cloud_ibs entries but then set num_ibs = num_gbl_ibs. That's only correct because f_neighborhood_ranks_own_location short-circuits to .true. for num_procs <= 2 — a cross-module invariant with nothing enforcing it. Use num_namelist_ibs + num_particle_cloud_ibs, or add a PROHIBIT on the mismatch.

7. No case default on packing_method. If it ever falls through, cloud_ibs is unallocated and num_cloud_ibs undefined, so deallocate(cloud_ibs) is a runtime error. m_checker.fpp:194-198 makes this unreachable today, but the failure mode went from benign to a crash — add the default abort.

8. Latent integer overflow, now reachable. hash_size = max(16, 4*particle_cloud(i)%num_particles) overflows default integer above ~537M particles, and max(16, <negative>) degrades silently to a 16-bucket table (O(N²) placement) rather than aborting. Raising num_ib_patches_max to 1.5e9 puts this inside the advertised range.

9. The delay before the collective MPI_FILE_OPEN. MPI_FILE_OPEN(MPI_COMM_WORLD, ...) is collective — rank-staggered arrival can't reduce contention on a single shared-file open; every rank just waits for the largest delay (rank 78000 ⇒ ~6.1M random_number calls). The existing s_delay_file_access call sites all precede per-rank file opens, where staggering does help. If you measured a real win here, please put the numbers in the PR; otherwise I'd drop it.

10. num_ib_patches_max = 1500000000 is dead code. It's referenced only by its own definition — case_validator.py:632 binds a same-named local to num_ib_patches_max_namelist. Delete it rather than bumping it; leaving a 1.5e9 constant named like an array bound is a trap.

11. Undocumented coupling between the two constants. num_local_ibs_max * 27 == num_ib_patches_max_namelist holds before (2000×27 = 54000) and after (30000×27 = 810000), and it has to: wall_overlap_distances(patch_id, i) is indexed by patch_id ∈ [1, num_ibs] with num_ibs bounded by num_ib_patches_max_namelist, while the array's first dimension is num_local_ibs_max*27. Nothing states or enforces this. Add a comment and a compile-time check so a future bump of one constant doesn't silently create an out-of-bounds write.

Minor / style

  • Half-finished rename: the subroutine is now s_delay_file_access, but ProcessRank, iDelay, nFileAccessDelayIterations, Dummy, Number remain CamelCase.
  • glbl_idx vs. the repo's established gbl_ prefix (gbl_patch_id, ib_gbl_idx_lookup).
  • stat=alloc_stat handling appears only on the random-box allocate; the lattice allocate and the caller's particle_cloud_ibs allocate have none.
  • The PROHIBIT in s_generate_particle_clouds compares against num_ib_patches_max_namelist, but the array is sized min(num_ib_patches_max_namelist, n_total_particles). Safe today (the sum can't exceed n_total_particles), but checking size(particle_cloud_ibs) would be self-evidently correct.
  • Continuation-line alignment at m_particle_cloud.fpp:296-297.

Test coverage

The tests checkbox is unchecked and nothing in tests/ exercises particle_cloud — the only coverage is examples/2D_mibm_particle_cloud/case.py. More to the point, f_neighborhood_ranks_own_location returns .true. unconditionally for num_procs <= 2, so the entire new filtering path is inert at typical CI rank counts — even adding a 1- or 2-rank test wouldn't touch it.

Worth adding: a ≥3-rank lattice-packing case asserting (a) the union of per-rank particles reconstructs the full set, (b) gbl_patch_id values are unique and dense over 1..num_gbl_ibs, and (c) results match a single-rank run.

Verdict

The generation rework is good and the constants clearly need to move for the target scale. The blockers before merge are the unscoped full-patch_ib GPU transfers in the per-timestep handoff path (#1), a justification and cleanup for the collision-detector swap (#2), and finishing the p == 0 fix in the random-box packer (#3). Everything else can be follow-up, though #10 and #11 are one-liners worth doing now.

@sbryngelson sbryngelson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier comment-only review with a blocking one, and moving the actionable items inline.

Overall: the neighborhood-aware generation rework is good, and the design is sound. The global-ID scheme is deterministic across ranks (glbl_idx advances for every candidate site whether kept or not, and both packers place exactly num_particles), so IDs stay dense and consistent; moving the moving_ibm scan to particle_cloud(:) specs is required by the filtering and correct; and moving the num_local_ibs > num_local_ibs_max PROHIBIT inside the loop fixes a real out-of-bounds write to local_ib_patch_ids.

The blockers are (1) the constant bumps turning the per-timestep patch_ib GPU transfers into 415 MB each way, (2) the unexplained collision-detector swap plus commented-out dead code, and (3) the p == 0 -> num_dims < 3 fix being applied in only one of the two packers. Details inline.

Two items with no diff anchor

ib_gbl_idx_lookup is still O(N_global). m_start_up.fpp:1332 allocates ib_gbl_idx_lookup(1:num_gbl_ibs) on device unconditionally -- 2 GB/rank at 500M particles -- and s_update_ib_lookup() zeroes it on host and transfers it both directions inside every handoff. Not introduced here, but it is the next wall for exactly the workload this PR targets, and it means the memory win applies to static beds only. Worth a note in the PR body so it does not get lost.

Test coverage. The tests checkbox is unchecked and nothing in tests/ exercises particle_cloud -- the only coverage is examples/2D_mibm_particle_cloud/case.py. More to the point, f_neighborhood_ranks_own_location returns .true. unconditionally for num_procs <= 2, so the entire new filtering path is inert at typical CI rank counts -- even adding a 1- or 2-rank test would not touch it. Worth adding a >=3-rank lattice-packing case asserting (a) the union of per-rank particles reconstructs the full set, (b) gbl_patch_id values are unique and dense over 1..num_gbl_ibs, and (c) results match a single-rank run.

Minor / style

  • glbl_idx vs. the repo's established gbl_ prefix (gbl_patch_id, ib_gbl_idx_lookup).
  • stat=alloc_stat handling appears only on the random-box allocate; the lattice allocate and the caller's particle_cloud_ibs allocate have none.
  • The PROHIBIT in s_generate_particle_clouds compares against num_ib_patches_max_namelist, but the array is sized min(num_ib_patches_max_namelist, n_total_particles). Safe today (the sum cannot exceed n_total_particles), but checking size(particle_cloud_ibs) would be self-evidently correct.
  • Continuation-line alignment at m_particle_cloud.fpp:296-297.

Comment thread src/common/m_constants.fpp Outdated
integer, parameter :: num_stl_models_max = 10
!> Maximum number of immersed boundary patches (legacy, not used for patch_ib sizing)
integer, parameter :: num_ib_patches_max = 2050000
integer, parameter :: num_ib_patches_max = 1500000000

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead constant -- delete rather than bump.

num_ib_patches_max is referenced only by its own definition. toolchain/mfc/case_validator.py:632 binds a same-named local to num_ib_patches_max_namelist, not to this. Raising it to 1.5e9 has no effect, and leaving a 1.5e9 constant named like an array bound is a trap for whoever reaches for it next.

Related: it also puts hash_size = max(16, 4*particle_cloud(i)%num_particles) in s_particle_cloud_random_box inside the advertised range -- that overflows default integer above ~537M particles, and max(16, <negative>) degrades silently to a 16-bucket table (O(N^2) placement) instead of aborting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant is gone in 75c7c61, thanks -- and the hash_size overflow goes with it in practice, since s_particle_cloud_random_box allocates placed(3, n_total_particles) and would run out of memory long before 537M particles.

One leftover from the deletion though: the doc comment above it was not removed, so m_constants.fpp:29-30 now reads

!> Maximum number of immersed boundary patches (legacy, not used for patch_ib sizing)
!> Fixed capacity of patch_ib (namelist patches + local particle bed subset after reduction)
integer, parameter  :: num_ib_patches_max_namelist = 54000

Two stacked !> blocks with the orphaned one binding to num_ib_patches_max_namelist -- which it now describes exactly backwards, since that constant is what sizes patch_ib. Drop the first line.

Reopening just for that one-line fix.

Comment thread src/common/m_constants.fpp Outdated
!> Fixed capacity of patch_ib (namelist patches + local particle bed subset after reduction)
integer, parameter :: num_ib_patches_max_namelist = 54000
integer, parameter :: num_local_ibs_max = 2000 !< Maximum number of immersed boundary patches (patch_ib)
integer, parameter :: num_ib_patches_max_namelist = 810000

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this 15x bump has large unconditional memory and per-timestep costs.

ib_patch_parameters is 60 reals + 6 ints + 1 logical ~= 512 B. With num_ib_patches_max_namelist: 54000 -> 810000:

Structure Before After
patch_ib (fixed-size, GPU_DECLARE) 27.6 MB 415 MB
collision_lookup (num_local_ibs_max*27*8, 4) 6.9 MB 104 MB
wall_overlap_distances 2.6 MB 39 MB
recv_bufs in s_handoff_ib_ownership 26.6 MB 399 MB

patch_ib is a fixed-size module array in the generated decls with the GPU flag set, so the 415 MB is resident on device for every simulation run, IB or not -- and pre_process carries the same 415 MB in host BSS, which matters at high ranks-per-node on CPU partitions.

Worse: m_ibm.fpp:1429 and :1454 do $:GPU_UPDATE(host='[patch_ib]') / device='[patch_ib]' on the whole array, and s_handoff_ib_ownership runs every time step when moving_immersed_boundary_flag is set (m_time_steppers.fpp:591). That is 415 MB D2H + 415 MB H2D per step, up from 27.6 MB -- in a PR whose purpose is performance at scale.

The cheap fix already exists elsewhere in the same file: m_ibm.fpp:83/:94 and m_data_output.fpp:923 use patch_ib(1:num_ibs). Scoping lines 1429/1454/1541 the same way costs nothing and removes the regression. Separately, worth considering making patch_ib allocatable and sized from the actual case rather than paying 415 MB statically.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverting the bumps removes the memory concern, but it also removes the capability this PR exists for, so I do not think this is settled.

s_reduce_ib_patch_array PROHIBITs on num_local_ibs > num_local_ibs_max (m_start_up.fpp:1296 and :1312). With num_local_ibs_max back at 2000, the branch aborts on its own advertised workload: 500M IBs over 78k ranks is ~6.4k locally-owned IBs per rank, and your note on the collision thread says 81k particles per rank. Either way, 40x over the cap. Did the Frontier run use locally-patched constants? If so, the branch as it stands does not reproduce it, and the constants need to come back.

What I was asking for was the other half of the tradeoff, not the revert: keep the bumps and stop moving the whole fixed-size array.

  • m_ibm.fpp:1429, :1454, :1541 still do GPU_UPDATE(host='[patch_ib]') / GPU_UPDATE(device='[patch_ib]') on the entire array, inside s_handoff_ib_ownership, which runs every time step when moving_immersed_boundary_flag is set (m_time_steppers.fpp:591). Scoping them to patch_ib(1:num_ibs) -- exactly what m_ibm.fpp:83/:94 and m_data_output.fpp:923 already do -- makes the cost proportional to the actual IB count rather than the compile-time cap. That is the change that makes 810000 affordable; without it the cap is priced into every time step whether or not the case has any IBs.
  • recv_bufs in the same routine is sized buf_size = storage_size(0)/8 + patch_bytes*num_local_ibs_max per neighbor over 26 neighbors (m_ibm.fpp:1459). At num_local_ibs_max = 30000 that is ~399 MB allocated per handoff regardless of how many patches actually migrate. Sizing it from the real new_count (exchange counts first, then allocate) removes that one too.

With both of those, I have no objection to 810000/30000 -- the point was never that the constants should not move, it was that the cost of moving them was being paid unconditionally by every run.

Reopening because the issue is live in either direction: either the constants come back up and the transfers get scoped, or they stay down and the PR description should say the full-system run is not reachable with the merged code.

Comment thread src/common/m_constants.fpp Outdated
integer, parameter :: num_ib_patches_max_namelist = 54000
integer, parameter :: num_local_ibs_max = 2000 !< Maximum number of immersed boundary patches (patch_ib)
integer, parameter :: num_ib_patches_max_namelist = 810000
integer, parameter :: num_local_ibs_max = 30000 !< Maximum number of immersed boundary patches (patch_ib)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undocumented coupling between these two constants.

num_local_ibs_max * 27 == num_ib_patches_max_namelist holds before (2000x27 = 54000) and after (30000x27 = 810000), and it has to: wall_overlap_distances(patch_id, i) is indexed by patch_id in [1, num_ibs] with num_ibs bounded by num_ib_patches_max_namelist, while the array's first dimension is num_local_ibs_max*27.

Nothing states or enforces this. Please add a comment plus a compile-time check so a future bump of one constant does not silently create an out-of-bounds write.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after 75c7c61 -- the constants went back down, but the relation num_local_ibs_max * 27 == num_ib_patches_max_namelist is exactly as load-bearing at 2000/54000 as it was at 30000/810000, and still unstated. wall_overlap_distances is allocated (num_local_ibs_max*27, 6) at m_collisions.fpp:46 but indexed by patch_id in [1, num_ibs], and num_ibs is bounded by num_ib_patches_max_namelist (m_start_up.fpp:1307). Bump either constant alone and you get an out-of-bounds write with nothing to catch it.

Cheapest fix is to make the relation structural rather than documented:

integer, parameter  :: num_local_ibs_max = 2000                              !< Max IBs owned by one rank
integer, parameter  :: num_ib_patches_max_namelist = num_local_ibs_max*27    !< Fixed capacity of patch_ib: one rank's 27-neighborhood

Then the coupling cannot be broken by editing one line, and the *27 documents where it comes from.

Comment thread src/simulation/m_collisions.fpp Outdated
! call s_detect_ib_collisions(ghost_points, ib_markers, num_gps, num_considered_collisions)
call s_detect_ib_collisions_n2(num_considered_collisions)
call s_detect_ib_collisions(ghost_points, ib_markers, num_gps, num_considered_collisions)
! call s_detect_ib_collisions_n2(num_considered_collisions)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: unexplained behavior change, and dead code left behind.

git log shows master deliberately runs the _n2 variant and this PR flips it back, with no mention in the description. The motivation is obvious at scale -- _n2 is O(num_ibs^2) over local patches, hopeless at 810k -- but these are not equivalent detectors: the ghost-point path only sees pairs whose IB markers are cell-adjacent, dedups serially on the host in O(num_raw x num_collisions), and then pushes the full collision_lookup (now 104 MB, per the m_constants.fpp comment) to device on every call. That is a different accuracy/cost tradeoff, not a bug fix.

Please state the reasoning in the PR body, and make this a case-file switch or delete the loser -- leaving commented-out code contradicts #1699, which just landed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a somewhat straightforward exercise to show that the cell-adjacency is a necessary requirement of spherical/circular collisions. There is no accuracy tradeoff. Previously when I was running at hundreds of local IBs, the cost of the two subroutines were similar, but one of more clear how it worked (_n2). Now at scale, with 81k particles per rank, it is clear that the performance is not similar in this limit, and the tradeoff is justified. I will follow deletion of ht eunused code, as I was not sure which I would use for a while. But this is not an accuracy loss.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- dead code is gone, and I take the point that at 81k particles per rank the O(N^2) variant is not a real option. Two things still worth doing.

Put the reasoning in the PR description. Right now the body does not mention the swap at all, so the next person reading git log sees master deliberately choose _n2, then this PR silently choose the other one. One sentence ("_n2 is O(num_ibs^2) per rank and untenable above ~1e4 local particles") closes that.

On "no accuracy tradeoff": the continuum statement is fine -- overlapping spheres necessarily have surfaces in contact. But the implemented test is not the continuum one. It is cell-centered ib_markers adjacency over a +/-1 stencil (m_collisions.fpp:266-276), which is a discrete proxy, and it differs from the geometric test in three ways that are worth knowing about even if none of them is a reason to keep _n2:

  1. The test is one-sided. if (gp_patch_id < neighbor_patch_id) records a pair only from the lower-encoded-id side, so the pair is seen only if the lower-id particle has a ghost point adjacent to the higher-id marker. Ghost points require a fluid cell within gp_layers (m_ibm.fpp:632-644); in a dense bed a contact-region cell can be enclosed by solid markers of both particles and fail that. You already canonicalize the ordering in the dedup loop (the swap at :306-313), so changing < to /= removes the asymmetry for free -- it doubles num_raw, which is still bounded by num_gps.
  2. exit neighbor_search at :281 stops after the first differing marker, so a ghost point touching two other particles contributes one pair. Fine when contacts span many ghost points; marginal for a one-cell contact patch in a tightly packed bed.
  3. Detection is grid-resolution-limited. At zero overlap the nearest A-marked and B-marked cell centers can be ~2dx apart, outside a +/-1 stencil, so a pair is registered only once overlap reaches O(dx) rather than at r1 + r2. For particles resolved with a few cells per radius that is a real slice of the soft-sphere force curve. That is a resolution requirement, not a bug -- but it is a resolution requirement that _n2 did not have, so it should be stated somewhere rather than described as equivalent.

Missing bound check. num_considered_collisions increments at :325 with no check against size(collision_lookup, 1) = num_local_ibs_max*27*8. That was tolerable when this was one of two paths; now that it is the only detector, an overflow here is a silent out-of-bounds write into a GPU-resident array. Please add a PROHIBIT.

Reopening for the PR-description note, the < -> /= change, and the bound check.

Comment thread src/simulation/m_particle_cloud.fpp
Comment thread src/simulation/m_particle_cloud.fpp Outdated
Comment thread src/simulation/m_particle_cloud.fpp
@:PROHIBIT(num_gbl_ibs > num_ib_patches_max_namelist, &
& "Total IB count exceeds patch_ib capacity. Increase num_ib_patches_max_namelist.")
do i = 1, num_bed_ibs
do i = 1, num_particle_cloud_ibs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile cross-module invariant.

This copies num_particle_cloud_ibs entries but then sets num_ibs = num_gbl_ibs two lines down. That is only correct because f_neighborhood_ranks_own_location short-circuits to .true. for num_procs <= 2, so the generation-time filter is a no-op here -- an invariant living in m_collisions.fpp with nothing enforcing it from this side. If that short-circuit ever changes, this silently publishes uninitialized patch_ib entries.

Use num_ibs = num_namelist_ibs + num_particle_cloud_ibs, or add a PROHIBIT on the mismatch. Same applies to the no-MPI branch at line 1322.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open. To be concrete about the failure mode, since it is not obvious from the diff:

The num_procs == 1 branch (m_start_up.fpp:1269-1283) writes num_particle_cloud_ibs entries into patch_ib, then sets num_ibs = num_gbl_ibs, where num_gbl_ibs = num_namelist_ibs + sum(particle_cloud(:)%num_particles). Those two counts agree only if generation kept every particle it placed -- which is true today solely because f_neighborhood_ranks_own_location short-circuits to .true. for num_procs <= 2. That is an invariant owned by m_collisions.fpp, asserted nowhere, and consumed here.

If that short-circuit ever gains a real check (say someone makes the 2-rank case exact), this branch publishes patch_ib(num_particle_cloud_ibs+1 : num_gbl_ibs) uninitialized, local_ib_patch_ids indexes into it, and ib_gbl_idx_lookup is allocated to a count that no longer matches -- all without a crash. Same code at the no-MPI branch, :1320-1331.

Either of these closes it:

num_ibs = num_namelist_ibs + num_particle_cloud_ibs

or keep the current line and add

@:PROHIBIT(num_namelist_ibs + num_particle_cloud_ibs /= num_gbl_ibs, &
           & "Particle cloud generation dropped patches on a single rank.")

The first is better -- it makes the count come from what was actually written.

file_loc = trim(case_dir) // '/restart_data' // trim(mpiiofs) // trim(file_loc)

call s_mpi_barrier()
call s_delay_file_access(proc_rank)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually help?

MPI_FILE_OPEN(MPI_COMM_WORLD, ...) is collective -- staggering rank arrival cannot reduce contention on a single shared-file open; every rank just ends up waiting for the largest delay (rank 78000 => ~6.1M random_number calls). Every other s_delay_file_access call site precedes a per-rank file open, where the staggering does do something.

If you measured a real win here at full-system scale, please put the numbers in the PR -- otherwise I would drop the barrier and the delay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open, and I want to be precise about why this one differs from the other call sites, because the rename made them all look alike.

Every pre-existing s_delay_file_access call precedes a per-rank open:

  • m_data_output.fpp:716 -> MPI_FILE_OPEN(MPI_COMM_SELF, ...) on <t_step>_<rank>.dat
  • m_data_output.fpp:964 -> per-rank ib_state_<t_step>_<rank>.dat
  • m_boundary_io.fpp:133/:236 -> per-rank bc_<rank>.dat
  • pre_process/m_data_output.fpp:457 -> same pattern

There, staggering spreads 78k independent metadata operations across time, which is the thing that helps.

The new site is before MPI_FILE_OPEN(MPI_COMM_WORLD, file_loc, ...) on a single shared ib.dat (:905). That call is collective: no rank returns from it until all ranks have entered, so staggering arrival cannot spread anything -- it just idles the early ranks. The added wall time per write is the maximum delay over all ranks, (num_procs/128)*10000 random_number calls, which at 78k ranks is ~6.1M iterations on the last rank, paid on every ib.dat write. The s_mpi_barrier() on the preceding line makes it strictly worse: it forces every rank to wait for the slowest before any of them starts its delay.

If you measured a win at full scale I will take the numbers over my reasoning -- MPI-IO on Lustre does surprising things, and it is possible you were working around something in the file-creation path specifically. But as written I would expect this to cost ~seconds per output step at 78k ranks and buy nothing, so please either post the timings or drop both lines.


!> Introduce a rank-dependent busy-wait delay to stagger parallel file access and reduce I/O contention.
impure subroutine DelayFileAccess(ProcessRank)
impure subroutine s_delay_file_access(ProcessRank)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename is the right direction, but it stopped at the subroutine name -- ProcessRank, iDelay, nFileAccessDelayIterations, Dummy and Number are all still CamelCase. Worth finishing while you are in here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after 75c7c61 -- ProcessRank, iDelay, nFileAccessDelayIterations, Number and Dummy are all still CamelCase in m_delay_file_access.f90:21-33. Five identifiers in one 12-line subroutine; worth finishing since the rename already touched every call site.

impure subroutine s_generate_particle_clouds(particle_cloud_ibs)
!> Generate all particle beds and fill particle_cloud_ibs. Called on all ranks before s_reduce_ib_patch_array. Each packing
!! method owns and allocates its own per-cloud working array (see s_particle_cloud_lattice / s_particle_cloud_random_box) and
!! hands back only the entries that fall within this rank's IB neighborhood

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

75c7c61 trimmed this docstring down, and it dropped the one sentence that was actually load-bearing:

Only the first num_particle_cloud_ibs of them are actually written - callers must use that count, not size(particle_cloud_ibs), since the remainder of the array is left uninitialized.

That is the invariant that makes size(particle_cloud_ibs) a bug rather than a synonym, and it belongs on the producing side where someone changing the allocation will see it -- the array is allocated to min(num_ib_patches_max_namelist, n_total_particles) at :48, so capacity and valid-entry count genuinely differ. s_reduce_ib_patch_array still states it on the consuming side (m_start_up.fpp:1233-1235), but that only helps the caller who already knows to look.

The gbl_patch_id sentence is worth keeping too -- "already the final, absolute global patch id, so s_reduce_ib_patch_array copies it as-is" is not deducible from the signature, and it is the thing a reader has to trust to believe the IDs stay dense across ranks.

Happy to see the rest go, just not those two.

@sbryngelson

Copy link
Copy Markdown
Member

Went through 75c7c61 and your two replies. Status roundup so nothing gets lost.

Closed:

  • p == 0 -> num_dims < 3: you were right, I was wrong. s_mpi_decompose_computational_domain requires (p+1)/num_procs_z >= num_stcls_min*recon_order, so a rank cannot own fewer than ~15 z-cells and the 2D branch is unreachable in 3D. Change is still an improvement and now consistent across both packers.
  • case default abort, num_cloud_ibs rename, deleting num_ib_patches_max, deleting s_detect_ib_collisions_n2 -- all done.
  • Lattice-only memory win is called out in the description already; that one is fine.

Reopened, because the commit did not address them:

  • The constant revert. Taking num_local_ibs_max back to 2000 removes the memory concern by removing the feature -- m_start_up.fpp:1296/:1312 will abort at ~6.4k local IBs/rank, and you cite 81k/rank. What I was asking for was scoping the unconditional whole-array patch_ib GPU transfers in s_handoff_ib_ownership (m_ibm.fpp:1429/:1454/:1541) and sizing recv_bufs from the real count, at which point 810000/30000 is affordable. Details on the thread. Did the Frontier run use patched constants locally?
  • Collision swap. Agreed _n2 is untenable at your scale, and the dead code is gone. But the ghost-point test is a discrete proxy for the geometric one and differs in three specific ways (one-sided < comparison, exit neighbor_search, O(dx) detection threshold), plus num_considered_collisions now increments with no bound check against collision_lookup. The < -> /= fix is free given you already canonicalize in the dedup loop.
  • Orphaned doc comment left behind by the constant deletion (m_constants.fpp:29), one line.

Still open, no response yet: the num_local_ibs_max*27 coupling (suggest making it structural: num_ib_patches_max_namelist = num_local_ibs_max*27), the single-rank num_ibs = num_gbl_ibs invariant, the delay before the collective MPI_FILE_OPEN, the unfinished CamelCase rename, and the ib_gbl_idx_lookup O(N_global) note for the PR body. Also flagged one new item: 75c7c61 removed the docstring sentence stating that only the first num_particle_cloud_ibs entries of particle_cloud_ibs are initialized, which is the invariant that keeps size() from being misused.

Blockers for merge, in order: the constants question (it determines whether half of this list is live), the collision bound check, and the ib.dat delay. Everything else is small.

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.

2 participants