Skip to content

Commit b68335b

Browse files
committed
Phase 3.1+3.2: extract BaseCluster, refactor Cluster as scheduler backend
Add Modules/BaseCluster.py containing the generic cluster interface: BaseCluster (attribute-locking machinery moved verbatim from Cluster, _check_calculator_interface, _pre_compute_hook template hook, compute_jobarray abstract method, the generic ensemble driver compute_ensemble/compute_ensemble_batch with the on-the-fly FLARE hooks, _compute_jobarray_thread, _ingest_result) and DirectCluster (in-process backend consuming DirectCalculator objects). The OTF loop is written ONCE here and inherited by every cluster flavor. Refactor Modules/Cluster.py: Cluster now inherits from BaseCluster and keeps its entire public API (constructor signature, scheduler/transport methods, namelist constants). The generic driver (compute_ensemble, compute_ensemble_batch, compute_single_jobarray) and the attribute locking (__setattr__, __getstate__, __setstate__) are dropped (inherited from BaseCluster); batch_size/job_number/max_recalc/lock are set by BaseCluster.__init__. Cluster implements compute_jobarray (the template hook, with collect_results now outside the lock — safe parallelization improvement with identical results) and _pre_compute_hook (mkdir the local workdir). De-QE-hardcode prepare_input_file: extensions come from calc.input_extension/output_extension (default .pwi/.pwo), the prefix print is guarded with getattr, and extra_input_files are shipped to the cluster (G2). compute_jobarray uses the calc extensions too (G3). get_output_path takes an out_extension argument. Register the new modules in meson.build (ASEClusterRunner, BaseCluster, ClusterCalculators). ClusterCalculators.py and ASEClusterRunner.py are stubs for now (filled in the next commit). Compatibility verified: OpticalQECluster (sets attrs before super().__init__), Relax.py namelist path, test_save_binary pickling, and the full non-release suite pass (the 3 failing tests are pre-existing on master and unrelated).
1 parent 14c8250 commit b68335b

5 files changed

Lines changed: 437 additions & 224 deletions

File tree

Modules/ASEClusterRunner.py

Whitespace-only changes.

