MPI_File_write_all / read_all collective buffering does not scale for 4D block-decomposed subarray views (Lustre): 6x slower than a user-level MPI_Alltoallv + POSIX transposition
Summary
For the canonical "N-D distributed array written in global lexicographic order" pattern —
MPI_Type_create_subarray + MPI_File_set_view + MPI_File_write_all — collective
buffering plateaus at ~30 GB/s from 64 nodes upward on Frontier's Orion Lustre, and does
not improve with client count. Both MPI_File_write_all and MPI_File_read_all are
affected; reads are worse, flat at 30–33 GB/s while POSIX pread of the same file on the
same ranks reaches 375 GB/s.
We replaced the MPI-IO collective with an explicit user-level two-phase implementation
(MPI_Alltoallv inside a row subcommunicator, followed by independent POSIX
lseek+write of a handful of large extents per rank) and obtained 6.0x on write and
8.4x on read at 128 nodes / 1024 ranks on the identical file layout, decomposition, and
byte ordering. This is in production in the Grid lattice QCD library as of commit
41f5a022047049ce61d4761897810bf8620470f5 and is now the default path
(d68b111d0645a0254078d1c33bf7a73079cbf0ac).
We would rather delete our workaround than maintain it, hence this report.
It would also benefit everyone if default MPI behavior is higher performance as this is a common pattern.
Environment
|
|
| Machine |
OLCF Frontier |
| MPI |
Cray MPICH from cpe/26.03 (compilers cce/21.0.0, rocm/7.2.0) |
| Filesystem |
Lustre (Orion), directory default layout — no explicit lfs setstripe |
| Ranks |
8 per node (1 per GCD), 4 → 128 nodes |
| Hints |
MPI_INFO_NULL throughout (no cb_nodes, cb_buffer_size, romio_cb_write set) |
| Application |
Grid, https://github.com/paboyle/Grid, branch develop |
The access pattern
A 4D lattice of sizeof(fobj) = 576 byte site objects is block-decomposed over a 4D
process grid. The file must be in global lexicographic order so that it is independent
of the decomposition used to write it — a different job with a different process grid
must be able to read it. This is exactly the case MPI_Type_create_subarray exists for:
/* fobj = 72 doubles per lattice site */
MPI_Type_contiguous(numword, MPI_DOUBLE, &mpiObject);
MPI_Type_commit(&mpiObject);
/* file view: my block inside the global lattice */
MPI_Type_create_subarray(ndim, gLattice, lLattice, gStart,
MPI_ORDER_FORTRAN, mpiObject, &fileArray);
MPI_Type_commit(&fileArray);
/* memory: the whole local block, contiguous */
MPI_Type_create_subarray(ndim, lLattice, lLattice, lStart,
MPI_ORDER_FORTRAN, mpiObject, &localArray);
MPI_Type_commit(&localArray);
MPI_File_open(comm, file, MPI_MODE_RDWR | MPI_MODE_CREATE, MPI_INFO_NULL, &fh);
MPI_File_set_view(fh, disp, mpiObject, fileArray, "native", MPI_INFO_NULL);
MPI_File_write_all(fh, iodata, 1, localArray, &status); /* <-- plateaus */
(Grid/parallelIO/BinaryIO.h, lines ~723–870.)
Concretely, at the 128-node point of the scan below:
- global lattice
32.32.256.1024, process grid 4.4.8.8, local block 8.8.32.128
- 151 MB per rank, 154.6 GB per record, 1024 ranks
- the file view gives each rank 32768 non-contiguous runs of 4608 bytes
(lLattice[0] * 576), strided through a 154.6 GB file
Small contiguous runs are intrinsic to the pattern, not a pathological choice: the
fastest-varying local extent is what it is. Rearranging them into large contiguous
extents is precisely the job of two-phase collective buffering.
Observed behaviour
Weak scan, local volume and therefore the file-view structure held identical at every
rung (151 MB/rank, 4608 B contiguous runs); only the number of Lustre clients changes.
Best of 3 repetitions, MB/s. raw is a non-lexicographic control in which each rank
writes one disjoint contiguous segment with POSIX — the zero-transposition upper bound.
Write
| nodes |
ranks |
record |
POSIX raw (control) |
MPI_File_write_all |
user-level two-phase |
| 4 |
32 |
4.8 GB |
6232 |
6483 |
6743 |
| 8 |
64 |
9.7 GB |
9681 |
12013 |
13605 |
| 16 |
128 |
19.3 GB |
19351 |
17618 |
26671 |
| 32 |
256 |
38.6 GB |
37634 |
27808 |
50962 |
| 64 |
512 |
77.3 GB |
73213 |
29539 |
103769 |
| 128 |
1024 |
154.6 GB |
97673 |
31675 |
189535 |
Read
| nodes |
ranks |
POSIX raw (control) |
MPI_File_read_all |
user-level two-phase |
| 4 |
32 |
26630 |
20040 |
17918 |
| 8 |
64 |
24986 |
28018 |
32246 |
| 16 |
128 |
52049 |
25693 |
59576 |
| 32 |
256 |
91583 |
36134 |
98925 |
| 64 |
512 |
199053 |
30495 |
209587 |
| 128 |
1024 |
375135 |
33419 |
279106 |
MPI-IO tracks the other two paths up to ~16 nodes and then stops scaling entirely. The
raw control shows the filesystem and the clients still have headroom of an order of
magnitude at that point. The read plateau is the more striking of the two: the file was
written by the same ranks moments earlier, so the client page cache is warm, and the
POSIX paths see it (375 GB/s) while MPI_File_read_all does not (33 GB/s).
Interaction with Lustre striping
Repeating the 128-node point with lfs setstripe -c -1 -S 8M on the directory:
|
default layout |
-c -1 -S 8M |
| POSIX raw |
97673 |
81728 |
MPI_File_write_all |
31675 |
77534 |
| user-level two-phase |
189535 |
86063 |
Maximum-width striping is what rescues the collective, 32 → 78 GB/s. It costs every
other path a factor of 1.2–2.2. So the collective's performance is strongly coupled to a
file layout that has to be chosen out of band, by the user, before the file is created,
and which is the wrong layout for everything else. Users who have "tuned MPI-IO" on
Lustre are, we suspect, mostly discovering this.
The workaround
We do the transposition ourselves. The key observation is that un-splitting the k
fastest dimensions of the process grid makes each rank's file contribution contiguous
in large pieces: rank r in a row subcommunicator of size R ends up owning whole
extents of prod_{d<k} gLattice[d] * lLattice[k] sites. k is chosen as the smallest
value that reaches a target extent size (default 4 MB). The row index is monotone in the
local lexicographic index, so the send buffer is the untouched local array and only one
MPI_Alltoallv is required:
/* phase 1: redistribute within the row subcommunicator.
sendcounts/senddispls derived analytically from the plan; iodata untouched. */
MPI_Alltoallv(iodata, sendcounts, senddispls, MPI_BYTE,
aggregated, recvcounts, recvdispls, MPI_BYTE, rowcomm);
/* phase 2: a handful of large contiguous writes per rank, no MPI-IO involved */
for (e = 0; e < nextent; e++) {
lseek(fd, offset + extentGsite[e]*sizeof(fobj), SEEK_SET);
write(fd, aggregated + extentLocal[e], extentSites[e]*sizeof(fobj));
}
At the 128-node point this gives 8 extents of 18 MB per rank instead of 32768 runs of
4608 B, at a memory cost of 144 MB/rank. Measured stage breakdown for the 154.6 GB
record (max over ranks):
aggregate exchange (write) stages: alloc 0.035 s permute 0.027 s alltoallv 0.062 s
aggregate write stages: create 0.0018 barrier 0.0043 open 0.020
seek+write 1.246 close 0.0009 skew 0.654 truncate 0.0043
The entire redistribution — an all-to-all of 154.6 GB of payload — costs 62 ms, i.e.
4% of the 1.49 s the record takes end to end. Whatever MPI_File_write_all is spending
its time on, it is not taking the most efficient approach for this pattern.
That is really the substance of the report. An optimal two-phase implementation of this
pattern should cost the network exchange plus the streaming write, and the network
exchange is nearly free and substantially better I/O is obtainable.
Questions / requests
- Is the ROMIO Lustre ADIO driver's aggregator selection and
cb_buffer_size policy
expected to plateau this way for MPI_ORDER_FORTRAN subarray views with few-KB
contiguous runs? Is there a hint combination that recovers the missing 6x on the
default layout — we tried none, deliberately, because out-of-the-box behaviour is
what our users get.
- Is the strong dependence on
lfs setstripe -c -1 intended? If the collective needs
wide striping, could ROMIO set the layout itself at MPI_MODE_CREATE, or at least
warn?
Methodology caveats, stated up front
- No
fsync / MPI_File_sync anywhere. All three paths are measured identically —
open, write, close — so the comparison is fair, but the absolute write figures include
whatever the client page cache absorbs. Compute nodes have 512 GB and the record is
1.2 GB/node, so absorption is possible at the small rungs.
- Reads are warm, for all three paths equally, for the reason given above.
- Best-of-3 is quoted. Lustre variability and contention from other jobs are real;
the qualitative result (MPI-IO flat from 64 nodes, the other two still climbing)
is reproducible across jobs and is visible in the means as well as the bests.
- Correctness is cross-checked: the aggregate path is verified byte-identical to the
MPI-IO path, and all three paths agree on a layout-independent checksum computed from
the global site index.
Reproducer
Grid, https://github.com/paboyle/Grid, branch develop:
| commit |
content |
41f5a022047049ce61d4761897810bf8620470f5 |
aggregation implementation, MPI_Alltoallv in the communicator layer |
d68b111d0645a0254078d1c33bf7a73079cbf0ac |
made the aggregate path the default |
1f55c13bc4fc84a1959b09b196f884661980e2a4 |
Frontier SLURM driver for the scan above |
Grid/parallelIO/BinaryIO.h — IOobject() holds both paths, selected by the
BINARYIO_AGGREGATE control bit; BuildAggregationPlan() is the plan; the MPI-IO path
is the !aggregate branch quoted above.
tests/IO/Test_aggregate_io.cc — standalone benchmark; times write and read for the
POSIX control, MPI-IO and the aggregate path, with correctness checking. Options
--aggregate-target <bytes>, --io-reps <n>, --io-no-correctness, --io-read-only.
systems/Frontier/aggregate_io_scaling.slurm — the exact weak scan whose numbers are
tabulated here; systems/Frontier/config-command is the build.
Raw job output for the scan is available on request (OLCF job 5234336).
- A simpler implementation that is standalone to MPI and C++ could be developed upon request if this is not all clear.
MPI_File_write_all / read_all collective buffering does not scale for 4D block-decomposed subarray views (Lustre): 6x slower than a user-level MPI_Alltoallv + POSIX transposition
Summary
For the canonical "N-D distributed array written in global lexicographic order" pattern —
MPI_Type_create_subarray+MPI_File_set_view+MPI_File_write_all— collectivebuffering plateaus at ~30 GB/s from 64 nodes upward on Frontier's Orion Lustre, and does
not improve with client count. Both
MPI_File_write_allandMPI_File_read_allareaffected; reads are worse, flat at 30–33 GB/s while POSIX
preadof the same file on thesame ranks reaches 375 GB/s.
We replaced the MPI-IO collective with an explicit user-level two-phase implementation
(
MPI_Alltoallvinside a row subcommunicator, followed by independent POSIXlseek+writeof a handful of large extents per rank) and obtained 6.0x on write and8.4x on read at 128 nodes / 1024 ranks on the identical file layout, decomposition, and
byte ordering. This is in production in the Grid lattice QCD library as of commit
41f5a022047049ce61d4761897810bf8620470f5and is now the default path(
d68b111d0645a0254078d1c33bf7a73079cbf0ac).We would rather delete our workaround than maintain it, hence this report.
It would also benefit everyone if default MPI behavior is higher performance as this is a common pattern.
Environment
cpe/26.03(compilerscce/21.0.0,rocm/7.2.0)lfs setstripeMPI_INFO_NULLthroughout (nocb_nodes,cb_buffer_size,romio_cb_writeset)developThe access pattern
A 4D lattice of
sizeof(fobj) = 576byte site objects is block-decomposed over a 4Dprocess grid. The file must be in global lexicographic order so that it is independent
of the decomposition used to write it — a different job with a different process grid
must be able to read it. This is exactly the case
MPI_Type_create_subarrayexists for:(
Grid/parallelIO/BinaryIO.h, lines ~723–870.)Concretely, at the 128-node point of the scan below:
32.32.256.1024, process grid4.4.8.8, local block8.8.32.128(
lLattice[0] * 576), strided through a 154.6 GB fileSmall contiguous runs are intrinsic to the pattern, not a pathological choice: the
fastest-varying local extent is what it is. Rearranging them into large contiguous
extents is precisely the job of two-phase collective buffering.
Observed behaviour
Weak scan, local volume and therefore the file-view structure held identical at every
rung (151 MB/rank, 4608 B contiguous runs); only the number of Lustre clients changes.
Best of 3 repetitions, MB/s.
rawis a non-lexicographic control in which each rankwrites one disjoint contiguous segment with POSIX — the zero-transposition upper bound.
Write
Read
MPI-IO tracks the other two paths up to ~16 nodes and then stops scaling entirely. The
rawcontrol shows the filesystem and the clients still have headroom of an order ofmagnitude at that point. The read plateau is the more striking of the two: the file was
written by the same ranks moments earlier, so the client page cache is warm, and the
POSIX paths see it (375 GB/s) while
MPI_File_read_alldoes not (33 GB/s).Interaction with Lustre striping
Repeating the 128-node point with
lfs setstripe -c -1 -S 8Mon the directory:-c -1 -S 8MMPI_File_write_allMaximum-width striping is what rescues the collective, 32 → 78 GB/s. It costs every
other path a factor of 1.2–2.2. So the collective's performance is strongly coupled to a
file layout that has to be chosen out of band, by the user, before the file is created,
and which is the wrong layout for everything else. Users who have "tuned MPI-IO" on
Lustre are, we suspect, mostly discovering this.
The workaround
We do the transposition ourselves. The key observation is that un-splitting the
kfastest dimensions of the process grid makes each rank's file contribution contiguous
in large pieces: rank
rin a row subcommunicator of sizeRends up owning wholeextents of
prod_{d<k} gLattice[d] * lLattice[k]sites.kis chosen as the smallestvalue that reaches a target extent size (default 4 MB). The row index is monotone in the
local lexicographic index, so the send buffer is the untouched local array and only one
MPI_Alltoallvis required:At the 128-node point this gives 8 extents of 18 MB per rank instead of 32768 runs of
4608 B, at a memory cost of 144 MB/rank. Measured stage breakdown for the 154.6 GB
record (max over ranks):
The entire redistribution — an all-to-all of 154.6 GB of payload — costs 62 ms, i.e.
4% of the 1.49 s the record takes end to end. Whatever
MPI_File_write_allis spendingits time on, it is not taking the most efficient approach for this pattern.
That is really the substance of the report. An optimal two-phase implementation of this
pattern should cost the network exchange plus the streaming write, and the network
exchange is nearly free and substantially better I/O is obtainable.
Questions / requests
cb_buffer_sizepolicyexpected to plateau this way for
MPI_ORDER_FORTRANsubarray views with few-KBcontiguous runs? Is there a hint combination that recovers the missing 6x on the
default layout — we tried none, deliberately, because out-of-the-box behaviour is
what our users get.
lfs setstripe -c -1intended? If the collective needswide striping, could ROMIO set the layout itself at
MPI_MODE_CREATE, or at leastwarn?
Methodology caveats, stated up front
fsync/MPI_File_syncanywhere. All three paths are measured identically —open, write, close — so the comparison is fair, but the absolute write figures include
whatever the client page cache absorbs. Compute nodes have 512 GB and the record is
1.2 GB/node, so absorption is possible at the small rungs.
the qualitative result (MPI-IO flat from 64 nodes, the other two still climbing)
is reproducible across jobs and is visible in the means as well as the bests.
MPI-IO path, and all three paths agree on a layout-independent checksum computed from
the global site index.
Reproducer
Grid, https://github.com/paboyle/Grid, branch
develop:41f5a022047049ce61d4761897810bf8620470f5MPI_Alltoallvin the communicator layerd68b111d0645a0254078d1c33bf7a73079cbf0ac1f55c13bc4fc84a1959b09b196f884661980e2a4Grid/parallelIO/BinaryIO.h—IOobject()holds both paths, selected by theBINARYIO_AGGREGATEcontrol bit;BuildAggregationPlan()is the plan; the MPI-IO pathis the
!aggregatebranch quoted above.tests/IO/Test_aggregate_io.cc— standalone benchmark; times write and read for thePOSIX control, MPI-IO and the aggregate path, with correctness checking. Options
--aggregate-target <bytes>,--io-reps <n>,--io-no-correctness,--io-read-only.systems/Frontier/aggregate_io_scaling.slurm— the exact weak scan whose numbers aretabulated here;
systems/Frontier/config-commandis the build.Raw job output for the scan is available on request (OLCF job 5234336).