Skip to content

[RFC] Batch replica selection for affine transfer plans #8935

Open

Description

@0z5a

Motivation

plan_transfer (#8623) takes an optional replica_selector: Callable[[ReplicaSelectionRequest], int]. When the target region of one piece meets a source region that several locations hold, the planner calls the selector once for that intersection and uses the returned location. Without a selector it takes the first holder, which is the lowest location.

This per-request form has two limits, which @jinyouzhi pointed out on #8885:

  • No load balancing. The selector sees one request at a time, so it cannot spread bytes across holders, and its result depends on call order.
  • No splitting. A region can come from only one holder, even when several hold it and the region is large.

The choice matters. On #8885, jinyouzhi measured repeated ZeRO-2 syncs: the lowest-rank default was the slowest policy, and "local first, then fewest assigned bytes" roughly halved the sync time (0.49 s vs 1.05–1.35 s).

On #8885, @jinyouzhi, @Achyuthan-S and I agreed on a batch form:

  • the planner applies a pluggable policy, but hands it every request at once;
  • the policy may split regions;
  • the planner checks the result exactly.

@Achyuthan-S takes the policy itself from mid-November, as the decision layer above the plan in #8252. This issue is meant to agree on the interface before then.

A naming point first: locations vs processes

The source_rank / target_rank fields of a plan are location keys of the two maps (pieces_by_rank), not process ranks. Which process holds each location is known only to the executor's TransferEndpoints, which keeps separate source and target mappings. When a trainer syncs into a separate engine, source location 0 and target location 0 are different processes; in the #8809 external-group test they map to group ranks 0 and 1. So "local first" cannot be decided from the maps alone, and the interface below says "location" where it means a map key and "process" where it means a group rank.

Proposal (strawman)

A multi-parameter planning entry

Balancing only works across the whole sync, not one parameter at a time, so the batch has to span parameters:

plans = plan_transfers(
    pairs,                    # Mapping[str, Tuple[ParamAffineMap, ParamAffineMap]]: name -> (target_map, source_map)
    limits=None,              # PlanBudget, per parameter as today
    *,
    endpoints=None,           # Optional[TransferEndpoints]: lets the policy see processes
    element_size=None,        # Optional[Mapping[str, int]]: bytes per element per parameter
    replica_policy=None,      # Callable[[Sequence[ReplicaRequest]], Sequence[ReplicaAnswer]]; None -> default below
)                             # -> Dict[str, TransferPlan]

plan_transfer(target_map, source_map, ...) stays as the one-parameter case. The executor already takes a batch (execute_transfers in #8809), so this mirrors it.

What the policy sees

One request per intersection of a target piece with a source region. Requests with a single holder are included too, so the policy knows the load it cannot move:

@dataclass(frozen=True)
class ReplicaRequest:
    parameter: str
    target_location: int                            # as TransferSegment.target_rank
    target_process: Optional[int]                   # endpoints.target[target_location], if endpoints were given
    logical_origin: Tuple[int, ...]
    shape: Tuple[int, ...]
    target_offset: int
    target_strides: Tuple[int, ...]
    candidates: Tuple[ReplicaCandidate, ...]        # validated holders; same type as today (source_rank = location)
    candidate_processes: Tuple[Optional[int], ...]  # endpoints.source[...] for each candidate, if given
    bytes: Optional[int]                            # elements x element_size[parameter], if given

Requests arrive in a fixed order, sorted by parameter, then target location, then origin.

What the policy returns

One answer per request, in the same order:

  • int: the whole region from that source location, as today.
  • Sequence[ReplicaPart]: a split into parts.
@dataclass(frozen=True)
class ReplicaPart:
    logical_origin: Tuple[int, ...]
    shape: Tuple[int, ...]
    source_location: int

What the planner checks

Everything the policy returns is checked before any segment is emitted. A failure raises InvalidReplicaPolicyError, as an invalid rank does today:

  1. exactly one answer per request;
  2. every location is one of that request's validated candidates;
  3. every part lies inside its request's box, the parts are pairwise disjoint, and their volumes sum to the request's volume, so they tile it exactly;
  4. the extra segments from splits stay within PlanBudget (max_segments, max_plan_bytes).

Each part becomes a segment. The planner computes its addresses from the chosen holder, as it does now, so a policy never handles offsets.

Default policy

The default needs no hardware information and does not split:

  • With endpoints: if a candidate runs in the same process as the target, use it (a local copy). Otherwise pick the candidate whose process has the fewest bytes assigned so far in this batch, counting single-holder requests. Ties go to the lowest location.
  • Without endpoints: fewest bytes assigned per source location, ties to the lowest location. There is no local preference, because the planner cannot tell what is local.

The default counts elements when element_size is not given, which is the same as bytes only if the whole sync uses one dtype.

It replaces "first holder" as the default, and the old behaviour stays available as a one-line policy.

Determinism

execute_transfers is collective: every member of the transfer group must run the same plan. If every rank plans for itself, the policy must be deterministic on the same input. If one process plans and ships the plan, it need not be. The interface doesn't choose between these, but the docs should say so.

Compatibility

An existing per-request replica_selector keeps working: the planner wraps it as a batch policy that answers each request on its own. We can deprecate it later or keep it as a convenience.

Out of scope

Open questions

  1. Batch scope. Is the whole sync (plan_transfers) the right unit? A policy that balances across several syncs would need state outside the planner.
  2. Split shape. Arbitrary sub-boxes, or only cuts along axis 0? Arbitrary boxes are no harder to check, but they can produce more strided segments.
  3. Where processes come from. Is TransferEndpoints the right input, or should the planner take a plain location-to-process mapping so it does not depend on the executor module?
  4. Placement of the default. Inside affine_transfer.py, or a small affine_replica_policy.py next to it, so policies don't grow the planner?
  5. The old selector. Keep the adapter, or deprecate it once the batch form lands?

Plan

  • Planner side: I can take plan_transfers, the request and answer types, the checks above, the default policy, and the per-request adapter.
    • Tests: randomized replicated maps where every policy answer must give identical copied data.
    • The existing adversarial planner cases must still be refused.
    • Policies that return a wrong location, a gap, an overlap or a part outside the box must be rejected.
  • Policy layer (@Achyuthan-S, from mid-November): cost-aware policies on top of this hook.
  • Baseline (@jinyouzhi): the ZeRO-2 measurement above becomes the reference for the default.

Feedback on the interface is welcome before anyone starts on it. cc @delock, since this builds on #8252 and #8230.

Activity

  1. Achyuthan-S commented on Oct 28, 2026

    @Achyuthan-S
    Contributor

    Thanks @0z5a, this matches what we agreed on #8885, and I'm happy with the split. Thoughts on the open questions:

    1. Batch scope: one sync is the right unit. A policy that balances across syncs can keep that state itself, as a callable object, without the planner holding any.
    2. Split shape: I'd start with cuts along axis 0. In row-major storage they keep each part one contiguous run per row, so splitting adds the fewest strided segments. Arbitrary sub-boxes can come later if a policy needs them.
    3. Where processes come from: I'd take a plain location-to-process mapping, so the planner doesn't import the executor. TransferEndpoints can produce that mapping.
    4. Placement: a separate affine_replica_policy.py. The cost-aware policies will grow, and keeping them there leaves the planner small.
    5. The old selector: keep the adapter for now.

    One link to the correctness work: checks 2 and 3 are exactly what makes any answer safe. If every part uses a validated holder and the parts tile the request, each target address gets one holder of the same logical element, and when the replicas agree the copied data is identical whichever holder is chosen. That's Theorem 3 in the proof draft I'm sharing for #8252, and your randomized "every policy answer gives identical data" test is the matching check.

  2. 0z5a commented on Oct 28, 2026

    @0z5a
    ContributorAuthor

    Thanks @Achyuthan-S, agreed on all five:

    1. One sync per batch. Any state across syncs lives in the policy object.
    2. Axis-0 cuts first. For now the planner rejects any other split shape, so widening it later is an additive change.
    3. A plain location→process mapping, one each for source and target since the namespaces differ. TransferEndpoints can produce them.
    4. Policies go in affine_replica_policy.py.
    5. Keep the per-request adapter.

    Thanks for the link to Theorem 3. I'll cite it in the randomized test's docstring.

    Some transport measurements from my side bear on what a cost-aware policy needs to know. Each item names its hardware.

    Splitting has a floor.

    • Setup: two GH200 hosts over InfiniBand 400G, executor-style rounds of torch point-to-point on NCCL.
    • Each round costs about 0.3 ms before any bytes move, so round time ≈ 0.3 ms + piece / 48 GB/s.
    • 2 MiB pieces reach 5–10 GB/s, 8 MiB pieces 22–38 GB/s (varying between runs), and 64 MiB and up 43–49 GB/s.
    • Neither NCCL knobs nor the UCCL net plugin moved the line rate by more than 2%. On this link the lever is message size, not transport.
    • So a part much smaller than a few tens of MB costs more in rounds than a second holder can give back.
    • Suggestion: plan_transfers takes a min_split_bytes, defaulting to something tied to the staging budget, and the planner refuses smaller splits the same way it refuses an invalid location.

    On PCIe, concurrency sets the ceiling, not the link.

    • Setup: 4×L40S without P2P, NCCL 2.30.7 over SHM.
    • A single flow tops out near 19.8 GB/s, because NCCL gives each local PCIe peer 2 p2p channels with 128 KiB chunks.
    • Two senders into one receiver reach 25.1 GB/s, one sender to two receivers 25.4, and 2→2 all-to-all 42.0.
    • So on such a box, feeding one target from two holders is worth about 27%. That is the case for allowing splits.
    • The chunk-size setting that lifts a single flow to 24.4 GB/s costs 28% on 2→2 all-to-all. Link costs have to be calibrated per traffic pattern, not per link, which is what [checkpoint] AffineIR transfer-plan analysis and link calibration (preview) jinyouzhi/DeepSpeed#6 sets out to measure.

    Across nodes the sign may flip.

    • Setup: H100 InfiniBand all-to-all runs on 4 and 16 nodes, with mixed-speed ports.
    • The time above the fluid bound came from receiver ports fed above their capacity, plus head-of-line coupling: flows that shared a sending NIC with traffic to a congested port slowed down too.
    • A flow model with that coupling term matched held-out runs, including the 16-node ones, to about 3% median error. The fluid bound was off by about 24%.
    • In that model, any receiver port fed above capacity triggers the coupling. So spreading one target's sources over several remote holders can hurt over a network, while it helps over PCIe.
    • Caveats: we measured this only with mixed-speed ports, and the mitigations (capping concurrent inflow, keeping fast and slow traffic apart) are model predictions, not measurements.

    For the interface I think this means two things:

    • The default stays simple: local first, then fewest assigned bytes per sending process, no splits. The plan fixes how many bytes each target receives, so balancing those is not the default's job.
    • Beyond what [RFC] Batch replica selection for affine transfer plans #8935's request already carries, a cost-aware policy needs to know the path between each candidate and the target: same process, same node over PCIe or NVLink, or the network. The location→process mapping plus a process→node mapping gives that. I'd add an optional process_node mapping rather than a full topology object.

    cc @jinyouzhi @delock

  3. Achyuthan-S commented on Oct 28, 2026

    @Achyuthan-S
    Contributor

    Thanks @0z5a, these measurements are exactly the inputs the policy needs, and thanks for citing Theorem 3.

    +1 to both additions:

    1. process_node: location→process plus process→node gives each candidate's path (same process, same node, or network), which is what the first cost-aware policy needs. A full topology object can wait until something needs it.
    2. min_split_bytes: your numbers suggest deriving it rather than fixing it. A part pays off only if moving its bytes saves more time than the round it adds, so roughly bytes > α·β for that path: 0.3 ms × 48 GB/s ≈ 14 MB on the GH200 link, in line with your "few tens of MB". A default tied to the staging budget is a good start, and a policy can pass a calibrated value.

    For mid-November I'm planning the policy as follows:

    That last part is where measurements matter most, so I'd like to take you up on the GH200 offer for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions