Skip to content

Commit 32ee9d8

Browse files
committed
perf(mta): replace O(N²) pair exchange and O(N_total) force allreduce
exchangeBackwardPairs: replace dense N²×sizeof(int) pair table with ring-based MPI_Sendrecv exchange (P-1 rounds). Uses unordered_set with PairHash for O(1) canonical pair lookup. Memory: O(max_pairs/rank). Communication: O(total_pairs). Force distribution: replace globalForceBuffer_ dense allreduce with sparse indexed exchange. Home forces applied directly (zero MPI); non-home forces packed as (globalMtaIdx, fx, fy, fz) tuples and exchanged via MPI_Gatherv + MPI_Bcast. Dense allreduce kept as fallback for N_total < 1000. Removes globalForceBuffer_ member.
1 parent 54ef227 commit 32ee9d8

2 files changed

Lines changed: 199 additions & 79 deletions

File tree

src/gromacs/applied_forces/metatomic/metatomic_forceprovider.cpp

Lines changed: 180 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@
4242
* pair_metatomic).
4343
*
4444
* Common design points:
45-
* - Forces: all-reduce on a global buffer because ForceWithVirial is not
46-
* communicated by dd_move_f. Only home atom forces are applied.
45+
* - Forces: home forces applied directly; non-home forces exchanged via
46+
* sparse indexed communication. ForceWithVirial is not communicated
47+
* by dd_move_f, so we handle it ourselves. Dense allreduce fallback
48+
* for small systems (N_total < 1000).
4749
* - Ghost deduplication: periodic ghost images share the same model index
4850
* but all GROMACS local indices are mapped via gmxLocalToMtaIdx_.
4951
*
@@ -59,7 +61,6 @@
5961
#include <cstdio>
6062

