Quantum 3xplorer (Q3)
An AI-native quantum simulation engine
Loading...
Searching...
No Matches
main_mpi.cpp
Go to the documentation of this file.
2#include <algorithm>
3#include <chrono>
4#include <filesystem>
5#include <stdexcept>
6#include <string>
7
9#include "io/cli.hpp"
11#include "io/logger_mpi.hpp"
15#include "utils/time.hpp"
16
17using namespace q3;
18
19// ─── CLI helpers ──────────────────────────────────────────────────────────────
20
21// ─── Entry point ──────────────────────────────────────────────────────────────
22
23int main(int argc, char* argv[]) {
25 auto options = cli::parse_options(argc, argv, "q3_mpi");
26
27 auto policy = std::make_unique<execution::PlanewaveMPIExecutionPolicy>();
28 log::init_mpi(options.log_file);
29
30 /**
31 * Answered before the input is resolved, so `--version` works in a
32 * directory holding no input -- which is where it is usually asked. MPI is
33 * already up, so init_mpi() has silenced every rank but the root and the
34 * line is printed once rather than once per rank; returning here runs the
35 * policy's destructor, which finalizes MPI.
36 */
37 if (options.print_version) {
38 cli::print_version("q3_mpi");
39 return 0;
40 }
41
42 /**
43 * Apply the requested log level only after init_mpi(): init replaces the
44 * default logger, so applying it earlier would be silently discarded.
45 */
46 if (!options.log_level.empty()) {
47 log::set_level(options.log_level);
48 }
49
50 /**
51 * Capture the rank now, while MPI is guaranteed live. The catch block below
52 * needs it to attribute a fatal error, and asking there would mean asking
53 * whether MPI is still up first -- MPI_Comm_rank after MPI_Finalize is
54 * itself undefined.
55 */
56 int world_rank_index = 0;
57 MPI_Comm_rank(MPI_COMM_WORLD, &world_rank_index);
58
59 try {
60 /**
61 * Every rank reads it and applies its verbosity; a non-root rank's console
62 * sink is already off, so that reaches only the rank's own log file.
63 */
64 auto input = cli::read_input(options, "q3_mpi");
65
66 /**
67 * An explicit CLI value overrides the input manifest. Otherwise honor
68 * control.number_of_kpoint_pools; zero in either source selects the
69 * documented one-pool default. Every rank parses the same input and
70 * configures the topology before the first physics collective.
71 */
72 int requested_number_of_pools = options.number_of_kpoint_pools_was_set
73 ? options.number_of_kpoint_pools
74 : input.number_of_kpoint_pools;
75 policy->configure_topology(requested_number_of_pools);
76
77 /**
78 * Switch the pool from splitting reciprocal space to splitting bands.
79 *
80 * This must happen before the basis is generated: it is what makes
81 * band_size() report 1, which in turn keeps the full G-sphere on every
82 * rank, selects the serial FFT grid, and leaves stress enabled, which
83 * does not survive the reciprocal-space split.
84 *
85 * It also has to happen after configure_topology, which builds the
86 * inter-pool communicator by splitting on the pool-local rank index.
87 * That pairing -- rank i of every pool with rank i of the others -- is
88 * what makes several pools work alongside distributed bands: within a
89 * pool every rank ends up holding the same whole density, because the
90 * bands were gathered back, so summing along the inter-pool axis gives
91 * every rank the total over all k-points. The pairing was written for
92 * the slab split, where rank i owns a matching slab in each pool; whole
93 * grids are the degenerate case of that and reduce just as correctly.
94 */
95 if (options.distribute_bands) {
96 policy->enable_band_distribution();
97 }
98
99 /**
100 * Report the topology before the first physics collective, so a run that
101 * stops later still says how its ranks were arranged. Without this the
102 * log is silent about rank count, pool count and ranks per pool, which
103 * makes "it hangs above N ranks" impossible to diagnose from output
104 * alone. The pool count printed here is what was asked for; the factory
105 * may still clamp it to the irreducible k-point count.
106 */
107 const char* pool_source =
108 options.number_of_kpoint_pools_was_set
109 ? "--pools"
110 : (input.number_of_kpoint_pools != 0 ? "control.number_of_kpoint_pools"
111 : "default");
112 /**
113 * Report the true ranks per pool rather than band_size(). The two agree
114 * for the reciprocal-space split, where band_size() *is* how many ranks
115 * share a pool -- but enabling band distribution sets it to 1 on purpose,
116 * to signal that each rank now holds whole coefficient vectors. Printing
117 * it here would then claim one rank per pool on a run using four, which
118 * is exactly the thing this line exists to make diagnosable.
119 */
120 int ranks_per_pool = policy->world_size() / std::max(policy->number_of_pools(), 1);
121 log::info("MPI topology: {} rank(s), {} k-point pool(s), {} rank(s) per pool ({}), "
122 "splitting {} within a pool",
123 policy->world_size(),
124 policy->number_of_pools(),
125 ranks_per_pool,
126 pool_source,
127 options.distribute_bands ? "bands" : "reciprocal space");
128
129 /**
130 * Every flavor builds its iterator on this rank's policy, so a unit of
131 * a workflow -- the SCF, the non-self-consistent pass of a band
132 * structure or a DOS, each step of a relaxation -- runs on the same
133 * ranks and topology. The policy stays owned here rather than by the
134 * iterator: it outlives the flavor, for the reductions after the run
135 * and for MPI_Finalize, which its destructor calls at the end of main.
136 */
137 bool is_root = policy->is_root();
138 IteratorFactory iterator_factory = [&policy](const InputData& run_input) {
139 return create_kohn_sham_iterator(run_input, *policy);
140 };
141 auto flavor = create_flavor(input.calculation, std::move(iterator_factory));
142
144 log::info(" Calculation type: {}", input.calculation);
145
146 generated::Results results = flavor->run(input);
147 if (is_root) {
148 write_results_json(results,
149 input.output_directory + "/" + input.prefix + "_results.json");
150 write_companion_outputs(input, results);
151 }
152
153 if (is_root) {
154 auto& profiling = q3::ProfilingCounters::instance();
155
156 /**
157 * Reduce counters across all ranks so the summary reflects the total
158 * work done across all pools, not just rank 0's contribution.
159 */
160 long long global_fft_calls = profiling.fft_call_count.load();
161 long long global_davidson_iterations = profiling.davidson_iteration_count.load();
162 long long global_hamiltonian_applications =
163 profiling.hamiltonian_application_count.load();
164 MPI_Reduce(
165 MPI_IN_PLACE, &global_fft_calls, 1, MPI_LONG_LONG, MPI_SUM, 0, MPI_COMM_WORLD);
166 MPI_Reduce(MPI_IN_PLACE,
167 &global_davidson_iterations,
168 1,
169 MPI_LONG_LONG,
170 MPI_SUM,
171 0,
172 MPI_COMM_WORLD);
173 MPI_Reduce(MPI_IN_PLACE,
174 &global_hamiltonian_applications,
175 1,
176 MPI_LONG_LONG,
177 MPI_SUM,
178 0,
179 MPI_COMM_WORLD);
180
181 q3::log::info(" Total FFT calls (all ranks): {}", global_fft_calls);
182 q3::log::info(" Total Davidson iterations (all ranks): {}",
183 global_davidson_iterations);
184 q3::log::info(" Total Hamiltonian applications (all ranks): {}",
185 global_hamiltonian_applications);
186
187 /**
188 * Memory is reported as a spread rather than a sum. Every rank
189 * holds its own copy of the workspace, so the interesting figures
190 * are the largest rank (what a node must fit) and the smallest
191 * (whether the ranks are in fact symmetric, which under
192 * --distribute-bands they are meant to be).
193 */
194 unsigned long long maximum_resident = profiling.peak_resident_bytes_now();
195 unsigned long long minimum_resident = maximum_resident;
196 MPI_Reduce(MPI_IN_PLACE,
197 &maximum_resident,
198 1,
199 MPI_UNSIGNED_LONG_LONG,
200 MPI_MAX,
201 0,
202 MPI_COMM_WORLD);
203 MPI_Reduce(MPI_IN_PLACE,
204 &minimum_resident,
205 1,
206 MPI_UNSIGNED_LONG_LONG,
207 MPI_MIN,
208 0,
209 MPI_COMM_WORLD);
210 if (maximum_resident > 0) {
211 q3::log::info(" Peak resident memory per rank: max {}, min {}",
214 }
215 profiling.davidson_timing.print_summary();
217 } else {
218 // Non-root ranks participate in the MPI_Reduce but do not print.
219 long long local_fft_calls = q3::ProfilingCounters::instance().fft_call_count.load();
220 long long local_davidson_iterations =
222 long long local_hamiltonian_applications =
224 MPI_Reduce(&local_fft_calls, nullptr, 1, MPI_LONG_LONG, MPI_SUM, 0, MPI_COMM_WORLD);
225 MPI_Reduce(
226 &local_davidson_iterations, nullptr, 1, MPI_LONG_LONG, MPI_SUM, 0, MPI_COMM_WORLD);
227 MPI_Reduce(&local_hamiltonian_applications,
228 nullptr,
229 1,
230 MPI_LONG_LONG,
231 MPI_SUM,
232 0,
233 MPI_COMM_WORLD);
234 unsigned long long local_resident =
236 MPI_Reduce(
237 &local_resident, nullptr, 1, MPI_UNSIGNED_LONG_LONG, MPI_MAX, 0, MPI_COMM_WORLD);
238 MPI_Reduce(
239 &local_resident, nullptr, 1, MPI_UNSIGNED_LONG_LONG, MPI_MIN, 0, MPI_COMM_WORLD);
240 }
241
242 } catch (const std::exception& exception) {
243 /**
244 * Report from whichever rank threw, on stderr, rather than through
245 * log::error(): init_mpi() silences every non-root rank, so a failure
246 * raised there would say nothing at all. That is not hypothetical --
247 * the factory's shared-pool refusal is raised identically on every
248 * rank, and the first to exit nonzero takes the whole job down with it,
249 * including rank 0 part-way through printing. The run then exited
250 * nonzero with no reason given, intermittently and only under load.
251 * Every rank that reaches here now says so, flushed, before it goes.
252 */
253 log::fatal("[rank " + std::to_string(world_rank_index) +
254 "] Fatal error: " + exception.what());
255
256 /**
257 * Abort so that a rank failing alone cannot leave its peers blocked in
258 * a collective forever. The execution policy is still alive here -- it
259 * is owned by main, outside the try block -- so nothing has finalized
260 * MPI on the way: MPI_Finalize is collective, and reached from a
261 * rank-local failure it would block until peers that are still doing
262 * physics arrived, which is never. The abort ends the process before
263 * the policy's destructor runs.
264 *
265 * The guard remains because MPI_Abort after MPI_Finalize is undefined
266 * -- in practice OpenMPI buries the message above under a wall of
267 * standard-violation warnings and exits 14 instead of the 1 asked for
268 * here -- and a finalized MPI means every rank reached the collective
269 * MPI_Finalize, so none is left hanging and returning normally is safe.
270 */
271 int is_finalized = 0;
272 MPI_Finalized(&is_finalized);
273 if (!is_finalized) {
274 MPI_Abort(MPI_COMM_WORLD, 1);
275 }
276 return 1;
277 }
278
279 /**
280 * MPI_Finalize is called by PlanewaveMPIExecutionPolicy's destructor, as
281 * `policy` goes out of scope on this return, after every rank has finished
282 * the run and the reductions above.
283 */
284 return 0;
285}
int main(int argc, char *argv[])
Definition main_mpi.cpp:23
void register_all()
No add-on is compiled in, so there is nothing to register.
Options parse_options(int argc, char *argv[], const std::string &binary_name)
Definition cli.hpp:175
void print_version(const std::string &binary_name)
Definition cli.hpp:45
InputData read_input(Options &options, const std::string &binary_name)
Definition cli.hpp:239
void print_footer()
Definition cli.hpp:278
void print_header()
Definition cli.hpp:274
void fatal(const std::string &message)
Definition logger.hpp:166
void init_mpi(const std::string &log_file="")
void info(spdlog::format_string_t< Args... > fmt, Args &&... args)
Definition logger.hpp:183
void set_level(const std::string &level)
Set log level: "trace", "debug", "info", "warn", "error", "off".
Definition logger.hpp:94
void write_results_json(const generated::Results &results, const std::string &output_path)
Write the results to output_path as results.json.
Definition results.hpp:191
std::unique_ptr< Flavor > create_flavor(const std::string &calculation_type, IteratorFactory iterator_factory={})
Definition factory.hpp:52
std::function< std::unique_ptr< PlanewaveKohnShamSCFIterator >(const InputData &)> IteratorFactory
Definition factory.hpp:59
void write_companion_outputs(const InputData &input, const generated::Results &results)
Write the companion outputs of the format input was read from, if it has any.
std::unique_ptr< PlanewaveKohnShamSCFIterator > create_kohn_sham_iterator(const InputData &input, const execution::PlanewaveExecutionPolicy &execution_policy)
Definition factory.hpp:64
Parsed input data — holds raw values read from input files.
Definition data.hpp:27
std::atomic< long long > fft_call_count
Definition counters.hpp:22
static ProfilingCounters & instance()
Definition counters.hpp:220
std::uint64_t peak_resident_bytes_now()
Definition counters.hpp:205
std::atomic< long long > hamiltonian_application_count
Definition counters.hpp:24
std::atomic< long long > davidson_iteration_count
Definition counters.hpp:23
static std::string format_gibibytes(std::uint64_t bytes)
Definition memory.hpp:152