Skip to content

Commit af38c2d

Browse files
authored
Merge branch 'main' into feature/default-activation
2 parents 80b0f1b + 1056645 commit af38c2d

11 files changed

Lines changed: 839 additions & 77 deletions

File tree

docs/cli/data_generation_offline.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ python scripts/data_generation_offline.py \
5151

5252
- **`--max-consecutive-errors`** (int, default: value of `--concurrency`) Abort after this many consecutive sample failures (each sample already retried `--max-retries` times). Prevents silently churning through the entire dataset when the server is down. Ignored when `--fail-on-error` is set.
5353

54+
### Multi-Node Arguments
55+
56+
- **`--world-size`** (int, default: `1`) Number of nodes participating in data generation. Each node is assigned a contiguous, non-overlapping chunk of the dataset. This is the number of nodes, not the number of GPUs.
57+
58+
- **`--rank`** (int, default: `0`) Zero-based index of the current node. Must be in the range `[0, world-size)`.
59+
5460
## Full Example
5561

5662
```bash

docs/user_guide/tutorials/train_eagle3_offline.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,22 @@ output/hidden_states/
154154
python scripts/launch_vllm.py model -- --data-parallel-size 8
155155
```
156156

157+
**Use multiple nodes:**
158+
159+
If you have access to multiple machines, each with its own vLLM server, you can split the dataset across them with `--world-size` and `--rank`. Each node generates a contiguous, non-overlapping chunk of the data into a shared (or later merged) output directory. See the [data_generation_offline.py cli reference](/cli/data_generation_offline.md) for details.
160+
161+
```bash
162+
# On node 0
163+
python scripts/data_generation_offline.py \
164+
--preprocessed-data ./output --output ./output/hidden_states \
165+
--max-samples 5000 --world-size 2 --rank 0
166+
167+
# On node 1
168+
python scripts/data_generation_offline.py \
169+
--preprocessed-data ./output --output ./output/hidden_states \
170+
--max-samples 5000 --world-size 2 --rank 1
171+
```
172+
157173
**Skip validation:**
158174

159175
Validation loads every single generated output file and confirms that the token ids match the sent request and the hidden states length matches expectation. This is generally not required but is a good sanity check. Turn this off to skip loading generated samples during data gen.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ dependencies = [
5353
"torchaudio",
5454
"torchvision",
5555
"tqdm>=4.66.3,<=4.67.3",
56-
"transformers>=4.56.1,<5.9.0",
56+
"transformers>=4.56.1,<5.10.0",
5757
"typer>=0.12.0",
5858
]
5959

scripts/data_generation_offline.py

Lines changed: 36 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@
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,
3236
generate_hidden_states_async,
37+
wait_for_lock_async,
3338
)
3439
from speculators.train.data import build_client_item
3540
from speculators.train.logger import setup_root_logger
@@ -164,72 +169,28 @@ def parse_args():
164169
"(default: value of --concurrency)"
165170
),
166171
)
172+
parser.add_argument(
173+
"--world-size",
174+
type=int,
175+
default=1,
176+
help=(
177+
"World size for multi-node data generation offline. IMPORTANT: this "
178+
"is the number of nodes (not the number of gpus). Defaults to 1"
179+
),
180+
)
181+
parser.add_argument(
182+
"--rank",
183+
type=int,
184+
default=0,
185+
help=(
186+
"Rank for multi-node data generation offline. IMPORTANT: this is "
187+
"the node index, not an index for a gpu. Must be in range[0, world_size)."
188+
" Defaults to 0"
189+
),
190+
)
167191
return parser.parse_args()
168192

169193