6163
#include <algorithm>
62-
#include <set>
6364
#include <string>
6465
#include <unordered_map>
6566
#include <unordered_set>
@@ -316,11 +317,6 @@ MetatomicForceProvider::MetatomicForceProvider(const MetatomicOptions& options,
316317
data_->evaluations_options->outputs.insert(energy_key, requested_output);
317318
data_->check_consistency = options_.params_.checkConsistency;
318319

319-
// Allocate global force buffer sized to total MTA atoms
320-
const auto& mtaIndices = options_.params_.mtaIndices_;
321-
const int32_t n_total = static_cast<int32_t>(mtaIndices.size());
322-
globalForceBuffer_.resize(n_total, RVec({ 0.0, 0.0, 0.0 }));
323-
324320
GMX_LOG(logger_.info)
325321
.asParagraph()
326322
.appendText("MetatomicForceProvider initialization complete.");
@@ -349,6 +345,7 @@ void MetatomicForceProvider::gatherAtomNumbersIndices(const MDModulesAtomsRedist
349345
mtaToGlobalMta_.clear();
350346
atomNumbers_.clear();
351347
gmxLocalToMtaIdx_.clear();
348+
globalMtaToLocalHome_.clear();
352349

353350
if (mpiComm_.isParallel())
354351
{
@@ -504,6 +501,14 @@ void MetatomicForceProvider::gatherAtomNumbersIndices(const MDModulesAtomsRedist
504501
}
505502
}
506503

504+
// Build reverse map: global MTA index -> local home model index.
505+
// Used by sparse force distribution to route incoming forces to home atoms.
506+
globalMtaToLocalHome_.reserve(numHomeMta_);
507+
for (int32_t i = 0; i < numHomeMta_; i++)
508+
{
509+
globalMtaToLocalHome_[mtaToGlobalMta_[i]] = i;
510+
}
511+
507512
GMX_RELEASE_ASSERT(std::count(atomNumbers_.begin(), atomNumbers_.end(), 0) == 0,
508513
"Some atom numbers not set.");
509514

@@ -715,64 +720,89 @@ void MetatomicForceProvider::exchangeBackwardPairs(const matrix box)
715720
return;
716721
}
717722

718-
const int32_t numTotalMta = static_cast<int32_t>(options_.params_.mtaIndices_.size());
719-
720-
// Step 1: Build global pair existence table via allreduce.
721-
// pairTable[gI * numTotalMta + gJ] = 1 if any rank has pair (gI, gJ).
722-
// After sumReduce, entries > 0 indicate pairs that exist somewhere.
723-
std::vector<int> pairTable(numTotalMta * numTotalMta, 0);
724723
const int nMyPairs = static_cast<int>(pairlistMta_.size() / 2);
724+
725+
// Step 1: Pack local pairs as flat (globalI, globalJ) buffer.
726+
std::vector<int> myPairsBuf(2 * nMyPairs);
725727
for (int k = 0; k < nMyPairs; k++)
726728
{
727-
const int32_t gI = mtaToGlobalMta_[pairlistMta_[2 * k]];
728-
const int32_t gJ = mtaToGlobalMta_[pairlistMta_[2 * k + 1]];
729-
pairTable[gI * numTotalMta + gJ] = 1;
729+
myPairsBuf[2 * k] = mtaToGlobalMta_[pairlistMta_[2 * k]];
730+
myPairsBuf[2 * k + 1] = mtaToGlobalMta_[pairlistMta_[2 * k + 1]];
730731
}
731-
mpiComm_.sumReduce(static_cast<std::size_t>(numTotalMta * numTotalMta),
732-
pairTable.data());
733732

734-
// Step 2: Build set of my home atoms and existing canonical pairs.
733+
// Step 2: Build set of my home atoms.
735734
std::unordered_set<int32_t> myHomeGlobalMta;
736735
for (int32_t i = 0; i < numHomeMta_; i++)
737736
{
738737
myHomeGlobalMta.insert(mtaToGlobalMta_[i]);
739738
}
740739

741-
// Canonical pair set {min(gI,gJ), max(gI,gJ)} to avoid half-list duplication.
742-
std::set<std::pair<int32_t, int32_t>> existingCanonical;
740+
// Step 3: Existing canonical pairs — O(1) lookup via hashed set.
741+
struct PairHash
742+
{
743+
std::size_t operator()(const std::pair<int32_t, int32_t>& p) const
744+
{
745+
// Combine the two 32-bit ints into one 64-bit value for a perfect hash.
746+
return std::hash<int64_t>()(static_cast<int64_t>(p.first) << 32
747+
| static_cast<uint32_t>(p.second));
748+
}
749+
};
750+
std::unordered_set<std::pair<int32_t, int32_t>, PairHash> existingCanonical;
751+
existingCanonical.reserve(nMyPairs);
743752
for (int k = 0; k < nMyPairs; k++)
744753
{
745-
const int32_t gI = mtaToGlobalMta_[pairlistMta_[2 * k]];
746-
const int32_t gJ = mtaToGlobalMta_[pairlistMta_[2 * k + 1]];
754+
const int32_t gI = myPairsBuf[2 * k];
755+
const int32_t gJ = myPairsBuf[2 * k + 1];
747756
existingCanonical.insert({ std::min(gI, gJ), std::max(gI, gJ) });
748757
}
749758

750-
// Step 3: Build global MTA → local index mapping.
759+
// Step 4: Global MTA → local index mapping.
751760
std::unordered_map<int32_t, int32_t> globalToLocal;
761+
globalToLocal.reserve(numLocalMta_);
752762
for (int32_t i = 0; i < numLocalMta_; i++)
753763
{
754764
globalToLocal[mtaToGlobalMta_[i]] = i;
755765
}
756766

757-
// Step 4: Find pairs I need but don't have.
758-
for (int32_t gI = 0; gI < numTotalMta; gI++)
767+
// Step 5: Ring exchange — P-1 rounds of MPI_Sendrecv.
768+
// Each round: send our pairs to rank+1, receive from rank-1.
769+
// Scan received pairs for those involving our home atoms.
770+
const int numRanks = mpiComm_.size();
771+
const int myRank = mpiComm_.rank();
772+
const int sendTo = (myRank + 1) % numRanks;
773+
const int recvFrom = (myRank - 1 + numRanks) % numRanks;
774+
775+
std::vector<int> sendBuf = myPairsBuf;
776+
std::vector<int> recvBuf;
777+
778+
for (int round = 0; round < numRanks - 1; round++)
759779
{
760-
for (int32_t gJ = 0; gJ < numTotalMta; gJ++)
780+
// Exchange counts first so receiver knows buffer size.
781+
int sendCount = static_cast<int>(sendBuf.size());
782+
int recvCount = 0;
783+
MPI_Sendrecv(&sendCount, 1, MPI_INT, sendTo, 0,
784+
&recvCount, 1, MPI_INT, recvFrom, 0,
785+
mpiComm_.comm(), MPI_STATUS_IGNORE);
786+
787+
recvBuf.resize(recvCount);
788+
MPI_Sendrecv(sendBuf.data(), sendCount, MPI_INT, sendTo, 1,
789+
recvBuf.data(), recvCount, MPI_INT, recvFrom, 1,
790+
mpiComm_.comm(), MPI_STATUS_IGNORE);
791+
792+
// Scan received pairs for those involving our home atoms.
793+
const int nRecvPairs = recvCount / 2;
794+
for (int k = 0; k < nRecvPairs; k++)
761795
{
762-
if (pairTable[gI * numTotalMta + gJ] == 0)
763-
{
764-
continue;
765-
}
796+
const int32_t gI = recvBuf[2 * k];
797+
const int32_t gJ = recvBuf[2 * k + 1];
766798

767-
// Pair must involve one of my home atoms.
768799
const bool iIsMyHome = myHomeGlobalMta.count(gI) > 0;
769800
const bool jIsMyHome = myHomeGlobalMta.count(gJ) > 0;
770801
if (!iIsMyHome && !jIsMyHome)
771802
{
772803
continue;
773804
}
774805

775-
// Skip if I already have this pair (in either direction).
776806
auto canonical = std::make_pair(std::min(gI, gJ), std::max(gI, gJ));
777807
if (existingCanonical.count(canonical) > 0)
778808
{
@@ -811,6 +841,9 @@ void MetatomicForceProvider::exchangeBackwardPairs(const matrix box)
811841
backwardShiftsMta_.push_back(shift);
812842
existingCanonical.insert(canonical);
813843
}
844+
845+
// Forward received buffer for the next round.
846+
sendBuf.swap(recvBuf);
814847
}
815848

816849
if (data_->debugEnabled)
@@ -821,7 +854,7 @@ void MetatomicForceProvider::exchangeBackwardPairs(const matrix box)
821854
if (fp)
822855
{
823856
std::fprintf(fp,
824-
"exchangeBackwardPairs: added %zu pairs "
857+
"exchangeBackwardPairs(ring): added %zu pairs "
825858
"(pairlist=%d, total=%zu)\n",
826859
backwardPairsMta_.size() / 2,
827860
nMyPairs,
@@ -832,12 +865,114 @@ void MetatomicForceProvider::exchangeBackwardPairs(const matrix box)
832865
}
833866

834867

868+
void MetatomicForceProvider::distributeNonHomeForces(const double* forces,
869+
ForceProviderOutput* outputs)
870+
{
871+
const int32_t numTotalMta = static_cast<int32_t>(options_.params_.mtaIndices_.size());
872+
873+
// For small systems, dense allreduce has lower latency than the
874+
// sparse exchange (gather counts + allgatherv).
875+
constexpr int32_t sparseThreshold = 1000;
876+
877+
if (numTotalMta < sparseThreshold)
878+
{
879+
// Dense fallback: allocate N_total buffer, scatter, allreduce, readback.
880+
std::vector<double> denseForces(3 * numTotalMta, 0.0);
881+
for (int32_t i = 0; i < numLocalMta_; i++)
882+
{
883+
int32_t g = mtaToGlobalMta_[i];
884+
denseForces[3 * g] = forces[3 * i];
885+
denseForces[3 * g + 1] = forces[3 * i + 1];
886+
denseForces[3 * g + 2] = forces[3 * i + 2];
887+
}
888+
mpiComm_.sumReduce(static_cast<std::size_t>(3 * numTotalMta), denseForces.data());
889+
890+
for (int32_t i = 0; i < numHomeMta_; i++)
891+
{
892+
int32_t gmxIdx = mtaToGmxLocal_[i];
893+
int32_t g = mtaToGlobalMta_[i];
894+
outputs->forceWithVirial_.force_[gmxIdx][0] += static_cast<real>(denseForces[3 * g]);
895+
outputs->forceWithVirial_.force_[gmxIdx][1] += static_cast<real>(denseForces[3 * g + 1]);
896+
outputs->forceWithVirial_.force_[gmxIdx][2] += static_cast<real>(denseForces[3 * g + 2]);
897+
}
898+
return;
899+
}
900+
901+
// Sparse path: apply home forces directly, exchange only non-home forces.
902+
903+
// Step 1: Apply home atom forces directly (no communication needed).
904+
for (int32_t i = 0; i < numHomeMta_; i++)
905+
{
906+
int32_t gmxIdx = mtaToGmxLocal_[i];
907+
outputs->forceWithVirial_.force_[gmxIdx][0] += static_cast<real>(forces[3 * i]);
908+
outputs->forceWithVirial_.force_[gmxIdx][1] += static_cast<real>(forces[3 * i + 1]);
909+
outputs->forceWithVirial_.force_[gmxIdx][2] += static_cast<real>(forces[3 * i + 2]);
910+
}
911+
912+
// Step 2: Pack non-home forces as sparse tuples (globalMtaIdx, fx, fy, fz).
913+
// Each tuple is 4 doubles: [globalMtaIdx_as_double, fx, fy, fz].
914+
const int32_t numNonHome = numLocalMta_ - numHomeMta_;
915+
std::vector<double> sendBuf(4 * numNonHome);
916+
for (int32_t i = numHomeMta_; i < numLocalMta_; i++)
917+
{
918+
int32_t k = i - numHomeMta_;
919+
sendBuf[4 * k] = static_cast<double>(mtaToGlobalMta_[i]);
920+
sendBuf[4 * k + 1] = forces[3 * i];
921+
sendBuf[4 * k + 2] = forces[3 * i + 1];
922+
sendBuf[4 * k + 3] = forces[3 * i + 2];
923+
}
924+
925+
// Step 3: Exchange counts via allreduce on a P-element array.
926+
const int numRanks = mpiComm_.size();
927+
std::vector<int> counts(numRanks, 0);
928+
counts[mpiComm_.rank()] = numNonHome;
929+
mpiComm_.sumReduce(ArrayRef<int>(counts));
930+
931+
// Step 4: Allgatherv via Gatherv + Bcast (thread-MPI compatible).
932+
int totalNonHome = 0;
933+
std::vector<int> displs(numRanks);
934+
for (int r = 0; r < numRanks; r++)
935+
{
936+
displs[r] = totalNonHome;
937+
totalNonHome += counts[r];
938+
}
939+
940+
// Scale counts/displs to doubles (4 per tuple)
941+
std::vector<int> dcounts(numRanks), ddispls(numRanks);
942+
for (int r = 0; r < numRanks; r++)
943+
{
944+
dcounts[r] = 4 * counts[r];
945+
ddispls[r] = 4 * displs[r];
946+
}
947+
948+
std::vector<double> recvBuf(4 * totalNonHome);
949+
MPI_Gatherv(sendBuf.data(), 4 * numNonHome, MPI_DOUBLE,
950+
recvBuf.data(), dcounts.data(), ddispls.data(), MPI_DOUBLE,
951+
mpiComm_.mainRank(), mpiComm_.comm());
952+
MPI_Bcast(recvBuf.data(), 4 * totalNonHome, MPI_DOUBLE,
953+
mpiComm_.mainRank(), mpiComm_.comm());
954+
955+
// Step 5: Scan received tuples for forces destined for our home atoms.
956+
for (int t = 0; t < totalNonHome; t++)
957+
{
958+
int32_t globalMtaIdx = static_cast<int32_t>(recvBuf[4 * t]);
959+
auto it = globalMtaToLocalHome_.find(globalMtaIdx);
960+
if (it != globalMtaToLocalHome_.end())
961+
{
962+
int32_t localHomeIdx = it->second;
963+
int32_t gmxIdx = mtaToGmxLocal_[localHomeIdx];
964+
outputs->forceWithVirial_.force_[gmxIdx][0] += static_cast<real>(recvBuf[4 * t + 1]);
965+
outputs->forceWithVirial_.force_[gmxIdx][1] += static_cast<real>(recvBuf[4 * t + 2]);
966+
outputs->forceWithVirial_.force_[gmxIdx][2] += static_cast<real>(recvBuf[4 * t + 3]);
967+
}
968+
}
969+
}
970+
971+
835972
void MetatomicForceProvider::calculateForces(const ForceProviderInput& inputs, ForceProviderOutput* outputs)
836973
{
837974
MetatomicTimer totalTimer("calculateForces", mpiComm_);
838975

839-
const int32_t numTotalMta = static_cast<int32_t>(options_.params_.mtaIndices_.size());
840-
841976
// Fill local positions (no MPI communication)
842977
{
843978
MetatomicTimer timer("gatherAtomPositions", mpiComm_);
@@ -1164,51 +1299,26 @@ void MetatomicForceProvider::calculateForces(const ForceProviderInput& inputs, F
11641299
toCPUTimer.stop();
11651300
}
11661301

1167-
// Force distribution via all-reduce.
1168-
// backward() produces forces on ALL local atoms (home + halo). Since
1169-
// ForceWithVirial forces are NOT communicated by dd_move_f (which only
1170-
// handles ForceWithShiftForces), we must all-reduce ourselves. Each rank
1171-
// scatters its local forces into a global buffer indexed by global MTA
1172-
// index. After all-reduce, each rank reads back only its home atoms.
1302+
// Force distribution: home forces applied directly, non-home forces
1303+
// exchanged via sparse indexed communication (or dense fallback for
1304+
// small systems). ForceWithVirial is NOT communicated by dd_move_f.
11731305
MetatomicTimer forceScatterTimer("forceScatter", mpiComm_);
11741306

1175-
auto forceAccessor = forceTensor.accessor<double, 2>();
1307+
const double* forceData = forceTensor.data_ptr<double>();
11761308

11771309
if (mpiComm_.isParallel())
11781310
{
1179-
globalForceBuffer_.resize(numTotalMta);
1180-
std::fill(globalForceBuffer_.begin(), globalForceBuffer_.end(), RVec({ 0.0, 0.0, 0.0 }));
1181-
1182-
// Scatter local forces into the global buffer.
1183-
for (int32_t i = 0; i < numLocalMta_; i++)
1184-
{
1185-
int32_t globalMtaIdx = mtaToGlobalMta_[i];
1186-
globalForceBuffer_[globalMtaIdx][0] = static_cast<real>(forceAccessor[i][0]);
1187-
globalForceBuffer_[globalMtaIdx][1] = static_cast<real>(forceAccessor[i][1]);
1188-
globalForceBuffer_[globalMtaIdx][2] = static_cast<real>(forceAccessor[i][2]);
1189-
}
1190-
1191-
mpiComm_.sumReduce(3 * numTotalMta, globalForceBuffer_.data()->as_vec());
1192-
1193-
// Apply forces only to home MTA atoms from the reduced buffer
1194-
for (int32_t i = 0; i < numHomeMta_; i++)
1195-
{
1196-
int32_t gmxIdx = mtaToGmxLocal_[i];
1197-
int32_t globalMtaIdx = mtaToGlobalMta_[i];
1198-
outputs->forceWithVirial_.force_[gmxIdx][0] += globalForceBuffer_[globalMtaIdx][0];
1199-
outputs->forceWithVirial_.force_[gmxIdx][1] += globalForceBuffer_[globalMtaIdx][1];
1200-
outputs->forceWithVirial_.force_[gmxIdx][2] += globalForceBuffer_[globalMtaIdx][2];
1201-
}
1311+
distributeNonHomeForces(forceData, outputs);
12021312
}
12031313
else
12041314
{
12051315
// Serial: apply forces directly
12061316
for (int32_t i = 0; i < numLocalMta_; i++)
12071317
{
12081318
int32_t gmxIdx = mtaToGmxLocal_[i];
1209-
outputs->forceWithVirial_.force_[gmxIdx][0] += static_cast<real>(forceAccessor[i][0]);
1210-
outputs->forceWithVirial_.force_[gmxIdx][1] += static_cast<real>(forceAccessor[i][1]);
1211-
outputs->forceWithVirial_.force_[gmxIdx][2] += static_cast<real>(forceAccessor[i][2]);
1319+
outputs->forceWithVirial_.force_[gmxIdx][0] += static_cast<real>(forceData[3 * i]);
1320+
outputs->forceWithVirial_.force_[gmxIdx][1] += static_cast<real>(forceData[3 * i + 1]);
1321+
outputs->forceWithVirial_.force_[gmxIdx][2] += static_cast<real>(forceData[3 * i + 2]);
12121322
}
12131323
}
12141324

0 commit comments

Comments
 (0)