Skip to content

[AffineIR] Calibrate affine transfer links per path (Plan analysis & calibration Part-2) - #8966

Open
jinyouzhi wants to merge 11 commits into
deepspeedai:masterfrom
jinyouzhi:affine-link-calibration
Open

jinyouzhi wants to merge 11 commits into
deepspeedai:masterfrom
jinyouzhi:affine-link-calibration

Conversation

@jinyouzhi

@jinyouzhi jinyouzhi commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Calibrate affine transfer links per path

Stacked on #8809 only for its docs page (affine-transfer.rst); the module itself needs nothing beyond master. Until #8809 merges, please review only the commits after the #8809 merge.

Why

A replica policy or cost model choosing between plans (#8252, #8935/#8936) needs link costs measured on the machine it runs on. Executor measurements show that round count and receiver sharing dominate sync time, so per-pair numbers are not enough on their own; this PR also measures 1→N and N→1.

What

deepspeed/checkpoint/affine_transfer_calibration.py:

  • calibrate_links(sizes, repeats, warmup, path_of, fan_width, fan_peers, group): collective over group (the default group unless given). Returns the same raw samples on every process, with no aggregation, for:

    • local copy;
    • peer (each ordered pair alone);
    • fan_out (1→N);
    • fan_in (N→1, receiver contention).

    Sizes run from 4 KiB to 256 MiB by default.

  • Each message is labelled by path_of(from_process, to_process). default_path_of gives local/peer; node_path_of(n) gives intra_node/inter_node; any callable works.

  • fit_links(calibration): per-path alpha + bytes / bandwidth, fitted to the per-size medians; peer_pairs per ordered pair; fans keyed by (root, peers), with send/receive giving each root's aggregate rate.

Weight transfers are one-way (a training process sends, a rollout process receives), so no pattern has a process send and receive at once.

It measures only. It does not plan, select replicas, or change the planner or executor.

Example (one node, 8 devices, default sizes)

pattern B70 xccl: alpha (µs) / GB/s L20 nccl (PCIe): alpha (µs) / GB/s H20 nccl (NVLink): alpha (µs) / GB/s
local 73 / 359 16 / 1534* 9 / 1881
peer 301 / 28.2 72 / 25.3 54 / 275
fan_out (7 peers) 531 / 3.4 141 / 3.6 88 / 49.6
fan_in (7 peers) 670 / 2.6 56 / 3.8 46 / 54.5
run time 88 s 34 s 13 s

* poor linear fit (max relative error 0.77); small copies are cache-resident.

Multi-node (2 × 8 H20, RoCE 8 × 200 Gb/s per node, node_path_of(8), 16 processes)

pattern alpha (µs) / GB/s max rel. error
peer, intra_node 57 / 53.8 0.07
peer, inter_node 97 / 16.6 (pairs 14.0–24.5) 0.50
fan_in, 7 intra-node peers: root receive 94.6 GB/s
fan_out, 8 cross-node peers: root send 62.1 GB/s median (33–78 over roots)
run time 72 s (+111 s for cross-node fans of width 1/4/8)
  • The same NVLink pair measured 275 GB/s in the 8-process group and 54 GB/s in the 16-process group over RoCE (105 GB/s with NCCL over TCP), so calibrate on the group and communication settings the transfer uses.

Tests

tests/unit/checkpoint/test_affine_transfer_calibration.py:

  • fit on synthetic data with known link costs, per-pair and per-peer-set fits;
  • schema and argument checks;
  • 4-rank runs checking completeness, sample counts, and fan path direction with an asymmetric path_of.

10 passed on 8× B70 (xccl). pytest tests/unit/checkpoint/ -k affine: 344 passed, 1 skipped on 8× H20 with torch 2.13.0+cu129 / NCCL 2.29.7 and with torch 2.9.1+cu129 / NCCL 2.29.7. Mutation check: flipping the fan_in label direction fails a test. Cross-node runs used NCCL 2.29.7 on both nodes (torch 2.13.0 and 2.9.1).

AI assistance (GitHub Copilot) was used for the multi-node runs and this description; I reviewed the changes and the results.

Follow-ups

@jinyouzhi jinyouzhi changed the title Calibrate affine transfer links per path, including fan-out and fan-in [AffineIR] Calibrate affine transfer links per path (Plan analysis & calibration Part-2) Oct 31, 2026
@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks, this is the input the cost model needs. I'll build the cost-aware policy on #8936 from fit_links here and analyze_transfers in #8967, rather than measuring separately. Two things the receiver side of the model needs from the fan-in data:

  1. Fan-in at several widths. The policy caps how many senders feed one receiver at once, so it needs the cost as a function of the number of concurrent senders, not only at world - 1. Could fan_width take a list, say (1, 2, 4, world - 1), with the fan fits keyed by width? The fans are cheap next to the pair sweep.
  2. The root's time, fitted. For a fan-in, the finish time is when the root has all k messages: root_seconds against k * bytes. That is the receiver's ingest bandwidth, and it currently stays in the raw result. A fitted entry beside the per-peer one would save every consumer from refitting it.

Your B70 numbers already show why the cap matters. If I read the per-link fit right, seven senders give about 7 × 2.6 ≈ 18 GB/s into one receiver, which is less than one pair alone (28.2 GB/s).

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S Yes, you're very observant—I agree. Based on these experiments, we can refine some of our policies.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks! If it's easier, I can send both as a small commit to your branch: fan_width accepting a list, with the fan fits keyed by width, and a fitted entry for the root's time. One refinement since: @0z5a measured flat incast on GH200 over Slingshot, so the policy should read the receive cap from the fit at each width rather than assume one.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S Sure, I have added you as my fork's collabration or I also can cherry-pick a commit provided by you. Thanks

@0z5a 0z5a 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.

Read the last commit (3cfa4153), as asked. The measurement design is careful: the barrier and synchronize brackets keep collectives out of timed regions, the pair sweep isolates each link, and the fans separate sender and receiver sharing. On CPU (gloo, 4 ranks), the calibration tests pass together with #8967's and the executor's (62).

A few thoughts, mostly about feeding a cost model directly:

  • Per-process send and receive capacity. A model of the form t = max over processes of max(sent / send_capacity, received / receive_capacity) needs each process's aggregate send and receive capacity. fit_links gives per-path link costs; the fan fits use the peers' times, and the roots' times, which carry the aggregate, stay only in the raw result. Computing bytes * len(peers) / root_seconds from the raw fan entries is a few lines. On CPU it gives a per-root figure for each direction. Having fit_links fit those per process (or per root) as send and receive would let #8967's ProcessReport.sent_bytes / received_bytes be priced directly.
  • Fan peers. The peers are root + 1 … root + fan_width, so with fan_width below the processes per node every fan stays within a node. If receive capacity differs across nodes, a way to choose the peers (a fan_peers(root) hook, or a stride) would let the fans cover it.
  • Defaults at scale. With the default fan_width = world - 1, each process can allocate (1 + world) * max(sizes), about 16 GiB at 64 ranks and 256 MiB, and the pair sweep is O(world²) barriers per size. The docstring says so, but a default capped at the processes per node, or a check against free memory, might save someone a surprise.
  • Process group. Calibration always uses the default group, while the executor runs on whatever group TransferEndpoints names. An optional group argument would let it measure the group the executor will actually use, e.g. an external trainer–rollout group, with the same rank numbering the analysis report uses.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S @0z5a Thanks both. I agree with the direction: a list of fan widths and a fitted root time (per-process send / receive). Before we key the fits by width alone, here is fan-in data at four widths on 8x B70 (PCIe, xccl, 256 MiB, median of 10). It shows that on a PCIe tree, which senders feed a receiver matters more than how many.

Topology. Four PCIe switches, each with two cards behind one Gen5 x16 root port, and two switches per socket: {0,1} {2,3} on socket 0 and {4,5} {6,7} on socket 1 (assuming ranks follow PCI address order). GH200 over Slingshot is a Clos, so every sender set looks alike there, which fits your flat incast.

Pair bandwidth, GB/s (row = sender, column = receiver):

from\to   0     1     2     3  |  4     5     6     7
0         -    8.3   7.9   8.3 | 27.3  27.5  27.6  27.8
1        6.0    -    5.5   3.6 | 27.4  27.5  27.4  27.5
2       53.9  53.7    -   53.6 | 14.5  14.6  14.6  14.6
3       53.6  53.5  53.8    -  | 14.6  14.6  14.6  14.6
4       27.4  27.3  27.4  27.3 |  -   14.4  14.3  14.4
5       27.3  27.3  27.4  27.4 | 25.2   -   25.3  25.3
6       27.6  27.7  27.0  27.3 |  8.8   8.7   -    8.6
7       27.5  27.8  27.3  27.4 | 42.8  42.9  43.0   -

These are stable across runs. Cross-socket traffic sits near 27 GB/s. Within a socket, bandwidth depends on the sender and the direction (2→0 is 54, 0→2 is 7.9, on the same switches), which the tree alone does not predict.

Fan-in root aggregate (k * bytes / root_seconds), GB/s, by width:

width min ~ max over roots spread
1 7.0 ~ 53.9 7.7x
2 12.6 ~ 55.2 4.4x
4 18.7 ~ 37.3 2.0x
7 16.4 ~ 23.6 1.4x

Three cases explain most of it:

  • 0 <- [1, 2] = 12.6: the root waits for the slowest sender (1→0 at 6.0), so the aggregate is about 2 × 6.
  • 1 <- [2, 3] = 55.2: two 54 GB/s senders reach only about 55 together, so a receive-side limit binds.
  • 3 <- [4, 5, 6, 7] = 27.9: four 27 GB/s senders together still get about 28, because the shared bottleneck is the cross-socket link, not the receiver.

So the finish time looks like max(slowest sender's bytes / pair bandwidth, bytes through a shared link / that link's capacity). Both terms depend on the sender set. A single fit per width would average 12.6 and 55.2 at width 2, and a single receive capacity per process would miss the shared cross-socket link.

Proposal for this PR:

  1. Width list and root fit: keep both as you describe. They are right for Clos and other uniform fabrics.
  2. Per-pair fit: add an optional per-pair entry in fit_links, keyed by (from, to) and fitted from the existing pair sweep, so no new measurement. It captures sender and direction asymmetry, which a path-class fit hides (the 28 GB/s peer fit here is the median of 3.6–54).
  3. Choosing fan peers: add a fan_peers(root, width) hook (0z5a's suggestion). The fan fits are keyed by the peer set, or by the peers' path classes, not by width alone. That way a fan can target "same switch", "same socket" or "cross socket" senders and measure the shared-link capacity directly. The raw entries already record peers.
  4. Docs: state that width-keyed fits assume a uniform fabric, and include this table as the counterexample.

On 0z5a's other points:

  • I'll add the optional group argument, with ranks numbered as in the analysis report.
  • I'll cap the default fan_width at the processes per node and document the memory needed.

@Achyuthan-S Please push your width-list and root-fit commit. I'll add the per-pair fit and the fan_peers hook on top so they don't conflict. Raw JSON for all four widths is available if you want to check the fits.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Pushed the width-list and root-fit commit:

  • fan_width takes one width or a list. Each fan sample records its width, and the result lists fan_widths.
  • fit_links keys the fan fits by width, and adds send and receive: per width, a fit per process of the root's own time against all the bytes it moved. That is the aggregate capacity @0z5a described, so [AffineIR] Analyze affine transfer batches before execution (Plan analysis & calibration Part-1) #8967's sent_bytes / received_bytes can be priced against it directly.
  • Docs and tests are updated: a synthetic test that recovers per-process capacities at two widths, and the 4-rank tests cover a list of widths. On CPU (gloo, 4 ranks), the calibration, executor and planner tests pass together (180).

Your B70 table is a great counterexample. It fits the same form once every shared resource is a term, not only the endpoints: a fan finishes when the busiest resource it crosses does, i.e. the max over each sender's link, the receiver, and any shared link such as your cross-socket hop, of the bytes through it over its capacity. Your three cases are one of each:

  • 0 <- [1, 2] is bound by the 1→0 link;
  • 1 <- [2, 3] by the receiver;
  • 3 <- [4..7] by the cross-socket link.

So your per-pair fit and fan_peers are what calibrate those resources. I kept the width as the only key here so your peer-set keys can replace it without a conflict.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S @0z5a Pushed ba8b39f (on top of Achyuthan's commit, with #8809's 11fe945 merged in).

Updates

  • Fan fits and send/receive are keyed by (root, tuple(sorted(peers))) instead of width, as Achyuthan anticipated; width is len(peers), and the width list and root fit stay.
  • fits['peer_pairs'][(src, dst)]: one fit per direction.
  • fan_peers(root, width): choose which peers form each fan.
  • group: calibrate inside a process group; ranks match [AffineIR] Analyze affine transfer batches before execution (Plan analysis & calibration Part-1) #8967's report.
  • Default width is capped at processes-per-node minus one, same-host peers first; docs note the buffer memory.
  • Docs: uniform-fabric note, the B70 table, and the busiest-resource model.

On the B70 counterexample: send/receive are the completion rate of a fan with those peers, bound by the busiest resource, not the root's capacity. Per-resource capacities are left to the #8936 policy, built from peer_pairs plus targeted fan_peers fans.

Validation: 8× L20 (driver 580.173.02, torch 2.14.1+cu130): calibration tests on 4 GPUs, 10 passed; external-group test on CPU passed 20 of 20 runs; pre-commit passed. Multi-node not validated.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks, this is the right split. Keying by peer set captures exactly what the B70 table showed, and I agree on the semantics: send/receive are the completion rate of a given fan, and the per-resource capacities belong in the policy. I'll derive them in the #8936 policy from peer_pairs plus targeted fan_peers fans, and check the result against your B70 table and 0z5a's GH200 and B300 runs.

One small thing for consumers: the fits now have tuple keys (peer_pairs[(src, dst)], fans by (root, peers)), so json.dumps(fits) raises. The raw calibration still serializes, so caching that and refitting on load works. A line in the docstring saying so would save someone the surprise.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

Good catch. I added a note to the fit_links() docstring: the returned fits contain tuple keys and are not directly JSON-serializable; callers should persist the raw calibrate_links() result and call fit_links() again after loading. The doc update is in my local worktree and will be included in the next signed-off PR update.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks for the JSON note and the H20 multi-node numbers. The same NVLink pair measuring 275 GB/s in one group and 54 GB/s in another is a useful warning, and I'll make "calibrate in the transfer's own group" explicit on the policy side.

One concern with dropping the concurrent pattern: some transfers do have processes sending and receiving at once.

  • Colocated resharding: on the same GPUs, ZeRO-3 → AutoTP, each GPU sends its partition and receives its TP shard.
  • Stage 2 of a relay: the engines on the target node all-gather, so each one sends and receives.
  • Pipelined relay: the relay GPUs receive stage 1 while they send stage 2.
  • Moves between trainers of different DP sizes.

@0z5a's B300 runs show what this costs: 618 GB/s per direction duplex, against 664 GB/s one-way. On GH200 the overlap held because the NIC's receive and the NVLink send are separate resources. So the policy needs a duplex number per path.

The shift pattern's labelling was the problem, not the measurement, and a simpler pattern would do: for example, each ordered pair exchanging both ways at once, labelled by the pair's path in each direction. If you'd rather keep this PR one-way, a line in the docs saying duplex isn't measured, plus a follow-up, works too.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S Thanks. I had assumed weight_sync in an RL loop is always one-way, so I missed the cases where a process sends and receives at the same time. Colocated resharding, relay stage 2 and the pipelined relay are all real ones, and the B300 duplex vs. one-way gap shows the policy needs this number. I'll bring the concurrent pattern back in this PR and add the duplex.

I agree the labelling was the problem. The old shift pattern made it unclear which participant each sample described.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S Pushed d00ba30, following your suggestion:

  • peer both ways: peer samples now carry both_ways. A two-way sample has a pair send to each other at once while every other process is idle, and gives one sample per direction, labelled with that direction's path. peer / peer_pairs stay one-way. peer_both_ways / peer_both_ways_pairs hold the two-way fits.
  • relay (the old shift pattern, relabelled): every process receives from p - shift while sending to p + shift. Each sample is one process's own time, labelled with its from_process / to_process and both receive_path and send_path. fit_links keys relay by (receive_path, send_path), so nothing is filed under a path it only half used.

H20, one node, 4 GPUs over NVLink, 256 MiB, median of 10, GB/s per direction: one-way 331–335, both ways 276–287 (0.83–0.86×), relay 282–287 (≈0.85×). Calibration tests pass on 4 GPUs (12 passed). Cross-node relay is not measured yet.

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks @jinyouzhi, d00ba30 covers all four cases. This is how the #8936 policy will read it:

  • colocated resharding and the all-gather stage of a relay: peer_both_ways / peer_both_ways_pairs, per direction;
  • the relay GPUs of a pipelined relay: relay[(inter_node, intra_node)], once a cross-node relay is measured;
  • one-way moves stay on peer / peer_pairs.

Your H20 numbers (both ways at 0.83–0.86× one-way) and @0z5a's B300 (618 vs 664 GB/s, 0.93×) show the same thing: the cost is real and depends on the machine, so it has to be calibrated. On CPU (gloo, 4 ranks) the calibration tests pass at d00ba30 (12 passed).

One small thing: the raw format gained both_ways and relay, but CALIBRATION_SCHEMA is still 1. A raw calibration saved before this commit, such as the earlier B70/H20 JSON, passes the schema check, and then fit_links raises KeyError: 'both_ways'. Since the docstring now recommends caching the raw result and refitting on load, either bump the schema to 2 or read old samples with sample.get('both_ways', False) and calibration.get('relay', []). Either works for me.

When you get a multi-node slot, the (inter_node, intra_node) relay is the number the pipelined relay needs. No rush.

@0z5a

0z5a commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Re-read up to d00ba303. One fix needed, plus one wording point.

  • The external-group executor test fails on this head. It asserts that calibration['peer'] holds exactly (0, 1) and (1, 0). Since d00ba303 the list also carries the two both_ways samples, so the worker's assertion fails on CPU (gloo), and the CPU lane will hit it. The calibration tests themselves pass (12). Keeping the assertion to one-way samples fixes it (passes here):

        assert sorted((sample['from_process'], sample['to_process']) for sample in calibration['peer']
                      if not sample['both_ways']) == [(0, 1), (1, 0)]
  • peer_both_ways is a pair time, not a per-direction time. Both directions of a two-way sample get the same time, the max over the two endpoints, so peer_both_ways_pairs[(a, b)] always equals peer_both_ways_pairs[(b, a)]. On an asymmetric tree like your B70 table (2→0 at 54 GB/s, 0→2 at 7.9 GB/s), the fast direction inherits the slow one's time. The fit_links docstring calls it the per-direction cost while the reverse direction is busy. Could it say instead that it is the pair's completion time, so the duplex loss should be read against the slower of the two one-way fits?

@Achyuthan-S

Copy link
Copy Markdown
Contributor

Agreed with @0z5a on both points. For the #8936 policy, I'll read peer_both_ways as the pair's completion time and compare it with the slower of the two one-way fits, rather than per direction as I wrote above.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

Thanks @0z5a, both fixed in 57a47ff: the external-group assertion keeps to one-way samples, and the docs now describe peer_both_ways as the pair's completion time, to be read against the slower one-way fit.

@jinyouzhi
jinyouzhi force-pushed the affine-link-calibration branch from 57a47ff to 851ea68 Compare November 11, 2026 10:11
@jinyouzhi
jinyouzhi marked this pull request as ready for review November 11, 2026 10:28
@chatgpt-codex-connector

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

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T10:36:21.628251Z 851ea68 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: 851ea68c11

ℹ️ 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".

fans = {'fan_out': [], 'fan_in': []}
for size in sizes:
for process in range(world):
local.append({'bytes': size, 'process': process, 'path': LOCAL, 'seconds': gathered[process][0][size]})

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 Label local samples through path_of

When a custom path_of distinguishes local devices, such as on a heterogeneous host, every local sample is still assigned the constant local instead of path_of(process, process). fit_links consequently pools unlike device-local costs into one fit despite the documented per-path sample contract, producing incorrect local estimates for such calibrations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d465334: local samples are now labeled path_of(process, process), like every other sample, so a path_of that tells local devices apart gets a separate local fit per label. test_fans_label_each_message_by_its_direction now checks the local labels too.

return {
'schema': CALIBRATION_SCHEMA,
'world_size': world,
'communication_backend': get_accelerator().communication_backend_name(),

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 Report the measured process group's backend

When the supplied or default process group uses a backend different from the accelerator's preferred default (for example, an explicitly created Gloo group on a CPU installation that also provides CCL), this records the accelerator default rather than the backend _exchange actually measured. Persisted calibrations are therefore mislabeled and cannot be reliably compared or reused; derive this metadata from the active process group instead.

AGENTS.md reference: AGENTS.md:L40-L40

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d465334: communication_backend now comes from the measured group, via a new deepspeed.comm.get_backend(group) that wraps the backend's existing get_backend. test_records_the_backend_it_measured patches the accelerator's preferred backend name to a decoy and checks that the recorded backend is still the group's.

jinyouzhi and others added 9 commits November 11, 2026 18:10
Add calibrate_links/fit_links: raw local, pairwise, concurrent, 1-to-N and
N-to-1 timings labelled by a path_of(from, to) hook, with a per-path
alpha + bytes/bandwidth summary, as measured inputs for transfer cost models.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit 3cfa415)
…ive capacity

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
(cherry picked from commit dc1722e)
…ode-capped default

- fit_links keys fan_out, fan_in, send and receive by (root, sorted peers),
  so fans of one width with different senders stay apart; the width is
  len(peers). Adds peer_pairs, a fit per ordered (from, to) pair.
- calibrate_links takes fan_peers(root, width) to choose the peers, and an
  optional group whose ranks number the processes.
- The default fan width is one less than the fewest processes on any node,
  and default peers are on the root's node first.
- Docs state that width-keyed fits assume a uniform fabric, with B70 data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit ba8b39f)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit f00ce17)
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit 2c43824)
… a path

