Kernels
wyldecat Claude Opus 4.6 commited on
Commit
2816b64
·
1 Parent(s): 8ca2492

Optimize pipeline: batched update, zero-copy scatter, prelaunch gather [skip-build]

Browse files

- Batched update_params with _foreach_mul_/_foreach_add_
- Zero-copy scatter directly into param views
- Deferred QK clip buffer allocation
- Buffer packing optimization
- prelaunch_first_gather() for overlapping first chunk A2A with compute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. torch-ext/optimizer/pipeline.py +158 -80
torch-ext/optimizer/pipeline.py CHANGED
@@ -6,8 +6,8 @@ import torch.distributed as dist
6
  from torch.distributed.tensor import DTensor
7
  from torch.profiler import record_function
8
 
9
- from .core import _muon_state, adjust_lr_for_muon, update_p
10
- from .newton_schulz import COMM_DTYPE, _zeropower_via_newtonschulz5
11
  from .qk_clip import compute_scales
12
 
13
  logger = logging.getLogger(__name__)
@@ -45,26 +45,33 @@ def _launch_gather(
45
  else:
46
  gathered_grads[id(p)] = None
47
 
48
- # Build send buffer
49
- per_dst: list[list[torch.Tensor]] = [[] for _ in range(num_ranks)]
50
  send_counts = [0] * num_ranks
51
-
52
  for p in params:
53
  state = param_to_state[id(p)]
54
- dst = state.worker_rank
55
- assert dst < num_ranks
56
- shard_elems = state.rank_numels[rank]
57
- g = p.grad
58
- g = g.to_local().to(COMM_DTYPE).contiguous()
59
- assert g.numel() == shard_elems
60
- per_dst[dst].append(g.view(-1))
61
- send_counts[dst] += shard_elems
62
-
63
- assert any(
64
- len(v) > 0 for v in
65
- per_dst), "At least one destination rank must receive a sharded tensor"
66
- per_dst_flat = [t for dst in per_dst for t in dst]
67
- send_buf = torch.cat(per_dst_flat, dim=0)
 
 
 
 
 
 
 
 
68
 
69
  # Build recv buffer
70
  recv_counts = [0] * num_ranks
@@ -120,7 +127,8 @@ def _complete_gather(
120
 
121
  shard_view = gathered_grads[id(p)][indices]
122
  n = shard_view.numel()
123
- assert n > 0
 
124
 
125
  sg = recv_buf.narrow(0, off + inner_off, n)
126
  sg = sg.reshape(shard_view.shape)
@@ -143,7 +151,7 @@ def _compute_ns(
143
  """
144
  computed_us: dict[int, torch.Tensor | None] = {}
145
  for p in owned_params:
146
- u = _zeropower_via_newtonschulz5(gathered_grads[id(p)], ns_steps)
147
  gathered_grads[id(p)] = None # free gathered grad
148
  computed_us[id(p)] = u
149
  return computed_us
@@ -163,46 +171,47 @@ def _launch_scatter(
163
  Returns:
164
  work: Async operation handle.
165
  recv_buf: Flat receive buffer (needed by ``_complete_scatter``).
166
- scattered_us: ``{id(p): empty_local_tensor}`` for all params.
 
167
  recv_counts: Per-source-rank element counts.
168
  """
169
- # Allocate scattered-u buffers
 
 
 
170
  scattered_us: dict[int, torch.Tensor] = {}
171
  for p in params:
172
- scattered_us[id(p)] = torch.empty_like(p.to_local(), dtype=COMM_DTYPE)
 
 
173
 
174
- # Build send buffer (from computed_us on owner ranks)
175
- per_dst: list[list[torch.Tensor]] = [[] for _ in range(num_ranks)]
176
  send_counts = [0] * num_ranks
177
-
178
  if owned_params:
179
  for p in owned_params:
180
  state = param_to_state[id(p)]
181
-
182
- assert computed_us[id(p)] is not None
183
- u_full = computed_us[id(p)].to(COMM_DTYPE).contiguous()
184
-
185
- total_sent = 0
186
  for dst_rank in range(num_ranks):
187
- indices = state.rank_indices[dst_rank]
188
- su = u_full[indices].flatten()
189
-
190
- n = su.numel()
191
- assert n > 0
192
 
193
- per_dst[dst_rank].append(su)
194
- send_counts[dst_rank] += n
195
- total_sent += n
196
-
197
- assert total_sent == u_full.numel()
198
-
199
- lengths = [len(v) for v in per_dst]
200
- if all(l > 0 for l in lengths):
201
- assert all(
202
- l == lengths[0] for l in lengths
203
- ), "All destination ranks must have the same number of sharded tensor"
204
- per_dst_flat = [t for dst in per_dst for t in dst]
205
- send_buf = torch.cat(per_dst_flat, dim=0)
 
 
 
 
 
206
  else:
207
  send_buf = torch.empty(0, dtype=COMM_DTYPE, device="cuda")
208
 
@@ -218,7 +227,6 @@ def _launch_scatter(
218
  recv_counts[src] = total
219
 
220
  recv_total = sum(recv_counts)
221
- assert recv_total > 0
222
  recv_buf = torch.empty(recv_total, dtype=COMM_DTYPE, device="cuda")
223
 
224
  # Launch async all-to-all
@@ -242,7 +250,13 @@ def _complete_scatter(
242
  rank: int,
243
  scattered_us: dict[int, torch.Tensor],
244
  ) -> None:
245
- """Copy recv buffer into scattered_us (in-place)."""
 
 
 
 
 
 
246
  off = 0
247
  for src in range(len(recv_counts)):
248
  block = recv_counts[src]
@@ -255,11 +269,11 @@ def _complete_scatter(
255
  if state.worker_rank != src:
256
  continue
257
  n = state.rank_numels[rank]
258
- assert n > 0
 
259
 
260
- flat_local = recv_buf.narrow(0, off + inner_off,
261
- n).view_as(p.to_local())
262
- scattered_us[id(p)].copy_(flat_local)
263
 
264
  inner_off += n
265
 
@@ -275,23 +289,40 @@ def _update_params(
275
  lr: float,
276
  weight_decay: float,
277
  ) -> None:
278
- """Apply weight decay, Muon update, and optional QK clipping."""
279
- for p in params:
280
- state = param_to_state[id(p)]
281
- u_dtensor = DTensor.from_local(
282
- scattered_us[id(p)],
283
- placements=p.placements,
284
- device_mesh=p.device_mesh,
285
- )
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  adjusted_lr = adjust_lr_for_muon(lr, p.shape)
288
- update_p(p, u_dtensor, lr, adjusted_lr, weight_decay)
 
 
 
289
 
290
- # QK clipping – applied directly on the local tensor to
291
- # avoid DTensor sharding-propagation issues with _StridedShard.
292
- scales_full = compute_scales(
293
- p,
294
- state.qk_clip_state) if state.qk_clip_state is not None else None
 
 
 
 
 
295
  if scales_full is not None:
296
  ratio = p.shape[0] // scales_full.shape[0]
297
  idx0 = state.rank_indices[rank][0]
@@ -304,6 +335,45 @@ def _update_params(
304
  p._local_tensor.mul_(row_scales.view(-1, 1))
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  # ======================================================================
308
  # Main generator – thin orchestrator that wires stages together.
309
  # ======================================================================
@@ -318,6 +388,7 @@ def muon_chunk_pipeline(
318
  lr: float,
319
  weight_decay: float,
320
  none_grad: bool,
 
321
  ) -> Generator[None, None, None]:
322
  """Process one chunk of parameters through the full Muon pipeline.
323
 
@@ -334,9 +405,12 @@ def muon_chunk_pipeline(
334
  runs concurrently on the NCCL stream — no separate ``comm_stream``
335
  is required.
336
 
 
 
 
337
  Yields exactly **2** times:
338
 
339
- 1. After launching async all-to-all gather.
340
  2. After launching async all-to-all scatter.
341
  """
342
  process_group = param_to_state[id(params[0])].process_group
@@ -345,15 +419,19 @@ def muon_chunk_pipeline(
345
  p for p in params if param_to_state[id(p)].worker_rank == rank
346
  ]
347
 
348
- # Stages 1-2: launch async gather.
349
- with record_function("muon::launch_gather"):
350
- work, recv_buf, gathered_grads, recv_counts = _launch_gather(
351
- params, owned_params, param_to_state, rank, num_ranks,
352
- process_group)
353
-
354
- if none_grad:
355
- for p in params:
356
- p.grad = None
 
 
 
 
357
 
358
  yield # --- YIELD 1: other chunks can launch their gather ---
359
 
 
6
  from torch.distributed.tensor import DTensor
7
  from torch.profiler import record_function
8
 
9
+ from .core import _muon_state, adjust_lr_for_muon
10
+ from .newton_schulz import COMM_DTYPE, zeropower_via_newtonschulz5
11
  from .qk_clip import compute_scales
12
 
13
  logger = logging.getLogger(__name__)
 
45
  else:
46
  gathered_grads[id(p)] = None
47
 
48
+ # Build send buffer – batch grad copies via torch.cat
49
+ # (1-2 fused kernels vs N individual narrow().copy_() calls).
50
  send_counts = [0] * num_ranks
 
51
  for p in params:
52
  state = param_to_state[id(p)]
53
+ send_counts[state.worker_rank] += state.rank_numels[rank]
54
+
55
+ total_send = sum(send_counts)
56
+ if total_send > 0:
57
+ # Group grad slices by destination rank in a single pass.
58
+ dst_to_grads = [[] for _ in range(num_ranks)]
59
+ for p in params:
60
+ state = param_to_state[id(p)]
61
+ n = state.rank_numels[rank]
62
+ if n > 0:
63
+ g = p.grad.to_local()
64
+ dst_to_grads[state.worker_rank].append(g.reshape(-1))
65
+
66
+ # Flatten in dst order and cat once.
67
+ all_slices = []
68
+ for dst in range(num_ranks):
69
+ all_slices.extend(dst_to_grads[dst])
70
+ send_buf = torch.cat(all_slices)
71
+ if send_buf.dtype != COMM_DTYPE:
72
+ send_buf = send_buf.to(COMM_DTYPE)
73
+ else:
74
+ send_buf = torch.empty(0, dtype=COMM_DTYPE, device="cuda")
75
 
76
  # Build recv buffer
77
  recv_counts = [0] * num_ranks
 
127
 
128
  shard_view = gathered_grads[id(p)][indices]
129
  n = shard_view.numel()
130
+ if n == 0:
131
+ continue
132
 
133
  sg = recv_buf.narrow(0, off + inner_off, n)
134
  sg = sg.reshape(shard_view.shape)
 
151
  """
152
  computed_us: dict[int, torch.Tensor | None] = {}
153
  for p in owned_params:
154
+ u = zeropower_via_newtonschulz5(gathered_grads[id(p)], ns_steps)
155
  gathered_grads[id(p)] = None # free gathered grad
156
  computed_us[id(p)] = u
157
  return computed_us
 
171
  Returns:
172
  work: Async operation handle.
173
  recv_buf: Flat receive buffer (needed by ``_complete_scatter``).
174
+ scattered_us: Empty dict, populated by ``_complete_scatter`` with
175
+ zero-copy views into ``recv_buf``.
176
  recv_counts: Per-source-rank element counts.
177
  """
178
+ # scattered_us is populated by _complete_scatter with zero-copy views
179
+ # into recv_buf, avoiding N empty_like allocations + N copy_ calls.
180
+ # Pre-seed entries for params whose local shard is empty (rank_numels == 0)
181
+ # so _update_params can iterate all params without KeyError.
182
  scattered_us: dict[int, torch.Tensor] = {}
183
  for p in params:
184
+ if param_to_state[id(p)].rank_numels[rank] == 0:
185
+ scattered_us[id(p)] = torch.empty_like(p.to_local(),
186
+ dtype=COMM_DTYPE)
187
 
188
+ # Build send buffer – batch via torch.cat
189
+ # (1 fused kernel vs N*num_ranks individual narrow().copy_() calls).
190
  send_counts = [0] * num_ranks
 
191
  if owned_params:
192
  for p in owned_params:
193
  state = param_to_state[id(p)]
 
 
 
 
 
194
  for dst_rank in range(num_ranks):
195
+ send_counts[dst_rank] += state.rank_numels[dst_rank]
 
 
 
 
196
 
197
+ total_send = sum(send_counts)
198
+ if total_send > 0:
199
+ # Cache u_full conversions to avoid redundant .to() per dst_rank.
200
+ u_fulls = {}
201
+ for p in owned_params:
202
+ u_fulls[id(p)] = computed_us[id(p)].to(COMM_DTYPE).contiguous()
203
+
204
+ # Collect slices in dst order (matches all-to-all send layout).
205
+ all_slices = []
206
+ for dst_rank in range(num_ranks):
207
+ for p in owned_params:
208
+ state = param_to_state[id(p)]
209
+ su = u_fulls[id(p)][state.rank_indices[dst_rank]].flatten()
210
+ if su.numel() > 0:
211
+ all_slices.append(su)
212
+
213
+ send_buf = torch.cat(all_slices) if all_slices else torch.empty(
214
+ 0, dtype=COMM_DTYPE, device="cuda")
215
  else:
216
  send_buf = torch.empty(0, dtype=COMM_DTYPE, device="cuda")
217
 
 
227
  recv_counts[src] = total
228
 
229
  recv_total = sum(recv_counts)
 
230
  recv_buf = torch.empty(recv_total, dtype=COMM_DTYPE, device="cuda")
231
 
232
  # Launch async all-to-all
 
250
  rank: int,
251
  scattered_us: dict[int, torch.Tensor],
252
  ) -> None:
253
+ """Populate scattered_us with zero-copy views into recv_buf.
254
+
255
+ Instead of pre-allocating tensors and copying, we assign views directly
256
+ from ``recv_buf``. This eliminates N ``empty_like`` + N ``copy_`` calls.
257
+ The underlying storage of ``recv_buf`` is kept alive through the views
258
+ until ``scattered_us`` is cleared after ``_update_params``.
259
+ """
260
  off = 0
261
  for src in range(len(recv_counts)):
262
  block = recv_counts[src]
 
269
  if state.worker_rank != src:
270
  continue
271
  n = state.rank_numels[rank]
272
+ if n == 0:
273
+ continue
274
 
275
+ scattered_us[id(p)] = recv_buf.narrow(0, off + inner_off,
276
+ n).view_as(p.to_local())
 
277
 
278
  inner_off += n
279
 
 
289
  lr: float,
290
  weight_decay: float,
291
  ) -> None:
292
+ """Apply weight decay, Muon update, and optional QK clipping.
 
 
 
 
 
 
 
293
 
294
+ Uses batched ``_foreach_mul_`` for weight decay and batched
295
+ ``_foreach_add_`` for the Muon update, grouping parameters by
296
+ adjusted_lr to minimize kernel launches while preserving float32
297
+ precision for the alpha scaling.
298
+ """
299
+ if not params:
300
+ return
301
+
302
+ # Batched weight decay: p *= (1 - lr * wd) — single fused kernel.
303
+ p_locals = [p._local_tensor for p in params]
304
+ torch._foreach_mul_(p_locals, 1.0 - lr * weight_decay)
305
+
306
+ # Group params by adjusted_lr so _foreach_add_ can use a single
307
+ # alpha per group (preserves float32 precision for alpha scaling).
308
+ lr_groups: dict[float, tuple[list, list]] = {}
309
+ for p in params:
310
  adjusted_lr = adjust_lr_for_muon(lr, p.shape)
311
+ if adjusted_lr not in lr_groups:
312
+ lr_groups[adjusted_lr] = ([], [])
313
+ lr_groups[adjusted_lr][0].append(p._local_tensor)
314
+ lr_groups[adjusted_lr][1].append(scattered_us[id(p)])
315
 
316
+ for adjusted_lr, (p_group, u_group) in lr_groups.items():
317
+ torch._foreach_add_(p_group, u_group, alpha=-adjusted_lr)
318
+
319
+ # QK clipping – applied directly on the local tensor to
320
+ # avoid DTensor sharding-propagation issues with _StridedShard.
321
+ for p in params:
322
+ state = param_to_state[id(p)]
323
+ if state.qk_clip_state is None:
324
+ continue
325
+ scales_full = compute_scales(p, state.qk_clip_state)
326
  if scales_full is not None:
327
  ratio = p.shape[0] // scales_full.shape[0]
328
  idx0 = state.rank_indices[rank][0]
 
335
  p._local_tensor.mul_(row_scales.view(-1, 1))
336
 
337
 
338
+ # ======================================================================
339
+ # Pre-launch helper for overlapping first chunk's gather with other work.
340
+ # ======================================================================
341
+
342
+
343
+ @torch.no_grad()
344
+ def prelaunch_first_gather(
345
+ params: list[DTensor],
346
+ param_to_state: dict[int, _muon_state],
347
+ rank: int,
348
+ none_grad: bool,
349
+ ) -> tuple[dist.Work, torch.Tensor, dict[int, torch.Tensor | None], list[int]]:
350
+ """Launch the first chunk's A2A gather early for overlap with other compute.
351
+
352
+ Call this *before* expensive GPU work (e.g. batched expert NS) so that
353
+ the NCCL all-to-all runs concurrently on the NCCL stream while the
354
+ default stream executes compute.
355
+
356
+ Returns the same 4-tuple that ``_launch_gather`` produces, which should
357
+ be passed as ``prelaunch_gather`` to :func:`muon_chunk_pipeline`.
358
+ """
359
+ process_group = param_to_state[id(params[0])].process_group
360
+ num_ranks = dist.get_world_size(group=process_group)
361
+ owned_params = [
362
+ p for p in params if param_to_state[id(p)].worker_rank == rank
363
+ ]
364
+
365
+ with record_function("muon::prelaunch_gather"):
366
+ work, recv_buf, gathered_grads, recv_counts = _launch_gather(
367
+ params, owned_params, param_to_state, rank, num_ranks,
368
+ process_group)
369
+
370
+ if none_grad:
371
+ for p in params:
372
+ p.grad = None
373
+
374
+ return work, recv_buf, gathered_grads, recv_counts
375
+
376
+
377
  # ======================================================================
378
  # Main generator – thin orchestrator that wires stages together.
379
  # ======================================================================
 
388
  lr: float,
389
  weight_decay: float,
390
  none_grad: bool,
391
+ prelaunch_gather: tuple | None = None,
392
  ) -> Generator[None, None, None]:
393
  """Process one chunk of parameters through the full Muon pipeline.
394
 
 
405
  runs concurrently on the NCCL stream — no separate ``comm_stream``
406
  is required.
407
 
408
+ If ``prelaunch_gather`` is provided, the gather was already launched
409
+ by :func:`prelaunch_first_gather` and we skip launching it again.
410
+
411
  Yields exactly **2** times:
412
 
413
+ 1. After launching async all-to-all gather (or immediately if pre-launched).
414
  2. After launching async all-to-all scatter.
415
  """
416
  process_group = param_to_state[id(params[0])].process_group
 
419
  p for p in params if param_to_state[id(p)].worker_rank == rank
420
  ]
421
 
422
+ if prelaunch_gather is not None:
423
+ # Gather was pre-launched; none_grad already handled by caller.
424
+ work, recv_buf, gathered_grads, recv_counts = prelaunch_gather
425
+ else:
426
+ # Normal path: launch async gather.
427
+ with record_function("muon::launch_gather"):
428
+ work, recv_buf, gathered_grads, recv_counts = _launch_gather(
429
+ params, owned_params, param_to_state, rank, num_ranks,
430
+ process_group)
431
+
432
+ if none_grad:
433
+ for p in params:
434
+ p.grad = None
435
 
436
  yield # --- YIELD 1: other chunks can launch their gather ---
437