Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.

Commit 22b9f84

Browse files
authored
feat: Multiresolution dataloader support (#88)
* feat: Multiresolution dataloader support Signed-off-by: Pranav Prashant Thombre <pthombre@nvidia.com> * Linting fixes Signed-off-by: Pranav Prashant Thombre <pthombre@nvidia.com> --------- Signed-off-by: Pranav Prashant Thombre <pthombre@nvidia.com>
1 parent a31128f commit 22b9f84

8 files changed

Lines changed: 2646 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .dataloader import (
16+
SequentialBucketSampler,
17+
build_multiresolution_dataloader,
18+
collate_fn_production,
19+
)
20+
from .multi_tier_bucketing import MultiTierBucketCalculator
21+
from .text_to_image_dataset import TextToImageDataset
22+
23+
24+
__all__ = [
25+
"TextToImageDataset",
26+
"MultiTierBucketCalculator",
27+
"SequentialBucketSampler",
28+
"build_multiresolution_dataloader",
29+
"collate_fn_production",
30+
]
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
import math
17+
from typing import Dict, Iterator, List, Optional, Tuple
18+
19+
import torch
20+
import torch.distributed as dist
21+
from torch.utils.data import DataLoader, Sampler
22+
23+
from dfm.src.automodel.datasets.multiresolutionDataloader.text_to_image_dataset import TextToImageDataset
24+
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
class SequentialBucketSampler(Sampler[List[int]]):
30+
"""
31+
Production-grade Sampler that:
32+
1. Supports Distributed Data Parallel (DDP) - splits data across GPUs
33+
2. Deterministic shuffling via torch.Generator (resumable training)
34+
3. Lazy batch generation (saves RAM compared to pre-computing all batches)
35+
4. Guarantees equal batch counts across all ranks (prevents DDP deadlocks)
36+
37+
- Processes all images in bucket A before moving to bucket B
38+
- Shuffles samples within each bucket (deterministically)
39+
- Drops incomplete batches at end of each bucket
40+
- Uses dynamic batch sizes based on resolution
41+
"""
42+
43+
def __init__(
44+
self,
45+
dataset: TextToImageDataset,
46+
base_batch_size: int = 32,
47+
base_resolution: Tuple[int, int] = (512, 512),
48+
drop_last: bool = True,
49+
shuffle_buckets: bool = True,
50+
shuffle_within_bucket: bool = True,
51+
dynamic_batch_size: bool = False,
52+
seed: int = 42,
53+
num_replicas: Optional[int] = None,
54+
rank: Optional[int] = None,
55+
):
56+
"""
57+
Args:
58+
dataset: TextToImageDataset
59+
base_batch_size: Batch size (fixed if dynamic_batch_size=False,
60+
or base for scaling if dynamic_batch_size=True)
61+
base_resolution: Reference resolution for batch size scaling
62+
(only used if dynamic_batch_size=True)
63+
drop_last: Drop incomplete batches
64+
shuffle_buckets: Shuffle bucket order
65+
shuffle_within_bucket: Shuffle samples within each bucket
66+
dynamic_batch_size: If True, scale batch size based on resolution.
67+
If False (default), use base_batch_size for all buckets.
68+
seed: Random seed for deterministic shuffling (resumable training)
69+
num_replicas: Number of distributed processes (auto-detected if None)
70+
rank: Rank of current process (auto-detected if None)
71+
"""
72+
self.dataset = dataset
73+
self.base_batch_size = base_batch_size
74+
self.base_resolution = base_resolution
75+
self.drop_last = drop_last
76+
self.shuffle_buckets = shuffle_buckets
77+
self.shuffle_within_bucket = shuffle_within_bucket
78+
self.dynamic_batch_size = dynamic_batch_size
79+
self.seed = seed
80+
self.epoch = 0
81+
82+
# Handle Distributed Training (DDP)
83+
if num_replicas is None:
84+
if dist.is_available() and dist.is_initialized():
85+
num_replicas = dist.get_world_size()
86+
else:
87+
num_replicas = 1
88+
if rank is None:
89+
if dist.is_available() and dist.is_initialized():
90+
rank = dist.get_rank()
91+
else:
92+
rank = 0
93+
94+
self.num_replicas = num_replicas
95+
self.rank = rank
96+
97+
self.bucket_keys = dataset.sorted_bucket_keys
98+
self.bucket_groups = dataset.bucket_groups
99+
self.calculator = dataset.calculator
100+
101+
# Pre-calculate total batches (same for all ranks)
102+
self._total_batches = self._calculate_total_batches()
103+
104+
logger.info("\nSequentialBucketSampler created:")
105+
logger.info(f" Total batches per rank: {self._total_batches}")
106+
logger.info(f" Dynamic batch size: {dynamic_batch_size}")
107+
logger.info(
108+
f" Base batch size: {base_batch_size}" + (f" @ {base_resolution}" if dynamic_batch_size else " (fixed)")
109+
)
110+
logger.info(f" DDP: rank {self.rank} of {self.num_replicas}")
111+
112+
def _get_batch_size(self, resolution: Tuple[int, int]) -> int:
113+
"""Get batch size for resolution (dynamic or fixed based on setting)."""
114+
if not self.dynamic_batch_size:
115+
return self.base_batch_size
116+
117+
return self.calculator.get_dynamic_batch_size(
118+
resolution,
119+
self.base_batch_size,
120+
self.base_resolution,
121+
)
122+
123+
def _calculate_total_batches(self) -> int:
124+
"""
125+
Calculate total batches ensuring ALL ranks get the same count.
126+
We pad each bucket to be divisible by (num_replicas * batch_size).
127+
"""
128+
count = 0
129+
for bucket_key in self.bucket_keys:
130+
total_indices = len(self.bucket_groups[bucket_key]["indices"])
131+
batch_size = self._get_batch_size(self.bucket_groups[bucket_key]["resolution"])
132+
133+
# Pad to make divisible by num_replicas first
134+
padded_total = math.ceil(total_indices / self.num_replicas) * self.num_replicas
135+
per_rank_indices = padded_total // self.num_replicas
136+
137+
if self.drop_last:
138+
count += per_rank_indices // batch_size
139+
else:
140+
count += (per_rank_indices + batch_size - 1) // batch_size
141+
142+
return count
143+
144+
def set_epoch(self, epoch: int):
145+
"""Crucial for reproducibility and different shuffles per epoch."""
146+
self.epoch = epoch
147+
148+
def __iter__(self) -> Iterator[List[int]]:
149+
# Deterministic generator - SAME seed across all ranks
150+
g = torch.Generator()
151+
g.manual_seed(self.seed + self.epoch)
152+
153+
# 1. Bucket Order Shuffling (deterministic, same across all ranks)
154+
current_bucket_keys = list(self.bucket_keys)
155+
if self.shuffle_buckets:
156+
perm = torch.randperm(len(current_bucket_keys), generator=g).tolist()
157+
current_bucket_keys = [current_bucket_keys[i] for i in perm]
158+
159+
# 2. Iterate Buckets
160+
for key in current_bucket_keys:
161+
bucket = self.bucket_groups[key]
162+
indices = bucket["indices"].copy()
163+
resolution = bucket["resolution"]
164+
batch_size = self._get_batch_size(resolution)
165+
166+
# 3. Deterministic Shuffle within bucket (same across all ranks)
167+
if self.shuffle_within_bucket:
168+
rand_indices = torch.randperm(len(indices), generator=g).tolist()
169+
indices = [indices[i] for i in rand_indices]
170+
171+
# 4. Pad indices to ensure equal distribution across ranks
172+
total_size = math.ceil(len(indices) / self.num_replicas) * self.num_replicas
173+
padding_size = total_size - len(indices)
174+
if padding_size > 0:
175+
# Pad by repeating indices from the beginning
176+
indices = indices + indices[:padding_size]
177+
178+
# 5. DDP Splitting: Subsample indices for this rank
179+
indices = indices[self.rank :: self.num_replicas]
180+
181+
# 6. Yield Batches (Lazy Evaluation)
182+
for i in range(0, len(indices), batch_size):
183+
batch = indices[i : i + batch_size]
184+
185+
if self.drop_last and len(batch) < batch_size:
186+
continue
187+
188+
if not batch:
189+
continue
190+
191+
yield batch
192+
193+
def __len__(self) -> int:
194+
return self._total_batches
195+
196+
def get_batch_info(self, batch_idx: int) -> Dict:
197+
"""Get information about a specific batch.
198+
199+
Note: With lazy evaluation, we don't pre-compute batches,
200+
so this returns bucket-level info for the estimated batch.
201+
"""
202+
# Estimate which bucket this batch belongs to
203+
running_count = 0
204+
for bucket_key in self.bucket_keys:
205+
bucket = self.bucket_groups[bucket_key]
206+
total_indices = len(bucket["indices"])
207+
batch_size = self._get_batch_size(bucket["resolution"])
208+
209+
padded_total = math.ceil(total_indices / self.num_replicas) * self.num_replicas
210+
per_rank_indices = padded_total // self.num_replicas
211+
212+
if self.drop_last:
213+
num_batches = per_rank_indices // batch_size
214+
else:
215+
num_batches = (per_rank_indices + batch_size - 1) // batch_size
216+
217+
if batch_idx < running_count + num_batches:
218+
return {
219+
"bucket_key": bucket_key,
220+
"resolution": bucket["resolution"],
221+
"batch_size": batch_size,
222+
"aspect_name": bucket["aspect_name"],
223+
}
224+
running_count += num_batches
225+
226+
return {}
227+
228+
229+
def collate_fn_production(batch: List[Dict]) -> Dict:
230+
"""Production collate function with verification."""
231+
# Verify all samples have same resolution
232+
resolutions = [tuple(item["crop_resolution"].tolist()) for item in batch]
233+
assert len(set(resolutions)) == 1, f"Mixed resolutions in batch: {set(resolutions)}"
234+
235+
# Stack tensors
236+
latents = torch.stack([item["latent"] for item in batch])
237+
crop_resolutions = torch.stack([item["crop_resolution"] for item in batch])
238+
original_resolutions = torch.stack([item["original_resolution"] for item in batch])
239+
crop_offsets = torch.stack([item["crop_offset"] for item in batch])
240+
241+
# Collect metadata
242+
prompts = [item["prompt"] for item in batch]
243+
image_paths = [item["image_path"] for item in batch]
244+
bucket_ids = [item["bucket_id"] for item in batch]
245+
aspect_ratios = [item["aspect_ratio"] for item in batch]
246+
247+
output = {
248+
"latent": latents,
249+
"crop_resolution": crop_resolutions,
250+
"original_resolution": original_resolutions,
251+
"crop_offset": crop_offsets,
252+
"prompt": prompts,
253+
"image_path": image_paths,
254+
"bucket_id": bucket_ids,
255+
"aspect_ratio": aspect_ratios,
256+
}
257+
258+
# Handle text encodings
259+
if "clip_hidden" in batch[0]:
260+
output["clip_hidden"] = torch.stack([item["clip_hidden"] for item in batch])
261+
output["clip_pooled"] = torch.stack([item["clip_pooled"] for item in batch])
262+
output["t5_hidden"] = torch.stack([item["t5_hidden"] for item in batch])
263+
else:
264+
output["clip_tokens"] = torch.stack([item["clip_tokens"] for item in batch])
265+
output["t5_tokens"] = torch.stack([item["t5_tokens"] for item in batch])
266+
267+
return output
268+
269+
270+
def build_multiresolution_dataloader(
271+
*,
272+
dataset: TextToImageDataset,
273+
base_batch_size: int,
274+
dp_rank: int,
275+
dp_world_size: int,
276+
base_resolution: Tuple[int, int] = (512, 512),
277+
drop_last: bool = True,
278+
shuffle: bool = True,
279+
dynamic_batch_size: bool = False,
280+
num_workers: int = 4,
281+
pin_memory: bool = True,
282+
prefetch_factor: int = 2,
283+
) -> Tuple[DataLoader, SequentialBucketSampler]:
284+
"""
285+
Build production dataloader with sequential bucket iteration and distributed training support.
286+
287+
Args:
288+
dataset: TextToImageDataset instance
289+
base_batch_size: Batch size (fixed, or base for scaling if dynamic_batch_size=True)
290+
dp_rank: Rank of current process in data parallel group
291+
dp_world_size: Total number of processes in data parallel group
292+
base_resolution: Reference resolution (only used if dynamic_batch_size=True)
293+
drop_last: Drop incomplete batches
294+
shuffle: Shuffle bucket order and samples within buckets each epoch
295+
dynamic_batch_size: If True, scale batch size based on resolution.
296+
If False (default), use base_batch_size for all buckets.
297+
num_workers: Number of data loading workers
298+
pin_memory: Pin memory for faster GPU transfer
299+
prefetch_factor: How many batches to prefetch per worker
300+
301+
Returns:
302+
Tuple of (DataLoader, SequentialBucketSampler) for production training
303+
"""
304+
sampler = SequentialBucketSampler(
305+
dataset,
306+
base_batch_size=base_batch_size,
307+
base_resolution=base_resolution,
308+
drop_last=drop_last,
309+
shuffle_buckets=shuffle,
310+
shuffle_within_bucket=shuffle,
311+
dynamic_batch_size=dynamic_batch_size,
312+
num_replicas=dp_world_size,
313+
rank=dp_rank,
314+
)
315+
316+
dataloader = DataLoader(
317+
dataset,
318+
batch_sampler=sampler,
319+
collate_fn=collate_fn_production,
320+
num_workers=num_workers,
321+
pin_memory=pin_memory,
322+
prefetch_factor=prefetch_factor if num_workers > 0 else None,
323+
persistent_workers=num_workers > 0, # Keep workers alive between epochs
324+
)
325+
326+
return dataloader, sampler

0 commit comments

Comments
 (0)