File size: 11,943 Bytes
5ed07ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""
Dynamic batch sampler for VoxCPM training.

Provides :class:`DynamicBatchSampler`, a :class:`torch.utils.data.Sampler`
that groups samples so the total token count per batch stays within a
configurable budget.  For distributed training it plans *global* steps
first (assigning samples to ranks greedily to balance cost) and then
partitions per rank.

Extracted from ``svs_data.py`` to keep batch-planning infrastructure
separate from dataset / collation logic.
"""

import random
from typing import List

import torch
import torch.distributed as dist


class DynamicBatchSampler(torch.utils.data.Sampler):
    """
    Dynamic batch sampler that groups samples based on their total_length.

    Instead of using a fixed batch_size, this sampler creates batches where
    the total number of tokens (sum of all sample lengths) doesn't exceed
    max_batch_tokens. This helps:
    1. Avoid OOM errors on long sequences
    2. Better GPU utilization by packing similar-length samples together

    For distributed training, batches are partitioned across GPUs using
    rank and world_size parameters.

    Implementation note:
        In distributed training this sampler acts as a global-step planner.
        It first constructs a *distributed step* as ``world_size`` local
        micro-batches together, then assigns samples greedily so the costs of
        those local micro-batches stay close. Only after the global steps are
        planned are they shuffled as whole units. This avoids the common
        failure mode where each rank independently gets a valid local batch
        but the same training step is badly imbalanced across ranks.

    Args:
        lengths: Pre-computed list of sequence lengths for each sample
        max_batch_tokens: Maximum total tokens per batch
        max_batch_size: Maximum number of samples per batch (optional cap)
        shuffle: Whether to shuffle samples each epoch
        drop_last: Whether to drop the last incomplete batch
        rank: Process rank for distributed training (0 for single GPU)
        world_size: Total number of processes (1 for single GPU)
        seed: Random seed for reproducible shuffling across processes
    """

    def __init__(
        self,
        lengths: List[int],
        max_batch_tokens: int,
        max_batch_size: int = 64,
        shuffle: bool = True,
        drop_last: bool = False,
        rank: int = 0,
        world_size: int = 1,
        seed: int = 42,
    ):
        self.lengths = lengths
        self.max_batch_tokens = max_batch_tokens
        self.max_batch_size = max_batch_size
        self.shuffle = shuffle
        self.drop_last = drop_last
        self.rank = rank
        self.world_size = world_size
        self.seed = seed
        self.epoch = 0
        self.batches: List[List[int]] | None = None
        self._start_batch = 0

        self._create_batches()

    def set_start_batch(self, n: int) -> None:
        """Skip the first ``n`` batches on the next ``__iter__`` (consumed once).

        Used by the resume path to fast-forward the dataloader to a saved
        position without materializing (decoding) the skipped batches. The
        offset is reset to 0 once an iterator is created, so subsequent epochs
        start from the beginning.
        """
        if self.batches is not None and n > len(self.batches):
            raise RuntimeError(
                f"start_batch={n} exceeds available batches ({len(self.batches)}) "
                f"for epoch={self.epoch}."
            )
        self._start_batch = max(0, int(n))

    def set_epoch(self, epoch: int):
        """Set the epoch for reproducible shuffling.

        Skips replanning when the requested epoch matches the currently
        planned one — resume paths call ``set_epoch(data_epoch)`` right
        after construction, which would otherwise redo an O(N·world_size²)
        pure-Python plan for nothing.
        """
        if epoch == self.epoch and self.batches is not None:
            return
        self.epoch = epoch
        self._create_batches()

    def _batch_max_len(self, batch: List[int]) -> int:
        if not batch:
            return 0
        return max(self.lengths[idx] for idx in batch)

    def _batch_cost(self, batch: List[int]) -> int:
        if not batch:
            return 0
        # Attention/activation cost tracks sequence length worse than a simple
        # token budget. Use B * L^2 as a better proxy so that the same
        # distributed step sees similarly expensive batches on every rank.
        max_len = self._batch_max_len(batch)
        return len(batch) * max_len * max_len

    @staticmethod
    def _batch_tokens_from_stats(count: int, max_len: int) -> int:
        if count <= 0 or max_len <= 0:
            return 0
        return count * max_len

    @staticmethod
    def _batch_cost_from_stats(count: int, max_len: int) -> int:
        if count <= 0 or max_len <= 0:
            return 0
        return count * max_len * max_len

    def _order_indices(self, rng: random.Random) -> List[int]:
        indices = list(range(len(self.lengths)))
        if not self.shuffle:
            return indices

        # Sort by length first, then shuffle within relatively small
        # sortish buckets. Smaller buckets keep lengths tighter than the
        # previous coarse bucketing, which reduces bad local orderings that
        # later force imbalanced distributed steps.
        sorted_indices = sorted(indices, key=lambda i: self.lengths[i])
        chunk_size = max(64, self.world_size * max(1, self.max_batch_size) * 8)
        chunk_size = min(chunk_size, 512)
        chunks = [
            sorted_indices[i:i + chunk_size]
            for i in range(0, len(sorted_indices), chunk_size)
        ]
        for chunk in chunks:
            rng.shuffle(chunk)
        rng.shuffle(chunks)
        return [idx for chunk in chunks for idx in chunk]

    def _candidate_rank_key(
        self,
        *,
        rank: int,
        sample_len: int,
        batch_sizes: List[int],
        batch_max_lens: List[int],
        step_idx: int,
    ):
        count = batch_sizes[rank]
        max_len = batch_max_lens[rank]
        new_count = count + 1
        new_max_len = max(max_len, sample_len)
        projected_tokens = new_count * new_max_len

        # Keep singleton overflow behaviour compatible with the previous
        # implementation: if one sample alone exceeds the budget, still allow
        # it as a batch of size 1 so the sample is not dropped.
        if count > 0 and (
            new_count > self.max_batch_size or projected_tokens > self.max_batch_tokens
        ):
            return None

        projected_cost = self._batch_cost_from_stats(new_count, new_max_len)

        simulated_costs = [
            self._batch_cost_from_stats(existing_count, existing_max_len)
            for existing_count, existing_max_len in zip(batch_sizes, batch_max_lens)
        ]
        simulated_costs[rank] = projected_cost
        cost_spread = max(simulated_costs) - min(simulated_costs)
        max_cost = max(simulated_costs)
        total_cost = sum(simulated_costs)

        simulated_tokens = [
            self._batch_tokens_from_stats(existing_count, existing_max_len)
            for existing_count, existing_max_len in zip(batch_sizes, batch_max_lens)
        ]
        simulated_tokens[rank] = projected_tokens
        token_spread = max(simulated_tokens) - min(simulated_tokens)

        # Rotate tie-breaking so the same rank is not always preferred when
        # costs are equal.
        rotated_rank = (rank - step_idx) % max(1, self.world_size)
        return (
            cost_spread,
            max_cost,
            total_cost,
            token_spread,
            projected_cost,
            projected_tokens,
            new_count,
            rotated_rank,
            rank,
        )

    def _plan_global_steps(
        self,
        ordered_indices: List[int],
        rng: random.Random,
    ) -> List[List[List[int]]]:
        if not ordered_indices:
            return []

        global_steps: List[List[List[int]]] = []
        next_index = 0
        step_idx = 0

        while next_index < len(ordered_indices):
            step_batches = [[] for _ in range(self.world_size)]
            batch_sizes = [0] * self.world_size
            batch_max_lens = [0] * self.world_size
            assigned_any = False

            while next_index < len(ordered_indices):
                sample_idx = ordered_indices[next_index]
                sample_len = self.lengths[sample_idx]

                candidates = []
                for rank in range(self.world_size):
                    candidate_key = self._candidate_rank_key(
                        rank=rank,
                        sample_len=sample_len,
                        batch_sizes=batch_sizes,
                        batch_max_lens=batch_max_lens,
                        step_idx=step_idx,
                    )
                    if candidate_key is not None:
                        candidates.append(candidate_key)

                if not candidates:
                    break

                chosen_rank = min(candidates)[-1]
                step_batches[chosen_rank].append(sample_idx)
                batch_sizes[chosen_rank] += 1
                batch_max_lens[chosen_rank] = max(batch_max_lens[chosen_rank], sample_len)
                assigned_any = True
                next_index += 1

            if not assigned_any:
                # Defensive fallback: this should only happen if the dataset is
                # empty, but avoid an infinite loop if constraints are invalid.
                break

            non_empty_ranks = sum(1 for batch in step_batches if batch)
            if self.drop_last and non_empty_ranks < self.world_size:
                break

            global_steps.append(step_batches)
            step_idx += 1

        if self.shuffle:
            rng.shuffle(global_steps)

        return global_steps

    def _create_batches(self):
        """Create per-rank batches via global distributed-step planning.

        The planner is fully deterministic given ``(seed, epoch)`` and its
        output is identical on every rank — so in distributed runs we let
        global rank 0 do the O(N·world_size²) Python plan once and
        broadcast the result. Every rank then extracts its own slice via
        ``self.rank`` (which is the data-parallel rank, not the global
        one), so correctness under FULL_SHARD and HYBRID_SHARD is
        preserved: replicas that share a dp_rank simply pick the same
        slice.
        """
        use_broadcast = dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1

        if use_broadcast:
            if dist.get_rank() == 0:
                rng = random.Random(self.seed + self.epoch)
                ordered_indices = self._order_indices(rng)
                global_steps = self._plan_global_steps(ordered_indices, rng)
                payload = [global_steps]
            else:
                payload = [None]
            dist.broadcast_object_list(payload, src=0)
            global_steps = payload[0]
        else:
            rng = random.Random(self.seed + self.epoch)
            ordered_indices = self._order_indices(rng)
            global_steps = self._plan_global_steps(ordered_indices, rng)

        self.batches = [
            step[self.rank]
            for step in global_steps
            if self.rank < len(step) and step[self.rank]
        ]

    def __iter__(self):
        # Note: batches are already shuffled in _create_batches() with deterministic seed.
        # _start_batch (set by set_start_batch for resume) is consumed once, then reset.
        start = self._start_batch
        self._start_batch = 0
        for batch in self.batches[start:]:
            yield batch

    def __len__(self):
        return len(self.batches)