Repository navigation
Check that converting through an affine map writes every element once - #8975
Conversation
Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Thanks, the mask approach is a clean way to make conversion fail closed without per-element Python. Since this was not run on GPU, I ran it on two machines:
Two gaps against the "every element once" contract: 1. A source view that overlaps itself is still silently wrong. m = ParamAffineMap((6,), {0: (4,), 1: (3,)}, {
0: [AffinePiece((2, 2), 0, (1, 1), 0, (2, 1), [0])], # source addresses 0,1,1,2
1: [AffinePiece((3,), 3, (1,), 0, (1,), [1])]})
m.validate() # passes
m.rebuild({0: torch.tensor([9., 8, 7, 6]), 1: torch.tensor([1., 2, 3])})
# CPU: [9, 7, 6, 1, 2, 3], shard value 8 is dropped, no error
# CUDA: [9, 8, 6, 1, 2, 3], shard value 7 is dropped insteadWhich value survives the aliased write depends on the device, so the same checkpoint converts to different tensors on CPU and GPU. 2. Single-holder vs replica overlap depends on piece order. The single-holder check only looks at the piece being written now, not at who wrote first: p = lambda loc: AffinePiece((4,), 0, (1,), 0, (1,), loc)
shapes = {0: (4,), 1: (4,)}
ParamAffineMap((4,), shapes, {0: [p([0, 1])], 1: [p([1])]}).rebuild(...) # refused
ParamAffineMap((4,), shapes, {1: [p([1])], 0: [p([0, 1])]}).rebuild(...) # acceptedThe same map is refused or accepted depending on |
0z5a
left a comment
There was a problem hiding this comment.
Read at cd35be35. Making conversion refuse instead of returning whatever torch.empty left is the right default.
I re-ran the corruption fuzz with a stricter oracle. The test extracts and rebuilds through the same corrupted map, so an error that cancels between the two goes unnoticed: the self-overlapping source view from jinyouzhi's comment round-trips exactly that way. A real checkpoint holds shards written by the correct layout, so I generated the shards from the uncorrupted map and rebuilt them through the corrupted one. Same generator and seed, 2,000 cases:
| refused | exact | silently wrong | |
|---|---|---|---|
| master | 868 | 711 | 421 |
| this PR | 1,303 | 697 | 0 |
So the claim holds under that oracle too. Switching the test to it would be cheap, a deepcopy of the map before corrupting, and it would catch cancelling errors if the generator ever produces them.
On jinyouzhi's first point: extract has the same gap. With his map, extract(torch.tensor([10., 11, 12, 13, 14, 15]), 0) returns [10, 11, 11, 12] without an error, and that is the AutoTP restore path. A source-overlap check in _validate_rank would cover rebuild and extract together.
On CPU, test_affine_shard_map.py, test_affine_transfer_plan.py, test_zero_affine.py and test_autoep_affine.py pass at this head (263).
…order Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
|
Thanks both, all three points are in the new commit:
New tests:
All of the new tests fail on master. One limit worth stating. A corruption that turns the map into a different valid layout, for example swapping the destinations of two equal-shaped pieces, passes every map check: the map is self-consistent and disagrees only with the memory it describes. That case needs a runtime digest of the data (SyncCheck-style), not a check on the map. |
|
Re-checked at |
|
Hi @Achyuthan-S thanks for hardening affine map and thanks @jinyouzhi @0z5a for reviewing. This PR also looks good to me. |
…peedai#8936) Implements the planner side of deepspeedai#8935. It does not close the issue: the cost-aware policies are the policy layer under deepspeedai#8252. **What it adds** - `plan_transfers(pairs, limits, *, source_processes, target_processes, element_size, min_split_bytes, replica_policy)` in `deepspeed/checkpoint/affine_transfer.py`. It plans every parameter of one sync and calls one replica policy with every request at once. - `ReplicaRequest` and `ReplicaPart`. - A request names the parameter, the target location, the region and its validated holders. When the caller passes the location→process mappings, it also names the processes behind them. - Regions with a single holder are included, so a policy sees load it cannot move. - Requests arrive in a fixed order: parameter, then target location, then origin. - An answer is either a holder location, or `ReplicaPart`s that cut the region along axis 0. - The planner checks every answer before it emits any segment, and raises `InvalidReplicaPolicyError` otherwise: - one answer per request; - only validated holders; - parts that tile the request exactly along axis 0; - part fields that are not iterable coordinates, rather than a raw `TypeError`; - with `min_split_bytes`, every part of a split is at least that large; - the parts of one parameter stay within `PlanBudget.max_pair_checks`, checked from the answer's length before the parts are expanded. - `deepspeed/checkpoint/affine_replica_policy.py` holds three policies: - `first_holder`: what `plan_transfer` does by default. - `local_first_least_loaded`: the `plan_transfers` default. It takes a holder in the target's own process first, then the sending process with the fewest bytes assigned so far in this batch. Ties go to the lowest location, and it never splits. - `per_request(selector)`: runs an existing `replica_selector` as a batch policy. **Review follow-up** (commit `773ddfd3`) - Non-iterable `ReplicaPart.logical_origin`/`shape` fields are refused as `InvalidReplicaPolicyError`; the pair budget now uses that error too, so every refused answer uses the documented class. - An over-budget answer is refused on its length before `_resolve` expands it. - `plan_transfers`, the request/answer protocol, the process mappings, `element_size` (one entry per parameter) and the split constraints are documented in `docs/code-docs/source/affine-transfer.rst`, the page from deepspeedai#8809. - Policy authors are told that every rank must return the same answers, so cost inputs must come from data every rank shares (for example `fit_links` on the calibration from deepspeedai#8966), never from a rank-local measurement; the executor's schedule digest refuses a mismatch. - The deepspeedai#8252 correctness theorem is cited as "Any consistent plan is exact" instead of by number. **What it does not change** `plan_transfer` behaves exactly as before; it shares its validation and emission steps with `plan_transfers`. AutoEP's `plan_expert_transfer` and its docs are untouched. **Decisions from deepspeedai#8935** - One sync per batch. - Only axis-0 splits for now. Other split shapes are refused, so widening later is an additive change. - Plain location→process mappings rather than `TransferEndpoints`, so the planner does not import the executor. - Policies live in their own module. - The per-request adapter stays. `min_split_bytes` comes from my last comment on deepspeedai#8935 and has not been agreed yet. It is optional and off by default. The `process_node` mapping proposed there is deferred. **Tests** `tests/unit/checkpoint/test_affine_replica_policy.py`, CPU only: - Random policies over 24 seeds, including random splits across holders, on five parameters: whole replicas, a partly replicated fused block, a vector and a scalar. Every plan writes each target element exactly once and matches `extract`. This is the check that matches the "Any consistent plan is exact" theorem of the correctness draft for deepspeedai#8252. - `first_holder` and `per_request` reproduce `plan_transfer`'s plans exactly. - The default policy: - copies locally when it can; - balances load, counting single-holder load; - never assumes a local copy without mappings. - Refused answers: - holders that are not candidates; - bools, floats, `None` and strings; - gaps, overlaps and parts outside the region; - cuts along axis 1, empty parts and parts with the wrong number of axes; - non-iterable part fields; - the wrong number of answers; - splits under `min_split_bytes`, and more parts than the budget allows, including parts accumulated across requests. - The maps are validated before the policy runs. - The pair budget is decided from the answer's length before the planner reads its parts. With `DS_ACCELERATOR=cpu` on macOS arm64 (Python 3.11, torch 2.15.0), the new file passes (65 tests) together with `test_affine_transfer_plan.py`, `test_autoep_affine.py` and `test_affine_shard_map.py` (306 passed), and `tests/unit/v1/moe/test_autoep_unit.py` (which covers `plan_expert_transfer`) together with `test_autoep_affine_universal.py` pass too (215 passed, 8 skipped). yapf 0.43.0, flake8 5.0.4, codespell, the license check and the other pre-commit hooks are clean. The five rejection/budget tests added here fail on the previous planner and pass now. **How it fits with other PRs** - The branch is rebased on current master (after deepspeedai#8809, deepspeedai#8885 and deepspeedai#8975); `plan_transfer` still shares `_prepare` / `_emit` with `plan_transfers`. - @jinyouzhi: the `affine_geometry.py` move can land before or after this; I'll rebase in whichever order suits you. - deepspeedai#8966 supplies the link calibration (`fit_links`) that cost-aware policies will read; the planner does not consume it. **Scope, compatibility and AI assistance** Only the CPU planning interface, its tests and its documentation change: no training-loop, GPU, ZeRO, offload or precision path is touched, so no compatibility-matrix cell or on-device serving check is affected, and none was run. AI assistance was used: the initial implementation was co-authored with Claude Opus 5.5, this review follow-up was prepared with an AI coding agent, and the submitter reviewed every changed line and ran the tests above. cc @Achyuthan-S @delock --------- Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ma, Guokai <guokai.ma@gmail.com>
Conversion trusts a map further than it checks it.
rebuild(whatds_to_universalcalls on convert) runs onlyvalidate(). That checks each rank's element count and where pieces write in the shard, from metadata, but it never looks at the source side. So a map can pass it while two ranks claim the same block of the parameter and no rank holds another.rebuildthen returns whatevertorch.emptyleft in the elements nobody holds.extract(what the AutoTP restore path calls) runs no check at all.validate()cannot place it. Check where column-split pieces write in a shard #8922 madevalidate_coverage()complete for such destinations, but conversion does not callvalidate_coverage(), because that check walks elements in Python.Reproduction on
master:(8,)parameter.validate()passes.rebuildreturns rank 1's values in elements 0–3 and uninitialised memory in 4–7, with no error.How often this happens:
master, 671 of 2,334 corrupted maps converted without an error and produced a wrong tensor. With this change, none do: each one is refused or still round-trips exactly.validate_coverage()round-trips exactly on both (801 of 801).Changes
rebuildextractrebuild's metadata check for its own rank:validate()restricted to one rank, through a new_validate_rank._validate_rank(and sovalidate())ValueErrorinstead of torch's ownRuntimeError.ValueError, "twice", "uncovered", "different data"), plus "itself" for the new check.Cost:
rebuildalready materialises the full tensor, so the fp32 states thatds_to_universalconverts need at most a quarter more memory, for the duration of one parameter's conversion.Tests
tests/unit/checkpoint/test_affine_shard_map.py:mastertest_conversion_refuses_a_destination_collision_of_any_strideextractandrebuildrefuse the collision from the full-check test of #8922, whichvalidate()acceptstest_replicas_cut_at_different_boundaries_are_compared_in_fulltest_conversion_refuses_a_map_that_does_not_cover_the_parameterrebuildrefuses a map that leaves an element unheld, and one where two ranks own one blocktest_a_replica_overlapping_a_single_holder_is_refused_in_either_ordertest_a_view_that_overlaps_itself_is_refusedextractandrebuildrefuse a source view that reads one address twice, a zero stride, and a destination view that writes one address twicetest_a_corrupted_map_is_refused_or_still_exactThe colliding map of #8922's full-check test is now a helper shared by both tests.
Validation
macOS 26.5 (arm64, CPU), Python 3.9.6, torch 2.8.0,
DS_ACCELERATOR=cpu LOCAL_SIZE=4.test_affine_shard_map.py,test_affine_transfer_plan.py,test_zero_affine.py,test_autoep_affine.pyandtest_autoep_affine_universal.py: 280 passed.test_universal_checkpoint.py,test_autotp_uc_checkpoint.py,test_autoep_checkpoint.py,test_autoep_unit.pyandtest_autoep_affine_universal.py: 344 passed.master(ba0d63ce2) side by side, and it fails exactly the same 57 tests.cpuinforeports noflags).test_storage_maps_match_runtime_ownership_and_plan_into_a_new_placement.ds_to_universal, then a universal load at another TP degree, ZeRO 1, gloo.o_projbias), and Llama with a tied embedding andvocab_parallel_lm_head(vocabulary of 67, uneven at every degree).exp_avgandexp_avg_sqare restored bit for bit, and the resumed losses match the uninterrupted run to within 4.8e-7.yapf0.43.0 andflake8are clean on both files.Refs #8252, #8922.
@delock