Skip to content

Commit 17c0e31

Browse files
authored
Merge pull request #3095 from bghira/agent/dataset-batch-distributed-runtime
Support variable dataset batch sizes across distributed ranks
2 parents d6d9b3b + 6d7021a commit 17c0e31

7 files changed

Lines changed: 484 additions & 35 deletions

File tree

simpletuner/helpers/data_backend/runtime/context_parallel_sync.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os
2323
import random
2424
from contextlib import contextmanager
25+
from dataclasses import dataclass
2526
from typing import Any, Optional, Tuple
2627

2728
import torch
@@ -40,6 +41,18 @@
4041
CP_SKIP_SAMPLING_SENTINEL = "__CP_SKIP_SAMPLING__"
4142

4243

44+
@dataclass(frozen=True)
45+
class DistributedBatchLayout:
46+
local_batch_size: int
47+
global_batch_size: int
48+
local_batch_offset: int
49+
data_rank: int
50+
data_parallel_size: int
51+
model_replica_size: int
52+
world_size: int
53+
data_replica_batch_sizes: tuple[int, ...]
54+
55+
4356
def _normalize_parallel_size(value: Any, name: str) -> int:
4457
if value is None:
4558
return 1
@@ -219,6 +232,122 @@ def get_model_replica_data_info(
219232
return True, data_rank, data_local_rank, data_group_size, dp_replicate_size
220233

221234

235+
def resolve_distributed_batch_layout(accelerator, local_batch_size: int) -> DistributedBatchLayout:
236+
"""Resolve sample counts and this data replica's offset using fixed-shape collectives."""
237+
local_batch_size = _normalize_parallel_size(local_batch_size, "local_batch_size")
238+
if local_batch_size < 1:
239+
raise ValueError("local_batch_size must be greater than 0.")
240+
241+
if accelerator is None:
242+
return DistributedBatchLayout(local_batch_size, local_batch_size, 0, 0, 1, 1, 1, (local_batch_size,))
243+
244+
world_size = _normalize_parallel_size(getattr(accelerator, "num_processes", 1), "num_processes")
245+
process_index = _normalize_parallel_size(getattr(accelerator, "process_index", 0), "process_index")
246+
if world_size == 1:
247+
return DistributedBatchLayout(local_batch_size, local_batch_size, 0, 0, 1, 1, 1, (local_batch_size,))
248+
249+
count = torch.tensor([local_batch_size], device=accelerator.device, dtype=torch.long)
250+
gathered_counts = accelerator.gather(count).reshape(-1)
251+
if gathered_counts.numel() != world_size:
252+
raise RuntimeError(
253+
f"Distributed batch count gather returned {gathered_counts.numel()} values for world size {world_size}."
254+
)
255+
world_batch_sizes = tuple(int(value) for value in gathered_counts.cpu().tolist())
256+
257+
data_enabled, data_rank, _data_local_rank, model_replica_size, data_parallel_size = get_model_replica_data_info(
258+
accelerator
259+
)
260+
if data_enabled:
261+
replica_batch_sizes = []
262+
for replica_rank in range(data_parallel_size):
263+
start = replica_rank * model_replica_size
264+
replica_counts = world_batch_sizes[start : start + model_replica_size]
265+
if len(set(replica_counts)) != 1:
266+
raise RuntimeError(
267+
"Model-parallel ranks received different local batch sizes: "
268+
f"data replica {replica_rank} reported {replica_counts}."
269+
)
270+
replica_batch_sizes.append(replica_counts[0])
271+
data_replica_batch_sizes = tuple(replica_batch_sizes)
272+
else:
273+
data_rank = process_index
274+
data_parallel_size = world_size
275+
model_replica_size = 1
276+
data_replica_batch_sizes = world_batch_sizes
277+
278+
return DistributedBatchLayout(
279+
local_batch_size=local_batch_size,
280+
global_batch_size=sum(data_replica_batch_sizes),
281+
local_batch_offset=sum(data_replica_batch_sizes[:data_rank]),
282+
data_rank=data_rank,
283+
data_parallel_size=data_parallel_size,
284+
model_replica_size=model_replica_size,
285+
world_size=world_size,
286+
data_replica_batch_sizes=data_replica_batch_sizes,
287+
)
288+
289+
290+
def gather_variable_batch_tensor(
291+
tensor: torch.Tensor,
292+
accelerator,
293+
layout: Optional[DistributedBatchLayout] = None,
294+
) -> torch.Tensor:
295+
"""Gather every rank's per-sample tensor when data replicas have different batch sizes."""
296+
if tensor.ndim < 1:
297+
raise ValueError("Variable batch tensor gather requires a batch dimension.")
298+
if layout is None:
299+
layout = resolve_distributed_batch_layout(accelerator, tensor.shape[0])
300+
if tensor.shape[0] != layout.local_batch_size:
301+
raise ValueError(f"Tensor batch dimension {tensor.shape[0]} does not match layout size {layout.local_batch_size}.")
302+
if layout.world_size == 1:
303+
return tensor.detach()
304+
305+
maximum_batch_size = max(layout.data_replica_batch_sizes)
306+
padded = tensor.detach()
307+
if padded.shape[0] < maximum_batch_size:
308+
padding = padded.new_zeros((maximum_batch_size - padded.shape[0], *padded.shape[1:]))
309+
padded = torch.cat((padded, padding), dim=0)
310+
311+
gathered = accelerator.gather(padded)
312+
expected_first_dimension = layout.world_size * maximum_batch_size
313+
if gathered.shape[0] != expected_first_dimension:
314+
raise RuntimeError(
315+
"Distributed tensor gather returned an unexpected first dimension: "
316+
f"expected {expected_first_dimension}, got {gathered.shape[0]}."
317+
)
318+
gathered = gathered.reshape(layout.world_size, maximum_batch_size, *padded.shape[1:])
319+
rank_tensors = []
320+
for world_rank in range(layout.world_size):
321+
replica_rank = world_rank // layout.model_replica_size
322+
batch_size = layout.data_replica_batch_sizes[replica_rank]
323+
rank_tensors.append(gathered[world_rank, :batch_size])
324+
return torch.cat(rank_tensors, dim=0)
325+
326+
327+
def gather_sample_weighted_scalar(value: torch.Tensor, local_batch_size: int, accelerator) -> torch.Tensor:
328+
"""Return a sample-weighted world mean from one fixed-shape contribution per rank."""
329+
local_batch_size = _normalize_parallel_size(local_batch_size, "local_batch_size")
330+
if local_batch_size < 1:
331+
raise ValueError("local_batch_size must be greater than 0.")
332+
if value.numel() != 1:
333+
raise ValueError("Sample-weighted scalar gather requires a scalar tensor.")
334+
335+
value = value.detach().float().reshape(())
336+
world_size = _normalize_parallel_size(getattr(accelerator, "num_processes", 1), "num_processes")
337+
if world_size == 1:
338+
return value
339+
340+
local_count = value.new_tensor(float(local_batch_size))
341+
contribution = torch.stack((value * local_count, local_count))
342+
gathered = accelerator.gather(contribution).reshape(-1, 2)
343+
if gathered.shape[0] != world_size:
344+
raise RuntimeError(
345+
f"Distributed loss gather returned {gathered.shape[0]} contributions for world size {world_size}."
346+
)
347+
totals = gathered.sum(dim=0)
348+
return totals[0] / totals[1]
349+
350+
222351
class ContextParallelBatchSynchronizer:
223352
"""
224353
Caches context-parallel information for efficient batch synchronization.

simpletuner/helpers/distillation/anyflow/distiller.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
from safetensors.torch import load_file, save_file
1111

1212
from simpletuner.helpers.data_backend.dataset_types import DatasetType
13-
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import get_model_replica_data_info
13+
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import (
14+
gather_variable_batch_tensor,
15+
resolve_distributed_batch_layout,
16+
)
1417
from simpletuner.helpers.distillation.anyflow.scheduler import AnyFlowValidationScheduler
1518
from simpletuner.helpers.distillation.common import DistillationBase
1619
from simpletuner.helpers.distillation.registry import DistillationRegistry
@@ -585,20 +588,17 @@ def _prepare_meanflow_pair(self, prepared_batch: Dict[str, Any], model) -> tuple
585588
r_base = torch.minimum(first, second)
586589

587590
accelerator = getattr(model, "accelerator", getattr(self.teacher_model, "accelerator", None))
588-
process_index = int(getattr(accelerator, "process_index", 0) or 0)
589-
num_processes = int(getattr(accelerator, "num_processes", 1) or 1)
590-
data_parallel_enabled, data_rank, _, _, data_parallel_size = get_model_replica_data_info(accelerator)
591-
if data_parallel_enabled:
592-
process_index = data_rank
593-
num_processes = data_parallel_size
594-
global_batch_size = batch_size * num_processes
595-
global_indices = process_index * batch_size + torch.arange(batch_size, device=device)
591+
batch_layout = resolve_distributed_batch_layout(accelerator, batch_size)
592+
self._distributed_batch_accelerator = accelerator
593+
self._distributed_batch_layout = batch_layout
594+
global_batch_size = batch_layout.global_batch_size
595+
global_indices = batch_layout.local_batch_offset + torch.arange(batch_size, device=device)
596596
diffusion_count = round(float(self.config["diffusion_ratio"]) * global_batch_size)
597597
consistency_count = round(float(self.config["consistency_ratio"]) * global_batch_size)
598598
effective_diffusion_count = min(diffusion_count, global_batch_size)
599599
effective_consistency_count = min(consistency_count, global_batch_size - effective_diffusion_count)
600600
arbitrary_count = global_batch_size - effective_diffusion_count - effective_consistency_count
601-
if process_index == 0 and not getattr(self, "_meanflow_branch_mix_logged", False):
601+
if batch_layout.data_rank == 0 and not getattr(self, "_meanflow_branch_mix_logged", False):
602602
self.logger.info(
603603
"AnyFlow interval mixture at global batch %d: diffusion=%d, consistency=%d, arbitrary=%d.",
604604
global_batch_size,
@@ -1253,13 +1253,16 @@ def _clear_prediction_buffers(output: Dict[str, Any]) -> None:
12531253
if isinstance(hidden_states_buffer, dict):
12541254
hidden_states_buffer.clear()
12551255

1256-
@staticmethod
1257-
def _gather_detached(tensor: torch.Tensor) -> torch.Tensor:
1258-
if not torch.distributed.is_available() or not torch.distributed.is_initialized():
1259-
return tensor.detach()
1260-
gathered = [torch.empty_like(tensor) for _ in range(torch.distributed.get_world_size())]
1261-
torch.distributed.all_gather(gathered, tensor.detach())
1262-
return torch.cat(gathered, dim=0)
1256+
def _gather_detached(self, tensor: torch.Tensor) -> torch.Tensor:
1257+
accelerator = getattr(
1258+
self,
1259+
"_distributed_batch_accelerator",
1260+
getattr(self.teacher_model, "accelerator", None),
1261+
)
1262+
layout = getattr(self, "_distributed_batch_layout", None)
1263+
if layout is None or layout.local_batch_size != tensor.shape[0]:
1264+
layout = resolve_distributed_batch_layout(accelerator, tensor.shape[0])
1265+
return gather_variable_batch_tensor(tensor, accelerator, layout)
12631266

12641267
def _discriminator_state_dict(self) -> Dict[str, torch.Tensor]:
12651268
adapter_name = str(self._discriminator_adapter_name)

simpletuner/helpers/models/common.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from simpletuner.diff2flow import DiffusionToFlowBridge
3535
from simpletuner.helpers.assistant_lora import build_adapter_stack, set_adapter_stack
36+
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import resolve_distributed_batch_layout
3637
from simpletuner.helpers.models.foundation_mixins import (
3738
AudioTransformMixin,
3839
PipelineSupportMixin,
@@ -4714,31 +4715,30 @@ def sample_flow_sigmas(self, batch: dict, state: dict) -> tuple[torch.Tensor, to
47144715
timesteps = base_timesteps.expand(bsz)
47154716
else:
47164717
if timestep_mode == "round-robin":
4717-
world_size = max(1, int(getattr(self.accelerator, "num_processes", 1) or 1))
4718-
process_index = int(getattr(self.accelerator, "process_index", 0) or 0)
4719-
if base_timesteps.numel() < bsz * world_size and not getattr(
4718+
batch_layout = resolve_distributed_batch_layout(self.accelerator, bsz)
4719+
global_batch_size = batch_layout.global_batch_size
4720+
if base_timesteps.numel() < global_batch_size and not getattr(
47204721
self, "_flow_custom_timestep_overlap_warning_logged", False
47214722
):
47224723
logger.warning(
47234724
"flow_timesteps_mode=round-robin has %s custom timestep(s), but the global batch "
47244725
"consumes %s sample(s) per step. Different ranks may reuse timestep entries on the same step; "
4725-
"provide at least train_batch_size * num_processes values for non-overlapping per-step "
4726-
"coverage.",
4726+
"provide at least the global per-step sample count for non-overlapping coverage.",
47274727
base_timesteps.numel(),
4728-
bsz * world_size,
4728+
global_batch_size,
47294729
)
47304730
self._flow_custom_timestep_overlap_warning_logged = True
47314731
if not hasattr(self, "_flow_custom_timestep_cursor"):
47324732
resume_step = getattr(self, "_flow_custom_timestep_resume_step", None)
47334733
completed_steps = int(resume_step if resume_step is not None else state.get("global_step", 0) or 0)
4734-
self._flow_custom_timestep_cursor = (
4735-
completed_steps * bsz * world_size + process_index * bsz
4736-
) % base_timesteps.numel()
4734+
self._flow_custom_timestep_cursor = (completed_steps * global_batch_size) % base_timesteps.numel()
47374735
if resume_step is not None:
47384736
delattr(self, "_flow_custom_timestep_resume_step")
47394737
cursor = int(getattr(self, "_flow_custom_timestep_cursor", 0))
4740-
indices = (torch.arange(bsz, device=self.accelerator.device) + cursor) % base_timesteps.numel()
4741-
self._flow_custom_timestep_cursor = (cursor + bsz * world_size) % base_timesteps.numel()
4738+
indices = (
4739+
torch.arange(bsz, device=self.accelerator.device) + cursor + batch_layout.local_batch_offset
4740+
) % base_timesteps.numel()
4741+
self._flow_custom_timestep_cursor = (cursor + global_batch_size) % base_timesteps.numel()
47424742
else:
47434743
indices = torch.randint(0, base_timesteps.numel(), (bsz,), device=self.accelerator.device)
47444744
sigmas = base_sigmas[indices]

simpletuner/helpers/training/trainer.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
run_distillation_cache_generation,
4949
)
5050
from simpletuner.helpers.data_backend.runtime import random_dataloader_iterator
51-
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import ContextParallelBatchSynchronizer
51+
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import (
52+
ContextParallelBatchSynchronizer,
53+
gather_sample_weighted_scalar,
54+
)
5255
from simpletuner.helpers.data_backend.runtime.schedule import normalize_start_epoch, normalize_start_step
5356
from simpletuner.helpers.distillation.composition import resolve_configured_distiller_requirement_profile
5457
from simpletuner.helpers.distillation.requirements import EMPTY_PROFILE, DistillerRequirementProfile
@@ -7031,11 +7034,11 @@ def train(self):
70317034
f"filepaths={batch_filepaths}, loss_logs={loss_logs_context})."
70327035
)
70337036

7034-
# Gather the losses across all processes for logging (if using distributed training)
7035-
avg_loss = self.accelerator.gather(loss.repeat(int(bsz))).mean()
7037+
# Keep metric collectives fixed-shape when ranks use different dataset batch sizes.
7038+
avg_loss = gather_sample_weighted_scalar(loss, int(bsz), self.accelerator)
70367039
self.train_loss += avg_loss.item() / self.config.gradient_accumulation_steps
70377040
if aux_loss_logs is not None:
7038-
avg_diffusion_loss = self.accelerator.gather(diffusion_loss.repeat(int(bsz))).mean()
7041+
avg_diffusion_loss = gather_sample_weighted_scalar(diffusion_loss, int(bsz), self.accelerator)
70397042
self.train_diffusion_loss += avg_diffusion_loss.item() / self.config.gradient_accumulation_steps
70407043
# Backpropagate
70417044
self.grad_norm = None

tests/helpers/distillation/test_anyflow_distiller.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from safetensors.torch import save_file
1212

1313
import tests.test_stubs # noqa: F401
14+
from simpletuner.helpers.data_backend.runtime.context_parallel_sync import DistributedBatchLayout
1415
from simpletuner.helpers.distillation.anyflow.distiller import AnyFlowDistiller
1516
from simpletuner.helpers.distillation.anyflow.scheduler import AnyFlowValidationScheduler
1617
from simpletuner.helpers.distillation.factory import DistillerFactory
@@ -529,8 +530,17 @@ def test_meanflow_branch_assignment_uses_data_replica_rank_with_context_parallel
529530
with (
530531
patch("torch.rand", side_effect=draws),
531532
patch(
532-
"simpletuner.helpers.distillation.anyflow.distiller.get_model_replica_data_info",
533-
return_value=(True, 1, 0, 2, 4),
533+
"simpletuner.helpers.distillation.anyflow.distiller.resolve_distributed_batch_layout",
534+
return_value=DistributedBatchLayout(
535+
local_batch_size=2,
536+
global_batch_size=8,
537+
local_batch_offset=2,
538+
data_rank=1,
539+
data_parallel_size=4,
540+
model_replica_size=2,
541+
world_size=8,
542+
data_replica_batch_sizes=(2, 2, 2, 2),
543+
),
534544
),
535545
):
536546
batch = _prepared_batch()
@@ -539,6 +549,53 @@ def test_meanflow_branch_assignment_uses_data_replica_rank_with_context_parallel
539549
self.assertTrue(torch.equal(batch["anyflow_diffusion_mask"], torch.tensor([True, True])))
540550
self.assertTrue(torch.equal(batch["anyflow_consistency_mask"], torch.tensor([False, False])))
541551

552+
def test_meanflow_branch_assignment_uses_rank_varying_batch_offsets(self):
553+
model = _FlowModel()
554+
distiller = AnyFlowDistiller(
555+
teacher_model=model,
556+
noise_scheduler=None,
557+
config={
558+
"model_type": "lora",
559+
"diffusion_ratio": 0.5,
560+
"consistency_ratio": 0.25,
561+
},
562+
)
563+
latents = torch.zeros(3, 1, 2, 2)
564+
noise = torch.ones_like(latents)
565+
sigmas = torch.tensor([0.9, 0.6, 0.3]).view(3, 1, 1, 1)
566+
batch = {
567+
"latents": latents,
568+
"noise": noise,
569+
"input_noise": noise.clone(),
570+
"sigmas": sigmas,
571+
"timesteps": torch.tensor([900.0, 600.0, 300.0]),
572+
"noisy_latents": (1 - sigmas) * latents + sigmas * noise,
573+
}
574+
draws = [torch.tensor([0.8, 0.7, 0.6]), torch.tensor([0.2, 0.4, 0.3])]
575+
layout = DistributedBatchLayout(
576+
local_batch_size=3,
577+
global_batch_size=4,
578+
local_batch_offset=1,
579+
data_rank=1,
580+
data_parallel_size=2,
581+
model_replica_size=1,
582+
world_size=2,
583+
data_replica_batch_sizes=(1, 3),
584+
)
585+
586+
with (
587+
patch("torch.rand", side_effect=draws),
588+
patch(
589+
"simpletuner.helpers.distillation.anyflow.distiller.resolve_distributed_batch_layout",
590+
return_value=layout,
591+
),
592+
):
593+
distiller._prepare_meanflow_pair(batch, model)
594+
595+
self.assertTrue(torch.equal(batch["anyflow_diffusion_mask"], torch.tensor([True, False, False])))
596+
self.assertTrue(torch.equal(batch["anyflow_consistency_mask"], torch.tensor([False, True, False])))
597+
self.assertTrue(torch.equal(batch["anyflow_arbitrary_mask"], torch.tensor([False, False, True])))
598+
542599
def test_meanflow_warns_once_when_global_batch_omits_enabled_branch(self):
543600
model = _FlowModel()
544601
distiller = AnyFlowDistiller(

0 commit comments

Comments
 (0)