pepe-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pepe/__init__.py +41 -0
- pepe/__main__.py +23 -0
- pepe/embedders/__init__.py +0 -0
- pepe/embedders/base_embedder.py +797 -0
- pepe/embedders/custom_embedder.py +647 -0
- pepe/embedders/esm_embedder.py +164 -0
- pepe/embedders/huggingface_embedder.py +200 -0
- pepe/model_selecter.py +99 -0
- pepe/parse_arguments.py +167 -0
- pepe/utils.py +905 -0
- pepe_cli-1.0.0.dist-info/METADATA +137 -0
- pepe_cli-1.0.0.dist-info/RECORD +18 -0
- pepe_cli-1.0.0.dist-info/WHEEL +5 -0
- pepe_cli-1.0.0.dist-info/entry_points.txt +3 -0
- pepe_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- pepe_cli-1.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_run.py +43 -0
pepe/utils.py
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
from torch.utils.data import Dataset
|
|
4
|
+
import re
|
|
5
|
+
import torch
|
|
6
|
+
import time
|
|
7
|
+
import gc
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
from transformers import RoFormerTokenizer
|
|
11
|
+
import threading, queue
|
|
12
|
+
from alive_progress import alive_bar
|
|
13
|
+
import shutil
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("src.utils")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenBudgetBatchSampler:
|
|
19
|
+
def __init__(self, dataset, token_budget):
|
|
20
|
+
self.dataset = dataset
|
|
21
|
+
self.token_budget = token_budget
|
|
22
|
+
|
|
23
|
+
# Assume all sequences have the same length (already padded)
|
|
24
|
+
sample_seq_len = len(
|
|
25
|
+
dataset[0][2]
|
|
26
|
+
) # dataset[idx] -> (label, seq_str, toks, mask)
|
|
27
|
+
self.batch_size = token_budget // sample_seq_len
|
|
28
|
+
|
|
29
|
+
self.batches = self._create_batches()
|
|
30
|
+
|
|
31
|
+
def _create_batches(self):
|
|
32
|
+
indices = list(range(len(self.dataset)))
|
|
33
|
+
return [
|
|
34
|
+
indices[i : i + self.batch_size]
|
|
35
|
+
for i in range(0, len(indices), self.batch_size)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __iter__(self):
|
|
39
|
+
return iter(self.batches)
|
|
40
|
+
|
|
41
|
+
def __len__(self):
|
|
42
|
+
return len(self.batches)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SequenceDictDataset(Dataset):
|
|
46
|
+
def __init__(self, sequences, substring_dict, context):
|
|
47
|
+
self.data = list(sequences.items()) # (label, seq)
|
|
48
|
+
if substring_dict:
|
|
49
|
+
self.substring_dict = substring_dict
|
|
50
|
+
self.context = context
|
|
51
|
+
self.filtered_substring_data = self._filter_substrings()
|
|
52
|
+
else:
|
|
53
|
+
self.substring_dict = None
|
|
54
|
+
self.context = None
|
|
55
|
+
self.filtered_substring_data = None
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def __getitem__(self, idx):
|
|
59
|
+
return self.data[idx]
|
|
60
|
+
|
|
61
|
+
def __len__(self):
|
|
62
|
+
return len(self.data)
|
|
63
|
+
|
|
64
|
+
def _filter_substrings(self):
|
|
65
|
+
"""Filter the substring_dict to only include sequences that are in the dataset."""
|
|
66
|
+
labels, _ = zip(*self.data)
|
|
67
|
+
filtered_substring_dict = {
|
|
68
|
+
label: self.substring_dict[label] for label in labels if label in self.substring_dict # type: ignore
|
|
69
|
+
}
|
|
70
|
+
assert len(filtered_substring_dict) == len(
|
|
71
|
+
self.data
|
|
72
|
+
), "Not all sequences have matching substrings."
|
|
73
|
+
return filtered_substring_dict.items()
|
|
74
|
+
|
|
75
|
+
def _get_substring_masks(self):
|
|
76
|
+
"""Mask tokens from sequence that are not in the provided substring."""
|
|
77
|
+
# Get the full sequences and substring
|
|
78
|
+
full_sequence_tokens = [entry[2] for entry in self.encoded_data] # type: ignore
|
|
79
|
+
substring_tokens = [entry[2] for entry in self.encoded_substring_data] # type: ignore
|
|
80
|
+
|
|
81
|
+
# Create masks for each sequence
|
|
82
|
+
masks = [
|
|
83
|
+
self._find_subsequence(full_seq, substring, self.pad_token_id) # type: ignore
|
|
84
|
+
for full_seq, substring in zip(full_sequence_tokens, substring_tokens)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
return list(masks)
|
|
88
|
+
|
|
89
|
+
def _find_subsequence(self, full_tensor, subtensor, pad_token_id=0):
|
|
90
|
+
subsequence_mask = torch.zeros_like(full_tensor)
|
|
91
|
+
|
|
92
|
+
# Remove padding from b
|
|
93
|
+
trimmed_subtensor = subtensor[subtensor != pad_token_id]
|
|
94
|
+
trimmed_subtensor_length = trimmed_subtensor.size(0)
|
|
95
|
+
if trimmed_subtensor_length == 0:
|
|
96
|
+
return subsequence_mask
|
|
97
|
+
|
|
98
|
+
full_tensor_length = full_tensor.size(0)
|
|
99
|
+
|
|
100
|
+
for start in range(full_tensor_length):
|
|
101
|
+
match_positions = []
|
|
102
|
+
full_tensor_index = start
|
|
103
|
+
subtensor_index = 0
|
|
104
|
+
|
|
105
|
+
while (
|
|
106
|
+
full_tensor_index < full_tensor_length
|
|
107
|
+
and subtensor_index < trimmed_subtensor_length
|
|
108
|
+
):
|
|
109
|
+
if full_tensor[full_tensor_index] == pad_token_id:
|
|
110
|
+
full_tensor_index += 1
|
|
111
|
+
continue
|
|
112
|
+
if full_tensor[full_tensor_index] == trimmed_subtensor[subtensor_index]:
|
|
113
|
+
match_positions.append(full_tensor_index)
|
|
114
|
+
full_tensor_index += 1
|
|
115
|
+
subtensor_index += 1
|
|
116
|
+
else:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
if subtensor_index == trimmed_subtensor_length:
|
|
120
|
+
subsequence_mask[match_positions] = 1
|
|
121
|
+
|
|
122
|
+
return subsequence_mask
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class HuggingFaceDataset(SequenceDictDataset):
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
sequences,
|
|
129
|
+
substring_dict,
|
|
130
|
+
context,
|
|
131
|
+
tokenizer,
|
|
132
|
+
max_length,
|
|
133
|
+
add_special_tokens=True,
|
|
134
|
+
):
|
|
135
|
+
super().__init__(sequences, substring_dict, context)
|
|
136
|
+
self.encoded_data = self._encode_sequences(
|
|
137
|
+
self.data, tokenizer, max_length, add_special_tokens
|
|
138
|
+
) # (label, seq, toks, attention_mask)
|
|
139
|
+
self.pad_token_id = tokenizer.pad_token_type_id
|
|
140
|
+
if self.substring_dict:
|
|
141
|
+
logger.info("Tokenizing substrings...")
|
|
142
|
+
self.encoded_substring_data = self._encode_sequences(
|
|
143
|
+
self.filtered_substring_data,
|
|
144
|
+
tokenizer,
|
|
145
|
+
"max_length",
|
|
146
|
+
add_special_tokens=False,
|
|
147
|
+
) # (label, seq, toks, attention_mask)
|
|
148
|
+
self.substring_masks = self._get_substring_masks()
|
|
149
|
+
|
|
150
|
+
def _encode_sequences(self, data, tokenizer, max_length, add_special_tokens):
|
|
151
|
+
labels, strs = zip(*data)
|
|
152
|
+
if isinstance(tokenizer, RoFormerTokenizer):
|
|
153
|
+
# RoFormerTokenizer requires space-separated tokenization
|
|
154
|
+
# for the input sequences
|
|
155
|
+
logger.info("Using RoFormerTokenizer, applying gap_sequence.")
|
|
156
|
+
strs = self._gap_sequence(strs)
|
|
157
|
+
max_token_length = max(len(seq.split(" ")) for seq in strs)
|
|
158
|
+
else:
|
|
159
|
+
# For other tokenizers, use the default tokenization
|
|
160
|
+
max_token_length = max(len(seq) for seq in strs)
|
|
161
|
+
|
|
162
|
+
if max_length == "max_length":
|
|
163
|
+
max_length = max_token_length
|
|
164
|
+
logger.info(f"Setting max_length to {max_length}.")
|
|
165
|
+
elif isinstance(max_length, int) and max_length < max_token_length:
|
|
166
|
+
logger.warning(
|
|
167
|
+
f"max_length {max_length} is less than the length of the longest sequence: {max_token_length}. Setting max_length to {max_token_length}."
|
|
168
|
+
)
|
|
169
|
+
max_length = max_token_length
|
|
170
|
+
loop_input_ids = []
|
|
171
|
+
loop_attention_mask = []
|
|
172
|
+
with alive_bar(len(strs), title="Tokenizing sequences...") as bar:
|
|
173
|
+
for s in strs:
|
|
174
|
+
out = tokenizer(
|
|
175
|
+
s,
|
|
176
|
+
truncation=True,
|
|
177
|
+
padding="max_length",
|
|
178
|
+
max_length=max_length,
|
|
179
|
+
add_special_tokens=add_special_tokens,
|
|
180
|
+
return_tensors="pt",
|
|
181
|
+
)
|
|
182
|
+
loop_input_ids.append(out.input_ids)
|
|
183
|
+
loop_attention_mask.append(out.attention_mask)
|
|
184
|
+
bar()
|
|
185
|
+
|
|
186
|
+
toks = torch.cat(loop_input_ids, dim=0)
|
|
187
|
+
attention_masks = torch.cat(loop_attention_mask, dim=0)
|
|
188
|
+
return list(zip(labels, strs, list(toks), list(attention_masks)))
|
|
189
|
+
|
|
190
|
+
def _gap_sequence(self, sequences: Sequence[str]) -> Sequence[str]:
|
|
191
|
+
"""Space-separated tokenization for RoFormer input."""
|
|
192
|
+
seqs = [" ".join(re.findall(r"\[.*?\]|.", sequence)) for sequence in sequences]
|
|
193
|
+
return seqs
|
|
194
|
+
|
|
195
|
+
def get_max_encoded_length(self):
|
|
196
|
+
return max(len(toks) for _, _, toks, _ in self.encoded_data)
|
|
197
|
+
|
|
198
|
+
def __getitem__(self, idx):
|
|
199
|
+
labels, seqs, toks, attention_masks = self.encoded_data[idx]
|
|
200
|
+
if self.substring_dict:
|
|
201
|
+
substring_masks = self.substring_masks[idx]
|
|
202
|
+
return labels, seqs, toks, attention_masks, substring_masks
|
|
203
|
+
else:
|
|
204
|
+
return labels, seqs, toks, attention_masks, None
|
|
205
|
+
|
|
206
|
+
def safe_collate(self, batch):
|
|
207
|
+
if self.substring_dict:
|
|
208
|
+
labels, seqs, toks, attention_matrices, substring_masks = zip(*batch)
|
|
209
|
+
return (
|
|
210
|
+
list(labels),
|
|
211
|
+
list(seqs),
|
|
212
|
+
torch.stack(toks),
|
|
213
|
+
torch.stack(attention_matrices),
|
|
214
|
+
torch.stack(substring_masks),
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
labels, seqs, toks, attention_matrices, _ = zip(*batch)
|
|
218
|
+
return (
|
|
219
|
+
list(labels),
|
|
220
|
+
list(seqs),
|
|
221
|
+
torch.stack(toks),
|
|
222
|
+
torch.stack(attention_matrices),
|
|
223
|
+
None,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def __len__(self):
|
|
227
|
+
return len(self.data)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ESMDataset(SequenceDictDataset):
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
sequences,
|
|
234
|
+
substring_dict,
|
|
235
|
+
context,
|
|
236
|
+
alphabet,
|
|
237
|
+
max_length,
|
|
238
|
+
prepend_bos=True,
|
|
239
|
+
append_eos=True,
|
|
240
|
+
):
|
|
241
|
+
super().__init__(sequences, substring_dict, context)
|
|
242
|
+
self.encoded_data = self._encode_sequences(
|
|
243
|
+
self.data,
|
|
244
|
+
alphabet,
|
|
245
|
+
max_length,
|
|
246
|
+
prepend_bos=prepend_bos,
|
|
247
|
+
append_eos=append_eos,
|
|
248
|
+
) # (label, seq, toks)
|
|
249
|
+
self.pad_token_id = alphabet.padding_idx
|
|
250
|
+
if self.substring_dict:
|
|
251
|
+
logger.info("Tokenizing substrings...")
|
|
252
|
+
self.encoded_substring_data = self._encode_sequences(
|
|
253
|
+
self.filtered_substring_data,
|
|
254
|
+
alphabet,
|
|
255
|
+
"max_length",
|
|
256
|
+
prepend_bos=False,
|
|
257
|
+
append_eos=False,
|
|
258
|
+
) # (label, seq, toks)
|
|
259
|
+
logger.info("Matching substrings to full sequences...")
|
|
260
|
+
self.substring_masks = self._get_substring_masks()
|
|
261
|
+
|
|
262
|
+
def _encode_sequences(
|
|
263
|
+
self, data, alphabet, max_length, prepend_bos=True, append_eos=True
|
|
264
|
+
):
|
|
265
|
+
labels, strs = zip(*data)
|
|
266
|
+
encoded = []
|
|
267
|
+
with alive_bar(len(strs), title="Tokenizing sequences...") as bar:
|
|
268
|
+
for s in strs:
|
|
269
|
+
seq_encoded = alphabet.encode(s)
|
|
270
|
+
encoded.append(seq_encoded)
|
|
271
|
+
bar()
|
|
272
|
+
|
|
273
|
+
max_encoded_length = max(len(seq_encoded) for seq_encoded in encoded)
|
|
274
|
+
if max_length == "max_length":
|
|
275
|
+
max_length = max_encoded_length
|
|
276
|
+
elif isinstance(max_length, int) and max_length < max_encoded_length:
|
|
277
|
+
logger.warning(
|
|
278
|
+
f"max_length {max_length} is less than the length of the longest sequence: {max_encoded_length}. Setting max_length to {max_encoded_length}."
|
|
279
|
+
)
|
|
280
|
+
tokens = torch.empty(
|
|
281
|
+
(len(encoded), max_length + int(prepend_bos) + int(append_eos)),
|
|
282
|
+
dtype=torch.int64,
|
|
283
|
+
)
|
|
284
|
+
tokens.fill_(alphabet.padding_idx)
|
|
285
|
+
for i, seq_encoded in enumerate(encoded):
|
|
286
|
+
if prepend_bos:
|
|
287
|
+
tokens[i, 0] = alphabet.cls_idx
|
|
288
|
+
seq = torch.tensor(seq_encoded, dtype=torch.int64)
|
|
289
|
+
tokens[
|
|
290
|
+
i,
|
|
291
|
+
int(prepend_bos) : len(seq_encoded) + int(prepend_bos),
|
|
292
|
+
] = seq
|
|
293
|
+
if append_eos:
|
|
294
|
+
tokens[i, len(seq_encoded) + int(prepend_bos)] = alphabet.eos_idx
|
|
295
|
+
return list(zip(labels, strs, list(tokens)))
|
|
296
|
+
|
|
297
|
+
def get_max_encoded_length(self):
|
|
298
|
+
return max(len(toks) for _, _, toks in self.encoded_data)
|
|
299
|
+
|
|
300
|
+
def safe_collate(self, batch):
|
|
301
|
+
if self.substring_dict:
|
|
302
|
+
labels, seqs, toks, _, substring_masks = zip(*batch)
|
|
303
|
+
return (
|
|
304
|
+
list(labels),
|
|
305
|
+
list(seqs),
|
|
306
|
+
torch.stack(toks),
|
|
307
|
+
None,
|
|
308
|
+
torch.stack(substring_masks),
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
labels, seqs, toks, _, _ = zip(*batch)
|
|
312
|
+
return list(labels), list(seqs), torch.stack(toks), None, None
|
|
313
|
+
|
|
314
|
+
def __getitem__(self, idx):
|
|
315
|
+
labels, seqs, toks = self.encoded_data[idx]
|
|
316
|
+
if self.substring_dict:
|
|
317
|
+
substring_masks = self.substring_masks[idx]
|
|
318
|
+
return labels, seqs, toks, None, substring_masks
|
|
319
|
+
else:
|
|
320
|
+
return labels, seqs, toks, None, None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def check_input_tokens(valid_tokens, sequences, model_name):
|
|
324
|
+
with alive_bar(
|
|
325
|
+
len(sequences), title="Checking input sequences for invalid tokens..."
|
|
326
|
+
) as bar:
|
|
327
|
+
for label, sequence in sequences.items():
|
|
328
|
+
if "esm" not in model_name:
|
|
329
|
+
sequence = re.findall(r"\[.*?\]|.", sequence)
|
|
330
|
+
if "antiberta" in model_name: # check for longest sequence
|
|
331
|
+
assert (
|
|
332
|
+
len(sequence) <= 256
|
|
333
|
+
), f"Antiberta2 does not support sequences longer than 256 tokens. Found {len(sequence)} tokens in sequence {label}."
|
|
334
|
+
|
|
335
|
+
else:
|
|
336
|
+
sequence = re.findall(r"<.*?>|.", sequence)
|
|
337
|
+
if not set(sequence).issubset(valid_tokens):
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"Invalid tokens found in sequence {label}: {set(sequence) - set(valid_tokens)}"
|
|
340
|
+
)
|
|
341
|
+
bar()
|
|
342
|
+
logger.info("No invalid tokens in input sequences.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def fasta_to_dict(fasta_path):
|
|
346
|
+
"""Convert FASTA file into a dictionary: {id: raw_sequence}."""
|
|
347
|
+
seq_dict = {}
|
|
348
|
+
sequence_id = None
|
|
349
|
+
sequence_aa = []
|
|
350
|
+
|
|
351
|
+
def flush():
|
|
352
|
+
nonlocal sequence_id, sequence_aa
|
|
353
|
+
if sequence_id:
|
|
354
|
+
seq_dict[sequence_id] = "".join(sequence_aa)
|
|
355
|
+
sequence_id, sequence_aa = None, []
|
|
356
|
+
|
|
357
|
+
with open(fasta_path, "r") as f:
|
|
358
|
+
for line_idx, line in enumerate(f):
|
|
359
|
+
line = line.strip()
|
|
360
|
+
if line.startswith(">"):
|
|
361
|
+
flush()
|
|
362
|
+
sequence_id = line[1:] or f"seqnum{line_idx:09d}"
|
|
363
|
+
else:
|
|
364
|
+
sequence_aa.append(line)
|
|
365
|
+
flush()
|
|
366
|
+
|
|
367
|
+
return seq_dict
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def flush_memmaps(obj):
|
|
371
|
+
"""Recursively flush memory maps."""
|
|
372
|
+
if hasattr(obj, "flush") and callable(obj.flush):
|
|
373
|
+
obj.flush()
|
|
374
|
+
gc.collect()
|
|
375
|
+
logger.debug("Flushed output")
|
|
376
|
+
elif isinstance(obj, dict):
|
|
377
|
+
for value in obj.values():
|
|
378
|
+
flush_memmaps(value)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def check_disk_free_space(path, min_free_bytes):
|
|
382
|
+
_, _, free = shutil.disk_usage(path)
|
|
383
|
+
if free < min_free_bytes:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f"Not enough disk space. Required: {min_free_bytes} bytes, Available: {free} bytes"
|
|
386
|
+
)
|
|
387
|
+
logger.info(f"Disk space check passed. Available: {free} bytes")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class IOFlushWorker(threading.Thread):
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
memmap_registry,
|
|
394
|
+
flush_bytes_limit=64 * 1024 * 1024,
|
|
395
|
+
global_dispatcher=None,
|
|
396
|
+
): # e.g. 64 MB
|
|
397
|
+
super().__init__()
|
|
398
|
+
self.memmap_registry = (
|
|
399
|
+
memmap_registry # Dict: (output_type, layer, head) → memmap
|
|
400
|
+
)
|
|
401
|
+
self.flush_limit = flush_bytes_limit
|
|
402
|
+
self.write_q = queue.Queue(maxsize=128)
|
|
403
|
+
self.buffer = {} # (output_type, layer, head) → list of (offset, array)
|
|
404
|
+
self.buffered_bytes = {} # (output_type, layer, head) → total bytes
|
|
405
|
+
self.total_buffered = 0
|
|
406
|
+
self.lock = threading.Lock()
|
|
407
|
+
self.shutdown_flag = threading.Event()
|
|
408
|
+
self.outstanding_enqueues = 0
|
|
409
|
+
self.done_enqueuing = threading.Event()
|
|
410
|
+
# Initially set done_enqueuing since there are no outstanding enqueues
|
|
411
|
+
self.done_enqueuing.set()
|
|
412
|
+
|
|
413
|
+
# Global checkpoint system (worker-agnostic)
|
|
414
|
+
self.global_dispatcher = global_dispatcher
|
|
415
|
+
self.last_checkpoint_time = time.time()
|
|
416
|
+
self.checkpoint_interval = 30 # seconds
|
|
417
|
+
|
|
418
|
+
def is_range_completed(self, output_type, layer, head, offset, length):
|
|
419
|
+
"""Check if a range is completed using the global checkpoint system"""
|
|
420
|
+
if self.global_dispatcher:
|
|
421
|
+
return self.global_dispatcher.is_range_completed_global(
|
|
422
|
+
output_type, layer, head, offset, length
|
|
423
|
+
)
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def mark_range_completed(self, output_type, layer, head, offset, length):
|
|
427
|
+
"""Mark a range as completed using the global checkpoint system"""
|
|
428
|
+
if self.global_dispatcher:
|
|
429
|
+
self.global_dispatcher.mark_range_completed_global(
|
|
430
|
+
output_type, layer, head, offset, length
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Save global checkpoint periodically
|
|
434
|
+
now = time.time()
|
|
435
|
+
if now - self.last_checkpoint_time > self.checkpoint_interval:
|
|
436
|
+
self.global_dispatcher._save_global_checkpoint()
|
|
437
|
+
self.last_checkpoint_time = now
|
|
438
|
+
|
|
439
|
+
# Deprecated: _merge_ranges is no longer used (global checkpoint system in place)
|
|
440
|
+
|
|
441
|
+
def queue_fullness(self):
|
|
442
|
+
return self.write_q.qsize() / self.write_q.maxsize
|
|
443
|
+
|
|
444
|
+
def run(self):
|
|
445
|
+
while not self.shutdown_flag.is_set():
|
|
446
|
+
try:
|
|
447
|
+
item = self.write_q.get(timeout=1)
|
|
448
|
+
except queue.Empty:
|
|
449
|
+
continue
|
|
450
|
+
if item is None:
|
|
451
|
+
# Mark the sentinel as done and break
|
|
452
|
+
self.write_q.task_done()
|
|
453
|
+
break
|
|
454
|
+
try:
|
|
455
|
+
key, offset, array = item
|
|
456
|
+
arr_bytes = array.nbytes
|
|
457
|
+
|
|
458
|
+
with self.lock:
|
|
459
|
+
self.buffer.setdefault(key, []).append((offset, array))
|
|
460
|
+
self.buffered_bytes[key] = (
|
|
461
|
+
self.buffered_bytes.get(key, 0) + arr_bytes
|
|
462
|
+
)
|
|
463
|
+
self.total_buffered += arr_bytes
|
|
464
|
+
|
|
465
|
+
if self.total_buffered >= self.flush_limit:
|
|
466
|
+
self.flush_all()
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.error(f"[IOFlushWorker] Exception during processing: {e}")
|
|
469
|
+
finally:
|
|
470
|
+
self.write_q.task_done()
|
|
471
|
+
|
|
472
|
+
# Process any remaining items in the queue after shutdown signal
|
|
473
|
+
while True:
|
|
474
|
+
try:
|
|
475
|
+
item = self.write_q.get_nowait()
|
|
476
|
+
if item is None:
|
|
477
|
+
self.write_q.task_done()
|
|
478
|
+
break
|
|
479
|
+
try:
|
|
480
|
+
key, offset, array = item
|
|
481
|
+
arr_bytes = array.nbytes
|
|
482
|
+
with self.lock:
|
|
483
|
+
self.buffer.setdefault(key, []).append((offset, array))
|
|
484
|
+
self.buffered_bytes[key] = (
|
|
485
|
+
self.buffered_bytes.get(key, 0) + arr_bytes
|
|
486
|
+
)
|
|
487
|
+
self.total_buffered += arr_bytes
|
|
488
|
+
except Exception as e:
|
|
489
|
+
logger.error(
|
|
490
|
+
f"[IOFlushWorker] Exception during shutdown processing: {e}"
|
|
491
|
+
)
|
|
492
|
+
finally:
|
|
493
|
+
self.write_q.task_done()
|
|
494
|
+
except queue.Empty:
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
# Final flush
|
|
498
|
+
try:
|
|
499
|
+
self.flush_all()
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"[IOFlushWorker] Exception during final flush: {e}")
|
|
502
|
+
|
|
503
|
+
def flush_all(self):
|
|
504
|
+
for key in list(self.buffer.keys()):
|
|
505
|
+
self.flush_key(key)
|
|
506
|
+
self.total_buffered = 0
|
|
507
|
+
|
|
508
|
+
def flush_key(self, key):
|
|
509
|
+
try:
|
|
510
|
+
mmap_handle = self.memmap_registry[key]
|
|
511
|
+
for offset, arr in self.buffer[key]:
|
|
512
|
+
mmap_handle[offset : offset + len(arr)] = arr
|
|
513
|
+
# Mark this range as completed for crash recovery
|
|
514
|
+
output_type, layer, head = key
|
|
515
|
+
self.mark_range_completed(output_type, layer, head, offset, len(arr))
|
|
516
|
+
mmap_handle.flush()
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.error(f"[IOFlushWorker] Exception during flush: {e}")
|
|
519
|
+
raise e
|
|
520
|
+
finally:
|
|
521
|
+
self.total_buffered -= self.buffered_bytes.get(key, 0)
|
|
522
|
+
self.buffered_bytes[key] = 0
|
|
523
|
+
self.buffer[key].clear()
|
|
524
|
+
|
|
525
|
+
def enqueue(self, output_type, layer, head, offset, array):
|
|
526
|
+
# Check if this range was already completed (crash recovery)
|
|
527
|
+
if self.is_range_completed(output_type, layer, head, offset, len(array)):
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"[IOFlushWorker] Skipping already completed range: {output_type}, {layer}, {head}, {offset}-{offset+len(array)}"
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
with self.lock:
|
|
534
|
+
if self.outstanding_enqueues == 0:
|
|
535
|
+
# Clear the event since we're about to have outstanding enqueues
|
|
536
|
+
self.done_enqueuing.clear()
|
|
537
|
+
self.outstanding_enqueues += 1
|
|
538
|
+
|
|
539
|
+
key = (output_type, layer, head)
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
while True:
|
|
543
|
+
try:
|
|
544
|
+
self.write_q.put((key, offset, array), timeout=1)
|
|
545
|
+
break
|
|
546
|
+
except queue.Full:
|
|
547
|
+
logger.warning(
|
|
548
|
+
"[IOFlushWorker] Write queue full, waiting to enqueue..."
|
|
549
|
+
)
|
|
550
|
+
time.sleep(0.1)
|
|
551
|
+
finally:
|
|
552
|
+
with self.lock:
|
|
553
|
+
self.outstanding_enqueues -= 1
|
|
554
|
+
if self.outstanding_enqueues == 0:
|
|
555
|
+
self.done_enqueuing.set()
|
|
556
|
+
|
|
557
|
+
def stop(self, max_wait_time=60, force_shutdown=True):
|
|
558
|
+
"""
|
|
559
|
+
Stop the worker with practical timeouts for terabyte operations.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
max_wait_time: Maximum time to wait for pending operations (seconds)
|
|
563
|
+
force_shutdown: If True, force shutdown after max_wait_time even if work remains
|
|
564
|
+
"""
|
|
565
|
+
logger.info(f"[IOFlushWorker] Initiating shutdown...")
|
|
566
|
+
|
|
567
|
+
# Signal that we're shutting down
|
|
568
|
+
self.shutdown_flag.set()
|
|
569
|
+
|
|
570
|
+
# For terabyte operations, we can't wait indefinitely
|
|
571
|
+
# Instead, we save our progress and allow graceful shutdown
|
|
572
|
+
if max_wait_time > 0:
|
|
573
|
+
logger.info(
|
|
574
|
+
f"[IOFlushWorker] Waiting up to {max_wait_time}s for pending operations..."
|
|
575
|
+
)
|
|
576
|
+
completed = self.done_enqueuing.wait(timeout=max_wait_time)
|
|
577
|
+
|
|
578
|
+
if not completed:
|
|
579
|
+
with self.lock:
|
|
580
|
+
remaining = self.outstanding_enqueues
|
|
581
|
+
buffered_mb = self.total_buffered / (1024 * 1024)
|
|
582
|
+
logger.warning(
|
|
583
|
+
f"[IOFlushWorker] Timeout with {remaining} enqueues and {buffered_mb:.1f}MB buffered"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if not force_shutdown:
|
|
587
|
+
logger.info(
|
|
588
|
+
"[IOFlushWorker] Continuing to wait since force_shutdown=False..."
|
|
589
|
+
)
|
|
590
|
+
self.done_enqueuing.wait() # Wait indefinitely
|
|
591
|
+
else:
|
|
592
|
+
logger.warning("[IOFlushWorker] Proceeding with forced shutdown")
|
|
593
|
+
else:
|
|
594
|
+
logger.info("[IOFlushWorker] Skipping wait for pending operations")
|
|
595
|
+
|
|
596
|
+
# Save final checkpoint before shutdown
|
|
597
|
+
if self.global_dispatcher:
|
|
598
|
+
self.global_dispatcher._save_global_checkpoint()
|
|
599
|
+
|
|
600
|
+
# Send sentinel value to stop the worker thread
|
|
601
|
+
while True:
|
|
602
|
+
try:
|
|
603
|
+
self.write_q.put(None, timeout=1)
|
|
604
|
+
break
|
|
605
|
+
except queue.Full:
|
|
606
|
+
logger.warning("Write queue full during shutdown; retrying...")
|
|
607
|
+
time.sleep(0.1)
|
|
608
|
+
|
|
609
|
+
# Wait for the worker thread to finish
|
|
610
|
+
self.join(timeout=30)
|
|
611
|
+
|
|
612
|
+
# Force one final flush and checkpoint
|
|
613
|
+
with self.lock:
|
|
614
|
+
try:
|
|
615
|
+
self.flush_all()
|
|
616
|
+
if self.global_dispatcher:
|
|
617
|
+
self.global_dispatcher._save_global_checkpoint()
|
|
618
|
+
except Exception as e:
|
|
619
|
+
logger.error(
|
|
620
|
+
f"[IOFlushWorker] Exception during final flush in stop(): {e}"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# Report final status
|
|
624
|
+
remaining_in_queue = self.write_q.qsize()
|
|
625
|
+
pending_buffered = sum(len(buf) for buf in self.buffer.values())
|
|
626
|
+
|
|
627
|
+
if remaining_in_queue > 1 or pending_buffered > 0:
|
|
628
|
+
logger.warning(
|
|
629
|
+
f"[IOFlushWorker] Final status: {remaining_in_queue} in queue, {pending_buffered} buffered"
|
|
630
|
+
)
|
|
631
|
+
if self.global_dispatcher:
|
|
632
|
+
total_ranges = sum(
|
|
633
|
+
len(ranges)
|
|
634
|
+
for ranges in self.global_dispatcher.global_completed_ranges.values()
|
|
635
|
+
)
|
|
636
|
+
logger.info(
|
|
637
|
+
f"[IOFlushWorker] Progress saved to global checkpoint: {len(self.global_dispatcher.global_completed_ranges)} keys, {total_ranges} ranges"
|
|
638
|
+
)
|
|
639
|
+
else:
|
|
640
|
+
logger.info("[IOFlushWorker] Clean shutdown - all data written")
|
|
641
|
+
|
|
642
|
+
logger.info("[IOFlushWorker] Shutdown complete")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class MultiIODispatcher:
|
|
646
|
+
def __init__(
|
|
647
|
+
self,
|
|
648
|
+
memmap_registry,
|
|
649
|
+
num_workers=4,
|
|
650
|
+
flush_bytes_limit=64 * 1024 * 1024,
|
|
651
|
+
heavy_output_type="embeddings_unpooled",
|
|
652
|
+
heavy_proportion=0.75,
|
|
653
|
+
checkpoint_dir=None,
|
|
654
|
+
):
|
|
655
|
+
self.num_workers = num_workers
|
|
656
|
+
self.heavy_output_type = heavy_output_type
|
|
657
|
+
self.checkpoint_dir = checkpoint_dir
|
|
658
|
+
|
|
659
|
+
# Centralized checkpoint system (worker-agnostic)
|
|
660
|
+
self.global_checkpoint_file = None
|
|
661
|
+
self.global_completed_ranges = {} # Shared across all workers
|
|
662
|
+
self.checkpoint_lock = threading.Lock()
|
|
663
|
+
|
|
664
|
+
if checkpoint_dir:
|
|
665
|
+
os.makedirs(checkpoint_dir, exist_ok=True)
|
|
666
|
+
self.global_checkpoint_file = os.path.join(
|
|
667
|
+
checkpoint_dir, "global_checkpoint.json"
|
|
668
|
+
)
|
|
669
|
+
self._load_global_checkpoint()
|
|
670
|
+
|
|
671
|
+
# Check if heavy keys exist
|
|
672
|
+
num_heavy_keys = sum(
|
|
673
|
+
1 for key in memmap_registry if key[0] == self.heavy_output_type
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if num_heavy_keys == 0:
|
|
677
|
+
logger.warning(
|
|
678
|
+
f"[MultiIODispatcher] WARNING: No keys found for heavy_output_type '{self.heavy_output_type}'. Reassigning all workers to light workload."
|
|
679
|
+
)
|
|
680
|
+
self.num_heavy_workers = 0
|
|
681
|
+
self.num_light_workers = num_workers
|
|
682
|
+
else:
|
|
683
|
+
self.num_heavy_workers = max(1, int(num_workers * heavy_proportion))
|
|
684
|
+
self.num_light_workers = num_workers - self.num_heavy_workers
|
|
685
|
+
assert self.num_light_workers >= 1, "You need at least one light worker"
|
|
686
|
+
self.workers = []
|
|
687
|
+
self.heavy_workers = []
|
|
688
|
+
self.light_workers = []
|
|
689
|
+
|
|
690
|
+
# Distribute files across workers by hashing the key
|
|
691
|
+
sharded_registries = [{} for _ in range(num_workers)]
|
|
692
|
+
|
|
693
|
+
for key, mmap in memmap_registry.items():
|
|
694
|
+
output_type = key[0] # assuming key = (output_type, layer, head)
|
|
695
|
+
if output_type == self.heavy_output_type and self.num_heavy_workers > 0:
|
|
696
|
+
# Only assign heavy keys to heavy workers
|
|
697
|
+
shard_id = hash(key) % self.num_heavy_workers
|
|
698
|
+
else:
|
|
699
|
+
# Assign light keys to light workers
|
|
700
|
+
shard_id = self.num_heavy_workers + (hash(key) % self.num_light_workers)
|
|
701
|
+
sharded_registries[shard_id][key] = mmap
|
|
702
|
+
|
|
703
|
+
for i, reg in enumerate(sharded_registries):
|
|
704
|
+
logger.info(f"[MultiIODispatcher] Worker {i} assigned {len(reg)} keys")
|
|
705
|
+
|
|
706
|
+
for i in range(num_workers):
|
|
707
|
+
worker = IOFlushWorker(
|
|
708
|
+
memmap_registry=sharded_registries[i],
|
|
709
|
+
flush_bytes_limit=flush_bytes_limit,
|
|
710
|
+
global_dispatcher=self, # Pass self as the global dispatcher
|
|
711
|
+
)
|
|
712
|
+
worker.start()
|
|
713
|
+
self.workers.append(worker)
|
|
714
|
+
# Assign worker groups
|
|
715
|
+
self.heavy_workers = (
|
|
716
|
+
self.workers[: self.num_heavy_workers] if self.num_heavy_workers > 0 else []
|
|
717
|
+
)
|
|
718
|
+
self.light_workers = self.workers[self.num_heavy_workers :]
|
|
719
|
+
|
|
720
|
+
def queue_fullness(self):
|
|
721
|
+
# Return max fullness across all workers (pessimistic view)
|
|
722
|
+
return max(worker.queue_fullness() for worker in self.workers)
|
|
723
|
+
|
|
724
|
+
def enqueue(self, output_type, layer, head, offset, array):
|
|
725
|
+
key = (output_type, layer, head)
|
|
726
|
+
if output_type == self.heavy_output_type:
|
|
727
|
+
# Route heavy output_type to heavy_workers (hashed for key affinity)
|
|
728
|
+
worker_id = hash(key) % self.num_heavy_workers
|
|
729
|
+
self.heavy_workers[worker_id].enqueue(
|
|
730
|
+
output_type, layer, head, offset, array
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
# Route other output_types to light_workers
|
|
734
|
+
worker_id = hash(key) % self.num_light_workers
|
|
735
|
+
self.light_workers[worker_id].enqueue(
|
|
736
|
+
output_type, layer, head, offset, array
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
def stop(self, max_wait_time=60, force_shutdown=True):
|
|
740
|
+
"""
|
|
741
|
+
Stop all workers with practical timeouts for terabyte operations.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
max_wait_time: Maximum time to wait for each worker
|
|
745
|
+
force_shutdown: If True, force shutdown after timeout
|
|
746
|
+
"""
|
|
747
|
+
logger.info(f"[MultiIODispatcher] Stopping {len(self.workers)} workers...")
|
|
748
|
+
for i, worker in enumerate(self.workers):
|
|
749
|
+
logger.info(f"[MultiIODispatcher] Stopping worker {i}...")
|
|
750
|
+
worker.stop(max_wait_time=max_wait_time, force_shutdown=force_shutdown)
|
|
751
|
+
logger.info("[MultiIODispatcher] All workers stopped")
|
|
752
|
+
|
|
753
|
+
def get_resume_info(self):
|
|
754
|
+
"""Get information about what can be resumed after a crash"""
|
|
755
|
+
total_completed_ranges = sum(
|
|
756
|
+
len(ranges) for ranges in self.global_completed_ranges.values()
|
|
757
|
+
)
|
|
758
|
+
total_completed_bytes = 0
|
|
759
|
+
|
|
760
|
+
resume_info = {
|
|
761
|
+
"global_checkpoint_file": self.global_checkpoint_file,
|
|
762
|
+
"total_completed_ranges": total_completed_ranges,
|
|
763
|
+
"num_workers": len(self.workers),
|
|
764
|
+
"keys_with_progress": {},
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
# Calculate total bytes and provide per-key breakdown
|
|
768
|
+
for key, ranges in self.global_completed_ranges.items():
|
|
769
|
+
key_bytes = sum(end - start for start, end in ranges)
|
|
770
|
+
total_completed_bytes += key_bytes
|
|
771
|
+
|
|
772
|
+
resume_info["keys_with_progress"][f"{key[0]}|{key[1]}|{key[2]}"] = {
|
|
773
|
+
"ranges": len(ranges),
|
|
774
|
+
"bytes": key_bytes,
|
|
775
|
+
"mb": key_bytes / (1024 * 1024),
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
resume_info["total_completed_bytes"] = total_completed_bytes
|
|
779
|
+
resume_info["total_completed_gb"] = total_completed_bytes / (1024 * 1024 * 1024)
|
|
780
|
+
|
|
781
|
+
return resume_info
|
|
782
|
+
|
|
783
|
+
def _load_global_checkpoint(self):
|
|
784
|
+
"""Load the global checkpoint that works regardless of worker count"""
|
|
785
|
+
if self.global_checkpoint_file and os.path.exists(self.global_checkpoint_file):
|
|
786
|
+
try:
|
|
787
|
+
with open(self.global_checkpoint_file, "r") as f:
|
|
788
|
+
data = json.load(f)
|
|
789
|
+
# Convert string keys back to tuples and ranges to sets
|
|
790
|
+
for key_str, ranges in data.get("completed_ranges", {}).items():
|
|
791
|
+
# Parse key string back to tuple
|
|
792
|
+
parts = key_str.split("|")
|
|
793
|
+
if len(parts) == 3:
|
|
794
|
+
output_type, layer, head = parts
|
|
795
|
+
# Convert layer and head back to appropriate types
|
|
796
|
+
layer = int(layer) if layer != "None" else None
|
|
797
|
+
head = int(head) if head != "None" else None
|
|
798
|
+
key = (output_type, layer, head)
|
|
799
|
+
self.global_completed_ranges[key] = set(
|
|
800
|
+
tuple(r) for r in ranges
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
total_ranges = sum(
|
|
804
|
+
len(ranges) for ranges in self.global_completed_ranges.values()
|
|
805
|
+
)
|
|
806
|
+
logger.info(
|
|
807
|
+
f"[MultiIODispatcher] Loaded global checkpoint: {len(self.global_completed_ranges)} keys, {total_ranges} ranges"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Print summary of what was already completed
|
|
811
|
+
if total_ranges > 0:
|
|
812
|
+
logger.info("[MultiIODispatcher] Resume info:")
|
|
813
|
+
for key, ranges in self.global_completed_ranges.items():
|
|
814
|
+
total_bytes = sum(end - start for start, end in ranges)
|
|
815
|
+
logger.info(
|
|
816
|
+
f" {key}: {len(ranges)} ranges, {total_bytes / (1024*1024):.1f}MB"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
except Exception as e:
|
|
820
|
+
logger.error(
|
|
821
|
+
f"[MultiIODispatcher] Failed to load global checkpoint: {e}"
|
|
822
|
+
)
|
|
823
|
+
# Reset to empty state on error
|
|
824
|
+
self.global_completed_ranges = {}
|
|
825
|
+
|
|
826
|
+
def _save_global_checkpoint(self):
|
|
827
|
+
"""Save the global checkpoint"""
|
|
828
|
+
if not self.global_checkpoint_file:
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
with self.checkpoint_lock:
|
|
832
|
+
try:
|
|
833
|
+
# Convert tuples to strings for JSON serialization
|
|
834
|
+
data = {
|
|
835
|
+
"completed_ranges": {
|
|
836
|
+
f"{key[0]}|{key[1]}|{key[2]}": list(ranges)
|
|
837
|
+
for key, ranges in self.global_completed_ranges.items()
|
|
838
|
+
},
|
|
839
|
+
"timestamp": time.time(),
|
|
840
|
+
"num_workers_used": self.num_workers,
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
# Write to temporary file first, then rename (atomic operation)
|
|
844
|
+
temp_file = self.global_checkpoint_file + ".tmp"
|
|
845
|
+
with open(temp_file, "w") as f:
|
|
846
|
+
json.dump(data, f, indent=2)
|
|
847
|
+
|
|
848
|
+
os.rename(temp_file, self.global_checkpoint_file)
|
|
849
|
+
|
|
850
|
+
total_ranges = sum(
|
|
851
|
+
len(ranges) for ranges in self.global_completed_ranges.values()
|
|
852
|
+
)
|
|
853
|
+
logger.info(
|
|
854
|
+
f"[MultiIODispatcher] Saved global checkpoint: {len(self.global_completed_ranges)} keys, {total_ranges} ranges"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
except Exception as e:
|
|
858
|
+
logger.error(
|
|
859
|
+
f"[MultiIODispatcher] Failed to save global checkpoint: {e}"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def is_range_completed_global(self, output_type, layer, head, offset, length):
|
|
863
|
+
"""Check if a range is completed in the global checkpoint (worker-agnostic)"""
|
|
864
|
+
key = (output_type, layer, head)
|
|
865
|
+
if key not in self.global_completed_ranges:
|
|
866
|
+
return False
|
|
867
|
+
|
|
868
|
+
end_offset = offset + length
|
|
869
|
+
completed_set = self.global_completed_ranges[key]
|
|
870
|
+
|
|
871
|
+
# Check if this range is fully covered by any completed range
|
|
872
|
+
for start, end in completed_set:
|
|
873
|
+
if start <= offset and end_offset <= end:
|
|
874
|
+
return True
|
|
875
|
+
return False
|
|
876
|
+
|
|
877
|
+
def mark_range_completed_global(self, output_type, layer, head, offset, length):
|
|
878
|
+
"""Mark a range as completed in the global checkpoint (worker-agnostic)"""
|
|
879
|
+
key = (output_type, layer, head)
|
|
880
|
+
|
|
881
|
+
with self.checkpoint_lock:
|
|
882
|
+
if key not in self.global_completed_ranges:
|
|
883
|
+
self.global_completed_ranges[key] = set()
|
|
884
|
+
|
|
885
|
+
self.global_completed_ranges[key].add((offset, offset + length))
|
|
886
|
+
|
|
887
|
+
# Merge overlapping ranges to keep the set manageable
|
|
888
|
+
self._merge_ranges_global(key)
|
|
889
|
+
|
|
890
|
+
def _merge_ranges_global(self, key):
|
|
891
|
+
"""Merge overlapping ranges in the global checkpoint"""
|
|
892
|
+
if key not in self.global_completed_ranges:
|
|
893
|
+
return
|
|
894
|
+
|
|
895
|
+
ranges = sorted(self.global_completed_ranges[key])
|
|
896
|
+
merged = []
|
|
897
|
+
|
|
898
|
+
for start, end in ranges:
|
|
899
|
+
if merged and start <= merged[-1][1]:
|
|
900
|
+
# Overlapping ranges - merge them
|
|
901
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
902
|
+
else:
|
|
903
|
+
merged.append((start, end))
|
|
904
|
+
|
|
905
|
+
self.global_completed_ranges[key] = set(merged)
|