Skip to content

Commit 32f0f59

Browse files
committed
Merge branch '287_spawn_pool' into 'dev'
Run process pools with spawn after pythonnet starts See merge request mass-spectrometry/corems!276
2 parents c47f9e7 + d30d472 commit 32f0f59

10 files changed

Lines changed: 254 additions & 79 deletions

File tree

‎corems/chroma_peak/calc/subset.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# This file contains functions for subsetting dataframes that contain mass feature data.
22
# This is based on the deimos package, found here: https://github.com/pnnl/deimos/blob/master/deimos/subset.py with some modifications.
33

4-
import multiprocessing as mp
54
from functools import partial
65

76
import numpy as np
87
import pandas as pd
98

9+
from corems.encapsulation.spawn_pool import spawn_pool
10+
1011
class MultiSamplePartitions:
1112
'''
1213
Generator object that will lazily build and return each partition constructed
@@ -157,7 +158,7 @@ def map(self, func, processes=1, **kwargs):
157158

158159
# Parallel
159160
else:
160-
with mp.Pool(processes=processes) as p:
161+
with spawn_pool(processes) as p:
161162
result = list(p.imap(partial(func, **kwargs), self))
162163

163164
# Add partition index

‎corems/encapsulation/spawn_pool.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Process pools that still return after pythonnet has started."""
2+
3+
from contextlib import contextmanager
4+
from multiprocessing import get_context
5+
from multiprocessing.pool import TERMINATE
6+
7+
8+
@contextmanager
9+
def spawn_pool(processes):
10+
"""Yield a spawn process pool and kill its workers on exit.
11+
12+
Parameters
13+
----------
14+
processes : int
15+
Number of worker processes. The caller chooses this count.
16+
17+
Yields
18+
------
19+
multiprocessing.pool.Pool
20+
Pool started with the ``spawn`` start method.
21+
22+
Notes
23+
-----
24+
Linux ``multiprocessing.Pool`` forks. A forked child deadlocks after
25+
pythonnet has started CoreCLR or Mono, so the pool never finishes.
26+
Spawn starts a new interpreter. Those workers ignore SIGTERM, and
27+
``Pool.terminate`` waits forever in its ``join``. The caller reads
28+
results inside the ``with`` block. On the way out, including when the
29+
body raises, the workers are killed and reaped.
30+
"""
31+
pool = get_context("spawn").Pool(processes)
32+
try:
33+
yield pool
34+
finally:
35+
_kill_spawn_pool(pool)
36+
37+
38+
def _kill_spawn_pool(pool):
39+
"""Stop a spawn pool without ``Pool.terminate``.
40+
41+
``terminate`` signals the workers with SIGTERM and then joins them.
42+
A worker that has loaded CoreCLR or Mono ignores that signal, so the
43+
join never returns. The results are already in the parent. Kill the
44+
workers and cancel the pool finalizer, which would call ``terminate``
45+
again at garbage collection.
46+
"""
47+
pool._state = TERMINATE
48+
for handler in (pool._worker_handler, pool._task_handler, pool._result_handler):
49+
handler._state = TERMINATE
50+
pool._change_notifier.put(None)
51+
for process in list(pool._pool):
52+
if process.is_alive():
53+
try:
54+
process.kill()
55+
except ProcessLookupError:
56+
pass
57+
process.join()
58+
pool._terminate.cancel()

‎corems/mass_spectra/calc/lc_calc.py‎

Lines changed: 46 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import numpy as np
66
import pandas as pd
7-
import warnings, scipy, multiprocessing
7+
import warnings, scipy
88

99
if not hasattr(np, 'trapezoid'): # numpy < 2.0
1010
np.trapezoid = np.trapz
@@ -18,6 +18,7 @@
1818

1919
from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
2020
from corems.encapsulation.plot_utils import _finalize_plot
21+
from corems.encapsulation.spawn_pool import spawn_pool
2122
from corems.mass_spectra.calc import SignalProcessing as sp
2223
from corems.mass_spectra.factory.chromat_data import EIC_Data
2324
from corems.mass_spectrum.input.numpyArray import ms_from_array_profile
@@ -4478,16 +4479,13 @@ def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=Tr
44784479
ms2_scan_filter=ms2_scan_filter)
44794480
else:
44804481
# Parallel processing
4481-
import multiprocessing
44824482
from tqdm import tqdm
44834483

44844484
if self.parameters.lcms_collection.cores > len(sample_mf_map):
44854485
ncores = len(sample_mf_map)
44864486
else:
44874487
ncores = self.parameters.lcms_collection.cores
44884488

4489-
pool = multiprocessing.Pool(ncores)
4490-
44914489
# Build arguments list for starmap
44924490
args_list = [
44934491
(sample_id, sample_mf_map[sample_id], add_ms2, auto_process_ms2,
@@ -4496,9 +4494,8 @@ def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=Tr
44964494
]
44974495

44984496
# Execute in parallel
4499-
mp_result = pool.starmap(self._reload_sample_mass_features, args_list)
4500-
pool.close()
4501-
pool.join()
4497+
with spawn_pool(ncores) as pool:
4498+
mp_result = pool.starmap(self._reload_sample_mass_features, args_list)
45024499

45034500
# Collect results back into samples
45044501
for i, sample_id in enumerate(tqdm(sample_mf_map.keys(), desc="Collecting reloaded mass features", unit="sample")):
@@ -5412,11 +5409,11 @@ def fill_missing_cluster_features(self):
54125409
ncores = len(self)
54135410
else:
54145411
ncores = self.parameters.lcms_collection.cores
5415-
pool = multiprocessing.Pool(ncores)
5416-
mp_result = pool.starmap(
5417-
self._search_for_targeted_mass_features_in_sample,
5418-
[(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)]
5419-
)
5412+
with spawn_pool(ncores) as pool:
5413+
mp_result = pool.starmap(
5414+
self._search_for_targeted_mass_features_in_sample,
5415+
[(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)]
5416+
)
54205417

54215418
for i in tqdm(range(sample_ct), desc="Collecting gap-filled features", unit="sample"):
54225419
self[i].induced_mass_features = mp_result[i]
@@ -5536,15 +5533,11 @@ def process_samples_pipeline(self, operations, description=None, keep_raw_data=F
55365533
results_by_operation[op_name][sample_id] = result
55375534
else:
55385535
# Parallel processing
5539-
import multiprocessing
5540-
55415536
if self.parameters.lcms_collection.cores > sample_ct:
55425537
ncores = sample_ct
55435538
else:
55445539
ncores = self.parameters.lcms_collection.cores
55455540

5546-
pool = multiprocessing.Pool(ncores)
5547-
55485541
# Build arguments for each sample
55495542
args_list = [
55505543
(sample_id, operations, runtime_params, False)
@@ -5554,45 +5547,43 @@ def process_samples_pipeline(self, operations, description=None, keep_raw_data=F
55545547
# Execute in parallel with progress tracking
55555548
results_by_operation = {op.name: {} for op in operations}
55565549

5557-
if show_progress:
5558-
from tqdm import tqdm
5559-
import time
5560-
5561-
# Use starmap_async for parallel execution with progress tracking
5562-
async_result = pool.starmap_async(self._execute_sample_pipeline, args_list)
5563-
5564-
# Poll for completion and update progress bar
5565-
print(description)
5566-
pbar = tqdm(
5567-
total=sample_ct,
5568-
desc="",
5569-
unit="sample",
5570-
position=0,
5571-
leave=True,
5572-
dynamic_ncols=True
5573-
)
5574-
prev_completed = 0
5575-
while not async_result.ready():
5576-
# Get number of completed tasks by checking remaining
5577-
completed = sample_ct - async_result._number_left
5578-
if completed > prev_completed:
5579-
pbar.update(completed - prev_completed)
5580-
prev_completed = completed
5581-
time.sleep(0.5) # Poll every 500ms to avoid spam
5582-
5583-
# Final update to 100%
5584-
if prev_completed < sample_ct:
5585-
pbar.update(sample_ct - prev_completed)
5586-
pbar.close()
5587-
5588-
# Get all results
5589-
mp_results = async_result.get()
5590-
else:
5591-
# Execute without progress
5592-
mp_results = pool.starmap(self._execute_sample_pipeline, args_list)
5593-
5594-
pool.close()
5595-
pool.join()
5550+
with spawn_pool(ncores) as pool:
5551+
if show_progress:
5552+
from tqdm import tqdm
5553+
import time
5554+
5555+
# Use starmap_async for parallel execution with progress tracking
5556+
async_result = pool.starmap_async(self._execute_sample_pipeline, args_list)
5557+
5558+
# Poll for completion and update progress bar
5559+
print(description)
5560+
pbar = tqdm(
5561+
total=sample_ct,
5562+
desc="",
5563+
unit="sample",
5564+
position=0,
5565+
leave=True,
5566+
dynamic_ncols=True
5567+
)
5568+
prev_completed = 0
5569+
while not async_result.ready():
5570+
# Get number of completed tasks by checking remaining
5571+
completed = sample_ct - async_result._number_left
5572+
if completed > prev_completed:
5573+
pbar.update(completed - prev_completed)
5574+
prev_completed = completed
5575+
time.sleep(0.5) # Poll every 500ms to avoid spam
5576+
5577+
# Final update to 100%
5578+
if prev_completed < sample_ct:
5579+
pbar.update(sample_ct - prev_completed)
5580+
pbar.close()
5581+
5582+
# Get all results
5583+
mp_results = async_result.get()
5584+
else:
5585+
# Execute without progress
5586+
mp_results = pool.starmap(self._execute_sample_pipeline, args_list)
55965587

55975588
# Collect results back into collection
55985589
for result in mp_results:

‎corems/mass_spectra/input/corems_hdf5.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import warnings
1717

1818
from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
19+
from corems.encapsulation.spawn_pool import spawn_pool
1920
from corems.encapsulation.input.parameter_from_json import (
2021
load_and_set_json_parameters_lcms,
2122
load_and_set_toml_parameters_lcms,
@@ -1231,9 +1232,9 @@ def get_lcms_collection(self, load_raw = False, load_light = True, use_original_
12311232
else:
12321233
ncores = self._cores
12331234
# Create a pool of workers (one for each core or sample, whichever is smaller)
1234-
pool = multiprocessing.Pool(ncores)
12351235
args = [(sample, load_raw, load_light, use_original_parser) for sample in samples]
1236-
lcms_objs = pool.starmap(self.get_lcms_obj, args)
1236+
with spawn_pool(ncores) as pool:
1237+
lcms_objs = pool.starmap(self.get_lcms_obj, args)
12371238
for sample_name, lcms_obj in zip(samples, lcms_objs):
12381239
lcms_coll._lcms[sample_name] = lcms_obj
12391240

‎corems/molecular_id/factory/MolecularLookupTable.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import io
77
import itertools
88
import json
9-
import multiprocessing
109
import pstats
1110
from copy import deepcopy
1211
from typing import Dict
@@ -17,6 +16,7 @@
1716

1817
from corems import chunks, timeit
1918
from corems.encapsulation.constant import Atoms
19+
from corems.encapsulation.spawn_pool import spawn_pool
2020
from corems.encapsulation.factory.parameters import MSParameters
2121
from corems.encapsulation.factory.processingSetting import (
2222
MolecularLookupDictSettings,
@@ -447,13 +447,11 @@ def runworker(self, molecular_search_settings, **kwargs):
447447
worker_args = [
448448
(chunk, settings.url_database) for chunk in list_insert_chunks
449449
]
450-
p = multiprocessing.Pool(settings.db_jobs)
451-
for class_list in tqdm(
452-
p.imap_unordered(insert_database_worker, worker_args), disable= not verbose
453-
):
454-
pass
455-
p.close()
456-
p.join()
450+
with spawn_pool(settings.db_jobs) as p:
451+
for class_list in tqdm(
452+
p.imap_unordered(insert_database_worker, worker_args), disable= not verbose
453+
):
454+
pass
457455

458456
return classes_list
459457

‎support_code/nmdc/lipidomics/lipidomics_workflow.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import sys
88

99
sys.path.append("./")
10-
from multiprocessing import Pool
1110
from pathlib import Path
1211
import datetime
1312
import tomlkit
@@ -16,6 +15,7 @@
1615
import pandas as pd
1716
import time
1817

18+
from corems.encapsulation.spawn_pool import spawn_pool
1919
from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra
2020
from corems.mass_spectra.input.mzml import MZMLSpectraParser
2121
from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader
@@ -682,7 +682,6 @@ def run_lipid_workflow(
682682
)
683683
mz_dicts.append(mz_dict)
684684
elif cores > 1:
685-
pool = Pool(cores)
686685
args = [
687686
(
688687
str(file_in),
@@ -694,9 +693,8 @@ def run_lipid_workflow(
694693
)
695694
for file_in, file_out in list(zip(files_list, out_paths_list))
696695
]
697-
mz_dicts = pool.starmap(run_lipid_sp_ms1, args)
698-
pool.close()
699-
pool.join()
696+
with spawn_pool(cores) as pool:
697+
mz_dicts = pool.starmap(run_lipid_sp_ms1, args)
700698

701699
# Skip ms2 spectral search for now, will add back once we have an improved MetabRefLCInterface method
702700
"""

‎support_code/nmdc/metabolomics/gcms_workflow.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
sys.path.append("./")
66

77
from pathlib import Path
8-
from multiprocessing import Pool
98
import cProfile
109

1110

11+
from corems.encapsulation.spawn_pool import spawn_pool
1212
from corems.molecular_id.input.nistMSI import ReadNistMSI
1313
from corems.mass_spectra.input.andiNetCDF import ReadAndiNetCDF
1414
from corems.molecular_id.search.compoundSearch import LowResMassSpectralMatch
@@ -103,11 +103,9 @@ def calibrate_and_search(out_put_file_name, jobs, dms_file_path):
103103
file_dialog.close()
104104

105105
# run in multiprocessing mode
106-
pool = Pool(jobs)
107106
args = [(file_path, ref_dict, cal_file_path) for file_path in file_locations[0]]
108-
gcmss = pool.map(run, args)
109-
pool.close()
110-
pool.join()
107+
with spawn_pool(jobs) as pool:
108+
gcmss = pool.map(run, args)
111109

112110
for file_index, gcms in enumerate(gcmss):
113111

‎support_code/nmdc/metabolomics/metabolomics_collection.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import time
33
import pandas as pd
44
import numpy as np
5-
from multiprocessing import Pool
65
import shutil
76

87
from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectraCollection
98
from corems.mass_spectra.output.export import LCMSMetabolomicsExport
109
from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader
1110
from corems.encapsulation.factory.parameters import LCMSParameters
11+
from corems.encapsulation.spawn_pool import spawn_pool
1212
from corems.molecular_id.search.database_interfaces import MSPInterface
1313
from corems.encapsulation.factory.parameters import hush_output
1414
from corems.mass_spectra.output.export import LCMSCollectionExport
@@ -309,7 +309,7 @@ def preprocess_raw_samples(raw_data_path, processed_folder, ncores=1, reprocess=
309309
process_args = [(raw_file, processed_folder, params) for raw_file in raw_files]
310310

311311
# Process samples in parallel
312-
with Pool(processes=ncores) as pool:
312+
with spawn_pool(ncores) as pool:
313313
processed_files = pool.map(process_single_sample, process_args)
314314

315315
print(f"Preprocessing complete: {time.time() - start_time:.1f} seconds using {ncores} cores")

0 commit comments

Comments
 (0)