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/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)