170-
def get_existing_hidden_state_indices(output_path: Path) -> list[int]:
171-
"""Find existing `hs_i.safetensors` files (where i is the file index)"""
172-
173-
existing_file_indices = []
174-
175-
if not output_path.exists():
176-
return existing_file_indices
177-
178-
for file_path in output_path.iterdir():
179-
if file_path.name.startswith("hs_") and file_path.name.endswith(".safetensors"):
180-
index_str = file_path.stem[3:] # Remove "hs_" prefix
181-
try:
182-
file_index = int(index_str)
183-
existing_file_indices.append(file_index)
184-
except ValueError:
185-
continue
186-
187-
return sorted(existing_file_indices)
188-
189-
190-
def get_indices_to_process(
191-
num_samples: int, max_samples: int | None, existing: list[int]
192-
) -> list[int]:
193-
"""Determines which indices should be processed. If max_samples is None
194-
returns all dataset indices not in existing. Otherwise gets the first
195-
`max_samples - len(existing)` samples not already in existing.
196-
197-
Args:
198-
num_samples: Total size of preprocessed dataset
199-
max_samples: (Optional) limit for number of samples to process
200-
existing: list of ids that have already been processed
201-
202-
Returns:
203-
list of dataset indices to process
204-
"""
205-
206-
if len(existing) >= num_samples:
207-
logger.info("All samples already processed!")
208-
return []
209-
if max_samples and len(existing) >= max_samples:
210-
logger.info("At least max_samples already processed!")
211-
return []
212-
213-
if len(existing) > 0:
214-
logger.info(f"Found {len(existing)} existing samples.")
215-
216-
existing_s = set(existing)
217-
if max_samples is None:
218-
return [i for i in range(num_samples) if i not in existing_s]
219-
220-
num_remaining = min(max_samples, num_samples) - len(existing)
221-
to_process = []
222-
cur = 0
223-
while num_remaining > 0 and cur < num_samples:
224-
if cur not in existing_s:
225-
to_process.append(cur)
226-
num_remaining -= 1
227-
228-
cur += 1
229-
230-
return to_process
231-
232-
233194
def check_safetensors_file(path: Path, tokens: list[int]):
234195
with safe_open(path, "pt") as f:
235196
t_ids = f.get_tensor("token_ids").tolist()
@@ -247,7 +208,7 @@ def check_safetensors_file(path: Path, tokens: list[int]):
247208
)
248209

249210

