Skip to content

[checkpoint] Batch replica selection for affine transfer plans - #8936

Merged
delock merged 3 commits into
deepspeedai:masterfrom
0z5a:replica-batch-policy-20261028
Nov 12, 2026
Merged

delock merged 3 commits into
deepspeedai:masterfrom
0z5a:replica-batch-policy-20261028

Conversation

@0z5a

@0z5a 0z5a commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Implements the planner side of #8935. It does not close the issue: the cost-aware policies are the policy layer under #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 ReplicaParts 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)

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 #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 #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 Define affine IR for checkpointing #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

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T09:59:49.703718Z 24881a4 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24881a444e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return _emit(prepared, choices, budget)


def plan_transfers(pairs: Mapping[str, Tuple[ParamAffineMap, ParamAffineMap]],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new batch planning API

For downstream users adopting plan_transfers, this patch exposes the entry point in __all__ plus a new callback/split protocol and default selection behavior, but adds no user documentation; the existing AutoEP docs only explain plan_expert_transfer and the old per-request replica_selector, so there is no supported reference for the process mappings, element sizes, split constraints, or policy answers. Add the required documentation alongside the feature.

AGENTS.md reference: AGENTS.md:L71-L71

Useful? React with 👍 / 👎.

Comment thread deepspeed/checkpoint/affine_transfer.py Outdated
if type(part.source_location) is not int or part.source_location not in holders:
raise InvalidReplicaPolicyError(f'{where}: location {part.source_location!r} is not a validated '
f'holder {sorted(holders)}')
origin, shape = tuple(part.logical_origin), tuple(part.shape)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate part fields before tuple conversion

When a custom policy returns a ReplicaPart whose logical_origin or shape is non-iterable (for example None), these tuple(...) calls raise a raw TypeError. The function's new contract says every invalid answer raises InvalidReplicaPolicyError; callers that catch AffineTransferError to reject a policy or fall back will instead abort, so convert and validate these fields under the policy-error path.

Useful? React with 👍 / 👎.

Comment thread deepspeed/checkpoint/affine_transfer.py Outdated
Comment on lines +664 to +667
parts = _resolve(request, item, answer, min_split_bytes, element_size)
choices[name][index] = parts
parts_per_name[name] += len(parts)
if parts_per_name[name] > budget.max_pair_checks:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce split budgets before materializing parts

When a custom policy accidentally returns a very large split for one request, _resolve materializes every LogicalBox and sorts the full answer before this cumulative check runs. Consequently even a tiny max_pair_checks does not bound planner work or memory, so a malformed policy can cause a large allocation or OOM before it is rejected; enforce the remaining per-parameter allowance while or before resolving the answer.

Useful? React with 👍 / 👎.

@Achyuthan-S Achyuthan-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this from the policy side, since the cost-aware policy will sit on this interface. It has what that policy needs:

  • Each request carries its bytes, the target process and every candidate's process.
  • The whole sync arrives in one call, in a fixed order, so a policy can balance load over all of it, including the single-holder load it can't move.

The topology and the calibration (#8966's fit_links) can live inside the policy, so I agree the process_node mapping can stay out of the planner. The refactor keeps plan_transfer's selection and emission as they were, and local_first_least_loaded does what its docstring says.

Checked on CPU:

  • At 24881a4: test_affine_replica_policy.py, test_affine_transfer_plan.py and test_affine_shard_map.py pass (251).
  • Merged with current master (after #8809, #8885 and #8975), the merge is clean, and the planner, executor, AutoEP and ZeRO-affine tests pass together (583 passed, 8 skipped).

A few small things:

  1. Codex's first P2 is real: a ReplicaPart with logical_origin=None raises a raw TypeError (I checked), not InvalidReplicaPolicyError. The second is minor: the policy's answer is already in memory by then, so checking len(answer) against the remaining max_pair_checks allowance before _resolve only saves the planner's own work on a malformed split. It's still cheap to add.
  2. Docs: #8809 has landed, so the plan_transfers section can go into affine-transfer.rst now.
  3. A note for policy authors: every rank has to return the same answers. #8809's schedule digest already refuses mismatched plans ("ranks disagree about the transfer schedule"), so this fails loudly. Still, one line in the docstring saying that cost inputs should come from data every rank shares, such as fit_links on the gathered calibration, would help.
  4. The test docstring cites "Theorem 3 of the proof draft". In the PDF I posted it's Theorem 4, "Any consistent plan is exact". Citing it by name would survive renumbering.

LGTM from the policy side. The first part of 1 is worth doing before merge, and the rest is up to you.

0z5a and others added 2 commits November 5, 2026 19:59
Add plan_transfers, which plans every parameter of one sync and hands a
replica policy every request at once, as agreed in deepspeedai#8935. The policy
returns a holder location per request, or splits a request along axis 0
across validated holders. The planner checks every answer before it
emits a segment: one answer per request, only validated holders, and
parts that tile the request exactly. So a policy decides where data is
read from, never what arrives.

Requests name map locations and, when the caller passes the
location-to-process mappings, the processes behind them, so a policy can
tell a local copy from a transfer. element_size turns elements into bytes
and min_split_bytes bounds every part of a split.

Policies live in affine_replica_policy.py: first_holder (plan_transfer's
default), local_first_least_loaded (the plan_transfers default) and
per_request, which runs an existing replica_selector as a batch policy.
plan_transfer itself is unchanged; it now shares its validation and
emission steps with plan_transfers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
A ReplicaPart whose logical_origin or shape is not iterable is now refused
with InvalidReplicaPolicyError instead of raising a raw TypeError, and an
answer with more parts than the remaining max_pair_checks allowance is
refused on its length before _resolve expands it. The pair budget now raises
InvalidReplicaPolicyError as well, so every refused answer uses the error the
planner documents.

Document plan_transfers, the request and answer protocol, the process
mappings, element sizes (one entry per parameter), split constraints and the
built-in policies in affine-transfer.rst, and tell policy authors that answers
must agree across ranks, so cost inputs must come from data every rank shares.
Cite the deepspeedai#8252 theorem "Any consistent plan is exact" by name instead of by
number.

Tests: the rejected-answer cases include non-iterable part fields,
test_the_pair_budget_is_checked_before_the_parts_are_read pins that the planner
decides from len(answer) without reading the parts, and both budget tests pin
the policy error, including parts accumulated across requests. All five fail on
the previous planner. On macOS arm64 CPU (Python 3.11, torch 2.15.0),
test_affine_replica_policy.py passes (65 tests), the four affine checkpoint
suites pass together (306), and the AutoEP unit suites pass (215 passed,
8 skipped). pre-commit (yapf, flake8, codespell, license) is clean.

Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
@0z5a
0z5a force-pushed the replica-batch-policy-20261028 branch from 24881a4 to 773ddfd Compare November 5, 2026 19:09
@0z5a

0z5a commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S thanks — all four are in 773ddfd3 (branch rebased on current master, after #8809, #8885 and #8975):

  1. A ReplicaPart whose logical_origin or shape is not iterable now raises InvalidReplicaPolicyError; the pair budget uses the same class, so every refused answer uses the documented one. The budget is decided from len(answer) before _resolve expands the parts, and it still accumulates across requests. The tests pin both (test_the_pair_budget_is_checked_before_the_parts_are_read, test_parts_count_against_the_budget_across_requests); the five rejection/budget cases fail on the previous planner.
  2. plan_transfers is documented in docs/code-docs/source/affine-transfer.rst: the request/answer protocol, source_processes/target_processes, element_size (one entry per parameter), the axis-0 split and min_split_bytes constraints, the policy error, and the three built-in policies.
  3. The plan_transfers and affine_replica_policy docstrings now say answers must agree on every rank, so cost inputs must come from data every rank shares (for example fit_links on the gathered calibration, [AffineIR] Calibrate affine transfer links per path (Plan analysis & calibration Part-2) #8966), and quote the executor's "ranks disagree about the transfer schedule (batch schedule)".
  4. The test docstring cites "Any consistent plan is exact" by name.

Validation on macOS arm64 CPU (Python 3.11, torch 2.15.0): 65 tests in the new file, 306 across the four affine checkpoint suites, and 215 passed / 8 skipped for the AutoEP suites; pre-commit (yapf, flake8, codespell, license) is clean. No GPU path is touched.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Re-checked 773ddfd: all four are in.

  • A ReplicaPart with a non-iterable logical_origin or shape now raises InvalidReplicaPolicyError, since the tuple(...) conversion is guarded. The pair budget is decided from len(answer) before _resolve, and the budget tests pass.
  • plan_transfers is documented in affine-transfer.rst, the docstrings say answers must agree on every rank, and the test cites "Any consistent plan is exact" by name.

I merged the branch with current master: the merge is clean, and the planner, replica-policy, shard-map, ZeRO-affine, AutoEP-affine and executor suites (including the external-group test) pass together on CPU, with 383 passed. LGTM from the policy side. The cost-aware policy will build directly on this interface.

@delock
delock enabled auto-merge November 12, 2026 03:55
@delock
delock added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@delock
delock added this pull request to the merge queue Nov 12, 2026
Merged via the queue into deepspeedai:master with commit 2d02194 Nov 12, 2026
18 checks passed
LiRunGuo added a commit to LiRunGuo/DeepSpeed that referenced this pull request Nov 12, 2026
); shards given at each sync follow offload/reload

Signed-off-by: RunguoLi <li19107254665@gmail.com>
@gpu-test-labeler gpu-test-labeler Bot added the skip-gpu-test Requires positive evidence that every changed behavior can be validated without a GPU. label Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-gpu-test Requires positive evidence that every changed behavior can be validated without a GPU.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants