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 *
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+
835972void 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