[DO NOT MERGE] trainer ft - #823
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for independent data parallelism (DP) by allowing DP replicas to function as independent Megatron instances. It refactors the Ray training group logic into cells, adds configuration for delayed data splitting, and implements consistency checks for dynamic global batch sizes. Feedback identifies a bug in the data splitting utility where the dynamic batch size was not propagated, suggests avoiding hardcoded configuration values, and recommends using strict zip operations for safer actor-critic connections.
| def split_train_data_by_dp(args, data: dict[str, Any], *, dp_size: int) -> list[dict[str, Any]]: | ||
| """Split the train data by data parallel size.""" | ||
| rollout_data = {} | ||
|
|
||
| if "prompt" in data: | ||
| rollout_data["prompt"] = data["prompt"] | ||
|
|
||
| total_lengths = [len(t) for t in data["tokens"]] | ||
| data["total_lengths"] = total_lengths | ||
|
|
||
| if args.balance_data: | ||
| partitions = get_seqlen_balanced_partitions(total_lengths, dp_size, equal_size=True) | ||
| else: | ||
| partitions = [range(i, len(total_lengths), dp_size) for i in range(dp_size)] | ||
|
|
||
| ans = [] | ||
|
|
||
| for i in range(dp_size): | ||
| rollout_data = {} | ||
| partition = partitions[i] | ||
| rollout_data["partition"] = partition | ||
| for key in [ | ||
| "tokens", | ||
| "multimodal_train_inputs", | ||
| "response_lengths", | ||
| "rewards", | ||
| "truncated", | ||
| "loss_masks", | ||
| "round_number", | ||
| "sample_indices", | ||
| "rollout_log_probs", | ||
| "rollout_routed_experts", | ||
| "prompt", | ||
| "teacher_log_probs", | ||
| ]: | ||
| if key not in data: | ||
| continue | ||
| val = [data[key][j] for j in partition] | ||
| rollout_data[key] = val | ||
| # keys that need to be splited at train side | ||
| for key in [ | ||
| "raw_reward", | ||
| "total_lengths", | ||
| ]: | ||
| if key not in data: | ||
| continue | ||
| rollout_data[key] = data[key] | ||
| ans.append(rollout_data) | ||
| return ans |
There was a problem hiding this comment.
There are a couple of issues in split_train_data_by_dp:
- The
rollout_datavariable initialized on lines 8-11 is unused because it's redefined inside the loop on line 24. This initial block can be removed for clarity. - More importantly,
dynamic_global_batch_sizeis not being propagated to the split data dictionaries. It is added to thedatadict inRolloutManager._convert_samples_to_train_data, but it's not one of the keys that are copied to eachrollout_datasplit. This will cause issues downstream where this value is expected. It should be added to the list of keys that are copied to each split, similar toraw_rewardandtotal_lengths.
Here is a suggested fix that addresses both points:
def split_train_data_by_dp(args, data: dict[str, Any], *, dp_size: int) -> list[dict[str, Any]]:
"""Split the train data by data parallel size."""
total_lengths = [len(t) for t in data["tokens"]]
data["total_lengths"] = total_lengths
if args.balance_data:
partitions = get_seqlen_balanced_partitions(total_lengths, dp_size, equal_size=True)
else:
partitions = [range(i, len(total_lengths), dp_size) for i in range(dp_size)]
ans = []
for i in range(dp_size):
rollout_data = {}
partition = partitions[i]
rollout_data["partition"] = partition
for key in [
"tokens",
"multimodal_train_inputs",
"response_lengths",
"rewards",
"truncated",
"loss_masks",
"round_number",
"sample_indices",
"rollout_log_probs",
"rollout_routed_experts",
"prompt",
"teacher_log_probs",
]:
if key not in data:
continue
val = [data[key][j] for j in partition]
rollout_data[key] = val
# keys that are not partitioned and should be present in all splits
for key in [
"raw_reward",
"total_lengths",
"dynamic_global_batch_size",
]:
if key not in data:
continue
rollout_data[key] = data[key]
ans.append(rollout_data)
return ans| from torchft.process_group import ProcessGroupNCCL | ||
|
|
||
| pg = ProcessGroupNCCL(timeout=timedelta(seconds=60)) | ||
| quorum_id = 0 |
There was a problem hiding this comment.
The quorum_id is hardcoded to 0. According to repository guidelines, model parameters and configuration values should be retrieved from the model configuration rather than being hardcoded. This improves flexibility and ensures consistency across different environments.
References
- Model parameters and configuration values should be retrieved from the model configuration rather than being hardcoded.
| def async_connect(self, critic_group): | ||
| return [ | ||
| actor.connect_actor_critic.remote(critic) | ||
| for actor, critic in zip(self._actor_handles, critic_group._actor_handles, strict=False) |
There was a problem hiding this comment.
The zip function is used with strict=False. However, in RayTrainGroup.connect, zip is used with strict=True when iterating over cells. For consistency and safety, it's better to use strict=True here as well. This will ensure that an error is raised if the number of actor and critic handles within a cell do not match, which would indicate a configuration problem.
| for actor, critic in zip(self._actor_handles, critic_group._actor_handles, strict=False) | |
| for actor, critic in zip(self._actor_handles, critic_group._actor_handles, strict=True) |
PR radixark#850 added test_eager_create_task in test_logging_utils.py which imports eager_create_task from miles.utils.async_utils, but the function was only defined in the trainer_ft/dev branch (PR radixark#823) and never merged to main, breaking CI fast tests. Cherry-pick the async_utils changes from PR radixark#823.
|
|
||
| def find_free_port() -> int: | ||
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | ||
| s.bind(("", 0)) |
Check warning
Code scanning / CodeQL
Binding a socket to all network interfaces Medium test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 months ago
To fix this, bind the temporary probe socket to loopback instead of all interfaces.
General approach: replace wildcard bind addresses ("" / "0.0.0.0") with a dedicated local interface, here 127.0.0.1, since this utility is used with MASTER_ADDR=localhost.
Best single change (without changing behavior): in tests/fast/dist_utils.py, update find_free_port() line 11 from:
s.bind(("", 0))
to:s.bind(("127.0.0.1", 0))
No new imports, methods, or dependencies are required.
| @@ -8,7 +8,7 @@ | ||
|
|
||
| def find_free_port() -> int: | ||
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | ||
| s.bind(("", 0)) | ||
| s.bind(("127.0.0.1", 0)) | ||
| return s.getsockname()[1] | ||
|
|
||
|
|
…1 injection Rename ExpectedReconfigure -> ReconfigureInfo and fold _shape_of into a ReconfigureInfo.from_event staticmethod. Soak now asserts num_successful_injections > 0: a soak that injected nothing exercised no fault tolerance and proved nothing.
RayTrainGroup records the active rollout_id at train() entry and the control server exposes it through a new read-only GET /api/v1/progress endpoint, so an external fault injector can observe training progress. The progress field defaults to None before the first train() call; the endpoint only reads it, so existing behavior is unchanged.
Thread a mock RayTrainGroup through the app fixture and assert the progress endpoint reflects group.current_rollout_id (including the pre-train null case).
The fault injector now polls GET /api/v1/progress and stops once training reaches num_steps - COOLDOWN_ROLLOUTS (=3), so the final rollouts run fault-free. poll+inject latency (~1s) is far below a rollout (~tens of s), so a fault lands in the observed rollout or at most the next one; COOLDOWN=3 absorbs that <=2-rollout slippage, putting the last heal no later than the final rollout. The soak therefore provably ends at full cell membership, with no trailing shrink.
…-shrink tolerance With the injector cooldown guaranteeing a fault-free tail, the soak now provably ends fully healed. Remove _is_tolerated_trailing_shrink and the final_rollout_id parameter, and assert unconditionally that the last reconfigure event restores full cell membership. Any sequence ending in a shrink now fails, even at the final rollout id.
…e tail Move training progress out of RayTrainGroup into a TrainingProgress holder owned by the control server, which runs in the training process. The inject-fault endpoint now reads the authoritative current rollout and rejects (409) any injection within the final COOLDOWN_ROLLOUTS, making the fault-free tail race-free instead of relying on injector-side poll timing.
…rces cooldown The fault injector and its scenario callers no longer need stop_at_rollout_id or progress polling; the server rejects tail injections directly, so the injector returns to its rollout-unaware form and its existing except path swallows the 409 without counting it.
…ace-free guarantee Replace the removed GET /api/v1/progress tests with inject-fault cooldown tests (forwarded when progress is None or before the tail, rejected with 409 inside the tail) and rewrite the README soak cooldown argument as a server-side rejection that does not depend on rollout duration.
…itness wording" This reverts commit 7b27da2.
This reverts commit 1dbb883.
…cument race-free guarantee" This reverts commit 7d76c8d.
…ver enforces cooldown" This reverts commit 375ee75.
Review comment on #1399: use pydantic NonNegativeInt for cell_index and rank_within_cell.
Review comment on #1401: decorating a coroutine would exit the with_context scope before the coroutine runs, silently dropping the context. Assert at decoration time until async support is needed.
Review comment on #1403: a scalar torch.Tensor in metrics fails MetricEvent validation/JSON serialization; unwrap via .item().
Review comments on #1404: reject non-positive buffer_size (would hit a ZeroDivisionError later) and negative num_ids (would silently decrement the monotonic counter). Raise ValueError per reviewer preference.
Review comment on #1409: 'rank' must always be skipped when grouping dump bundles (absolute rank IDs differ between FT and non-FT runs). The only caller passing custom keys already includes it; enforce the invariant instead of silently dropping it.
Review comments on #1411: a missing event_dir made load_reconfigure_events return [] and let an empty expectation pass silently; fail fast with a clear message instead.
Review nits on #1412.
Review comment on #1413: raw data_ptr values can collide across devices, which would alias unrelated storages during checkpoint transfer. Include the device in the dedup key and cover mixed-device encoding with tests.
Review comment on #1395: if destroying the local process group raised, the ray.get on the engines' already-in-flight destroy calls was skipped, so a caller on the FT reconnect path could recreate the group while engines were still tearing down.
Review comment on #1387: reload previously recreated groups from only (ranks, backend), silently dropping timeout, pg_options, group_desc and any other arguments. Store the original new_group args/kwargs verbatim and replay them on reload so the rebuilt group matches the original exactly.
Review comments on #1397: the FT delayed-split copy in miles/utils/data_utils.py had drifted from the rollout-side original - it was missing rollout_indexer_topk and opd_reverse_kl (added on main after the fork), so the delayed-split path silently dropped them. Delete data_utils.py: the split logic lives only in train_data_conversion.py as split_train_data_by_dp_raw (key list is the union; seq_witness_ids is injected only on the delayed path), with split_train_data_by_dp a thin wrapper that ray.puts each partition.
Review comment on #1418: init() marked the cell StateAllocatedAlive before dispatching the remote init. Flip the order so alive means init completed; a failed init now transitions Uninitialized -> Errored (indep_dp_info=None) -> Stopped via the existing kill_on_failure path instead of relying on the premature alive state.
# Conflicts: # miles/backends/megatron_utils/model_provider.py # miles/ray/actor_group.py # miles/utils/arguments.py
The single ft label ran all 14 FT e2e entries at once. Split it by scenario type so PRs can trigger a quick FT signal separately from the long soak runs (measured runtimes: comparison scenarios take 4-14 min each, random-crash soak 26-56 min, realistic-gsm8k 2-3 h): - ft-fast: comparison scenarios (no_failure, deterministic, with_failure entries, including real-rollout modes) - ft-slow: soak scenarios (random-crash survival, realistic-gsm8k convergence) The matching run-ci-ft-fast / run-ci-ft-slow GitHub labels must be created in repo settings; run-ci-ft is obsolete.
ci-sglang-pr: #28524
ci-megatron-pr: #21
sgl-project/sglang#28524
radixark/Megatron-LM#21