A concurrent sample times a process's send to process + shift and its receive from process - shift together, but was labelled by the send path alone. Across nodes, an intra-node send paired with an inter-node receive takes the inter-node time, so the intra_node concurrent fit came out at 7.5 GB/s over TCP against 65 GB/s for clean intra-node processes on 2x8 H20. Record receive_paths and fit each path only from processes whose send and receive both took it. Document calibrating on the group and settings the transfer uses.

Signed-off-by: iLeGend <824040212@qq.com>
(cherry picked from commit 575bef3)
Weight transfers are one-way: a training process sends and a rollout
process receives, so no process sends and receives at once. The
concurrent shift pattern timed every process doing both, which matches
no transfer, and needed per-direction path filtering to fit at all.
Remove it with its fit, docs and tests, and drop the hardware-specific
measurements from the docs.

Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit 17aa897)
Some transfers have a process send and receive at the same time: colocated resharding, the all-gather stage of a relay, a pipelined relay, and moves between trainers of different DP sizes.

peer samples gain both_ways. A two-way sample has a pair send to each other at once, with every other process idle, and gives one sample per direction labeled with that direction's path. fit_links keeps peer and peer_pairs one-way and adds peer_both_ways and peer_both_ways_pairs, so a policy can see what a link loses per direction when both directions are busy.