250-
async def worker(
211+
async def worker( # noqa: C901
251212
client,
252213
model: str,
253214
queue: "asyncio.Queue[dict[str, Any]]",
@@ -288,6 +249,10 @@ async def worker(
288249
timeout=request_timeout,
289250
max_retries=max_retries,
290251
)
252+
lock_path = hidden_states_path + ".lock"
253+
if Path(lock_path).exists(): # noqa: ASYNC240
254+
await wait_for_lock_async(lock_path)
255+
291256
async with write_semaphore: # Limit number of active disk writes
292257
await asyncio.to_thread(
293258
shutil.move, hidden_states_path, target_hidden_states_path
@@ -374,7 +339,11 @@ async def generate_and_save_hidden_states(args, dataset):
374339
num_samples = len(dataset)
375340

376341
to_process = get_indices_to_process(
377-
num_samples, args.max_samples, existing_file_indices
342+
num_samples,
343+
args.max_samples,
344+
existing_file_indices,
345+
args.world_size,
346+
args.rank,
378347
)
379348
if not to_process:
380349
return
@@ -441,6 +410,8 @@ async def generate_and_save_hidden_states(args, dataset):
441410

442411
def main():
443412
args = parse_args()
413+
if int(args.rank) < 0 or int(args.rank) >= int(args.world_size):
414+
raise ValueError("--rank must be in range [0, world_size)")
444415
setup_root_logger()
445416

446417
logger.info("EAGLE Offline Data Generation")
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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_set: set[int] = set()
11+
12+
if not output_path.exists():
13+
return []
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_set.add(file_index)
21+
except ValueError:
22+
continue
23+
24+
return sorted(existing_file_indices_set)
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+
target = min(max_samples, num_samples) if max_samples is not None else num_samples
50+
51+
if target <= 0:
52+
return []
53+
54+
chunk_size = target // world_size
55+
remainder = target % world_size
56+
# Distribute remainder across the first `remainder` ranks so chunks differ
57+
# by at most 1.
58+
start = rank * chunk_size + min(rank, remainder)
59+
end = start + chunk_size + (1 if rank < remainder else 0)
60+
61+
existing_s = set(existing)
62+
to_process = [i for i in range(start, end) if i not in existing_s]
63+
64+
if not to_process:
65+
logger.info("All samples for this rank already processed!")
66+
return []
67+
68+
if len(existing_s & set(range(start, end))) > 0:
69+
logger.info(
70+
f"Found {len(existing_s & set(range(start, end)))} existing samples"
71+
f" for rank {rank}."
72+
)
73+
74+
return to_process

src/speculators/data_generation/vllm_client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
2+
import fcntl
23
import functools
34
import logging
5+
import os
46
import time
57
from typing import TYPE_CHECKING, Any, TypedDict
68

@@ -122,6 +124,47 @@ class ClientItem(TypedDict):
122124
instead of passing `token_ids` to Completions API."""
123125

124126

127+
async def _poll_lock_async(fd, poll_interval):
128+
while True:
129+
try:
130+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
131+
return
132+
except BlockingIOError:
133+
await asyncio.sleep(poll_interval)
134+
135+
136+
async def wait_for_lock_async(lock_path, timeout=10.0, poll_interval=0.1):
137+
fd = os.open(lock_path, os.O_RDONLY)
138+
try:
139+
await asyncio.wait_for(_poll_lock_async(fd, poll_interval), timeout=timeout)
140+
except BaseException:
141+
os.close(fd)
142+
raise
143+
os.close(fd)
144+
os.remove(lock_path)
145+
146+
147+
def wait_for_lock(lock_path, timeout=10.0, poll_interval=0.1):
148+
fd = os.open(lock_path, os.O_RDONLY)
149+
try:
150+
deadline = time.monotonic() + timeout
151+
while True:
152+
try:
153+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
154+
break
155+
except BlockingIOError:
156+
if time.monotonic() >= deadline:
157+
raise TimeoutError(
158+
f"Timed out waiting for lock: {lock_path}"
159+
) from None
160+
time.sleep(poll_interval)
161+
except BaseException:
162+
os.close(fd)
163+
raise
164+
os.close(fd)
165+
os.remove(lock_path)
166+
167+
125168
@with_retries
126169
async def generate_hidden_states_async(
127170
client: openai.AsyncClient,

src/speculators/train/data.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
DEFAULT_REQUEST_TIMEOUT,
2222
ClientItem,
2323
generate_hidden_states,
24+
wait_for_lock,
2425
)
2526
from speculators.train.noise_transforms import TransformTensors
2627

@@ -259,6 +260,11 @@ def _compute_approx_lengths(self) -> list[int]:
259260
def _maybe_load_hs_file(self, index: int) -> dict[str, torch.Tensor] | None:
260261
file_idx = self._map_to_file_idx(index)
261262
candidate_path = self.hidden_states_path / f"hs_{file_idx}.safetensors"
263+
264+
lock_path = str(candidate_path) + ".lock"
265+
if Path(lock_path).exists():
266+
wait_for_lock(lock_path)
267+
262268
if candidate_path.exists():
263269
return load_file(candidate_path)
264270

@@ -279,19 +285,22 @@ def _maybe_generate_hs(self, index: int) -> dict[str, torch.Tensor] | None:
279285
timeout=self.request_timeout,
280286
max_retries=self.max_retries,
281287
)
282-
except Exception as e: # noqa: BLE001
283-
warnings.warn(str(e), stacklevel=1)
284-
return None
285288

286-
loaded_hs = load_file(hs_filepath)
289+
loaded_hs = load_file(hs_filepath)
287290

288-
match self.on_generate:
289-
case "cache":
290-
file_idx = self._map_to_file_idx(index)
291-
target_path = self.hidden_states_path / f"hs_{file_idx}.safetensors"
292-
shutil.move(hs_filepath, target_path)
293-
case "delete":
294-
Path(hs_filepath).unlink()
291+
match self.on_generate:
292+
case "cache":
293+
file_idx = self._map_to_file_idx(index)
294+
target_path = self.hidden_states_path / f"hs_{file_idx}.safetensors"
295+
shutil.move(hs_filepath, target_path)
296+
case "delete":
297+
Path(hs_filepath).unlink()
298+
except Exception as e: # noqa: BLE001
299+
warnings.warn(
300+
f"Failed to load/cache hidden states for sample {index}: {e}",
301+
stacklevel=1,
302+
)
303+
return None
295304

296305
return loaded_hs
297306

0 commit comments

Comments
 (0)