Modules/BaseCluster.py

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
# -*- coding: utf-8 -*-
2+
"""Generic interface for computing SSCHA ensembles 'on a cluster'.
3+
4+
A 'cluster' here is any execution backend capable of computing energies,
5+
forces and (optionally) stresses for a list of structures: a remote HPC
6+
with a job scheduler (sscha.Cluster.Cluster), the same machinery mocked
7+
locally (sscha.LocalCluster.LocalCluster), or a direct in-process
8+
calculator (DirectCluster).
9+
10+
The ensemble driver (compute_ensemble_batch), including the on-the-fly
11+
(FLARE) learning loop, is implemented ONCE in this module. Backends only
12+
implement `compute_jobarray`, which obtains the raw results for a list of
13+
ensemble indices.
14+
"""
15+
from __future__ import print_function
16+
17+
import sys
18+
import os
19+
import threading
20+
import difflib
21+
22+
import numpy as np
23+
24+
# SETUP THE CODATA 2006, To match the QE definition of Rydberg (as in Cluster.py)
25+
try:
26+
import ase.units
27+
units = ase.units.create_units("2006")
28+
except Exception:
29+
units = {"Ry": 13.605698066, "Bohr": 1 / 1.889725989}
30+
31+
32+
class BaseCluster(object):
33+
"""Abstract execution backend for ensemble calculations.
34+
35+
Subclasses must implement :func:`compute_jobarray` and declare the
36+
calculator interface they consume via `_required_calculator_interface`.
37+
"""
38+
39+
# Attributes a calculator must expose to be used with this cluster.
40+
_required_calculator_interface = ("copy",)
41+
42+
def __init__(self, batch_size=1000, job_number=1, max_recalc=10):
43+
"""
44+
Parameters
45+
----------
46+
batch_size : int
47+
Maximum number of job arrays computed in parallel per cycle.
48+
With on-the-fly learning active, one cycle is the learning
49+
batch: the GP is updated/retrained (and the remaining
50+
structures re-predicted) after each cycle, so the effective
51+
OTF batch size is `batch_size * job_number` structures.
52+
job_number : int
53+
Number of structures grouped in a single job array.
54+
max_recalc : int
55+
Maximum number of resubmission cycles for failed jobs.
56+
"""
57+
self.batch_size = batch_size
58+
self.job_number = job_number
59+
self.max_recalc = max_recalc
60+
self.lock = None # threading.Lock(), created per compute_ensemble_batch
61+
62+
# NOTE: subclasses set their own attributes and then call
63+
# self._lock_attributes() at the end of their __init__.
64+
# Setting attributes BEFORE super().__init__() also works (they are
65+
# picked up into __total_attributes__), as OpticalQECluster does.
66+
67+
# ------------------ attribute locking machinery ------------------ #
68+
# (moved verbatim from sscha.Cluster.Cluster)
69+
70+
def _lock_attributes(self):
71+
"""Forbid setting attributes not defined up to this point."""
72+
self.__total_attributes__ = [item for item in self.__dict__.keys()]
73+
self.fixed_attributes = True # This must be the last attribute to be setted
74+
75+
def __setattr__(self, name, value):
76+
if "fixed_attributes" in self.__dict__:
77+
if name in self.__total_attributes__:
78+
super(BaseCluster, self).__setattr__(name, value)
79+
elif self.fixed_attributes:
80+
similar_objects = str(difflib.get_close_matches(name, self.__total_attributes__))
81+
ERROR_MSG = """
82+
Error, the attribute '{}' is not a member of '{}'.
83+
Suggested similar attributes: {} ?
84+
""".format(name, type(self).__name__, similar_objects)
85+
raise AttributeError(ERROR_MSG)
86+
87+
if name.endswith("_name"):
88+
key = "use_{}".format(name.split("_")[0])
89+
self.__dict__[key] = True
90+
else:
91+
super(BaseCluster, self).__setattr__(name, value)
92+
93+
def __getstate__(self):
94+
"""Return the picklable state (the thread lock cannot be pickled)."""
95+
state = self.__dict__.copy()
96+
state["lock"] = None
97+
return state
98+
99+
def __setstate__(self, state):
100+
state["lock"] = None
101+
self.__dict__.update(state)
102+
103+
# ------------------ template-method hooks ------------------ #
104+
105+
def _check_calculator_interface(self, calc):
106+
"""Fail early with a clear error if calc cannot run on this cluster."""
107+
missing = [m for m in self._required_calculator_interface
108+
if not hasattr(calc, m)]
109+
if missing:
110+
raise TypeError(
111+
"Error, the calculator {} cannot be used with {}.\n"
112+
"Missing methods/attributes: {}".format(
113+
type(calc).__name__, type(self).__name__, missing))
114+
115+
def _pre_compute_hook(self, ensemble, calc):
116+
"""Called once before the first submission cycle (default: nothing)."""
117+
118+
def compute_jobarray(self, ensemble, calc, jobs_id):
119+
"""Compute one job array and return the raw results.
120+
121+
MUST be implemented by subclasses.
122+
123+
Parameters
124+
----------
125+
ensemble : sscha.Ensemble.Ensemble
126+
The ensemble being computed.
127+
calc : a calculator of the interface required by this cluster
128+
A private copy for this job array (created with calc.copy()).
129+
jobs_id : list of int
130+
The ensemble indices to compute.
131+
132+
Returns
133+
-------
134+
list, aligned with jobs_id, of raw result dicts or None (failure).
135+
Each dict must provide "energy" [eV], "forces" [eV/Angstrom],
136+
optionally "stress" (ASE Voigt xx,yy,zz,yz,xz,xy, -eV/Angstrom^3)
137+
and "structure" (CC.Structure, for the consistency check);
138+
any extra key is stored in ensemble.all_properties.
139+
"""
140+
raise NotImplementedError("compute_jobarray must be implemented by subclasses")
141+
142+
# ------------------ the generic ensemble driver ------------------ #
143+
144+
def compute_ensemble(self, ensemble, calc, get_stress=True, timeout=None):
145+
"""Run the whole ensemble on this cluster (see compute_ensemble_batch)."""
146+
self.compute_ensemble_batch(ensemble, calc, get_stress, timeout)
147+
148+
def compute_ensemble_batch(self, ensemble, calc, get_stress=True, timeout=None):
149+
"""
150+
RUN THE ENSEMBLE WITH BATCH SUBMISSION (generic driver)
151+
=======================================================
152+
153+
If the ensemble has an active on-the-fly ML model
154+
(``ensemble.gp_model is not None``, set via ``ensemble.set_otf``),
155+
each cycle of ab-initio computations is followed by a GP
156+
update/retrain, and the remaining structures are re-checked against
157+
the model: those predicted with acceptable uncertainty are filled
158+
with the ML prediction and never computed. One learning cycle
159+
computes up to ``batch_size * job_number`` structures.
160+
"""
161+
self._check_calculator_interface(calc)
162+
163+
# Track the remaining configurations
164+
success = [False] * ensemble.N
165+
166+
# Setup if the ensemble has the stress
167+
ensemble.has_stress = get_stress
168+
169+
self._pre_compute_hook(ensemble, calc)
170+
171+
# Get the expected number of batch
172+
num_batch_offset = int(ensemble.N / self.batch_size)
173+
174+
# ==================== OTF SETUP (FLARE) ====================
175+
use_otf = getattr(ensemble, "gp_model", None) is not None
176+
dft_counts = 0
177+
if use_otf:
178+
number_of_atoms = ensemble.structures[0].get_ase_atoms().get_global_number_of_atoms()
179+
ensemble._otf_setup_defaults(number_of_atoms)
180+
remaining = list(range(ensemble.N))
181+
ensemble._otf_predict(ensemble.structures, remaining)
182+
for i in range(ensemble.N):
183+
if ensemble.force_computed[i]:
184+
success[i] = True
185+
186+
# Run until some work has not finished
187+
recalc = 0
188+
self.lock = threading.Lock()
189+
while np.sum(np.array(success, dtype=int) - 1) != 0:
190+
threads = []
191+
cycle_results = {}
192+
193+
print("[CYCLE] SUCCESS: ", success)
194+
print("[CYCLE] STOPPING CONDITION:", np.sum(np.array(success, dtype=int) - 1))
195+
196+
# Get the remaining jobs
197+
false_mask = np.array(success) == False
198+
false_id = np.arange(ensemble.N)[false_mask]
199+
200+
count = 0
201+
# Submit in parallel
202+
jobs = [false_id[i:i + self.job_number] for i in range(0, len(false_id), self.job_number)]
203+
# Create a local copy of the calculator for each thread, to avoid conflicting modifications
204+
calculators = [calc.copy() for i in range(0, len(jobs))]
205+
206+
for k_th, job in enumerate(jobs):
207+
# Submit only the batch size
208+
if count >= self.batch_size:
209+
break
210+
t = threading.Thread(target=self._compute_jobarray_thread,
211+
args=(ensemble, calculators[k_th], job,
212+
get_stress, cycle_results, success))
213+
t.start()
214+
threads.append(t)
215+
count += 1
216+
217+
# Wait until all the job have finished
218+
for t in threads:
219+
t.join(timeout)
220+
221+
# ============ OTF UPDATE / TRAIN / PREDICT ============
222+
if use_otf and cycle_results:
223+
# Main thread only, deterministic order (G7)
224+
dft_counts += len(cycle_results)
225+
for num in sorted(cycle_results):
226+
res = cycle_results[num]
227+
dft_stress = np.array(res["stress"], dtype=float) \
228+
if (get_stress and "stress" in res) else None
229+
ensemble._otf_update_from_structure(
230+
ensemble.structures[num],
231+
dft_energy=res["energy"], # eV
232+
dft_frcs=res["forces"], # eV/Ang
233+
dft_stress=dft_stress, # ASE Voigt, -eV/Ang^3
234+
)
235+
ensemble._otf_maybe_train_and_write(dft_counts)
236+
# Re-check the remaining structures against the model
237+
remaining = [int(i) for i in np.arange(ensemble.N)[np.array(success) == False]]
238+
ensemble._otf_predict(ensemble.structures, remaining)
239+
for i in range(ensemble.N):
240+
if ensemble.force_computed[i]:
241+
success[i] = True
242+
243+
print("[CYCLE] [END] SUCCESS: ", success)
244+
print("[CYCLE] [END] STOPPING CONDITION:", np.sum(np.array(success, dtype=int) - 1))
245+
246+
recalc += 1
247+
if recalc > num_batch_offset + self.max_recalc:
248+
print("Expected batch ordinary resubmissions:", num_batch_offset)
249+
raise ValueError("Error, resubmissions exceeded the maximum number of %d" % self.max_recalc)
250+
251+
if use_otf:
252+
ensemble._clean_runs(dft_counts)
253+
254+
print("CALCULATION ENDED: all properties: {}".format(ensemble.all_properties))
255+
256+
def _compute_jobarray_thread(self, ensemble, calc, jobs_id,
257+
get_stress, cycle_results, success):
258+
"""Thread worker: obtain raw results, then ingest them (locked)."""
259+
raw_results = self.compute_jobarray(ensemble, calc, jobs_id)
260+
261+
# Thread safe operation
262+
self.lock.acquire()
263+
try:
264+
print("[THREAD {}] submitted calculations: {}".format(
265+
threading.get_native_id(), list(jobs_id)))
266+
for pos, res in enumerate(raw_results):
267+
num = int(jobs_id[pos])
268+
print("[THREAD {}] ADDING RESULT {} = {}".format(
269+
threading.get_native_id(), num, res))
270+
ok = self._ingest_result(ensemble, res, num, get_stress)
271+
success[num] = ok
272+
if ok:
273+
cycle_results[num] = res # keep raw eV results for the OTF update (G6)
274+
finally:
275+
self.lock.release()
276+
277+
def _ingest_result(self, ensemble, res, num, get_stress):
278+
"""Validate one raw result and write it into the ensemble arrays.
279+
280+
Returns True if the result was complete and stored, False otherwise
281+
(the job will be resubmitted, up to max_recalc).
282+
"""
283+
if res is None:
284+
return False
285+
286+
# Check if the run was good
287+
check_e = "energy" in res
288+
check_f = "forces" in res
289+
check_s = "stress" in res
290+
291+
# Check the structure
292+
if "structure" in res:
293+
error_struct = np.linalg.norm(ensemble.structures[num].coords.ravel()
294+
- res["structure"].coords.ravel())
295+
if error_struct > 1e-2:
296+
print("ERROR IDENTIFYING STRUCTURE!")
297+
MSG = """
298+
Error in thread {}.
299+
Displacement between the expected structure {}
300+
and the one readed from the calculator
301+
is of {} A.
302+
""".format(threading.get_native_id(), num, error_struct)
303+
print(MSG)
304+
ensemble.structures[num].save_scf(
305+
't_{}_error_struct_generated_{}.scf'.format(threading.get_native_id(), num))
306+
res["structure"].save_scf(
307+
't_{}_error_struct_readed_{}.scf'.format(threading.get_native_id(), num))
308+
return False
309+
else:
310+
print("[WARNING] no check on the structure.")
311+
312+
is_success = check_e and check_f
313+
if get_stress:
314+
is_success = is_success and check_s
315+
316+
if not is_success:
317+
return False
318+
319+
res_only_extra = {x: res[x] for x in res if x not in ["energy", "forces", "stress", "structure"]}
320+
ensemble.all_properties[num].update(res_only_extra)
321+
ensemble.energies[num] = res["energy"] / units["Ry"]
322+
ensemble.forces[num, :, :] = res["forces"] / units["Ry"]
323+
ensemble.force_computed[num] = True
324+
325+
if get_stress:
326+
stress = np.zeros((3, 3), dtype=np.float64)
327+
stress[0, 0] = res["stress"][0]
328+
stress[1, 1] = res["stress"][1]
329+
stress[2, 2] = res["stress"][2]
330+
stress[1, 2] = res["stress"][3]
331+
stress[2, 1] = res["stress"][3]
332+
stress[0, 2] = res["stress"][4]
333+
stress[2, 0] = res["stress"][4]
334+
stress[0, 1] = res["stress"][5]
335+
stress[1, 0] = res["stress"][5]
336+
# Remember, ase has a very strange definition of the stress
337+
ensemble.stresses[num, :, :] = -stress * units["Bohr"]**3 / units["Ry"]
338+
ensemble.stress_computed[num] = True
339+
return True
340+
341+
342+
class DirectCluster(BaseCluster):
343+
"""
344+
DIRECT (IN-PROCESS) CLUSTER
345+
===========================
346+
347+
A 'cluster' that computes the ensemble directly in the current process,
348+
without writing any file and without any job scheduler. It consumes
349+
`DirectCalculator` objects (e.g. ASEDirectCalculator wrapping any ASE
350+
calculator).
351+
352+
The learning-cycle size is still `batch_size * job_number`; threads are
353+
used across job arrays exactly like in the scheduler-based clusters
354+
(each thread owns a private calculator copy). Note that pure-Python ASE
355+
calculators are bound by the GIL; the parallelism is still useful for
356+
calculators that release it (NumPy-heavy or subprocess-based codes), and
357+
the interface stays identical to the other clusters.
358+
"""
359+
360+
_required_calculator_interface = ("copy", "compute")
361+
362+
def __init__(self, batch_size=1000, job_number=1, max_recalc=10):
363+
super().__init__(batch_size=batch_size, job_number=job_number,
364+
max_recalc=max_recalc)
365+
self._lock_attributes()
366+
367+
def compute_jobarray(self, ensemble, calc, jobs_id):
368+
"""Compute each structure in-process via calc.compute(structure)."""
369+
results = []
370+
for num in jobs_id:
371+
try:
372+
results.append(calc.compute(ensemble.structures[int(num)]))
373+
except Exception as exc:
374+
sys.stderr.write("JOB {} resulted in error:\n{}\n".format(num, exc))
375+
sys.stderr.flush()
376+
results.append(None)
377+
return results

0 commit comments

Comments
 (0)