The shift pattern returns as relay: every process receives from process - shift while sending to process + shift. Each sample is one process's own time, labeled with who it received from and sent to and with both the receive_path and the send_path, since that time covers both. fit_links fits relay per (receive_path, send_path), so no sample is filed under a path it only half used.

Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit d00ba30)
Both directions of a both_ways sample carry the pair's completion time,
so peer_both_ways_pairs[(a, b)] equals peer_both_ways_pairs[(b, a)].
Say so in fit_links and the docs, and compare against the slower of the
two one-way fits.

Keep the external-group executor test's assertion to one-way peer
samples, since calibrate_links now also returns both_ways samples.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
(cherry picked from commit 57a47ff)
banxingmjj pushed a commit to openanolis/DeepSpeed that referenced this pull request Nov 12, 2026
…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>
@delock
delock self-requested a review November 12, 2026 08:29
A path_of that tells local devices apart, as on a host with unlike
accelerators, had every local copy labeled 'local' and pooled into one
fit. Local samples now carry path_of(process, process) like every other
sample.

communication_backend recorded the accelerator's preferred backend,
which need not be the one the measured group runs on. It now comes from
the group itself, through a new deepspeed.comm.get_backend that wraps the
backend's existing get_backend.

Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
@Achyuthan-S

Copy link
Copy Markdown
Contributor

Thanks @jinyouzhi, d465334 covers both Codex points, and @0z5a's two are in as well. One from my earlier comment is still open at d465334: CALIBRATION_SCHEMA is still 1, while fit_links now reads sample['both_ways'] and calibration['relay'] directly. A raw calibration saved before d00ba30 still passes the schema check and then raises KeyError: 'both_ways', and the docstring now recommends caching the raw result and refitting on load. Bumping the schema to 2, or reading old samples with .get('both_ways', False) and .get('relay', []), fixes it. LGTM otherwise.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

@Achyuthan-S Fixed in f678c9d: CALIBRATION_SCHEMA is now 2, so a raw calibration saved before two-way and relay samples is refused with a clear error rather than misread. A test feeds fit_links such a legacy result.

fit_links reads peer samples' both_ways and the raw relay list. A raw
calibration saved before those existed still claimed schema 1, so it
passed the schema check and then raised KeyError. Schema 2 makes
fit_links refuse it with a clear error instead.

Signed-off-by: Jin, Youzhi <youzhi.jin@intel.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

This branch has not been deployed

No deployments
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