Skip to content

Commit 6115c89

Browse files
committed
Add tests for multi-node data gen partitioning logic
Signed-off-by: Fynn Schmitt-Ulms <fschmitt@redhat.com>
1 parent 53fa74e commit 6115c89

4 files changed

Lines changed: 196 additions & 71 deletions

File tree

scripts/data_generation_offline.py

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
from safetensors import safe_open
2727
from tqdm import tqdm
2828

29+
from speculators.data_generation.offline import (
30+
get_existing_hidden_state_indices,
31+
get_indices_to_process,
32+
)
2933
from speculators.data_generation.vllm_client import (
3034
DEFAULT_MAX_RETRIES,
3135
DEFAULT_REQUEST_TIMEOUT,
@@ -184,77 +188,6 @@ def parse_args():
184188
return parser.parse_args()
185189

186190

187-
def get_existing_hidden_state_indices(output_path: Path) -> list[int]:
188-
"""Find existing `hs_i.safetensors` files (where i is the file index)"""
189-
190-
existing_file_indices = []
191-
192-
if not output_path.exists():
193-
return existing_file_indices
194-
195-
for file_path in output_path.iterdir():
196-
if file_path.name.startswith("hs_") and file_path.name.endswith(".safetensors"):
197-
index_str = file_path.stem[3:] # Remove "hs_" prefix
198-
try:
199-
file_index = int(index_str)
200-
existing_file_indices.append(file_index)
201-
except ValueError:
202-
continue
203-
204-
return sorted(existing_file_indices)
205-
206-
207-
def get_indices_to_process(
208-
num_samples: int,
209-
max_samples: int | None,
210-
existing: list[int],
211-
world_size: int,
212-
rank: int,
213-
) -> list[int]:
214-
"""Determines which indices should be processed. If max_samples is None
215-
returns all dataset indices not in existing. Otherwise gets the first
216-
`max_samples - len(existing)` samples not already in existing.
217-
218-
Args:
219-
num_samples: Total size of preprocessed dataset
220-
max_samples: (Optional) limit for number of samples to process
221-
existing: list of ids that have already been processed
222-
world_size: Number of nodes to generate on
223-
rank: The rank of the local node
224-
225-
Returns:
226-
list of dataset indices to process
227-
"""
228-
229-
if len(existing) >= num_samples:
230-
logger.info("All samples already processed!")
231-
return []
232-
233-
target = min(max_samples, num_samples) if max_samples else num_samples
234-
235-
chunk_size = target // world_size
236-
remainder = target % world_size
237-
# Distribute remainder across the first `remainder` ranks so chunks differ
238-
# by at most 1.
239-
start = rank * chunk_size + min(rank, remainder)
240-
end = start + chunk_size + (1 if rank < remainder else 0)
241-
242-
existing_s = set(existing)
243-
to_process = [i for i in range(start, end) if i not in existing_s]
244-
245-
if not to_process:
246-
logger.info("All samples for this rank already processed!")
247-
return []
248-
249-
if len(existing_s & set(range(start, end))) > 0:
250-
logger.info(
251-
f"Found {len(existing_s & set(range(start, end)))} existing samples"
252-
f" for rank {rank}."
253-
)
254-
255-
return to_process
256-
257-
258191
def check_safetensors_file(path: Path, tokens: list[int]):
259192
with safe_open(path, "pt") as f:
260193
t_ids = f.get_tensor("token_ids").tolist()
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import logging
2+
from pathlib import Path
3+
4+
logger = logging.getLogger(__name__)
5+
6+
7+
def get_existing_hidden_state_indices(output_path: Path) -> list[int]:
8+
"""Find existing `hs_i.safetensors` files (where i is the file index)"""
9+
10+
existing_file_indices = []
11+
12+
if not output_path.exists():
13+
return existing_file_indices
14+
15+
for file_path in output_path.iterdir():
16+
if file_path.name.startswith("hs_") and file_path.name.endswith(".safetensors"):
17+
index_str = file_path.stem[3:] # Remove "hs_" prefix
18+
try:
19+
file_index = int(index_str)
20+
existing_file_indices.append(file_index)
21+
except ValueError:
22+
continue
23+
24+
return sorted(existing_file_indices)
25+
26+
27+
def get_indices_to_process(
28+
num_samples: int,
29+
max_samples: int | None,
30+
existing: list[int],
31+
world_size: int,
32+
rank: int,
33+
) -> list[int]:
34+
"""Determines which indices should be processed. If max_samples is None
35+
returns all dataset indices not in existing. Otherwise gets the first
36+
`max_samples - len(existing)` samples not already in existing.
37+
38+
Args:
39+
num_samples: Total size of preprocessed dataset
40+
max_samples: (Optional) limit for number of samples to process
41+
existing: list of ids that have already been processed
42+
world_size: Number of nodes to generate on
43+
rank: The rank of the local node
44+
45+
Returns:
46+
list of dataset indices to process
47+
"""
48+
49+
if len(existing) >= num_samples:
50+
logger.info("All samples already processed!")
51+
return []
52+
53+
target = min(max_samples, num_samples) if max_samples else num_samples
54+
55+
chunk_size = target // world_size
56+
remainder = target % world_size
57+
# Distribute remainder across the first `remainder` ranks so chunks differ
58+
# by at most 1.
59+
start = rank * chunk_size + min(rank, remainder)
60+
end = start + chunk_size + (1 if rank < remainder else 0)
61+
62+
existing_s = set(existing)
63+
to_process = [i for i in range(start, end) if i not in existing_s]
64+
65+
if not to_process:
66+
logger.info("All samples for this rank already processed!")
67+
return []
68+
69+
if len(existing_s & set(range(start, end))) > 0:
70+
logger.info(
71+
f"Found {len(existing_s & set(range(start, end)))} existing samples"
72+
f" for rank {rank}."
73+
)
74+
75+
return to_process

tests/unit/data_generation/__init__.py

Whitespace-only changes.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import pytest
2+
3+
from speculators.data_generation.offline import (
4+
get_existing_hidden_state_indices,
5+
get_indices_to_process,
6+
)
7+
8+
9+
# ===== get_indices_to_process Tests =====
10+
11+
12+
class TestGetIndicesToProcess:
13+
def test_single_node_no_max_samples(self):
14+
result = get_indices_to_process(10, None, [], world_size=1, rank=0)
15+
assert result == list(range(10))
16+
17+
def test_single_node_with_max_samples(self):
18+
result = get_indices_to_process(10, 5, [], world_size=1, rank=0)
19+
assert result == [0, 1, 2, 3, 4]
20+
21+
def test_single_node_max_samples_exceeds_num_samples(self):
22+
result = get_indices_to_process(5, 10, [], world_size=1, rank=0)
23+
assert result == list(range(5))
24+
25+
def test_single_node_with_existing(self):
26+
result = get_indices_to_process(10, None, [2, 5, 7], world_size=1, rank=0)
27+
assert result == [0, 1, 3, 4, 6, 8, 9]
28+
29+
def test_all_samples_already_processed(self):
30+
result = get_indices_to_process(5, None, list(range(5)), world_size=1, rank=0)
31+
assert result == []
32+
33+
def test_multi_node_even_split(self):
34+
r0 = get_indices_to_process(10, None, [], world_size=2, rank=0)
35+
r1 = get_indices_to_process(10, None, [], world_size=2, rank=1)
36+
assert r0 == [0, 1, 2, 3, 4]
37+
assert r1 == [5, 6, 7, 8, 9]
38+
39+
def test_multi_node_uneven_split(self):
40+
r0 = get_indices_to_process(10, None, [], world_size=3, rank=0)
41+
r1 = get_indices_to_process(10, None, [], world_size=3, rank=1)
42+
r2 = get_indices_to_process(10, None, [], world_size=3, rank=2)
43+
assert r0 == [0, 1, 2, 3]
44+
assert r1 == [4, 5, 6]
45+
assert r2 == [7, 8, 9]
46+
47+
def test_multi_node_no_overlap_and_full_coverage(self):
48+
num_samples = 17
49+
world_size = 4
50+
all_indices = []
51+
for rank in range(world_size):
52+
chunk = get_indices_to_process(
53+
num_samples, None, [], world_size=world_size, rank=rank
54+
)
55+
all_indices.extend(chunk)
56+
assert sorted(all_indices) == list(range(num_samples))
57+
assert len(all_indices) == len(set(all_indices))
58+
59+
def test_multi_node_with_max_samples(self):
60+
r0 = get_indices_to_process(100, 10, [], world_size=2, rank=0)
61+
r1 = get_indices_to_process(100, 10, [], world_size=2, rank=1)
62+
assert r0 == [0, 1, 2, 3, 4]
63+
assert r1 == [5, 6, 7, 8, 9]
64+
65+
def test_multi_node_with_existing(self):
66+
result = get_indices_to_process(10, None, [1, 3], world_size=2, rank=0)
67+
assert result == [0, 2, 4]
68+
69+
def test_multi_node_rank_fully_processed(self):
70+
result = get_indices_to_process(
71+
10, None, [0, 1, 2, 3, 4], world_size=2, rank=0
72+
)
73+
assert result == []
74+
75+
def test_existing_exceeds_num_samples(self):
76+
result = get_indices_to_process(5, None, list(range(10)), world_size=1, rank=0)
77+
assert result == []
78+
79+
80+
# ===== get_existing_hidden_state_indices Tests =====
81+
82+
83+
class TestGetExistingHiddenStateIndices:
84+
def test_nonexistent_directory(self, tmp_path):
85+
result = get_existing_hidden_state_indices(tmp_path / "nonexistent")
86+
assert result == []
87+
88+
def test_empty_directory(self, tmp_path):
89+
result = get_existing_hidden_state_indices(tmp_path)
90+
assert result == []
91+
92+
def test_finds_safetensor_files(self, tmp_path):
93+
(tmp_path / "hs_0.safetensors").touch()
94+
(tmp_path / "hs_3.safetensors").touch()
95+
(tmp_path / "hs_7.safetensors").touch()
96+
result = get_existing_hidden_state_indices(tmp_path)
97+
assert result == [0, 3, 7]
98+
99+
def test_ignores_non_numeric_suffixes(self, tmp_path):
100+
(tmp_path / "hs_0.safetensors").touch()
101+
(tmp_path / "hs_abc.safetensors").touch()
102+
(tmp_path / "hs_.safetensors").touch()
103+
result = get_existing_hidden_state_indices(tmp_path)
104+
assert result == [0]
105+
106+
def test_ignores_unrelated_files(self, tmp_path):
107+
(tmp_path / "hs_0.safetensors").touch()
108+
(tmp_path / "other_file.txt").touch()
109+
(tmp_path / "hs_1.pt").touch()
110+
result = get_existing_hidden_state_indices(tmp_path)
111+
assert result == [0]
112+
113+
def test_results_are_sorted(self, tmp_path):
114+
for i in [9, 2, 5, 0]:
115+
(tmp_path / f"hs_{i}.safetensors").touch()
116+
result = get_existing_hidden_state_indices(tmp_path)
117+
assert result == [0, 2, 5, 9]

0 commit comments

Comments
 (0)