From bde96850013bfb8ac86bf3a43beda235271979f2 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 9 Apr 2026 21:06:32 -0700 Subject: [PATCH 001/124] Add nvtx ranges for mtp Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 18 ++++++++++++++++++ .../common/language_module/language_module.py | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index abf1bbf585b..1747545df19 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -39,6 +39,8 @@ get_asyncio_loop, get_model_config, get_pg_size, + nvtx_range_pop, + nvtx_range_push, unwrap_model, ) @@ -905,6 +907,7 @@ def _compute_serial_mtp_and_sample(self): num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) for depth in range(num_depths): + nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") position_ids = (base_position + depth).unsqueeze(0) # [1, active_request_count] token_ids = next_token_ids.unsqueeze(0) # [1, active_request_count] @@ -915,12 +918,14 @@ def _compute_serial_mtp_and_sample(self): token_ids = F.pad(token_ids, (0, pad_count)) position_ids = F.pad(position_ids, (0, pad_count)) + nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/forward") current_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=current_hidden, next_token_ids=token_ids, position_ids=position_ids, depth=depth, ) + nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/forward") # Strip padding from logits only. Hidden states stay padded+SP # between depths to avoid redundant gather/scatter round-trips. @@ -932,19 +937,24 @@ def _compute_serial_mtp_and_sample(self): # Broadcast MTP logits across pipeline stages. if self.model_is_pipeline_parallel: + nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/pp-broadcast") mtp_logits_2d = broadcast_from_last_pipeline_stage( [active_request_count, self.vocab_size], dtype=self.model_config.params_dtype, tensor=mtp_logits_2d, pp_group=self.pp_group, ) + nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/pp-broadcast") # Sample speculative token using the same sampling parameters. + nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/sample") spec_tokens = self._sample_from_logits_2d(mtp_logits_2d) self._sampled_mtp_tokens_cuda[depth, :active_request_count] = spec_tokens + nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/sample") # Use sampled token as input for the next depth. next_token_ids = spec_tokens + nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}") # Clean up cached hidden states. if has_mtp: @@ -1715,6 +1725,7 @@ def _dummy_serial_mtp_forward(self): dummy_position_ids = torch.zeros((1, padded_count), device=device, dtype=torch.long) for depth in range(num_depths): + nvtx_range_push(f"mtp-spec-decoding/dummy-depth-{depth}") mtp_logits_2d = None if has_mtp: dummy_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( @@ -1733,6 +1744,7 @@ def _dummy_serial_mtp_forward(self): tensor=mtp_logits_2d, pp_group=self.pp_group, ) + nvtx_range_pop(f"mtp-spec-decoding/dummy-depth-{depth}") def _dynamic_step_context_bookkeeping(self) -> Dict[str, Tensor]: """Update the dynamic inference context after sampling. @@ -1873,9 +1885,13 @@ async def async_generate_output_tokens_dynamic_batch( if self.num_speculative_tokens > 0: # Phase 1: Verify speculative tokens using base logits only. + nvtx_range_push("mtp-spec-decoding/verify") self._dynamic_step_sample_logits_and_verify_tokens(logits, input_ids) + nvtx_range_pop("mtp-spec-decoding/verify") # Phase 2: Rewind KV cache for rejected tokens. + nvtx_range_push("mtp-spec-decoding/rewind-kv-cache") self._rewind_kv_cache() + nvtx_range_pop("mtp-spec-decoding/rewind-kv-cache") # Disable MoE padding for MTP computation if self.model_config.moe_pad_experts_for_cuda_graph_inference: @@ -1883,7 +1899,9 @@ async def async_generate_output_tokens_dynamic_batch( set_decode_expert_padding(unwrapped_model, False) # Phase 3: Compute MTP serially with correct (verified) inputs. + nvtx_range_push("mtp-spec-decoding/serial-mtp") self._compute_serial_mtp_and_sample() + nvtx_range_pop("mtp-spec-decoding/serial-mtp") else: self._dynamic_step_sample_logits(logits) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index e8bb564e759..f39274e945a 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -30,6 +30,8 @@ get_tensor_model_parallel_group_if_none, is_te_min_version, make_tp_sharded_tensor_for_checkpoint, + nvtx_range_pop, + nvtx_range_push, ) @@ -343,19 +345,23 @@ def compute_mtp_single_step( """ layer_idx = 0 if self.mtp.mtp_use_repeated_layer else depth + nvtx_range_push(f"mtp-single-step/depth-{depth}/mtp-layer") mtp_hidden = self.mtp.layers[layer_idx].forward_single_position( hidden_states=hidden_states, next_token_ids=next_token_ids, position_ids=position_ids, embedding=self.embedding, ) + nvtx_range_pop(f"mtp-single-step/depth-{depth}/mtp-layer") output_weight = None if self.share_embeddings_and_output_weights: output_weight = self.shared_embedding_or_output_weight() + nvtx_range_push(f"mtp-single-step/depth-{depth}/output-layer") logits, _ = self.output_layer(mtp_hidden, weight=output_weight, runtime_gather_output=True) logits = self._scale_logits(logits) + nvtx_range_pop(f"mtp-single-step/depth-{depth}/output-layer") return mtp_hidden, logits From 7587f2f00d81c5f7214e3dfda9cf3d628fa26451 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 9 Apr 2026 21:33:08 -0700 Subject: [PATCH 002/124] perf benchmarking Signed-off-by: Keshav Santhanam --- .../inference/gpt/gpt_dynamic_inference.py | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/examples/inference/gpt/gpt_dynamic_inference.py b/examples/inference/gpt/gpt_dynamic_inference.py index f02aae9c221..2800742c420 100644 --- a/examples/inference/gpt/gpt_dynamic_inference.py +++ b/examples/inference/gpt/gpt_dynamic_inference.py @@ -284,10 +284,6 @@ def main(): args_defaults={'no_load_rng': True, 'no_load_optim': True}, ) - # Start Nsight profiler. - if os.environ.get("NSIGHT_PREFIX"): - torch.cuda.cudart().cudaProfilerStart() - level_str = os.getenv("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_str, logging.INFO) logging.basicConfig(level=level, force=True) @@ -350,8 +346,23 @@ def main(): print(setup_prefix) print("~~~") + # Warmup: run one untimed iteration so CUDA caches, JIT kernels, and + # allocator pools are ready before the measured runs. + if args.inference_repeat_n > 1: + print("Running warmup iteration ...") + engine.reset() + run_inference(requests, engine) + torch.cuda.synchronize() + engine.reset() + + # Start CUDA profiler after warmup so nsys traces only the measured runs. + if os.environ.get("NSIGHT_PREFIX"): + torch.cuda.cudart().cudaProfilerStart() + # Run and time test, optionally `args.inference_repeat_n` times. throughputs = [] + cuda_start_event = torch.cuda.Event(enable_timing=True) + cuda_end_event = torch.cuda.Event(enable_timing=True) for _ in range(args.inference_repeat_n): # Reset engine. @@ -359,19 +370,29 @@ def main(): torch.cuda.reset_peak_memory_stats() - # Trial. + # Synchronize before starting the timer to avoid measuring stale GPU work. + torch.cuda.synchronize() + + # Trial — use both wall-clock and CUDA events for accurate GPU timing. t = get_curr_time() + cuda_start_event.record() result = run_inference(requests, engine) + cuda_end_event.record() step_times = result["step_times"] add_times = result["add_times"] output_times = result["output_times"] total_output_tokens = result["total_output_tokens"] torch.cuda.synchronize() total_time = get_curr_time() - t + cuda_elapsed_ms = cuda_start_event.elapsed_time(cuda_end_event) stats = torch.cuda.memory_stats() throughput = total_output_tokens / total_time throughputs.append(throughput) + # Stop CUDA profiler after measured runs. + if os.environ.get("NSIGHT_PREFIX"): + torch.cuda.cudart().cudaProfilerStop() + # Validate all requests finished. for request in requests: assert request.state == "finished", f"request.state == '{request.state}' != 'finished'." @@ -505,19 +526,17 @@ def escape_str(s): # f"count [ p {p_count}, d {d_count} ]." # ) capture_str = f"{engine.capture_stats['time']:.2f} sec" if engine.capture_stats else "--" + cuda_throughput = total_output_tokens / (cuda_elapsed_ms / 1000.0) print( f"{setup_prefix} … " f"throughput: {throughput:.3f} tok/s … ", f"total time: {total_time:.3f}s … " + f"cuda time: {cuda_elapsed_ms:.1f}ms ({cuda_throughput:.3f} tok/s) … " f"mem {peak_alloc_gb:.1f}/{peak_resvd_gb:.1f} GB … " f"steps: {engine.context.step_count:d} … " f"capture {capture_str}", ) print("~~~") - # Stop Nsight profiler. - if os.environ.get("NSIGHT_PREFIX"): - torch.cuda.cudart().cudaProfilerStop() - if __name__ == "__main__": main() From b8bdd12f874e846a8459dfec547b470a9464d54c Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 9 Apr 2026 22:02:24 -0700 Subject: [PATCH 003/124] Remove simpy dependency Signed-off-by: Keshav Santhanam --- .../inference/gpt/gpt_dynamic_inference_12b.sh | 1 - .../inference/gpt/gpt_dynamic_inference_357m.sh | 1 - examples/inference/gpt/utils.py | 17 ++++++----------- .../cuda_graphs.sh | 1 - 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/examples/inference/gpt/gpt_dynamic_inference_12b.sh b/examples/inference/gpt/gpt_dynamic_inference_12b.sh index ca21bb170a5..d848fdb51b7 100644 --- a/examples/inference/gpt/gpt_dynamic_inference_12b.sh +++ b/examples/inference/gpt/gpt_dynamic_inference_12b.sh @@ -5,7 +5,6 @@ set -u # Libraries. -pip install simpy pip install sentencepiece pip install tiktoken diff --git a/examples/inference/gpt/gpt_dynamic_inference_357m.sh b/examples/inference/gpt/gpt_dynamic_inference_357m.sh index cc99bdddec1..d0c126cd191 100644 --- a/examples/inference/gpt/gpt_dynamic_inference_357m.sh +++ b/examples/inference/gpt/gpt_dynamic_inference_357m.sh @@ -5,7 +5,6 @@ set -u # Libraries. -pip install simpy pip install sentencepiece pip install tiktoken diff --git a/examples/inference/gpt/utils.py b/examples/inference/gpt/utils.py index c9b1c05c544..ca26985e046 100644 --- a/examples/inference/gpt/utils.py +++ b/examples/inference/gpt/utils.py @@ -106,18 +106,13 @@ def get_time_offsets( random.seed(seed) - import simpy # Guard against this import in test case - - # Generate random time offsets. - def arrival(r): - while True: - yield env.timeout(random.expovariate(r)) - time_offsets.append(env.now) - + # Generate Poisson arrival times by accumulating exponential inter-arrival intervals. time_offsets = [] - env = simpy.Environment() - env.process(arrival(incoming_requests_per_sec)) - env.run(incoming_requests_duration) + current_time = 0.0 + while current_time < incoming_requests_duration: + current_time += random.expovariate(incoming_requests_per_sec) + if current_time < incoming_requests_duration: + time_offsets.append(current_time) # Ensure at least a single request. if len(time_offsets) == 0: diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh index 641019c9750..ed0a5a622c2 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh @@ -3,7 +3,6 @@ set -u # Libraries. -uv pip install simpy uv pip install tiktoken # Environment variables. From e948bc9c3d2aff28afe7011754751da1ed4e7e10 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 9 Apr 2026 23:03:23 -0700 Subject: [PATCH 004/124] cuda graphs for mtp Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 94 +++++++++++++++++++ .../text_generation_controller.py | 59 ++++++++---- megatron/core/transformer/cuda_graphs.py | 27 +++++- .../core/transformer/transformer_layer.py | 11 +++ 4 files changed, 173 insertions(+), 18 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index c3ab08c10f1..fbbfe01ada0 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -63,6 +63,7 @@ get_pg_src_rank, internal_api, trace_async_exceptions, + unwrap_model, ) from .async_zmq_communicator import AsyncZMQCommunicator @@ -395,6 +396,11 @@ def create_cuda_graphs(self, reset_context: bool = True): if is_inference_optimized_ep: unset_inference_cuda_graphed_iteration_for_ep_inference(unwrapped_model) + # MTP CUDA graph warmup: capture graphs for the MTP TransformerLayers + # used during speculative decoding. This must happen after decoder graph + # warmup so that the MTP graphs are captured independently. + self._create_mtp_cuda_graphs(controller, context) + # Memory usage. time_end = time.time() mem_stats_end = torch.cuda.memory_stats() @@ -419,6 +425,94 @@ def create_cuda_graphs(self, reset_context: bool = True): self.capture_stats = capture_stats + def _create_mtp_cuda_graphs(self, controller, context): + """Capture CUDA graphs for MTP TransformerLayers used in speculative decoding. + + Derives the set of MTP batch sizes from the decoder CUDA graph batch dimensions + (decode-only entries), enables ``_mtp_cuda_graph_enabled`` on each MTP + TransformerLayer, then runs a single ``compute_mtp_single_step`` per batch + size to trigger graph capture. With ``mtp_use_repeated_layer`` (the common + case) one call covers every depth; with unique layers the remaining depths + will capture lazily on first real inference call. + """ + num_mtp_heads = controller.num_mtp_heads + num_spec_tokens = controller.num_speculative_tokens or 0 + if num_mtp_heads == 0 or num_spec_tokens == 0: + return + + model = controller.inference_wrapped_model.model + unwrapped = unwrap_model(model) + if not hasattr(unwrapped, 'mtp'): + return + + model_config = model.config + + # Collect decode-only batch sizes from the decoder graph dimensions. + tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) + sp_enabled = model_config.sequence_parallel and tp_size > 1 + mtp_batch_sizes = set() + for dim in context.cuda_graph_batch_dimensions_list: + if dim.prefill_req_count == 0 and dim.decode_req_count > 0: + n = dim.decode_req_count + if sp_enabled: + n += (tp_size - n % tp_size) % tp_size + mtp_batch_sizes.add(n) + if not mtp_batch_sizes: + return + + # Enable the flag on every MTP TransformerLayer so that + # _should_call_local_cudagraph returns True. + for layer in unwrapped.mtp.layers: + tl = layer.mtp_model_layer + if hasattr(tl, 'cudagraph_manager'): + tl._mtp_cuda_graph_enabled = True + + # Store sorted batch sizes on the controller for runtime padding lookup. + controller._mtp_cuda_graph_batch_sizes = sorted(mtp_batch_sizes) + + device = torch.cuda.current_device() + dtype = model_config.params_dtype + hidden_size = model_config.hidden_size + + # Enable inference dispatcher for EP during MTP graph capture. + is_inference_optimized_ep = ( + model_config.transformer_impl == "inference_optimized" + and model_config.expert_model_parallel_size > 1 + ) + if is_inference_optimized_ep: + set_inference_cuda_graphed_iteration_for_ep_inference(model) + + logging.info( + "> MTP CUDA graph warmup: %d batch size(s)", len(mtp_batch_sizes), + ) + + for batch_size in sorted(mtp_batch_sizes): + dummy_hidden = torch.zeros((batch_size, 1, hidden_size), device=device, dtype=dtype) + if sp_enabled: + from megatron.core.tensor_parallel.mappings import ( + scatter_to_sequence_parallel_region, + ) + + dummy_hidden = scatter_to_sequence_parallel_region( + dummy_hidden, group=controller.inference_wrapped_model.tp_group + ) + dummy_token_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) + dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) + + # One call per batch size; depth=0 warms the shared layer (repeated + # mode) or the first unique layer (non-repeated mode). + unwrapped.compute_mtp_single_step( + hidden_states=dummy_hidden, + next_token_ids=dummy_token_ids, + position_ids=dummy_position_ids, + depth=0, + ) + + if is_inference_optimized_ep: + unset_inference_cuda_graphed_iteration_for_ep_inference(model) + + logging.info("> MTP CUDA graph warmup complete") + @internal_api async def start_listening_to_data_parallel_coordinator( self, diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 1747545df19..3bc719b209f 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -891,19 +891,28 @@ def _compute_serial_mtp_and_sample(self): tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 if sp_enabled: - pad_count = (tp_size - active_request_count % tp_size) % tp_size - padded_count = active_request_count + pad_count + padded_count = active_request_count + (tp_size - active_request_count % tp_size) % tp_size else: - pad_count = 0 + padded_count = active_request_count + + # Further pad to match a pre-captured CUDA graph batch size. + mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) + if mtp_cuda_graph_sizes: + for size in mtp_cuda_graph_sizes: + if size >= padded_count: + padded_count = size + break + + pad_count = padded_count - active_request_count - # Pad hidden states to align with the tensor parallel size. - if has_mtp and sp_enabled: + # Pad hidden states and scatter for sequence parallelism. + if has_mtp: if pad_count > 0: current_hidden = F.pad(current_hidden, (0, 0, 0, 0, 0, pad_count)) - - current_hidden = scatter_to_sequence_parallel_region( - current_hidden, group=self.inference_wrapped_model.tp_group - ) + if sp_enabled: + current_hidden = scatter_to_sequence_parallel_region( + current_hidden, group=self.inference_wrapped_model.tp_group + ) num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) for depth in range(num_depths): @@ -1638,7 +1647,8 @@ def dummy_forward(self): if not context.cuda_graph_batch_dimensions_list: self.inference_wrapped_model.dummy_forward() - # Disable MoE padding for MTP computation + # Disable MoE padding for MTP computation. + # No CUDA graphs in this path (cuda_graph_batch_dimensions_list is empty). if self.model_config.moe_pad_experts_for_cuda_graph_inference: unwrapped_model = unwrap_model(self.inference_wrapped_model.model) set_decode_expert_padding(unwrapped_model, False) @@ -1661,10 +1671,12 @@ def dummy_forward(self): # fallback to eager dummy forward self.inference_wrapped_model.dummy_forward() - # Disable MoE padding for MTP computation + # Disable MoE padding for MTP computation, unless CUDA graphs + # are active (the graphs were captured with padding enabled). if self.model_config.moe_pad_experts_for_cuda_graph_inference: - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - set_decode_expert_padding(unwrapped_model, False) + if not context.using_cuda_graph_this_step(): + unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + set_decode_expert_padding(unwrapped_model, False) # When speculative decoding is active, the real EP ranks perform serial # MTP forward passes after the main forward pass. MTP layers may contain @@ -1715,12 +1727,23 @@ def _dummy_serial_mtp_forward(self): sp_enabled = self.model_config.sequence_parallel and tp_size > 1 padded_count = tp_size if sp_enabled else 1 + # When MTP CUDA graphs are active, pad to the smallest captured batch + # size so the dummy rank also replays a pre-captured graph (the EP + # collectives must match the real ranks). + mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) + if mtp_cuda_graph_sizes: + padded_count = mtp_cuda_graph_sizes[0] + dummy_hidden = None if has_mtp: # Minimal dummy tensors — just enough to drive the MTP layer forward # so that the MoE all-to-all collectives are issued. # Depth 0 uses full-format hidden; subsequent depths use SP format. - dummy_hidden = torch.zeros((1, 1, hidden_size), device=device, dtype=dtype) + dummy_hidden = torch.zeros((padded_count, 1, hidden_size), device=device, dtype=dtype) + if sp_enabled: + dummy_hidden = scatter_to_sequence_parallel_region( + dummy_hidden, group=self.inference_wrapped_model.tp_group + ) dummy_token_ids = torch.zeros((1, padded_count), device=device, dtype=torch.long) dummy_position_ids = torch.zeros((1, padded_count), device=device, dtype=torch.long) @@ -1893,10 +1916,12 @@ async def async_generate_output_tokens_dynamic_batch( self._rewind_kv_cache() nvtx_range_pop("mtp-spec-decoding/rewind-kv-cache") - # Disable MoE padding for MTP computation + # Disable MoE padding for MTP computation, unless CUDA graphs + # are active (the graphs were captured with padding enabled). if self.model_config.moe_pad_experts_for_cuda_graph_inference: - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - set_decode_expert_padding(unwrapped_model, False) + if not context.using_cuda_graph_this_step(): + unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + set_decode_expert_padding(unwrapped_model, False) # Phase 3: Compute MTP serially with correct (verified) inputs. nvtx_range_push("mtp-spec-decoding/serial-mtp") diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index c7631519e43..3eb0902f242 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -258,6 +258,12 @@ def _determine_if_first_last_layer_of_this_vp_chunk(base_module): if not hasattr(base_module, "layer_number"): return True, True + # MTP layers have their own numbering separate from the decoder stack. + # Treat each one as self-contained so the buffer-reuse logic does not + # try to chain them with decoder layers. + if getattr(base_module, 'is_mtp_layer', False): + return True, True + # find all first/last layers of this PP stage first_layer_numbers = [] last_layer_numbers = [] @@ -1485,6 +1491,15 @@ def call_ddp_preforward_hook(self, module): # Only hooks from Mcore DDP, which take no args, should be called at this point. hook(module) + @staticmethod + def _is_mtp_inference(megatron_module, kwargs): + """Check if this call is an MTP layer running under inference mode.""" + return ( + 'inference_context' not in kwargs or not kwargs.get('inference_context') + ) and getattr(megatron_module, 'is_mtp_layer', False) and getattr( + megatron_module, '_mtp_cuda_graph_enabled', False + ) + def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): '''Returns a valid cudagraph runner for the current forward call. The cudagraph corresponding to this call is the first element of 'self.cudagraph_runners'. @@ -1494,6 +1509,7 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): over different microbatches by tracking their respective fwd and bwd passes.''' if reuse_cudagraphs: is_inference_mode = 'inference_context' in kwargs.keys() and kwargs['inference_context'] + is_mtp_inference = self._is_mtp_inference(megatron_module, kwargs) if is_inference_mode: is_static_batching = kwargs['inference_context'].is_static_batching() if is_static_batching: @@ -1503,6 +1519,10 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): else: padded_batch_dimensions = kwargs['inference_context'].padded_batch_dimensions runner = self.inference_cudagraphs_lookup_table[padded_batch_dimensions] + elif is_mtp_inference: + # MTP layers have no inference_context; key by hidden_states shape. + mtp_key = ('mtp', kwargs['hidden_states'].shape) + runner = self.inference_cudagraphs_lookup_table.get(mtp_key) else: # Todo: For training, we could also cache runners based on input shape. # If autograd is currently disabled, it doesnt matter if a runner was created @@ -1545,6 +1565,8 @@ def is_valid(r): ) else: self.inference_cudagraphs_lookup_table[padded_batch_dimensions] = runner + elif is_mtp_inference: + self.inference_cudagraphs_lookup_table[mtp_key] = runner else: # Create cudagraphs for every microbatch if _CudagraphGlobalRecord.cudagraph_created: @@ -1574,7 +1596,10 @@ def __call__(self, megatron_module, args, kwargs): kwargs (dict): The keyword args to be passed to the module. """ - is_inference_mode = 'inference_context' in kwargs.keys() and kwargs['inference_context'] + is_inference_mode = ( + ('inference_context' in kwargs.keys() and kwargs['inference_context']) + or self._is_mtp_inference(megatron_module, kwargs) + ) is_in_checkpoint_fwd = is_checkpointing() if HAVE_TE_GRAPHS: is_in_checkpoint_fwd = is_in_checkpoint_fwd or is_fp8_activation_recompute_enabled() diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index cf63199347c..e95b071466a 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -1278,6 +1278,17 @@ def _should_call_local_cudagraph(self, *args, **kwargs): using_cuda_graph = kwargs['inference_context'].using_cuda_graph_this_step() if using_cuda_graph: return True + # MTP inference path: CUDA graphs for MTP layers during speculative decoding. + # MTP layers don't receive an inference_context, so they use their own flag. + elif ( + not self.training + and hasattr(self, 'cudagraph_manager') + and getattr(self, 'is_mtp_layer', False) + and getattr(self, '_mtp_cuda_graph_enabled', False) + and kwargs.get('attention_mask') is None + and not self.config.cuda_graph_scope + ): + return True return False def get_layer_norm_weights(self): From 6c60f3405b49574ba4ba8f5e0ad41e1a109eb75b Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 9 Apr 2026 23:47:01 -0700 Subject: [PATCH 005/124] mtp cuda graphs Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 3bc719b209f..4cc21c7fff4 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -836,6 +836,39 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: spec_tokens[indices] = tokens return spec_tokens + def _resolve_mtp_cuda_graph_batch_size(self, local_padded_count: int) -> int: + """Resolve MTP CUDA graph batch size, syncing across EP ranks if needed. + + Finds the smallest pre-captured CUDA graph batch size that fits + ``local_padded_count``, then all-reduces (MAX) across the EP group so + every EP rank — including the dummy rank which sends 0 — agrees on the + same graph size. + + Returns ``local_padded_count`` unchanged when MTP CUDA graphs are not + active or EP is not in use. + """ + mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) + if not mtp_cuda_graph_sizes: + return local_padded_count + + padded_count = local_padded_count + for size in mtp_cuda_graph_sizes: + if size >= padded_count: + padded_count = size + break + + ep_group = self.inference_wrapped_model.inference_context.expert_model_parallel_group + if ep_group is not None: + sync_tensor = torch.tensor( + [padded_count], dtype=torch.int32, device=torch.cuda.current_device() + ) + torch.distributed.all_reduce( + sync_tensor, op=torch.distributed.ReduceOp.MAX, group=ep_group + ) + padded_count = sync_tensor.item() + + return padded_count + def _compute_serial_mtp_and_sample(self): """Compute MTP logits serially after verification and sample speculative tokens. @@ -895,14 +928,9 @@ def _compute_serial_mtp_and_sample(self): else: padded_count = active_request_count - # Further pad to match a pre-captured CUDA graph batch size. - mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) - if mtp_cuda_graph_sizes: - for size in mtp_cuda_graph_sizes: - if size >= padded_count: - padded_count = size - break - + # Further pad to match a pre-captured CUDA graph batch size and sync + # across EP ranks so the dummy rank uses the same graph. + padded_count = self._resolve_mtp_cuda_graph_batch_size(padded_count) pad_count = padded_count - active_request_count # Pad hidden states and scatter for sequence parallelism. @@ -1727,12 +1755,10 @@ def _dummy_serial_mtp_forward(self): sp_enabled = self.model_config.sequence_parallel and tp_size > 1 padded_count = tp_size if sp_enabled else 1 - # When MTP CUDA graphs are active, pad to the smallest captured batch - # size so the dummy rank also replays a pre-captured graph (the EP - # collectives must match the real ranks). - mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) - if mtp_cuda_graph_sizes: - padded_count = mtp_cuda_graph_sizes[0] + # When MTP CUDA graphs are active, sync with real EP ranks to agree + # on batch size (dummy's local value is small; the EP all-reduce MAX + # picks the real ranks' padded_count). + padded_count = self._resolve_mtp_cuda_graph_batch_size(padded_count) dummy_hidden = None if has_mtp: From 90ffe8ab3c7d4287bdfa87c603dbdaf1b3377abc Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 11:51:42 -0700 Subject: [PATCH 006/124] fix cuda graphs Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 28 ++++++++++--------- megatron/core/transformer/cuda_graphs.py | 16 +++++++---- .../transformer/multi_token_prediction.py | 14 ++++++++++ .../core/transformer/transformer_layer.py | 11 -------- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index fbbfe01ada0..da42683d46a 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -426,14 +426,19 @@ def create_cuda_graphs(self, reset_context: bool = True): self.capture_stats = capture_stats def _create_mtp_cuda_graphs(self, controller, context): - """Capture CUDA graphs for MTP TransformerLayers used in speculative decoding. + """Capture CUDA graphs for MTP layers used in speculative decoding. Derives the set of MTP batch sizes from the decoder CUDA graph batch dimensions - (decode-only entries), enables ``_mtp_cuda_graph_enabled`` on each MTP - TransformerLayer, then runs a single ``compute_mtp_single_step`` per batch - size to trigger graph capture. With ``mtp_use_repeated_layer`` (the common - case) one call covers every depth; with unique layers the remaining depths - will capture lazily on first real inference call. + (decode-only entries), then runs a single ``compute_mtp_single_step`` per + batch size to trigger graph capture. Each ``MultiTokenPredictionLayer`` + already has a ``CudaGraphManager`` wrapping ``forward_single_position`` + (created in ``__init__``), so the full MTP forward (embedding lookup, + projection, transformer layer, and final layernorm) is captured in a + single graph. + + With ``mtp_use_repeated_layer`` (the common case) one call covers every + depth; with unique layers the remaining depths will capture lazily on + first real inference call. """ num_mtp_heads = controller.num_mtp_heads num_spec_tokens = controller.num_speculative_tokens or 0 @@ -447,6 +452,10 @@ def _create_mtp_cuda_graphs(self, controller, context): model_config = model.config + # Only proceed when local CUDA graphs are enabled. + if model_config.cuda_graph_impl != "local": + return + # Collect decode-only batch sizes from the decoder graph dimensions. tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) sp_enabled = model_config.sequence_parallel and tp_size > 1 @@ -460,13 +469,6 @@ def _create_mtp_cuda_graphs(self, controller, context): if not mtp_batch_sizes: return - # Enable the flag on every MTP TransformerLayer so that - # _should_call_local_cudagraph returns True. - for layer in unwrapped.mtp.layers: - tl = layer.mtp_model_layer - if hasattr(tl, 'cudagraph_manager'): - tl._mtp_cuda_graph_enabled = True - # Store sorted batch sizes on the controller for runtime padding lookup. controller._mtp_cuda_graph_batch_sizes = sorted(mtp_batch_sizes) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 3eb0902f242..343dfa8fcbe 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -241,8 +241,10 @@ def _check_supported_type(meta): DynamicInferenceContext, ArgMetadata, } - assert meta.type in _SUPPORTED_TYPES or is_dataclass( - meta.value + assert ( + meta.type in _SUPPORTED_TYPES + or is_dataclass(meta.value) + or callable(meta.value) ), f"Cudagraphs received an arg of type {meta.type} which is not supported." @@ -260,7 +262,9 @@ def _determine_if_first_last_layer_of_this_vp_chunk(base_module): # MTP layers have their own numbering separate from the decoder stack. # Treat each one as self-contained so the buffer-reuse logic does not - # try to chain them with decoder layers. + # try to chain them with decoder layers. Uses getattr rather than + # isinstance so it covers both the inner TransformerLayer (is_mtp_layer=True) + # and the outer MultiTokenPredictionLayer (is_mtp_layer=True). if getattr(base_module, 'is_mtp_layer', False): return True, True @@ -1494,11 +1498,11 @@ def call_ddp_preforward_hook(self, module): @staticmethod def _is_mtp_inference(megatron_module, kwargs): """Check if this call is an MTP layer running under inference mode.""" + from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer + return ( 'inference_context' not in kwargs or not kwargs.get('inference_context') - ) and getattr(megatron_module, 'is_mtp_layer', False) and getattr( - megatron_module, '_mtp_cuda_graph_enabled', False - ) + ) and isinstance(megatron_module, MultiTokenPredictionLayer) def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): '''Returns a valid cudagraph runner for the current forward call. diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 8fe7a2636b0..632b3e4c464 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -741,6 +741,7 @@ def __init__( mamba_submodules: Optional[MambaStackSubmodules] = None, ): super().__init__(config=config) + self.is_mtp_layer = True self.sequence_parallel = config.sequence_parallel self.submodules = submodules self.layer_number = layer_number + get_mtp_layer_offset(self.config, vp_stage) @@ -847,6 +848,19 @@ def __init__( ) self.offload_context = nullcontext() + # Create cuda graph manager wrapping forward_single_position so that + # the full MTP forward (embedding, projection, transformer, layernorm) + # is captured in a single graph. + if config.cuda_graph_impl == "local" and not config.cuda_graph_scope: + from megatron.core.transformer.cuda_graphs import CudaGraphManager + + self.mtp_cudagraph_manager = CudaGraphManager( + config, + base_module=self, + function_name="forward_single_position", + need_backward=False, + ) + def _get_embeddings( self, input_ids: torch.Tensor, diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index e95b071466a..cf63199347c 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -1278,17 +1278,6 @@ def _should_call_local_cudagraph(self, *args, **kwargs): using_cuda_graph = kwargs['inference_context'].using_cuda_graph_this_step() if using_cuda_graph: return True - # MTP inference path: CUDA graphs for MTP layers during speculative decoding. - # MTP layers don't receive an inference_context, so they use their own flag. - elif ( - not self.training - and hasattr(self, 'cudagraph_manager') - and getattr(self, 'is_mtp_layer', False) - and getattr(self, '_mtp_cuda_graph_enabled', False) - and kwargs.get('attention_mask') is None - and not self.config.cuda_graph_scope - ): - return True return False def get_layer_norm_weights(self): From 8ce0a4be44ea545884452e5d72b537b5688560a1 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 12:00:19 -0700 Subject: [PATCH 007/124] cuda graph fix Signed-off-by: Keshav Santhanam --- .../core/models/common/language_module/language_module.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index f39274e945a..f4363947111 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -352,6 +352,10 @@ def compute_mtp_single_step( position_ids=position_ids, embedding=self.embedding, ) + # CudaGraphManager.replay_graph_capture always wraps outputs in a + # tuple. Unwrap when forward_single_position is CUDA-graphed. + if isinstance(mtp_hidden, tuple): + mtp_hidden = mtp_hidden[0] nvtx_range_pop(f"mtp-single-step/depth-{depth}/mtp-layer") output_weight = None From 84129e7f83a04cf46bf39da375d4ff695a315533 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 12:04:47 -0700 Subject: [PATCH 008/124] Fix dummy_position_ids dtype Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index da42683d46a..eb7a9f73b83 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -499,7 +499,7 @@ def _create_mtp_cuda_graphs(self, controller, context): dummy_hidden, group=controller.inference_wrapped_model.tp_group ) dummy_token_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) - dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) + dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.int32) # One call per batch size; depth=0 warms the shared layer (repeated # mode) or the first unique layer (non-repeated mode). From 9bfa0fca0f860ec8717f9a27068578a625b1abcb Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 15:36:11 -0700 Subject: [PATCH 009/124] Try to compile more Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 109 ++++++++---------- 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 4cc21c7fff4..870668c8a6b 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -836,6 +836,7 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: spec_tokens[indices] = tokens return spec_tokens + @torch.compile() def _resolve_mtp_cuda_graph_batch_size(self, local_padded_count: int) -> int: """Resolve MTP CUDA graph batch size, syncing across EP ranks if needed. @@ -1054,6 +1055,7 @@ def _sample_speculative_logits( return output_tokens, repeats + @torch.compile() def _verify_speculative_tokens( self, output_tokens: Tensor, @@ -1064,80 +1066,48 @@ def _verify_speculative_tokens( num_prefill_requests: int, active_request_count: int, ) -> tuple: - """Verify speculative tokens against input tokens and compute acceptance. - - Creates an accepted tokens mask where: - - For prefill requests, the token is always accepted. - - For decode requests, the first token (base token) is always accepted, then we compare - sampled tokens with input tokens and accept consecutive matches. - Then finds the index of the last accepted token per request. - - Example (assume 1, 2, and 0 spec tokens are accepted in the first 3 decode requests): - input_tokens_required: [ a5 a6s a7s | b3 b4s b5s | c6 c7s c8s | d2 | e4 ] # Size 11 - Output tokens [ a6o a7o a8o | b40 b5o b6o | c7o c8o c9o | d3o | e5o ] - Output tokens right shift [ d3o a6o a7o | a8o b40 b5o | b6o c7o c8o | c9o | d3o ] - Accepted tokens mask [ 1 1 0 | 1 1 1 | 1 0 0 | 1 | 1 ] - Last one indices [ 1 | 5 | 6 | 9 | 10 ] - - Returns: - tuple: (last_one_indices, accepted_tokens_mask, input_tokens_required) where - last_one_indices contains the index of the last accepted token per request. - """ + """Verify speculative tokens against input tokens without data-dependent graph breaks.""" if input_tokens_required.ndim == 2: - assert ( - input_tokens_required.shape[0] == 1 - ), f"Expected input_tokens_required to have 1 row, but got {input_tokens_required.shape}" input_tokens_required = input_tokens_required.squeeze(0) - # Initialize mask with False to prevent boundary bleed + device = input_tokens_required.device + + # Initialize mask functionally accepted_tokens_mask = torch.zeros_like(input_tokens_required, dtype=torch.bool) # Make all prefill tokens accepted token_to_prefill_idx = torch.repeat_interleave(request_in_prefill_status_tensor, repeats) - accepted_tokens_mask[token_to_prefill_idx == 1] = True + accepted_tokens_mask = accepted_tokens_mask | (token_to_prefill_idx == 1) - # Safe decode token verification without cross-batch boundary contamination - decode_mask_2d = None - if num_decode_requests > 0: - decode_len = num_decode_requests * (self.num_speculative_tokens + 1) + decode_len = num_decode_requests * (self.num_speculative_tokens + 1) + last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) - decode_inputs = input_tokens_required[:decode_len].reshape( - num_decode_requests, self.num_speculative_tokens + 1 - ) - decode_outputs = output_tokens[:decode_len].reshape( - num_decode_requests, self.num_speculative_tokens + 1 - ) + # Vectorized decode token verification + # Using .view(-1, ...) safely handles cases where num_decode_requests == 0 without python branches + decode_inputs = input_tokens_required[:decode_len].view(-1, self.num_speculative_tokens + 1) + decode_outputs = output_tokens[:decode_len].view(-1, self.num_speculative_tokens + 1) + decode_outputs_shifted = decode_outputs.roll(1, dims=1) - # Shift outputs right by 1 *within* each request to align sampled tokens with input targets - decode_outputs_shifted = decode_outputs.roll(1, dims=1) - decode_mask_2d = decode_inputs == decode_outputs_shifted - # The first token (base token) is always accepted - decode_mask_2d[:, 0] = True - # Enforce consecutive acceptance: cummin propagates False to the right - decode_mask_2d = decode_mask_2d.cummin(dim=1).values - accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() - - last_one_indices = torch.full( - (active_request_count,), -1, device=input_tokens_required.device - ) + # Functionally build the mask: The first token (base token) is always accepted + first_col_true = torch.ones_like(decode_inputs[:, :1], dtype=torch.bool) + rest_cols = (decode_inputs[:, 1:] == decode_outputs_shifted[:, 1:]) + decode_mask_2d = torch.cat([first_col_true, rest_cols], dim=1) - if num_decode_requests > 0: - # Summing the consecutive mask gives the count; subtract 1 for the local index - local_last_indices = decode_mask_2d.sum(dim=1) - 1 - row_offsets = torch.arange(num_decode_requests, device=last_one_indices.device) * ( - self.num_speculative_tokens + 1 - ) - last_one_indices[:num_decode_requests] = row_offsets + local_last_indices + # Enforce consecutive acceptance: cummin propagates False to the right + decode_mask_2d = decode_mask_2d.cummin(dim=1).values + accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() - if num_prefill_requests > 0: - decode_len = num_decode_requests * (self.num_speculative_tokens + 1) - prefill_valid = ( - torch.nonzero(accepted_tokens_mask[decode_len:]).squeeze(-1) + decode_len - ) - last_one_indices[num_decode_requests:] = prefill_valid + # Compute last accepted indices for decode requests + local_last_indices = decode_mask_2d.sum(dim=1) - 1 + row_offsets = torch.arange(num_decode_requests, device=device) * (self.num_speculative_tokens + 1) + last_one_indices[:num_decode_requests] = row_offsets + local_last_indices - return last_one_indices, accepted_tokens_mask, input_tokens_required + # Compute last accepted indices for prefill requests mathematically instead of using torch.nonzero + prefill_valid = decode_len + torch.arange(num_prefill_requests, device=device) + last_one_indices[num_decode_requests:] = prefill_valid + return last_one_indices, accepted_tokens_mask, input_tokens_required + def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_ids: Tensor): """ Sample tokens from logits for dynamic batching with speculative tokens and verify the tokens. @@ -1186,7 +1156,26 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id active_request_count, ) ) + + self._prepare_speculative_tokens_for_next_forward_pass( + num_decode_requests, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_tokens_mask, + input_tokens_required, + ) + @torch.compile() + def _prepare_speculative_tokens_for_next_forward_pass( + self, + num_decode_requests: int, + output_tokens: torch.Tensor, + required_logit_indices: torch.Tensor, + last_one_indices: torch.Tensor, + accepted_tokens_mask: torch.Tensor, + input_tokens_required: torch.Tensor, + ): # Store the final sampled tokens for the next forward pass. final_sampled_tokens = output_tokens[last_one_indices] self._sampled_tokens_cuda[: len(final_sampled_tokens)] = final_sampled_tokens From cbc12708eb61663ce0c10a61c7da14bb29c9c4ab Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 16:14:01 -0700 Subject: [PATCH 010/124] remove extra ep sync Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 8 +- .../text_generation_controller.py | 75 +++++++------------ 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index eb7a9f73b83..1b9a75612cf 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -456,13 +456,15 @@ def _create_mtp_cuda_graphs(self, controller, context): if model_config.cuda_graph_impl != "local": return - # Collect decode-only batch sizes from the decoder graph dimensions. + # Collect batch sizes from all graph dimensions. MTP serial forward + # runs on all active requests (decode + prefill), so we need graphs + # for total request counts, not just decode-only counts. tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) sp_enabled = model_config.sequence_parallel and tp_size > 1 mtp_batch_sizes = set() for dim in context.cuda_graph_batch_dimensions_list: - if dim.prefill_req_count == 0 and dim.decode_req_count > 0: - n = dim.decode_req_count + n = dim.req_count + if n > 0: if sp_enabled: n += (tp_size - n % tp_size) % tp_size mtp_batch_sizes.add(n) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 870668c8a6b..3b2cca71143 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -583,6 +583,23 @@ def _dynamic_step_context_init( is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, ) + # Precompute MTP CUDA graph padded batch size from the already EP-synced + # padded_batch_dimensions. This avoids an extra EP all-reduce on the MTP + # hot path — all ranks derive the same value from the matched graph. + if ( + getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None + and context.using_cuda_graph_this_step() + ): + self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count + tp_size = get_pg_size(self.inference_wrapped_model.tp_group) + sp_enabled = self.model_config.sequence_parallel and tp_size > 1 + if sp_enabled: + self._mtp_resolved_padded_count += ( + tp_size - self._mtp_resolved_padded_count % tp_size + ) % tp_size + else: + self._mtp_resolved_padded_count = None + # If using symmetric kernels and we are using using nccl # for prefill turn off symmetric kernels symmetric_ar_type = self.model_config.symmetric_ar_type @@ -836,40 +853,6 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: spec_tokens[indices] = tokens return spec_tokens - @torch.compile() - def _resolve_mtp_cuda_graph_batch_size(self, local_padded_count: int) -> int: - """Resolve MTP CUDA graph batch size, syncing across EP ranks if needed. - - Finds the smallest pre-captured CUDA graph batch size that fits - ``local_padded_count``, then all-reduces (MAX) across the EP group so - every EP rank — including the dummy rank which sends 0 — agrees on the - same graph size. - - Returns ``local_padded_count`` unchanged when MTP CUDA graphs are not - active or EP is not in use. - """ - mtp_cuda_graph_sizes = getattr(self, '_mtp_cuda_graph_batch_sizes', None) - if not mtp_cuda_graph_sizes: - return local_padded_count - - padded_count = local_padded_count - for size in mtp_cuda_graph_sizes: - if size >= padded_count: - padded_count = size - break - - ep_group = self.inference_wrapped_model.inference_context.expert_model_parallel_group - if ep_group is not None: - sync_tensor = torch.tensor( - [padded_count], dtype=torch.int32, device=torch.cuda.current_device() - ) - torch.distributed.all_reduce( - sync_tensor, op=torch.distributed.ReduceOp.MAX, group=ep_group - ) - padded_count = sync_tensor.item() - - return padded_count - def _compute_serial_mtp_and_sample(self): """Compute MTP logits serially after verification and sample speculative tokens. @@ -921,17 +904,15 @@ def _compute_serial_mtp_and_sample(self): next_token_ids = self._sampled_tokens_cuda[:active_request_count].clone() current_hidden = last_accepted_hidden if has_mtp else None - # Compute padding needed to make batch a multiple of tp_size for SP compatibility. + # Compute padding needed to make batch compatible with SP and CUDA graphs. tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 - if sp_enabled: + if self._mtp_resolved_padded_count is not None: + padded_count = self._mtp_resolved_padded_count + elif sp_enabled: padded_count = active_request_count + (tp_size - active_request_count % tp_size) % tp_size else: padded_count = active_request_count - - # Further pad to match a pre-captured CUDA graph batch size and sync - # across EP ranks so the dummy rank uses the same graph. - padded_count = self._resolve_mtp_cuda_graph_batch_size(padded_count) pad_count = padded_count - active_request_count # Pad hidden states and scatter for sequence parallelism. @@ -1738,16 +1719,14 @@ def _dummy_serial_mtp_forward(self): hidden_size = self.model_config.hidden_size num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) - # Pad token_ids/position_ids to nearest multiple of tp_size so that the - # embedding can reduce-scatter evenly across TP ranks. + # Use precomputed MTP CUDA graph batch size when available; + # otherwise use minimal SP-compatible size. tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 - padded_count = tp_size if sp_enabled else 1 - - # When MTP CUDA graphs are active, sync with real EP ranks to agree - # on batch size (dummy's local value is small; the EP all-reduce MAX - # picks the real ranks' padded_count). - padded_count = self._resolve_mtp_cuda_graph_batch_size(padded_count) + if getattr(self, '_mtp_resolved_padded_count', None) is not None: + padded_count = self._mtp_resolved_padded_count + else: + padded_count = tp_size if sp_enabled else 1 dummy_hidden = None if has_mtp: From 768774342cdd84d6a6adc15c77ddc1e918383348 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 22:39:37 -0700 Subject: [PATCH 011/124] Add nvtx ranges Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 3b2cca71143..e571ae825a6 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -865,6 +865,7 @@ def _compute_serial_mtp_and_sample(self): (scattered along the first dimension) between MTP depths to avoid a redundant gather + scatter round-trip per depth. """ + nvtx_range_push("mtp-spec-decoding/serial-mtp-init") context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count active_slice = slice(context.paused_request_count, context.total_request_count) @@ -925,6 +926,7 @@ def _compute_serial_mtp_and_sample(self): ) num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + nvtx_range_pop("mtp-spec-decoding/serial-mtp-init") for depth in range(num_depths): nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") position_ids = (base_position + depth).unsqueeze(0) # [1, active_request_count] @@ -1109,6 +1111,7 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id # Get the logit indices for tokens that need sampling. # These indices are always needed for input_ids slicing and tracking # accepted sequence positions, even when logits are pre-sliced. + nvtx_range_push("mtp-spec-decoding/verify/logit-indices") required_logit_indices = context.speculative_required_logit_indices(logits.device) if context.config.materialize_only_last_token_logits: @@ -1118,13 +1121,17 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id required_logits = logits.squeeze(0)[ required_logit_indices, : ] # Shape [num_required, vocab_size] + nvtx_range_pop("mtp-spec-decoding/verify/logit-indices") # Sample tokens from logits + nvtx_range_push("mtp-spec-decoding/verify/sample") output_tokens, repeats = self._sample_speculative_logits( required_logits, request_in_prefill_status_tensor ) + nvtx_range_pop("mtp-spec-decoding/verify/sample") # Verify speculative tokens against input tokens. + nvtx_range_push("mtp-spec-decoding/verify/verify-tokens") input_tokens_required = input_ids[0, required_logit_indices] last_one_indices, accepted_tokens_mask, input_tokens_required = ( self._verify_speculative_tokens( @@ -1137,7 +1144,9 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id active_request_count, ) ) - + nvtx_range_pop("mtp-spec-decoding/verify/verify-tokens") + + nvtx_range_push("mtp-spec-decoding/verify/prepare-next") self._prepare_speculative_tokens_for_next_forward_pass( num_decode_requests, output_tokens, @@ -1146,6 +1155,7 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id accepted_tokens_mask, input_tokens_required, ) + nvtx_range_pop("mtp-spec-decoding/verify/prepare-next") @torch.compile() def _prepare_speculative_tokens_for_next_forward_pass( From 407e12d252ebb57891944816548a133d0021ae0f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 22:52:37 -0700 Subject: [PATCH 012/124] Remove cpu sync Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index e571ae825a6..0bbf657a78d 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -1101,12 +1101,6 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id request_in_prefill_status_tensor = context.request_in_prefill_status_tensor[ context.paused_request_count : context.total_request_count ] - request_query_lengths = context.request_query_lengths[ - context.paused_request_count : context.total_request_count - ] - - num_prefill_requests = request_in_prefill_status_tensor.sum().item() - num_decode_requests = active_request_count - num_prefill_requests # Get the logit indices for tokens that need sampling. # These indices are always needed for input_ids slicing and tracking @@ -1130,6 +1124,9 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id ) nvtx_range_pop("mtp-spec-decoding/verify/sample") + num_prefill_requests = context.num_prefill_requests + num_decode_requests = active_request_count - num_prefill_requests + # Verify speculative tokens against input tokens. nvtx_range_push("mtp-spec-decoding/verify/verify-tokens") input_tokens_required = input_ids[0, required_logit_indices] From e54458c4ea1ed83a23c23e900ef806742cb06de8 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 23:15:44 -0700 Subject: [PATCH 013/124] Avoid h2d syncs Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 0bbf657a78d..48d277bdcfc 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -715,9 +715,16 @@ def _dynamic_step_sample_bookkeeping(self): bucket_map[sampling_params].append(request_index) # Just unpack the key directly! + device = torch.cuda.current_device() self._torch_sampling_buckets = [ (indices, *sampling_params) for sampling_params, indices in bucket_map.items() ] + # Pre-compute index tensors on GPU so that _sample_from_logits_2d + # (called once per MTP depth) avoids repeated H2D copies. + self._torch_sampling_bucket_index_tensors = [ + torch.tensor(indices, device=device, dtype=torch.long) + for indices, *_ in self._torch_sampling_buckets + ] def _rewind_kv_cache(self): """Update the KV cache bookkeeping for speculative decoding. @@ -838,18 +845,15 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: Tensor: Sampled tokens of shape [num_requests]. """ spec_token_list = [] - indices_list = [] - for request_indices, temp, top_k, top_p in self._torch_sampling_buckets: - request_indices_tensor = torch.tensor( - request_indices, device=logits_2d.device, dtype=torch.long - ) + for idx_tensor, (_, temp, top_k, top_p) in zip( + self._torch_sampling_bucket_index_tensors, self._torch_sampling_buckets + ): spec_token_list.append( - self._torch_sampling_func(logits_2d[request_indices_tensor, :], temp, top_k, top_p) + self._torch_sampling_func(logits_2d[idx_tensor, :], temp, top_k, top_p) ) - indices_list.append(request_indices_tensor) spec_tokens = torch.empty(logits_2d.shape[0], device=logits_2d.device, dtype=torch.int64) - for tokens, indices in zip(spec_token_list, indices_list): + for tokens, indices in zip(spec_token_list, self._torch_sampling_bucket_index_tensors): spec_tokens[indices] = tokens return spec_tokens @@ -1014,12 +1018,11 @@ def _sample_speculative_logits( output_tokens_jumbled_list = [] token_order_list = [] - for request_indices, temp, top_k, top_p in self._torch_sampling_buckets: - request_indices_tensor = torch.tensor( - request_indices, device=token_to_request_index.device - ) + for idx_tensor, (_, temp, top_k, top_p) in zip( + self._torch_sampling_bucket_index_tensors, self._torch_sampling_buckets + ): required_indices = torch.where( - torch.isin(token_to_request_index, request_indices_tensor) + torch.isin(token_to_request_index, idx_tensor) )[0] output_tokens_jumbled_list.append( self._torch_sampling_func(required_logits[required_indices, :], temp, top_k, top_p) From 70afe4c1e29c26bc0692fc773571aa7325d9ae3b Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 23:26:12 -0700 Subject: [PATCH 014/124] Linting Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 4 +--- .../text_generation_controller.py | 18 ++++++++++-------- megatron/core/transformer/cuda_graphs.py | 9 +++------ 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 1b9a75612cf..006fe56b7f4 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -486,9 +486,7 @@ def _create_mtp_cuda_graphs(self, controller, context): if is_inference_optimized_ep: set_inference_cuda_graphed_iteration_for_ep_inference(model) - logging.info( - "> MTP CUDA graph warmup: %d batch size(s)", len(mtp_batch_sizes), - ) + logging.info("> MTP CUDA graph warmup: %d batch size(s)", len(mtp_batch_sizes)) for batch_size in sorted(mtp_batch_sizes): dummy_hidden = torch.zeros((batch_size, 1, hidden_size), device=device, dtype=dtype) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 48d277bdcfc..b30c90a2d99 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -915,7 +915,9 @@ def _compute_serial_mtp_and_sample(self): if self._mtp_resolved_padded_count is not None: padded_count = self._mtp_resolved_padded_count elif sp_enabled: - padded_count = active_request_count + (tp_size - active_request_count % tp_size) % tp_size + padded_count = ( + active_request_count + (tp_size - active_request_count % tp_size) % tp_size + ) else: padded_count = active_request_count pad_count = padded_count - active_request_count @@ -1021,9 +1023,7 @@ def _sample_speculative_logits( for idx_tensor, (_, temp, top_k, top_p) in zip( self._torch_sampling_bucket_index_tensors, self._torch_sampling_buckets ): - required_indices = torch.where( - torch.isin(token_to_request_index, idx_tensor) - )[0] + required_indices = torch.where(torch.isin(token_to_request_index, idx_tensor))[0] output_tokens_jumbled_list.append( self._torch_sampling_func(required_logits[required_indices, :], temp, top_k, top_p) ) @@ -1057,7 +1057,7 @@ def _verify_speculative_tokens( input_tokens_required = input_tokens_required.squeeze(0) device = input_tokens_required.device - + # Initialize mask functionally accepted_tokens_mask = torch.zeros_like(input_tokens_required, dtype=torch.bool) @@ -1076,7 +1076,7 @@ def _verify_speculative_tokens( # Functionally build the mask: The first token (base token) is always accepted first_col_true = torch.ones_like(decode_inputs[:, :1], dtype=torch.bool) - rest_cols = (decode_inputs[:, 1:] == decode_outputs_shifted[:, 1:]) + rest_cols = decode_inputs[:, 1:] == decode_outputs_shifted[:, 1:] decode_mask_2d = torch.cat([first_col_true, rest_cols], dim=1) # Enforce consecutive acceptance: cummin propagates False to the right @@ -1085,7 +1085,9 @@ def _verify_speculative_tokens( # Compute last accepted indices for decode requests local_last_indices = decode_mask_2d.sum(dim=1) - 1 - row_offsets = torch.arange(num_decode_requests, device=device) * (self.num_speculative_tokens + 1) + row_offsets = torch.arange(num_decode_requests, device=device) * ( + self.num_speculative_tokens + 1 + ) last_one_indices[:num_decode_requests] = row_offsets + local_last_indices # Compute last accepted indices for prefill requests mathematically instead of using torch.nonzero @@ -1093,7 +1095,7 @@ def _verify_speculative_tokens( last_one_indices[num_decode_requests:] = prefill_valid return last_one_indices, accepted_tokens_mask, input_tokens_required - + def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_ids: Tensor): """ Sample tokens from logits for dynamic batching with speculative tokens and verify the tokens. diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 343dfa8fcbe..de6216934ef 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -242,9 +242,7 @@ def _check_supported_type(meta): ArgMetadata, } assert ( - meta.type in _SUPPORTED_TYPES - or is_dataclass(meta.value) - or callable(meta.value) + meta.type in _SUPPORTED_TYPES or is_dataclass(meta.value) or callable(meta.value) ), f"Cudagraphs received an arg of type {meta.type} which is not supported." @@ -1601,9 +1599,8 @@ def __call__(self, megatron_module, args, kwargs): kwargs (dict): The keyword args to be passed to the module. """ is_inference_mode = ( - ('inference_context' in kwargs.keys() and kwargs['inference_context']) - or self._is_mtp_inference(megatron_module, kwargs) - ) + 'inference_context' in kwargs.keys() and kwargs['inference_context'] + ) or self._is_mtp_inference(megatron_module, kwargs) is_in_checkpoint_fwd = is_checkpointing() if HAVE_TE_GRAPHS: is_in_checkpoint_fwd = is_in_checkpoint_fwd or is_fp8_activation_recompute_enabled() From 5e84f8552080002587568d9d526016d1ec2070cc Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 23:46:54 -0700 Subject: [PATCH 015/124] compile rewind_kv_cache Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 173 +++++++++--------- .../test_text_generation_controller.py | 9 +- 2 files changed, 93 insertions(+), 89 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index b30c90a2d99..42982688ff7 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -726,114 +726,109 @@ def _dynamic_step_sample_bookkeeping(self): for indices, *_ in self._torch_sampling_buckets ] - def _rewind_kv_cache(self): + @torch.compile() + def _rewind_kv_cache(self) -> tuple: """Update the KV cache bookkeeping for speculative decoding. After forward pass with speculative tokens, some tokens may be rejected. - This function "rewinds" the KV cache bookkeeping to reflect only the accepted tokens. - - When speculative tokens are rejected, we need to: - 1. Update request_kv_length_offsets (total sequence length) - 2. Update request_last_kv_block_offset (position within last block) - 3. If rewinding crosses a block boundary: - - Reduce request_kv_block_counts - - Update request_last_kv_block_id to point to the previous block - - Clear the entry in request_to_kv_block_ids for the released block - - Release the block back to the allocator + This function "rewinds" the KV cache bookkeeping to reflect only the accepted + tokens. All operations use fixed-shape tensors (no data-dependent branches, + no boolean indexing, no torch.nonzero) so the entire function is torch-compilable. + + Returns (blocks_to_release, remove_mask) for the caller to release blocks + back to the allocator outside the compiled graph. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count active_request_slice = slice(context.paused_request_count, context.total_request_count) - # Get the accepted token counts for each request - # Note: _accepted_token_counts is indexed from 0 to active_request_count-1 accepted_tokens_per_request = self._accepted_token_counts_per_request[:active_request_count] - # Number of tokens to rewind (rejected speculative tokens) - num_tokens_to_rewind = self.num_speculative_tokens - accepted_tokens_per_request - - # For prefill requests, no speculative tokens were forwarded through the model, - # so there is nothing to rewind. request_in_prefill_status = context.request_in_prefill_status_tensor[active_request_slice] - num_tokens_to_rewind[request_in_prefill_status == 1] = 0 + request_last_kv_block_offset = context.request_last_kv_block_offset[active_request_slice] + request_kv_length_offsets = context.request_kv_length_offsets[active_request_slice] + request_kv_block_counts = context.request_kv_block_counts[active_request_slice] + request_last_kv_block_id = context.request_last_kv_block_id[active_request_slice] + request_to_kv_block_ids = context.request_to_kv_block_ids[active_request_slice] + block_size_tokens = context.block_size_tokens - # Save the original offset BEFORE modifying to correctly detect block boundary crossing - original_offset = context.request_last_kv_block_offset[active_request_slice].clone() + num_tokens_to_rewind = self.num_speculative_tokens - accepted_tokens_per_request - # Check which requests need to rewind to a previous block BEFORE modifying - # A request crosses back to a previous block if: original_offset - num_tokens_to_rewind < 0 - remove_allocated_blocks_mask = (original_offset - num_tokens_to_rewind) < 0 + # Zero out rewind for prefill requests (no data-dependent branch) + num_tokens_to_rewind = torch.where( + request_in_prefill_status == 1, 0, num_tokens_to_rewind + ) - # Update the offsets - context.request_last_kv_block_offset[active_request_slice] = ( - original_offset - num_tokens_to_rewind - ) % context.block_size_tokens + original_offset = request_last_kv_block_offset.clone() + remove_mask = (original_offset - num_tokens_to_rewind) < 0 - context.request_kv_length_offsets[active_request_slice] = ( - context.request_kv_length_offsets[active_request_slice] - num_tokens_to_rewind + # Update offsets + request_last_kv_block_offset.copy_( + (original_offset - num_tokens_to_rewind) % block_size_tokens ) + request_kv_length_offsets -= num_tokens_to_rewind - # No need to update request_query_lengths (It will be set correctly in the next iteration) - - # For requests that crossed back to a previous block, we need to: - # 1. Reduce the block count by 1 - # 2. Get the block ID to release (current request_last_kv_block_id) - # 3. Update request_last_kv_block_id to point to the previous block - # 4. Clear the entry in request_to_kv_block_ids for the released block - # 5. Release the block back to the allocator - if remove_allocated_blocks_mask.any(): - # Get indices of requests that need to release a block (relative to active requests) - requests_needing_release = torch.nonzero(remove_allocated_blocks_mask, as_tuple=True)[0] - # Convert to absolute indices in the context tensors - absolute_indices = requests_needing_release + context.paused_request_count - - # No clone needed: advanced (fancy) indexing with a tensor already returns - # a copy, not a view. - blocks_to_release = context.request_last_kv_block_id[absolute_indices] - - # Reduce block counts for requests that crossed back - context.request_kv_block_counts[absolute_indices] -= 1 - - # Get the new block counts after decrement - new_block_counts = context.request_kv_block_counts[absolute_indices] - - # Update request_last_kv_block_id to point to the previous block - # and clear the released block entry in request_to_kv_block_ids - # Vectorized implementation using advanced indexing: - # Note: new_block_counts is guaranteed to be > 0 for all requests here, since - # crossing back to a previous block implies the request had at least 2 blocks. - - # Update request_last_kv_block_id to point to the previous block (at index new_count - 1) - context.request_last_kv_block_id[absolute_indices] = context.request_to_kv_block_ids[ - absolute_indices, new_block_counts - 1 - ] + # Save current last block IDs before modifications (blocks to potentially release) + blocks_to_release = request_last_kv_block_id.clone() + + # Conditionally decrement block counts where block boundary is crossed + request_kv_block_counts -= remove_mask.to(request_kv_block_counts.dtype) - # Clear the released block entry (at index new_count, which was the old last block) - context.request_to_kv_block_ids[absolute_indices, new_block_counts] = -1 + # Get previous block IDs using gather (for requests crossing block boundary). + # For requests not crossing, the gathered value is unused (discarded by torch.where). + prev_block_idx = torch.clamp(request_kv_block_counts - 1, min=0) + prev_block_ids = request_to_kv_block_ids.gather( + 1, prev_block_idx.unsqueeze(1) + ).squeeze(1) - # Release the blocks back to the allocator - context.kv_block_allocator.release_memory_blocks(blocks_to_release) + # Conditionally update last block ID to point to previous block + request_last_kv_block_id.copy_( + torch.where(remove_mask, prev_block_ids, request_last_kv_block_id) + ) - # Mamba speculative rewind state update + # Clear released block entries using scatter. + # For requests crossing boundary: write -1 at index new_block_count. + # For others: write back the existing value (no-op). + scatter_idx = torch.clamp( + request_kv_block_counts, max=request_to_kv_block_ids.shape[1] - 1 + ) + current_vals = request_to_kv_block_ids.gather( + 1, scatter_idx.unsqueeze(1) + ).squeeze(1) + clear_vals = torch.where(remove_mask, -1, current_vals) + request_to_kv_block_ids.scatter_( + 1, scatter_idx.unsqueeze(1), clear_vals.unsqueeze(1) + ) + + # Mamba speculative rewind state update. + # torch.compile treats `context.is_hybrid_model` as a static guard, so this + # compiles into two specializations (hybrid vs. non-hybrid) with no graph break. if context.is_hybrid_model: - active_mamba_indices = context.mamba_metadata.request_to_mamba_state_idx[ + mamba_state_idx = context.mamba_metadata.request_to_mamba_state_idx[ active_request_slice ] - is_decode_mask = context.request_in_prefill_status_tensor[active_request_slice] == 0 - decode_mamba_indices = active_mamba_indices[is_decode_mask] - accepted_tokens_per_decode_request = accepted_tokens_per_request[is_decode_mask] - - if decode_mamba_indices.numel() > 0: - context.mamba_conv_states[:, decode_mamba_indices] = ( - context.mamba_intermediate_conv_states[ - :, decode_mamba_indices, accepted_tokens_per_decode_request - ] - ) - context.mamba_ssm_states[:, decode_mamba_indices] = ( - context.mamba_intermediate_ssm_states[ - :, decode_mamba_indices, accepted_tokens_per_decode_request - ] - ) + is_decode_mask = request_in_prefill_status == 0 # [N] + + # Gather intermediate states for ALL active requests using fixed-shape + # advanced indexing (no boolean indexing / dynamic shapes). + # For prefill requests the gathered values are discarded by torch.where. + intermediate_conv = context.mamba_intermediate_conv_states[ + :, mamba_state_idx, accepted_tokens_per_request + ] # [L, N, D] + current_conv = context.mamba_conv_states[:, mamba_state_idx] # [L, N, D] + context.mamba_conv_states[:, mamba_state_idx] = torch.where( + is_decode_mask[None, :, None], intermediate_conv, current_conv + ) + + intermediate_ssm = context.mamba_intermediate_ssm_states[ + :, mamba_state_idx, accepted_tokens_per_request + ] # [L, N, D] + current_ssm = context.mamba_ssm_states[:, mamba_state_idx] # [L, N, D] + context.mamba_ssm_states[:, mamba_state_idx] = torch.where( + is_decode_mask[None, :, None], intermediate_ssm, current_ssm + ) + + return blocks_to_release, remove_mask def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: """Sample tokens from 2D logits using existing sampling parameters. @@ -1919,7 +1914,13 @@ async def async_generate_output_tokens_dynamic_batch( nvtx_range_pop("mtp-spec-decoding/verify") # Phase 2: Rewind KV cache for rejected tokens. nvtx_range_push("mtp-spec-decoding/rewind-kv-cache") - self._rewind_kv_cache() + blocks_to_release, remove_mask = self._rewind_kv_cache() + # Release blocks back to the allocator (not compilable due to + # allocator state mutation). release_memory_blocks handles + # empty tensors. + context.kv_block_allocator.release_memory_blocks( + blocks_to_release[remove_mask] + ) nvtx_range_pop("mtp-spec-decoding/rewind-kv-cache") # Disable MoE padding for MTP computation, unless CUDA graphs diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 9c6564f6989..a8d699effde 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1191,7 +1191,8 @@ def test_rewind_kv_cache(self, is_hybrid_model): [1, 0], device='cuda' ) - self.text_generation_controller._rewind_kv_cache() + blocks_to_release, remove_mask = self.text_generation_controller._rewind_kv_cache() + ctx.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) # Assert offsets updated assert torch.equal( @@ -1315,7 +1316,8 @@ def test_rewind_kv_cache_with_prefix_caching_ref_counts(self): [1, 0], device='cuda' ) - self.text_generation_controller._rewind_kv_cache() + blocks_to_release, remove_mask = self.text_generation_controller._rewind_kv_cache() + ctx.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) # Req 1 should have released block 20 (ref count decremented). assert ctx.kv_block_allocator.block_ref_counts[20].item() == 1 @@ -1355,7 +1357,8 @@ def test_rewind_kv_cache_does_not_release_shared_prefix_blocks(self): [0], device='cuda' ) - self.text_generation_controller._rewind_kv_cache() + blocks_to_release, remove_mask = self.text_generation_controller._rewind_kv_cache() + ctx.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) # Only block 40 should be released, not blocks 10, 20, or 30. assert ctx.request_kv_block_counts[0].item() == 3 From e2020b2c8f33917ba5537ace07afa58bc670b13d Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 10 Apr 2026 23:48:03 -0700 Subject: [PATCH 016/124] Linting Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 42982688ff7..947cf5e9194 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -755,9 +755,7 @@ def _rewind_kv_cache(self) -> tuple: num_tokens_to_rewind = self.num_speculative_tokens - accepted_tokens_per_request # Zero out rewind for prefill requests (no data-dependent branch) - num_tokens_to_rewind = torch.where( - request_in_prefill_status == 1, 0, num_tokens_to_rewind - ) + num_tokens_to_rewind = torch.where(request_in_prefill_status == 1, 0, num_tokens_to_rewind) original_offset = request_last_kv_block_offset.clone() remove_mask = (original_offset - num_tokens_to_rewind) < 0 @@ -777,9 +775,7 @@ def _rewind_kv_cache(self) -> tuple: # Get previous block IDs using gather (for requests crossing block boundary). # For requests not crossing, the gathered value is unused (discarded by torch.where). prev_block_idx = torch.clamp(request_kv_block_counts - 1, min=0) - prev_block_ids = request_to_kv_block_ids.gather( - 1, prev_block_idx.unsqueeze(1) - ).squeeze(1) + prev_block_ids = request_to_kv_block_ids.gather(1, prev_block_idx.unsqueeze(1)).squeeze(1) # Conditionally update last block ID to point to previous block request_last_kv_block_id.copy_( @@ -789,16 +785,10 @@ def _rewind_kv_cache(self) -> tuple: # Clear released block entries using scatter. # For requests crossing boundary: write -1 at index new_block_count. # For others: write back the existing value (no-op). - scatter_idx = torch.clamp( - request_kv_block_counts, max=request_to_kv_block_ids.shape[1] - 1 - ) - current_vals = request_to_kv_block_ids.gather( - 1, scatter_idx.unsqueeze(1) - ).squeeze(1) + scatter_idx = torch.clamp(request_kv_block_counts, max=request_to_kv_block_ids.shape[1] - 1) + current_vals = request_to_kv_block_ids.gather(1, scatter_idx.unsqueeze(1)).squeeze(1) clear_vals = torch.where(remove_mask, -1, current_vals) - request_to_kv_block_ids.scatter_( - 1, scatter_idx.unsqueeze(1), clear_vals.unsqueeze(1) - ) + request_to_kv_block_ids.scatter_(1, scatter_idx.unsqueeze(1), clear_vals.unsqueeze(1)) # Mamba speculative rewind state update. # torch.compile treats `context.is_hybrid_model` as a static guard, so this @@ -1918,9 +1908,7 @@ async def async_generate_output_tokens_dynamic_batch( # Release blocks back to the allocator (not compilable due to # allocator state mutation). release_memory_blocks handles # empty tensors. - context.kv_block_allocator.release_memory_blocks( - blocks_to_release[remove_mask] - ) + context.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) nvtx_range_pop("mtp-spec-decoding/rewind-kv-cache") # Disable MoE padding for MTP computation, unless CUDA graphs From 56b93ff7d18ab576e7bd0e5ec77147c9c253a21c Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:07:13 -0700 Subject: [PATCH 017/124] Fix verify_speculative_tokens Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 947cf5e9194..c0a741bf181 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -1026,13 +1026,11 @@ def _sample_speculative_logits( return output_tokens, repeats - @torch.compile() + @torch.compile(dynamic=True) def _verify_speculative_tokens( self, output_tokens: Tensor, input_tokens_required: Tensor, - request_in_prefill_status_tensor: Tensor, - repeats: Tensor, num_decode_requests: int, num_prefill_requests: int, active_request_count: int, @@ -1046,10 +1044,6 @@ def _verify_speculative_tokens( # Initialize mask functionally accepted_tokens_mask = torch.zeros_like(input_tokens_required, dtype=torch.bool) - # Make all prefill tokens accepted - token_to_prefill_idx = torch.repeat_interleave(request_in_prefill_status_tensor, repeats) - accepted_tokens_mask = accepted_tokens_mask | (token_to_prefill_idx == 1) - decode_len = num_decode_requests * (self.num_speculative_tokens + 1) last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) @@ -1068,6 +1062,9 @@ def _verify_speculative_tokens( decode_mask_2d = decode_mask_2d.cummin(dim=1).values accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() + # All prefill tokens are accepted + accepted_tokens_mask[decode_len:] = True + # Compute last accepted indices for decode requests local_last_indices = decode_mask_2d.sum(dim=1) - 1 row_offsets = torch.arange(num_decode_requests, device=device) * ( @@ -1124,8 +1121,6 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id self._verify_speculative_tokens( output_tokens, input_tokens_required, - request_in_prefill_status_tensor, - repeats, num_decode_requests, num_prefill_requests, active_request_count, From 98a32eff3fca4d7efcc86c0e2454512ed3c6315c Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:15:11 -0700 Subject: [PATCH 018/124] More fixes Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index c0a741bf181..4c8f2b4118b 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -1048,9 +1048,8 @@ def _verify_speculative_tokens( last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) # Vectorized decode token verification - # Using .view(-1, ...) safely handles cases where num_decode_requests == 0 without python branches - decode_inputs = input_tokens_required[:decode_len].view(-1, self.num_speculative_tokens + 1) - decode_outputs = output_tokens[:decode_len].view(-1, self.num_speculative_tokens + 1) + decode_inputs = input_tokens_required[:decode_len].view(num_decode_requests, self.num_speculative_tokens + 1) + decode_outputs = output_tokens[:decode_len].view(num_decode_requests, self.num_speculative_tokens + 1) decode_outputs_shifted = decode_outputs.roll(1, dims=1) # Functionally build the mask: The first token (base token) is always accepted From 3fefb93454b9dcdff30b484e3bde6abd655a40de Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:34:34 -0700 Subject: [PATCH 019/124] Add torch.compile back Signed-off-by: Keshav Santhanam --- .../text_generation_controllers/text_generation_controller.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 4c8f2b4118b..70c484b7e9c 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -791,8 +791,6 @@ def _rewind_kv_cache(self) -> tuple: request_to_kv_block_ids.scatter_(1, scatter_idx.unsqueeze(1), clear_vals.unsqueeze(1)) # Mamba speculative rewind state update. - # torch.compile treats `context.is_hybrid_model` as a static guard, so this - # compiles into two specializations (hybrid vs. non-hybrid) with no graph break. if context.is_hybrid_model: mamba_state_idx = context.mamba_metadata.request_to_mamba_state_idx[ active_request_slice @@ -1026,7 +1024,7 @@ def _sample_speculative_logits( return output_tokens, repeats - @torch.compile(dynamic=True) + @torch.compile() def _verify_speculative_tokens( self, output_tokens: Tensor, From 96668d6e6116f7b3cf439956368862b5827ab411 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:35:03 -0700 Subject: [PATCH 020/124] Linting Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 70c484b7e9c..90d420c6c1d 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -1046,8 +1046,12 @@ def _verify_speculative_tokens( last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) # Vectorized decode token verification - decode_inputs = input_tokens_required[:decode_len].view(num_decode_requests, self.num_speculative_tokens + 1) - decode_outputs = output_tokens[:decode_len].view(num_decode_requests, self.num_speculative_tokens + 1) + decode_inputs = input_tokens_required[:decode_len].view( + num_decode_requests, self.num_speculative_tokens + 1 + ) + decode_outputs = output_tokens[:decode_len].view( + num_decode_requests, self.num_speculative_tokens + 1 + ) decode_outputs_shifted = decode_outputs.roll(1, dims=1) # Functionally build the mask: The first token (base token) is always accepted From 9a955775fd42a1d46210beadac80d06f481dde8f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:46:02 -0700 Subject: [PATCH 021/124] Try to fix tests Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 90d420c6c1d..df64b3b0694 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -802,18 +802,24 @@ def _rewind_kv_cache(self) -> tuple: # For prefill requests the gathered values are discarded by torch.where. intermediate_conv = context.mamba_intermediate_conv_states[ :, mamba_state_idx, accepted_tokens_per_request - ] # [L, N, D] - current_conv = context.mamba_conv_states[:, mamba_state_idx] # [L, N, D] + ] # [L, N, *conv_shape] + current_conv = context.mamba_conv_states[:, mamba_state_idx] # [L, N, *conv_shape] + conv_mask = is_decode_mask.reshape( + 1, -1, *([1] * (intermediate_conv.ndim - 2)) + ) context.mamba_conv_states[:, mamba_state_idx] = torch.where( - is_decode_mask[None, :, None], intermediate_conv, current_conv + conv_mask, intermediate_conv, current_conv ) intermediate_ssm = context.mamba_intermediate_ssm_states[ :, mamba_state_idx, accepted_tokens_per_request - ] # [L, N, D] - current_ssm = context.mamba_ssm_states[:, mamba_state_idx] # [L, N, D] + ] # [L, N, *ssm_shape] + current_ssm = context.mamba_ssm_states[:, mamba_state_idx] # [L, N, *ssm_shape] + ssm_mask = is_decode_mask.reshape( + 1, -1, *([1] * (intermediate_ssm.ndim - 2)) + ) context.mamba_ssm_states[:, mamba_state_idx] = torch.where( - is_decode_mask[None, :, None], intermediate_ssm, current_ssm + ssm_mask, intermediate_ssm, current_ssm ) return blocks_to_release, remove_mask From 74edd58ca72e8e700eae7289077e01951bbeb10a Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:50:00 -0700 Subject: [PATCH 022/124] Fix test Signed-off-by: Keshav Santhanam --- .../test_text_generation_controller.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index a8d699effde..01f145e84b3 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1124,6 +1124,9 @@ def mock_sampling_func(logits, *args, **kwargs): # Override sampling to return our predictable mock outputs self.text_generation_controller._torch_sampling_buckets = [([0, 1], 1.0, 1, 0.0)] + self.text_generation_controller._torch_sampling_bucket_index_tensors = [ + torch.tensor([0, 1], device='cuda', dtype=torch.long) + ] self.text_generation_controller._torch_sampling_func = mock.MagicMock( side_effect=mock_sampling_func ) From b6466d21b062ab3363eafb7d366aa80822f4b7ff Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:53:57 -0700 Subject: [PATCH 023/124] More test fixes Signed-off-by: Keshav Santhanam --- .../test_text_generation_controller.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 01f145e84b3..d1b6d59ac48 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1255,6 +1255,9 @@ def test_speculative_multinomial_sampling(self): # Set up a bucket that forces multinomial sampling (top_p = 0.9, top_k = 0) # _torch_sampling_buckets format: (indices, temp, top_k, top_p) self.text_generation_controller._torch_sampling_buckets = [([0, 1], 1.0, 0, 0.9)] + self.text_generation_controller._torch_sampling_bucket_index_tensors = [ + torch.tensor([0, 1], device='cuda', dtype=torch.long) + ] # Since we are actually testing the internal math of `_torch_sampling_func` handling the shapes, # we DO NOT mock `_torch_sampling_func` here. We want it to run natively to prove it doesn't crash. @@ -1494,6 +1497,9 @@ def test_mtp_sp_padding_real_ranks(self, active_request_count): # Greedy sampling: top_k=1 selects the argmax token deterministically. ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] + ctrl._torch_sampling_bucket_index_tensors = [ + torch.arange(active_request_count, device='cuda', dtype=torch.long) + ] # Run the MTP forward pass ctrl._compute_serial_mtp_and_sample() From 6941afb08c67a1b964808a4076d7a1b6150a435f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 00:57:21 -0700 Subject: [PATCH 024/124] Bug fix Signed-off-by: Keshav Santhanam --- .../text_generation_controllers/text_generation_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index df64b3b0694..06346e6e542 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -901,7 +901,7 @@ def _compute_serial_mtp_and_sample(self): # Compute padding needed to make batch compatible with SP and CUDA graphs. tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 - if self._mtp_resolved_padded_count is not None: + if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count elif sp_enabled: padded_count = ( From 94452d55f8d917106e1a80514eddd109c95b6579 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 01:00:39 -0700 Subject: [PATCH 025/124] Linting Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 06346e6e542..cb323e5625d 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -804,9 +804,7 @@ def _rewind_kv_cache(self) -> tuple: :, mamba_state_idx, accepted_tokens_per_request ] # [L, N, *conv_shape] current_conv = context.mamba_conv_states[:, mamba_state_idx] # [L, N, *conv_shape] - conv_mask = is_decode_mask.reshape( - 1, -1, *([1] * (intermediate_conv.ndim - 2)) - ) + conv_mask = is_decode_mask.reshape(1, -1, *([1] * (intermediate_conv.ndim - 2))) context.mamba_conv_states[:, mamba_state_idx] = torch.where( conv_mask, intermediate_conv, current_conv ) @@ -815,9 +813,7 @@ def _rewind_kv_cache(self) -> tuple: :, mamba_state_idx, accepted_tokens_per_request ] # [L, N, *ssm_shape] current_ssm = context.mamba_ssm_states[:, mamba_state_idx] # [L, N, *ssm_shape] - ssm_mask = is_decode_mask.reshape( - 1, -1, *([1] * (intermediate_ssm.ndim - 2)) - ) + ssm_mask = is_decode_mask.reshape(1, -1, *([1] * (intermediate_ssm.ndim - 2))) context.mamba_ssm_states[:, mamba_state_idx] = torch.where( ssm_mask, intermediate_ssm, current_ssm ) From 774ec538545e4413a11efb0ab316700c06267b18 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 11 Apr 2026 14:33:50 -0700 Subject: [PATCH 026/124] kernelize Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 219 ++++----- .../triton_kernels.py | 461 ++++++++++++++++++ 2 files changed, 544 insertions(+), 136 deletions(-) create mode 100644 megatron/core/inference/text_generation_controllers/triton_kernels.py diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index cb323e5625d..7d1e6e98787 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -53,6 +53,12 @@ HAVE_TE = False from megatron.core.inference.batch_dimensions_utils import InferenceBatchDimensions +from megatron.core.inference.text_generation_controllers.triton_kernels import ( + mamba_state_selective_copy, + prepare_next_forward_pass, + rewind_kv_cache, + verify_speculative_tokens, +) # pylint: disable=line-too-long @@ -176,6 +182,12 @@ def _init_mtp_sampling_tensor(self): ) * -1 ) + self._accepted_token_counts_per_request = torch.zeros( + max_requests, dtype=torch.int64, device=device + ) + self._last_accepted_seq_indices_buf = torch.empty( + max_requests, dtype=torch.int64, device=device + ) @staticmethod def tokenize_prompt(tokenizer, prompt: str, add_BOS: bool = False) -> List[int]: @@ -726,14 +738,13 @@ def _dynamic_step_sample_bookkeeping(self): for indices, *_ in self._torch_sampling_buckets ] - @torch.compile() def _rewind_kv_cache(self) -> tuple: """Update the KV cache bookkeeping for speculative decoding. After forward pass with speculative tokens, some tokens may be rejected. This function "rewinds" the KV cache bookkeeping to reflect only the accepted - tokens. All operations use fixed-shape tensors (no data-dependent branches, - no boolean indexing, no torch.nonzero) so the entire function is torch-compilable. + tokens. The core bookkeeping is handled by a Triton kernel (one thread per + request). Mamba hybrid-model state updates remain in PyTorch. Returns (blocks_to_release, remove_mask) for the caller to release blocks back to the allocator outside the compiled graph. @@ -750,72 +761,52 @@ def _rewind_kv_cache(self) -> tuple: request_kv_block_counts = context.request_kv_block_counts[active_request_slice] request_last_kv_block_id = context.request_last_kv_block_id[active_request_slice] request_to_kv_block_ids = context.request_to_kv_block_ids[active_request_slice] - block_size_tokens = context.block_size_tokens - - num_tokens_to_rewind = self.num_speculative_tokens - accepted_tokens_per_request - - # Zero out rewind for prefill requests (no data-dependent branch) - num_tokens_to_rewind = torch.where(request_in_prefill_status == 1, 0, num_tokens_to_rewind) - - original_offset = request_last_kv_block_offset.clone() - remove_mask = (original_offset - num_tokens_to_rewind) < 0 - - # Update offsets - request_last_kv_block_offset.copy_( - (original_offset - num_tokens_to_rewind) % block_size_tokens - ) - request_kv_length_offsets -= num_tokens_to_rewind - # Save current last block IDs before modifications (blocks to potentially release) - blocks_to_release = request_last_kv_block_id.clone() - - # Conditionally decrement block counts where block boundary is crossed - request_kv_block_counts -= remove_mask.to(request_kv_block_counts.dtype) - - # Get previous block IDs using gather (for requests crossing block boundary). - # For requests not crossing, the gathered value is unused (discarded by torch.where). - prev_block_idx = torch.clamp(request_kv_block_counts - 1, min=0) - prev_block_ids = request_to_kv_block_ids.gather(1, prev_block_idx.unsqueeze(1)).squeeze(1) - - # Conditionally update last block ID to point to previous block - request_last_kv_block_id.copy_( - torch.where(remove_mask, prev_block_ids, request_last_kv_block_id) + # --- Triton kernel: core KV-cache rewind --- + blocks_to_release, remove_mask = rewind_kv_cache( + accepted_counts=accepted_tokens_per_request, + prefill_status=request_in_prefill_status, + last_kv_block_offset=request_last_kv_block_offset, + kv_length_offsets=request_kv_length_offsets, + kv_block_counts=request_kv_block_counts, + last_kv_block_id=request_last_kv_block_id, + kv_block_ids=request_to_kv_block_ids, + num_speculative_tokens=self.num_speculative_tokens, + block_size_tokens=context.block_size_tokens, ) - # Clear released block entries using scatter. - # For requests crossing boundary: write -1 at index new_block_count. - # For others: write back the existing value (no-op). - scatter_idx = torch.clamp(request_kv_block_counts, max=request_to_kv_block_ids.shape[1] - 1) - current_vals = request_to_kv_block_ids.gather(1, scatter_idx.unsqueeze(1)).squeeze(1) - clear_vals = torch.where(remove_mask, -1, current_vals) - request_to_kv_block_ids.scatter_(1, scatter_idx.unsqueeze(1), clear_vals.unsqueeze(1)) - - # Mamba speculative rewind state update. + # --- Mamba speculative rewind state update (Triton, zero-alloc) --- + # + # The original code gathered full (L, N, *state_shape) temporaries via + # advanced indexing (always a copy), then used torch.where to select + # between intermediate and current, creating 3 large temps per state + # type (conv + SSM = 6 total). For large models this was hundreds of + # MB of transient GPU memory and a frequent OOM trigger. + # + # The Triton kernel below writes directly from + # intermediate[layer, slot, accepted, ...] + # into + # current[layer, slot, ...] + # for decode requests only, with zero temporary allocations. if context.is_hybrid_model: mamba_state_idx = context.mamba_metadata.request_to_mamba_state_idx[ active_request_slice ] - is_decode_mask = request_in_prefill_status == 0 # [N] - - # Gather intermediate states for ALL active requests using fixed-shape - # advanced indexing (no boolean indexing / dynamic shapes). - # For prefill requests the gathered values are discarded by torch.where. - intermediate_conv = context.mamba_intermediate_conv_states[ - :, mamba_state_idx, accepted_tokens_per_request - ] # [L, N, *conv_shape] - current_conv = context.mamba_conv_states[:, mamba_state_idx] # [L, N, *conv_shape] - conv_mask = is_decode_mask.reshape(1, -1, *([1] * (intermediate_conv.ndim - 2))) - context.mamba_conv_states[:, mamba_state_idx] = torch.where( - conv_mask, intermediate_conv, current_conv + mamba_state_selective_copy( + intermediate_states=context.mamba_intermediate_conv_states, + current_states=context.mamba_conv_states, + prefill_status=request_in_prefill_status, + state_idx=mamba_state_idx, + accepted_counts=accepted_tokens_per_request, + num_layers=context.num_mamba_layers, ) - - intermediate_ssm = context.mamba_intermediate_ssm_states[ - :, mamba_state_idx, accepted_tokens_per_request - ] # [L, N, *ssm_shape] - current_ssm = context.mamba_ssm_states[:, mamba_state_idx] # [L, N, *ssm_shape] - ssm_mask = is_decode_mask.reshape(1, -1, *([1] * (intermediate_ssm.ndim - 2))) - context.mamba_ssm_states[:, mamba_state_idx] = torch.where( - ssm_mask, intermediate_ssm, current_ssm + mamba_state_selective_copy( + intermediate_states=context.mamba_intermediate_ssm_states, + current_states=context.mamba_ssm_states, + prefill_status=request_in_prefill_status, + state_idx=mamba_state_idx, + accepted_counts=accepted_tokens_per_request, + num_layers=context.num_mamba_layers, ) return blocks_to_release, remove_mask @@ -1026,7 +1017,6 @@ def _sample_speculative_logits( return output_tokens, repeats - @torch.compile() def _verify_speculative_tokens( self, output_tokens: Tensor, @@ -1035,51 +1025,14 @@ def _verify_speculative_tokens( num_prefill_requests: int, active_request_count: int, ) -> tuple: - """Verify speculative tokens against input tokens without data-dependent graph breaks.""" - if input_tokens_required.ndim == 2: - input_tokens_required = input_tokens_required.squeeze(0) - - device = input_tokens_required.device - - # Initialize mask functionally - accepted_tokens_mask = torch.zeros_like(input_tokens_required, dtype=torch.bool) - - decode_len = num_decode_requests * (self.num_speculative_tokens + 1) - last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) - - # Vectorized decode token verification - decode_inputs = input_tokens_required[:decode_len].view( - num_decode_requests, self.num_speculative_tokens + 1 - ) - decode_outputs = output_tokens[:decode_len].view( - num_decode_requests, self.num_speculative_tokens + 1 - ) - decode_outputs_shifted = decode_outputs.roll(1, dims=1) - - # Functionally build the mask: The first token (base token) is always accepted - first_col_true = torch.ones_like(decode_inputs[:, :1], dtype=torch.bool) - rest_cols = decode_inputs[:, 1:] == decode_outputs_shifted[:, 1:] - decode_mask_2d = torch.cat([first_col_true, rest_cols], dim=1) - - # Enforce consecutive acceptance: cummin propagates False to the right - decode_mask_2d = decode_mask_2d.cummin(dim=1).values - accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() - - # All prefill tokens are accepted - accepted_tokens_mask[decode_len:] = True - - # Compute last accepted indices for decode requests - local_last_indices = decode_mask_2d.sum(dim=1) - 1 - row_offsets = torch.arange(num_decode_requests, device=device) * ( - self.num_speculative_tokens + 1 + """Verify speculative tokens against input tokens (Triton kernel).""" + return verify_speculative_tokens( + input_tokens=input_tokens_required, + output_tokens=output_tokens, + num_decode_requests=num_decode_requests, + num_prefill_requests=num_prefill_requests, + num_speculative_tokens=self.num_speculative_tokens, ) - last_one_indices[:num_decode_requests] = row_offsets + local_last_indices - - # Compute last accepted indices for prefill requests mathematically instead of using torch.nonzero - prefill_valid = decode_len + torch.arange(num_prefill_requests, device=device) - last_one_indices[num_decode_requests:] = prefill_valid - - return last_one_indices, accepted_tokens_mask, input_tokens_required def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_ids: Tensor): """ @@ -1142,7 +1095,6 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, logits: Tensor, input_id ) nvtx_range_pop("mtp-spec-decoding/verify/prepare-next") - @torch.compile() def _prepare_speculative_tokens_for_next_forward_pass( self, num_decode_requests: int, @@ -1152,37 +1104,32 @@ def _prepare_speculative_tokens_for_next_forward_pass( accepted_tokens_mask: torch.Tensor, input_tokens_required: torch.Tensor, ): - # Store the final sampled tokens for the next forward pass. - final_sampled_tokens = output_tokens[last_one_indices] - self._sampled_tokens_cuda[: len(final_sampled_tokens)] = final_sampled_tokens - - # Store the last accepted positions in the packed sequence for serial - # MTP computation after verification. - self._last_accepted_seq_indices = required_logit_indices[last_one_indices] + """Prepare accepted speculative tokens for the next forward pass (Triton kernel). - # Extract accepted tokens and counts for decode requests. - # For prefill it is always set to 1. For decode, the first token is always accepted, - # then we compare with input tokens and accept the next tokens if its a match. - # - # Example (continuing from above): - # input_tokens_required: [ a5 a6s a7s | b3 b4s b5s | c6 c7s c8s | d2 | e4 ] - # Accepted tokens mask [ 1 1 0 | 1 1 1 | 1 0 0 | 1 | 1 ] - # Accepted tokens [ [a6s -1] | [b4s b5s] | [-1 -1] ] # Only decode requests (prefill defaults to -1) - # Accepted token counts [ 1 | 2 | 0 ] # Prefill defaults to 0 - input_tokens_required[accepted_tokens_mask == 0] = -1 # Mask out non-accepted tokens - input_tokens_decode_mode = input_tokens_required[ - : num_decode_requests * (self.num_speculative_tokens + 1) - ] - input_tokens_reshaped = input_tokens_decode_mode.reshape( - -1, self.num_speculative_tokens + 1 - ) # shape: [num_decode_requests, num_speculative_tokens + 1] - - # Skip the first token of every decode request (i.e a5, b3, c6) - accepted_tokens = input_tokens_reshaped[:, 1:] - self._accepted_tokens_per_request[: accepted_tokens.shape[0], :] = accepted_tokens - self._accepted_token_counts_per_request = (self._accepted_tokens_per_request != -1).sum( - dim=1 + Example: + input_tokens_required: [ a5 a6s a7s | b3 b4s b5s | c6 c7s c8s | d2 | e4 ] + Accepted tokens mask [ 1 1 0 | 1 1 1 | 1 0 0 | 1 | 1 ] + Accepted tokens [ [a6s -1] | [b4s b5s] | [-1 -1] ] (decode only; prefill → -1) + Accepted token counts [ 1 | 2 | 0 ] (prefill defaults to 0) + """ + active_request_count = last_one_indices.shape[0] + prepare_next_forward_pass( + num_decode_requests=num_decode_requests, + output_tokens=output_tokens, + required_logit_indices=required_logit_indices, + last_one_indices=last_one_indices, + accepted_tokens_mask=accepted_tokens_mask, + input_tokens=input_tokens_required, + sampled_tokens_buf=self._sampled_tokens_cuda, + last_accepted_seq_buf=self._last_accepted_seq_indices_buf, + accepted_tokens_per_request=self._accepted_tokens_per_request, + accepted_token_counts=self._accepted_token_counts_per_request, + num_speculative_tokens=self.num_speculative_tokens, ) + # Expose the active slice so downstream code sees the right length. + self._last_accepted_seq_indices = self._last_accepted_seq_indices_buf[ + :active_request_count + ] def _dynamic_step_sample_logits(self, logits: Tensor): """Sample tokens from logits for dynamic batching. diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py new file mode 100644 index 00000000000..e023e027fdc --- /dev/null +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -0,0 +1,461 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import math + +import torch + +try: + import triton + import triton.language as tl + + HAVE_TRITON = True +except ImportError: + from unittest.mock import MagicMock + + from megatron.core.utils import null_decorator + + triton = MagicMock() + triton.jit = null_decorator + tl = MagicMock() + HAVE_TRITON = False + + +# --------------------------------------------------------------------------- +# Kernel 1: KV-cache rewind for speculative decoding +# --------------------------------------------------------------------------- +@triton.jit +def _rewind_kv_cache_kernel( + # Per-request input (read-only) + ACCEPTED_COUNTS_PTR, + PREFILL_STATUS_PTR, + # Per-request state (read-write, updated in-place) + LAST_KV_BLOCK_OFFSET_PTR, + KV_LENGTH_OFFSETS_PTR, + KV_BLOCK_COUNTS_PTR, + LAST_KV_BLOCK_ID_PTR, + # 2-D table [N, max_blocks] (read-write) + KV_BLOCK_IDS_PTR, + # Per-request outputs + BLOCKS_TO_RELEASE_PTR, + REMOVE_MASK_PTR, + # Strides / limits + kv_block_ids_stride, + max_blocks_minus_1, + # Compile-time constants + NUM_SPEC_TOKENS: tl.constexpr, + BLOCK_SIZE_TOKENS: tl.constexpr, +): + """Rewind KV-cache bookkeeping for one request after speculative verification. + + Grid: (active_request_count,) + Each program handles exactly one request. + """ + pid = tl.program_id(0) + + # --- Load per-request scalars --- + accepted = tl.load(ACCEPTED_COUNTS_PTR + pid) + prefill = tl.load(PREFILL_STATUS_PTR + pid) + last_offset = tl.load(LAST_KV_BLOCK_OFFSET_PTR + pid) + kv_length = tl.load(KV_LENGTH_OFFSETS_PTR + pid) + block_count = tl.load(KV_BLOCK_COUNTS_PTR + pid) + last_block_id = tl.load(LAST_KV_BLOCK_ID_PTR + pid) + + # --- Compute rewind (zero for prefill requests) --- + num_to_rewind = tl.where(prefill == 1, 0, NUM_SPEC_TOKENS - accepted) + diff = last_offset - num_to_rewind + remove = diff < 0 + + # Python-style modulo: ((diff % M) + M) % M to handle negative diff + new_offset = ((diff % BLOCK_SIZE_TOKENS) + BLOCK_SIZE_TOKENS) % BLOCK_SIZE_TOKENS + tl.store(LAST_KV_BLOCK_OFFSET_PTR + pid, new_offset) + tl.store(KV_LENGTH_OFFSETS_PTR + pid, kv_length - num_to_rewind) + + # Save current last block id (will be released by caller if remove is True) + tl.store(BLOCKS_TO_RELEASE_PTR + pid, last_block_id) + + # Decrement block count when a block boundary was crossed + new_block_count = tl.where(remove, block_count - 1, block_count) + tl.store(KV_BLOCK_COUNTS_PTR + pid, new_block_count) + + # Gather previous block id from the 2-D table + kv_row_base = pid.to(tl.int64) * kv_block_ids_stride + prev_idx = tl.maximum(new_block_count - 1, 0) + prev_block_id = tl.load(KV_BLOCK_IDS_PTR + kv_row_base + prev_idx) + + # Conditionally update last block id + tl.store(LAST_KV_BLOCK_ID_PTR + pid, tl.where(remove, prev_block_id, last_block_id)) + + # Clear released block entry via scatter + scatter_idx = tl.minimum(new_block_count, max_blocks_minus_1) + current_val = tl.load(KV_BLOCK_IDS_PTR + kv_row_base + scatter_idx) + tl.store(KV_BLOCK_IDS_PTR + kv_row_base + scatter_idx, tl.where(remove, -1, current_val)) + + # Output remove mask for the caller (to release blocks outside this kernel) + tl.store(REMOVE_MASK_PTR + pid, remove) + + +def rewind_kv_cache( + accepted_counts, + prefill_status, + last_kv_block_offset, + kv_length_offsets, + kv_block_counts, + last_kv_block_id, + kv_block_ids, + num_speculative_tokens, + block_size_tokens, +): + """Launch the KV-cache rewind Triton kernel. + + Returns: + (blocks_to_release, remove_mask) — same semantics as the original + torch.compile'd ``_rewind_kv_cache`` (KV-cache portion only; Mamba + state updates are handled separately by the caller). + """ + N = accepted_counts.shape[0] + if N == 0: + return ( + torch.empty(0, device=accepted_counts.device, dtype=last_kv_block_id.dtype), + torch.empty(0, device=accepted_counts.device, dtype=torch.bool), + ) + + blocks_to_release = torch.empty_like(last_kv_block_id) + remove_mask = torch.empty(N, device=accepted_counts.device, dtype=torch.bool) + + _rewind_kv_cache_kernel[(N,)]( + accepted_counts, + prefill_status, + last_kv_block_offset, + kv_length_offsets, + kv_block_counts, + last_kv_block_id, + kv_block_ids, + blocks_to_release, + remove_mask, + kv_block_ids_stride=kv_block_ids.stride(0), + max_blocks_minus_1=kv_block_ids.shape[1] - 1, + NUM_SPEC_TOKENS=num_speculative_tokens, + BLOCK_SIZE_TOKENS=block_size_tokens, + ) + return blocks_to_release, remove_mask + + +# --------------------------------------------------------------------------- +# Kernel 2: Verify speculative tokens +# --------------------------------------------------------------------------- +@triton.jit +def _verify_speculative_tokens_kernel( + INPUT_TOKENS_PTR, + OUTPUT_TOKENS_PTR, + # Outputs + ACCEPTED_MASK_PTR, + LAST_ONE_INDICES_PTR, + # Runtime scalars + num_decode_requests, + decode_len, + # Compile-time constants + STRIDE: tl.constexpr, # num_speculative_tokens + 1 + BLOCK_SIZE: tl.constexpr, # next_power_of_2(STRIDE) +): + """Verify speculative tokens for one request. + + Grid: (active_request_count,) + Programs 0..num_decode_requests-1 handle decode requests. + Programs num_decode_requests..end handle prefill requests. + """ + pid = tl.program_id(0) + + if pid < num_decode_requests: + base = pid * STRIDE + offsets = tl.arange(0, BLOCK_SIZE) + valid = offsets < STRIDE + + input_toks = tl.load(INPUT_TOKENS_PTR + base + offsets, mask=valid, other=0) + + # Build shifted output: shifted[i] = output[i-1]. + # Position 0 uses a dummy load (always accepted regardless). + safe_shifted = tl.where(offsets > 0, offsets - 1, 0) + shifted_output = tl.load(OUTPUT_TOKENS_PTR + base + safe_shifted, mask=valid, other=0) + + # First token is always accepted; rest must match shifted output. + match = tl.where(offsets == 0, 1, (input_toks == shifted_output).to(tl.int32)) + match = tl.where(valid, match, 0) + + # Consecutive acceptance via cumulative-sum trick: + # accepted[i] iff cumsum(match)[i] == i + 1 + cumsum = tl.cumsum(match, axis=0) + accepted = (cumsum == (offsets + 1)) & valid + + tl.store(ACCEPTED_MASK_PTR + base + offsets, accepted, mask=valid) + + accepted_count = tl.sum(accepted.to(tl.int32)) + tl.store(LAST_ONE_INDICES_PTR + pid, (base + accepted_count - 1).to(tl.int64)) + else: + # Prefill request — single token, always accepted + prefill_idx = decode_len + (pid - num_decode_requests) + tl.store(ACCEPTED_MASK_PTR + prefill_idx, 1) + tl.store(LAST_ONE_INDICES_PTR + pid, prefill_idx.to(tl.int64)) + + +def verify_speculative_tokens( + input_tokens, + output_tokens, + num_decode_requests, + num_prefill_requests, + num_speculative_tokens, +): + """Launch the speculative-token verification Triton kernel. + + Returns: + (last_one_indices, accepted_tokens_mask, input_tokens) + matching the original ``_verify_speculative_tokens`` signature. + """ + if input_tokens.ndim == 2: + input_tokens = input_tokens.squeeze(0) + + device = input_tokens.device + active_request_count = num_decode_requests + num_prefill_requests + stride = num_speculative_tokens + 1 + decode_len = num_decode_requests * stride + + accepted_tokens_mask = torch.zeros_like(input_tokens, dtype=torch.bool) + last_one_indices = torch.full( + (active_request_count,), -1, device=device, dtype=torch.long + ) + + if active_request_count > 0: + block_size = triton.next_power_of_2(stride) + _verify_speculative_tokens_kernel[(active_request_count,)]( + input_tokens, + output_tokens, + accepted_tokens_mask, + last_one_indices, + num_decode_requests=num_decode_requests, + decode_len=decode_len, + STRIDE=stride, + BLOCK_SIZE=block_size, + ) + + return last_one_indices, accepted_tokens_mask, input_tokens + + +# --------------------------------------------------------------------------- +# Kernel 3: Prepare speculative tokens for next forward pass +# --------------------------------------------------------------------------- +@triton.jit +def _prepare_next_forward_pass_kernel( + OUTPUT_TOKENS_PTR, + REQUIRED_LOGIT_INDICES_PTR, + LAST_ONE_INDICES_PTR, + INPUT_TOKENS_PTR, + ACCEPTED_MASK_PTR, + # Outputs + SAMPLED_TOKENS_OUT_PTR, + LAST_ACCEPTED_SEQ_OUT_PTR, + ACCEPTED_TOKENS_OUT_PTR, + ACCEPTED_COUNTS_OUT_PTR, + # Strides + accepted_tokens_out_stride, + # Runtime scalars + num_decode_requests, + # Compile-time constants + STRIDE: tl.constexpr, # num_speculative_tokens + 1 + NUM_SPEC_TOKENS: tl.constexpr, + SPEC_BLOCK_SIZE: tl.constexpr, # next_power_of_2(NUM_SPEC_TOKENS) +): + """Gather final tokens and extract accepted speculative tokens per request. + + Grid: (active_request_count,) + """ + pid = tl.program_id(0) + + # --- Gather final sampled token and sequence index for every request --- + idx = tl.load(LAST_ONE_INDICES_PTR + pid) + tl.store(SAMPLED_TOKENS_OUT_PTR + pid, tl.load(OUTPUT_TOKENS_PTR + idx)) + tl.store(LAST_ACCEPTED_SEQ_OUT_PTR + pid, tl.load(REQUIRED_LOGIT_INDICES_PTR + idx)) + + # --- For decode requests: extract accepted tokens and count --- + if pid < num_decode_requests: + base = pid * STRIDE + spec_offsets = tl.arange(0, SPEC_BLOCK_SIZE) + spec_valid = spec_offsets < NUM_SPEC_TOKENS + token_positions = base + 1 + spec_offsets # skip first (base) token + + tokens = tl.load(INPUT_TOKENS_PTR + token_positions, mask=spec_valid, other=0) + mask_val = tl.load(ACCEPTED_MASK_PTR + token_positions, mask=spec_valid, other=0) + accepted = mask_val != 0 + + result = tl.where(accepted & spec_valid, tokens, -1) + + out_base = pid.to(tl.int64) * accepted_tokens_out_stride + tl.store(ACCEPTED_TOKENS_OUT_PTR + out_base + spec_offsets, result, mask=spec_valid) + + count = tl.sum((accepted & spec_valid).to(tl.int64)) + tl.store(ACCEPTED_COUNTS_OUT_PTR + pid, count) + + +def prepare_next_forward_pass( + num_decode_requests, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_tokens_mask, + input_tokens, + sampled_tokens_buf, + last_accepted_seq_buf, + accepted_tokens_per_request, + accepted_token_counts, + num_speculative_tokens, +): + """Launch the prepare-next-forward-pass Triton kernel. + + Writes results into the pre-allocated buffers provided by the caller. + """ + active_request_count = last_one_indices.shape[0] + if active_request_count == 0: + return + + stride = num_speculative_tokens + 1 + spec_block_size = triton.next_power_of_2(num_speculative_tokens) + + _prepare_next_forward_pass_kernel[(active_request_count,)]( + output_tokens, + required_logit_indices, + last_one_indices, + input_tokens, + accepted_tokens_mask, + sampled_tokens_buf, + last_accepted_seq_buf, + accepted_tokens_per_request, + accepted_token_counts, + accepted_tokens_out_stride=accepted_tokens_per_request.stride(0), + num_decode_requests=num_decode_requests, + STRIDE=stride, + NUM_SPEC_TOKENS=num_speculative_tokens, + SPEC_BLOCK_SIZE=spec_block_size, + ) + + +# --------------------------------------------------------------------------- +# Kernel 4: Mamba state selective copy (eliminates temporary allocations) +# --------------------------------------------------------------------------- +@triton.jit +def _mamba_state_selective_copy_kernel( + # Source: intermediate states [L, M, S+1, *state_shape] + SRC_PTR, + # Destination: current states [L, M, *state_shape] + DST_PTR, + # Per-request index arrays + PREFILL_STATUS_PTR, # [N] 0=decode, 1=prefill + STATE_IDX_PTR, # [N] maps request → mamba state slot + ACCEPTED_PTR, # [N] accepted token index per request + # Strides (in elements) + src_stride_layer, + src_stride_slot, + src_stride_spec, + dst_stride_layer, + dst_stride_slot, + # Data size + STATE_SIZE, + # Compile-time + BLOCK_SIZE: tl.constexpr, +): + """Copy intermediate Mamba state to current state for decode requests. + + Grid: (N, L, num_chunks) + - dim 0: active request index + - dim 1: mamba layer index + - dim 2: chunk of the flattened state vector + + For prefill requests the program is a no-op. For decode requests it + copies ``intermediate[layer, slot, accepted, :]`` → + ``current[layer, slot, :]``, performing a direct in-place update with + **zero temporary allocations**. + """ + pid_req = tl.program_id(0) + pid_layer = tl.program_id(1) + pid_chunk = tl.program_id(2) + + # Skip prefill requests immediately. + prefill = tl.load(PREFILL_STATUS_PTR + pid_req) + if prefill == 1: + return + + state_idx = tl.load(STATE_IDX_PTR + pid_req).to(tl.int64) + accepted = tl.load(ACCEPTED_PTR + pid_req).to(tl.int64) + + chunk_start = pid_chunk * BLOCK_SIZE + offsets = tl.arange(0, BLOCK_SIZE) + elem_offsets = chunk_start + offsets + mask = elem_offsets < STATE_SIZE + + src_base = ( + pid_layer.to(tl.int64) * src_stride_layer + + state_idx * src_stride_slot + + accepted * src_stride_spec + ) + dst_base = pid_layer.to(tl.int64) * dst_stride_layer + state_idx * dst_stride_slot + + data = tl.load(SRC_PTR + src_base + elem_offsets, mask=mask) + tl.store(DST_PTR + dst_base + elem_offsets, data, mask=mask) + + +def mamba_state_selective_copy( + intermediate_states, + current_states, + prefill_status, + state_idx, + accepted_counts, + num_layers, +): + """Copy accepted intermediate Mamba states to current states in-place. + + For each **decode** request, copies + ``intermediate[layer, slot, accepted_count, ...]`` → + ``current[layer, slot, ...]`` for every Mamba layer. + + This replaces the original pattern of:: + + gathered = intermediate[:, idx, accepted] # large temporary COPY + current_val = current[:, idx] # large temporary COPY + result = torch.where(mask, gathered, current_val) # another allocation + current[:, idx] = result # scatter back + + …which allocated **three** full-sized temporaries per state type. + The Triton kernel writes directly in-place with zero temporaries. + + Args: + intermediate_states: ``(L, M, S+1, *state_shape)`` — intermediate buffer. + current_states: ``(L, M, *state_shape)`` — current state buffer (updated in-place). + prefill_status: ``(N,)`` int tensor — 0 for decode, 1 for prefill. + state_idx: ``(N,)`` int tensor — mamba state slot index per request. + accepted_counts: ``(N,)`` int tensor — accepted token index per request. + num_layers: number of Mamba layers (first dim of the state tensors). + """ + N = prefill_status.shape[0] + if N == 0: + return + + # The state vector to copy per (layer, request) is the product of all + # trailing dimensions after the speculative-token axis. + # intermediate shape: (L, M, S+1, *state_shape) → state_size = prod(state_shape) + state_size = math.prod(intermediate_states.shape[3:]) + + BLOCK_SIZE = 1024 + num_chunks = triton.cdiv(state_size, BLOCK_SIZE) + grid = (N, num_layers, num_chunks) + + _mamba_state_selective_copy_kernel[grid]( + intermediate_states, + current_states, + prefill_status, + state_idx, + accepted_counts, + src_stride_layer=intermediate_states.stride(0), + src_stride_slot=intermediate_states.stride(1), + src_stride_spec=intermediate_states.stride(2), + dst_stride_layer=current_states.stride(0), + dst_stride_slot=current_states.stride(1), + STATE_SIZE=state_size, + BLOCK_SIZE=BLOCK_SIZE, + ) From d9c2902d0e0b1d374cbf5c38237c745ea4fc7d95 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 11:45:31 -0700 Subject: [PATCH 027/124] more perf optimizations Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 7d1e6e98787..740106f4803 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -189,6 +189,25 @@ def _init_mtp_sampling_tensor(self): max_requests, dtype=torch.int64, device=device ) + # Cache invariant values for serial MTP to avoid CPU overhead on + # the hot path (unwrap_model, is_pipeline_last_stage, get_pg_size, + # etc. are constant across inference steps). + self._serial_mtp_unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + self._serial_mtp_is_last_pp_stage = is_pipeline_last_stage(self.pp_group) + tp_size = get_pg_size(self.inference_wrapped_model.tp_group) + self._serial_mtp_tp_size = tp_size + self._serial_mtp_sp_enabled = self.model_config.sequence_parallel and tp_size > 1 + self._serial_mtp_num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + + # Pre-allocate padded buffers for per-depth token/position IDs so + # the depth loop avoids repeated unsqueeze + F.pad overhead. + self._mtp_token_ids_buf = torch.zeros( + [1, max_requests], dtype=torch.int64, device=device + ) + self._mtp_position_ids_buf = torch.zeros( + [1, max_requests], dtype=torch.int64, device=device + ) + @staticmethod def tokenize_prompt(tokenizer, prompt: str, add_BOS: bool = False) -> List[int]: """Utility to tokenize the input prompts. @@ -850,10 +869,14 @@ def _compute_serial_mtp_and_sample(self): active_request_count = context.total_request_count - context.paused_request_count active_slice = slice(context.paused_request_count, context.total_request_count) - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + # Use cached values to avoid CPU overhead from unwrap_model, + # is_pipeline_last_stage, get_pg_size, etc. on every step. + unwrapped_model = self._serial_mtp_unwrapped_model + sp_enabled = self._serial_mtp_sp_enabled + num_depths = self._serial_mtp_num_depths # On non-last pipeline stages, the model won't have decoder hidden states. - has_mtp = is_pipeline_last_stage(self.pp_group) and hasattr( + has_mtp = self._serial_mtp_is_last_pp_stage and hasattr( unwrapped_model, '_decoder_hidden_states_cache' ) @@ -864,7 +887,7 @@ def _compute_serial_mtp_and_sample(self): # When SP is active the decoder output is in scattered format # [S/TP, B, H], but _last_accepted_seq_indices are indices into # the full (gathered) sequence. - if self.model_config.sequence_parallel: + if sp_enabled: hidden_states = gather_from_sequence_parallel_region( hidden_states, group=self.inference_wrapped_model.tp_group ) @@ -886,11 +909,10 @@ def _compute_serial_mtp_and_sample(self): current_hidden = last_accepted_hidden if has_mtp else None # Compute padding needed to make batch compatible with SP and CUDA graphs. - tp_size = get_pg_size(self.inference_wrapped_model.tp_group) - sp_enabled = self.model_config.sequence_parallel and tp_size > 1 if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count elif sp_enabled: + tp_size = self._serial_mtp_tp_size padded_count = ( active_request_count + (tp_size - active_request_count % tp_size) % tp_size ) @@ -907,25 +929,27 @@ def _compute_serial_mtp_and_sample(self): current_hidden, group=self.inference_wrapped_model.tp_group ) - num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + # Prepare pre-allocated padded buffers for the depth loop so each + # iteration avoids unsqueeze + F.pad tensor creation overhead. + token_ids_buf = self._mtp_token_ids_buf[:, :padded_count] + position_ids_buf = self._mtp_position_ids_buf[:, :padded_count] + nvtx_range_pop("mtp-spec-decoding/serial-mtp-init") for depth in range(num_depths): nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") - position_ids = (base_position + depth).unsqueeze(0) # [1, active_request_count] - token_ids = next_token_ids.unsqueeze(0) # [1, active_request_count] + + # Write active region into pre-allocated buffers (padding region + # stays zero-filled from initialization / previous zero-fill). + token_ids_buf[0, :active_request_count] = next_token_ids + position_ids_buf[0, :active_request_count] = base_position + depth mtp_logits_2d = None if has_mtp: - # Pad token_ids and position_ids each iteration (they change per depth). - if pad_count > 0: - token_ids = F.pad(token_ids, (0, pad_count)) - position_ids = F.pad(position_ids, (0, pad_count)) - nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/forward") current_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=current_hidden, - next_token_ids=token_ids, - position_ids=position_ids, + next_token_ids=token_ids_buf, + position_ids=position_ids_buf, depth=depth, ) nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/forward") @@ -1850,23 +1874,28 @@ async def async_generate_output_tokens_dynamic_batch( # Phase 2: Rewind KV cache for rejected tokens. nvtx_range_push("mtp-spec-decoding/rewind-kv-cache") blocks_to_release, remove_mask = self._rewind_kv_cache() - # Release blocks back to the allocator (not compilable due to - # allocator state mutation). release_memory_blocks handles - # empty tensors. - context.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) nvtx_range_pop("mtp-spec-decoding/rewind-kv-cache") # Disable MoE padding for MTP computation, unless CUDA graphs # are active (the graphs were captured with padding enabled). if self.model_config.moe_pad_experts_for_cuda_graph_inference: if not context.using_cuda_graph_this_step(): - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - set_decode_expert_padding(unwrapped_model, False) + set_decode_expert_padding(self._serial_mtp_unwrapped_model, False) # Phase 3: Compute MTP serially with correct (verified) inputs. nvtx_range_push("mtp-spec-decoding/serial-mtp") self._compute_serial_mtp_and_sample() nvtx_range_pop("mtp-spec-decoding/serial-mtp") + + # Phase 4: Release freed blocks back to the allocator. + # Deferred from Phase 2 to avoid a GPU pipeline drain: + # blocks_to_release[remove_mask] is boolean-mask indexing whose + # output size is data-dependent, forcing a CUDA sync. By + # deferring to after serial MTP, the sync overlaps with + # already-completed GPU work instead of stalling before it. + context.kv_block_allocator.release_memory_blocks( + blocks_to_release[remove_mask] + ) else: self._dynamic_step_sample_logits(logits) From 45cc1eb58f800b8f0732b52a8e25d3f2e46b166e Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 12:18:52 -0700 Subject: [PATCH 028/124] Fix dtype Signed-off-by: Keshav Santhanam --- .../text_generation_controllers/text_generation_controller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 740106f4803..896caae5cd2 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -902,7 +902,9 @@ def _compute_serial_mtp_and_sample(self): # The next position to predict starts at that cache length. adjusted_offsets = context.request_kv_length_offsets[active_slice] processed_tokens = context.request_query_lengths[active_slice] - base_position = adjusted_offsets + processed_tokens + # Cast to int64: context tensors are int32 but position_ids should be + # int64 to match CUDA graph capture dtype expectations. + base_position = (adjusted_offsets + processed_tokens).to(torch.int64) # Start with the freshly sampled base token. next_token_ids = self._sampled_tokens_cuda[:active_request_count].clone() From 6c57910b6590872329f8bb53ce4c17f8cb18b569 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 12:24:42 -0700 Subject: [PATCH 029/124] Fix dtype Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 006fe56b7f4..bbd1f617b3f 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -499,7 +499,7 @@ def _create_mtp_cuda_graphs(self, controller, context): dummy_hidden, group=controller.inference_wrapped_model.tp_group ) dummy_token_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) - dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.int32) + dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.int64) # One call per batch size; depth=0 warms the shared layer (repeated # mode) or the first unique layer (non-repeated mode). From 68923e22998768e7d6f09517d1eaea21a04f61f3 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 13:18:39 -0700 Subject: [PATCH 030/124] Fix cuda graph Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index de6216934ef..ea985694336 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1619,6 +1619,18 @@ def __call__(self, megatron_module, args, kwargs): # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) + if not runner.fwd_graph_recorded and self._is_mtp_inference( + megatron_module, kwargs + ): + # No pre-warmed graph for this MTP batch size — run eagerly + # instead of attempting lazy capture. Lazy MTP graph capture + # would fail for models with MoE layers because the AlltoAll + # token dispatcher performs host synchronization + # (d2h_event.synchronize) that is illegal inside CUDA graph + # capture, and the graph-safe inference dispatcher is only + # enabled during explicit warmup. + return self.func(*args, **kwargs) + if not runner.fwd_graph_recorded: # Reuse graph input-output buffers for inference local_args, local_kwargs = args, kwargs From 0066e4212f06563e1f7d8773623f8af1b2047dda Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 14:13:45 -0700 Subject: [PATCH 031/124] Clean up CUDA graph Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 22 +++++++------------ .../transformer/multi_token_prediction.py | 2 -- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index ea985694336..08cd47634d5 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1426,8 +1426,13 @@ def __init__( config: TransformerConfig object containing CUDA graph settings for memory pooling, graph retention, gradient accumulation, FP8/FP4, and warmup steps. """ + from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer + rng_tracker = get_cuda_rng_tracker() self.need_backward = need_backward + # is_mtp implies inference mode: MTP is only cuda-graphed for inference + # (forward_single_position), not for training which uses the regular forward path. + self.is_mtp = isinstance(base_module, MultiTokenPredictionLayer) if function_name is not None: func = getattr(base_module, function_name) @@ -1493,15 +1498,6 @@ def call_ddp_preforward_hook(self, module): # Only hooks from Mcore DDP, which take no args, should be called at this point. hook(module) - @staticmethod - def _is_mtp_inference(megatron_module, kwargs): - """Check if this call is an MTP layer running under inference mode.""" - from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer - - return ( - 'inference_context' not in kwargs or not kwargs.get('inference_context') - ) and isinstance(megatron_module, MultiTokenPredictionLayer) - def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): '''Returns a valid cudagraph runner for the current forward call. The cudagraph corresponding to this call is the first element of 'self.cudagraph_runners'. @@ -1511,7 +1507,7 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): over different microbatches by tracking their respective fwd and bwd passes.''' if reuse_cudagraphs: is_inference_mode = 'inference_context' in kwargs.keys() and kwargs['inference_context'] - is_mtp_inference = self._is_mtp_inference(megatron_module, kwargs) + is_mtp_inference = self.is_mtp if is_inference_mode: is_static_batching = kwargs['inference_context'].is_static_batching() if is_static_batching: @@ -1600,7 +1596,7 @@ def __call__(self, megatron_module, args, kwargs): """ is_inference_mode = ( 'inference_context' in kwargs.keys() and kwargs['inference_context'] - ) or self._is_mtp_inference(megatron_module, kwargs) + ) or self.is_mtp is_in_checkpoint_fwd = is_checkpointing() if HAVE_TE_GRAPHS: is_in_checkpoint_fwd = is_in_checkpoint_fwd or is_fp8_activation_recompute_enabled() @@ -1619,9 +1615,7 @@ def __call__(self, megatron_module, args, kwargs): # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) - if not runner.fwd_graph_recorded and self._is_mtp_inference( - megatron_module, kwargs - ): + if not runner.fwd_graph_recorded and self.is_mtp: # No pre-warmed graph for this MTP batch size — run eagerly # instead of attempting lazy capture. Lazy MTP graph capture # would fail for models with MoE layers because the AlltoAll diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 632b3e4c464..0ca0eb9cc11 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -1022,7 +1022,6 @@ def forward_single_position( rotary_pos_emb: Optional[Tensor] = None, rotary_pos_cos: Optional[Tensor] = None, rotary_pos_sin: Optional[Tensor] = None, - inference_params=None, packed_seq_params: Optional[PackedSeqParams] = None, sequence_len_offset: Optional[Tensor] = None, ) -> Tensor: @@ -1053,7 +1052,6 @@ def forward_single_position( rotary_pos_emb=rotary_pos_emb, rotary_pos_cos=rotary_pos_cos, rotary_pos_sin=rotary_pos_sin, - inference_params=inference_params, packed_seq_params=packed_seq_params, sequence_len_offset=sequence_len_offset, ) From 0a235ff5a9d0acb51e5101950e5b0c55fc12f263 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 14:31:41 -0700 Subject: [PATCH 032/124] Clean up graph Signed-off-by: Keshav Santhanam --- megatron/core/transformer/multi_token_prediction.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 0ca0eb9cc11..cc42b383bfa 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -848,13 +848,13 @@ def __init__( ) self.offload_context = nullcontext() - # Create cuda graph manager wrapping forward_single_position so that + # Create cuda graph manager for forward_single_position so that # the full MTP forward (embedding, projection, transformer, layernorm) # is captured in a single graph. if config.cuda_graph_impl == "local" and not config.cuda_graph_scope: from megatron.core.transformer.cuda_graphs import CudaGraphManager - self.mtp_cudagraph_manager = CudaGraphManager( + self.cudagraph_manager = CudaGraphManager( config, base_module=self, function_name="forward_single_position", @@ -1012,6 +1012,14 @@ def _postprocess(self, hidden_states: torch.Tensor): return hidden_states + def _should_call_local_cudagraph(self, *args, **kwargs): + """MTP cuda-graphs forward_single_position, not forward. + + Disable the MegatronModule.__call__ interceptor so the training forward + path is not routed through the cuda graph manager. + """ + return False + def forward_single_position( self, hidden_states: Tensor, From 0d6243adda53e7b814ae861dd583d233147165df Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 14:45:13 -0700 Subject: [PATCH 033/124] Fix graph Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 4 ++++ megatron/core/transformer/cuda_graphs.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index bbd1f617b3f..19a2b6c910b 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -488,6 +488,9 @@ def _create_mtp_cuda_graphs(self, controller, context): logging.info("> MTP CUDA graph warmup: %d batch size(s)", len(mtp_batch_sizes)) + from megatron.core.transformer.cuda_graphs import _set_capture_end, _set_capture_start + + _set_capture_start() for batch_size in sorted(mtp_batch_sizes): dummy_hidden = torch.zeros((batch_size, 1, hidden_size), device=device, dtype=dtype) if sp_enabled: @@ -509,6 +512,7 @@ def _create_mtp_cuda_graphs(self, controller, context): position_ids=dummy_position_ids, depth=0, ) + _set_capture_end() if is_inference_optimized_ep: unset_inference_cuda_graphed_iteration_for_ep_inference(model) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 08cd47634d5..d36e46706ef 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1615,7 +1615,7 @@ def __call__(self, megatron_module, args, kwargs): # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) - if not runner.fwd_graph_recorded and self.is_mtp: + if not runner.fwd_graph_recorded and self.is_mtp and not is_graph_capturing(): # No pre-warmed graph for this MTP batch size — run eagerly # instead of attempting lazy capture. Lazy MTP graph capture # would fail for models with MoE layers because the AlltoAll From 5bfd41cc7c031f28e82a91b16d779e592a1b3687 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 16:27:31 -0700 Subject: [PATCH 034/124] Debugging Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 25 ++++++++----------- megatron/core/transformer/cuda_graphs.py | 3 +++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 896caae5cd2..f5dc5311b50 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -201,10 +201,10 @@ def _init_mtp_sampling_tensor(self): # Pre-allocate padded buffers for per-depth token/position IDs so # the depth loop avoids repeated unsqueeze + F.pad overhead. - self._mtp_token_ids_buf = torch.zeros( + self._mtp_token_ids_buf = torch.empty( [1, max_requests], dtype=torch.int64, device=device ) - self._mtp_position_ids_buf = torch.zeros( + self._mtp_position_ids_buf = torch.empty( [1, max_requests], dtype=torch.int64, device=device ) @@ -614,13 +614,11 @@ def _dynamic_step_context_init( is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, ) + # Precompute MTP CUDA graph padded batch size from the already EP-synced - # padded_batch_dimensions. This avoids an extra EP all-reduce on the MTP + # padded_batch_dimensions. This avoids an extra EP all-reduce on the MTP # hot path — all ranks derive the same value from the matched graph. - if ( - getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None - and context.using_cuda_graph_this_step() - ): + if getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None: self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 @@ -913,13 +911,9 @@ def _compute_serial_mtp_and_sample(self): # Compute padding needed to make batch compatible with SP and CUDA graphs. if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count - elif sp_enabled: - tp_size = self._serial_mtp_tp_size - padded_count = ( - active_request_count + (tp_size - active_request_count % tp_size) % tp_size - ) + assert not sp_enabled or padded_count % self._serial_mtp_tp_size == 0 else: - padded_count = active_request_count + assert not has_mtp pad_count = padded_count - active_request_count # Pad hidden states and scatter for sequence parallelism. @@ -948,6 +942,7 @@ def _compute_serial_mtp_and_sample(self): mtp_logits_2d = None if has_mtp: nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/forward") + print(f"Rank {torch.distributed.get_rank()} Running real MTP depth {depth} with hidden_states={current_hidden.shape}, next_token_ids={token_ids_buf.shape}, position_ids={position_ids_buf.shape} (padded_count={padded_count})") current_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=current_hidden, next_token_ids=token_ids_buf, @@ -1693,8 +1688,9 @@ def _dummy_serial_mtp_forward(self): sp_enabled = self.model_config.sequence_parallel and tp_size > 1 if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count + assert not sp_enabled or padded_count % tp_size == 0 else: - padded_count = tp_size if sp_enabled else 1 + assert not has_mtp dummy_hidden = None if has_mtp: @@ -1713,6 +1709,7 @@ def _dummy_serial_mtp_forward(self): nvtx_range_push(f"mtp-spec-decoding/dummy-depth-{depth}") mtp_logits_2d = None if has_mtp: + print(f"Rank {torch.distributed.get_rank()} Running dummy MTP depth {depth} with hidden_states={dummy_hidden.shape}, next_token_ids={dummy_token_ids.shape}, position_ids={dummy_position_ids.shape} (padded_count={padded_count})") dummy_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=dummy_hidden, next_token_ids=dummy_token_ids, diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index d36e46706ef..0604144312b 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1623,6 +1623,7 @@ def __call__(self, megatron_module, args, kwargs): # (d2h_event.synchronize) that is illegal inside CUDA graph # capture, and the graph-safe inference dispatcher is only # enabled during explicit warmup. + print(f"MTP cuda graph: eager fallback (shape={kwargs['hidden_states'].shape})") return self.func(*args, **kwargs) if not runner.fwd_graph_recorded: @@ -1662,6 +1663,8 @@ def __call__(self, megatron_module, args, kwargs): ) # Now replay the graph + if self.is_mtp: + print(f"MTP cuda graph: replaying (shape={kwargs['hidden_states'].shape})") out = runner.replay_graph_capture(self.is_first_microbatch, args, kwargs) elif self.training or is_in_checkpoint_fwd: runner = self.get_cudagraph_runner( From 95ed9a037a5a33382c89e5b680eb8d9988195661 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 16:47:51 -0700 Subject: [PATCH 035/124] Fix graphs Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 29 +++++++++++++++++-- megatron/core/transformer/cuda_graphs.py | 5 ++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index f5dc5311b50..19759979932 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -618,7 +618,13 @@ def _dynamic_step_context_init( # Precompute MTP CUDA graph padded batch size from the already EP-synced # padded_batch_dimensions. This avoids an extra EP all-reduce on the MTP # hot path — all ranks derive the same value from the matched graph. - if getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None: + # When the main model falls back to eager mode, padded_batch_dimensions + # is computed from local values without EP sync, so we cannot use it; + # MTP will also run eagerly with a locally SP-aligned batch size. + if ( + getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None + and context.using_cuda_graph_this_step() + ): self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count tp_size = get_pg_size(self.inference_wrapped_model.tp_group) sp_enabled = self.model_config.sequence_parallel and tp_size > 1 @@ -629,6 +635,16 @@ def _dynamic_step_context_init( else: self._mtp_resolved_padded_count = None + # Tell MTP layers whether to use CUDA graphs this step. When the main + # model falls back to eager mode, MTP must also run eagerly across all + # EP ranks — otherwise some ranks may replay a captured graph while + # others run eagerly, causing EP collectives to hang. + if getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None: + use_mtp_graphs = context.using_cuda_graph_this_step() + if hasattr(unwrapped_model, 'mtp'): + for layer in unwrapped_model.mtp.layers: + layer.use_mtp_cuda_graphs = use_mtp_graphs + # If using symmetric kernels and we are using using nccl # for prefill turn off symmetric kernels symmetric_ar_type = self.model_config.symmetric_ar_type @@ -910,10 +926,19 @@ def _compute_serial_mtp_and_sample(self): # Compute padding needed to make batch compatible with SP and CUDA graphs. if getattr(self, '_mtp_resolved_padded_count', None) is not None: + # CUDA-graph path: use the EP-synced padded count derived from the + # matched graph dimensions. padded_count = self._mtp_resolved_padded_count assert not sp_enabled or padded_count % self._serial_mtp_tp_size == 0 + elif has_mtp: + # Eager path (no CUDA graphs this step): pad only for SP alignment + # using the local request count. + padded_count = active_request_count + if sp_enabled: + tp_size = self._serial_mtp_tp_size + padded_count += (tp_size - padded_count % tp_size) % tp_size else: - assert not has_mtp + padded_count = active_request_count pad_count = padded_count - active_request_count # Pad hidden states and scatter for sequence parallelism. diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 0604144312b..289e7ca1043 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1612,6 +1612,11 @@ def __call__(self, megatron_module, args, kwargs): out = runner.replay_graph_capture(self.is_first_microbatch, args, kwargs) else: if is_inference_mode: + # When the main model is in eager mode, MTP must also run + # eagerly so that all EP ranks take the same code path. + if self.is_mtp and not getattr(megatron_module, 'use_mtp_cuda_graphs', False): + return self.func(*args, **kwargs) + # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) From 4b1d093500bc85d10d31acbf7a4507231d6bd7c5 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 22:11:54 -0700 Subject: [PATCH 036/124] Fix graphs Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 289e7ca1043..c581ae152c2 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1614,7 +1614,13 @@ def __call__(self, megatron_module, args, kwargs): if is_inference_mode: # When the main model is in eager mode, MTP must also run # eagerly so that all EP ranks take the same code path. - if self.is_mtp and not getattr(megatron_module, 'use_mtp_cuda_graphs', False): + # Skip this guard during graph capturing (warmup) since the + # attribute is only set at runtime by the controller. + if ( + self.is_mtp + and not getattr(megatron_module, 'use_mtp_cuda_graphs', False) + and not is_graph_capturing() + ): return self.func(*args, **kwargs) # Inference generation mode creates graphs immediately From 7913032bc8099efd25e19656ad5f9d198c629d77 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 22:23:18 -0700 Subject: [PATCH 037/124] Remove debug prints Signed-off-by: Keshav Santhanam --- .../text_generation_controllers/text_generation_controller.py | 2 -- megatron/core/transformer/cuda_graphs.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 19759979932..686d2be7dc5 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -967,7 +967,6 @@ def _compute_serial_mtp_and_sample(self): mtp_logits_2d = None if has_mtp: nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/forward") - print(f"Rank {torch.distributed.get_rank()} Running real MTP depth {depth} with hidden_states={current_hidden.shape}, next_token_ids={token_ids_buf.shape}, position_ids={position_ids_buf.shape} (padded_count={padded_count})") current_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=current_hidden, next_token_ids=token_ids_buf, @@ -1734,7 +1733,6 @@ def _dummy_serial_mtp_forward(self): nvtx_range_push(f"mtp-spec-decoding/dummy-depth-{depth}") mtp_logits_2d = None if has_mtp: - print(f"Rank {torch.distributed.get_rank()} Running dummy MTP depth {depth} with hidden_states={dummy_hidden.shape}, next_token_ids={dummy_token_ids.shape}, position_ids={dummy_position_ids.shape} (padded_count={padded_count})") dummy_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=dummy_hidden, next_token_ids=dummy_token_ids, diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index c581ae152c2..2bbcbaed91c 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1634,7 +1634,6 @@ def __call__(self, megatron_module, args, kwargs): # (d2h_event.synchronize) that is illegal inside CUDA graph # capture, and the graph-safe inference dispatcher is only # enabled during explicit warmup. - print(f"MTP cuda graph: eager fallback (shape={kwargs['hidden_states'].shape})") return self.func(*args, **kwargs) if not runner.fwd_graph_recorded: @@ -1674,8 +1673,6 @@ def __call__(self, megatron_module, args, kwargs): ) # Now replay the graph - if self.is_mtp: - print(f"MTP cuda graph: replaying (shape={kwargs['hidden_states'].shape})") out = runner.replay_graph_capture(self.is_first_microbatch, args, kwargs) elif self.training or is_in_checkpoint_fwd: runner = self.get_cudagraph_runner( From a39dffd62129475ecdab6c6cddc657dfe22f8858 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 23:09:38 -0700 Subject: [PATCH 038/124] Clean up Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 17 +-- .../text_generation_controller.py | 124 ++++++------------ .../triton_kernels.py | 17 +-- .../common/language_module/language_module.py | 2 +- megatron/core/transformer/cuda_graphs.py | 23 +--- 5 files changed, 53 insertions(+), 130 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index fc2bcb616a7..9e828f113d2 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -429,17 +429,10 @@ def create_cuda_graphs(self, reset_context: bool = True): def _create_mtp_cuda_graphs(self, controller, context): """Capture CUDA graphs for MTP layers used in speculative decoding. - Derives the set of MTP batch sizes from the decoder CUDA graph batch dimensions - (decode-only entries), then runs a single ``compute_mtp_single_step`` per - batch size to trigger graph capture. Each ``MultiTokenPredictionLayer`` - already has a ``CudaGraphManager`` wrapping ``forward_single_position`` - (created in ``__init__``), so the full MTP forward (embedding lookup, - projection, transformer layer, and final layernorm) is captured in a - single graph. - - With ``mtp_use_repeated_layer`` (the common case) one call covers every - depth; with unique layers the remaining depths will capture lazily on - first real inference call. + Derives the set of MTP batch sizes from the decoder CUDA graph batch + dimensions, then runs ``compute_mtp_single_step`` per batch size to + trigger graph capture. With ``mtp_use_repeated_layer`` one call covers + every depth; with unique layers the remaining depths capture lazily. """ num_mtp_heads = controller.num_mtp_heads num_spec_tokens = controller.num_speculative_tokens or 0 @@ -457,7 +450,7 @@ def _create_mtp_cuda_graphs(self, controller, context): if model_config.cuda_graph_impl != "local": return - # Collect batch sizes from all graph dimensions. MTP serial forward + # Collect batch sizes from all graph dimensions. MTP serial forward # runs on all active requests (decode + prefill), so we need graphs # for total request counts, not just decode-only counts. tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 686d2be7dc5..ef95acd34cc 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -189,18 +189,14 @@ def _init_mtp_sampling_tensor(self): max_requests, dtype=torch.int64, device=device ) - # Cache invariant values for serial MTP to avoid CPU overhead on - # the hot path (unwrap_model, is_pipeline_last_stage, get_pg_size, - # etc. are constant across inference steps). - self._serial_mtp_unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - self._serial_mtp_is_last_pp_stage = is_pipeline_last_stage(self.pp_group) - tp_size = get_pg_size(self.inference_wrapped_model.tp_group) - self._serial_mtp_tp_size = tp_size - self._serial_mtp_sp_enabled = self.model_config.sequence_parallel and tp_size > 1 - self._serial_mtp_num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) - - # Pre-allocate padded buffers for per-depth token/position IDs so - # the depth loop avoids repeated unsqueeze + F.pad overhead. + # Cache values that are constant across inference steps. + self._unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + self._is_last_pp_stage = is_pipeline_last_stage(self.pp_group) + self._tp_size = get_pg_size(self.inference_wrapped_model.tp_group) + self._sp_enabled = self.model_config.sequence_parallel and self._tp_size > 1 + self._num_mtp_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + + # Pre-allocate padded buffers for per-depth token/position IDs. self._mtp_token_ids_buf = torch.empty( [1, max_requests], dtype=torch.int64, device=device ) @@ -614,28 +610,22 @@ def _dynamic_step_context_init( is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, ) - - # Precompute MTP CUDA graph padded batch size from the already EP-synced - # padded_batch_dimensions. This avoids an extra EP all-reduce on the MTP - # hot path — all ranks derive the same value from the matched graph. - # When the main model falls back to eager mode, padded_batch_dimensions - # is computed from local values without EP sync, so we cannot use it; - # MTP will also run eagerly with a locally SP-aligned batch size. + + # Derive the MTP padded batch size from the EP-synced graph dimensions. + # In eager mode MTP uses locally SP-aligned batch size instead. if ( getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None and context.using_cuda_graph_this_step() ): self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count - tp_size = get_pg_size(self.inference_wrapped_model.tp_group) - sp_enabled = self.model_config.sequence_parallel and tp_size > 1 - if sp_enabled: + if self._sp_enabled: self._mtp_resolved_padded_count += ( - tp_size - self._mtp_resolved_padded_count % tp_size - ) % tp_size + self._tp_size - self._mtp_resolved_padded_count % self._tp_size + ) % self._tp_size else: self._mtp_resolved_padded_count = None - # Tell MTP layers whether to use CUDA graphs this step. When the main + # Tell MTP layers whether to use CUDA graphs this step. When the main # model falls back to eager mode, MTP must also run eagerly across all # EP ranks — otherwise some ranks may replay a captured graph while # others run eagerly, causing EP collectives to hang. @@ -764,8 +754,7 @@ def _dynamic_step_sample_bookkeeping(self): self._torch_sampling_buckets = [ (indices, *sampling_params) for sampling_params, indices in bucket_map.items() ] - # Pre-compute index tensors on GPU so that _sample_from_logits_2d - # (called once per MTP depth) avoids repeated H2D copies. + # Pre-compute index tensors on GPU to avoid per-step H2D copies. self._torch_sampling_bucket_index_tensors = [ torch.tensor(indices, device=device, dtype=torch.long) for indices, *_ in self._torch_sampling_buckets @@ -776,8 +765,8 @@ def _rewind_kv_cache(self) -> tuple: After forward pass with speculative tokens, some tokens may be rejected. This function "rewinds" the KV cache bookkeeping to reflect only the accepted - tokens. The core bookkeeping is handled by a Triton kernel (one thread per - request). Mamba hybrid-model state updates remain in PyTorch. + tokens. The core bookkeeping is handled by a Triton kernel (one thread per + request). Mamba hybrid-model state updates remain in PyTorch. Returns (blocks_to_release, remove_mask) for the caller to release blocks back to the allocator outside the compiled graph. @@ -808,19 +797,7 @@ def _rewind_kv_cache(self) -> tuple: block_size_tokens=context.block_size_tokens, ) - # --- Mamba speculative rewind state update (Triton, zero-alloc) --- - # - # The original code gathered full (L, N, *state_shape) temporaries via - # advanced indexing (always a copy), then used torch.where to select - # between intermediate and current, creating 3 large temps per state - # type (conv + SSM = 6 total). For large models this was hundreds of - # MB of transient GPU memory and a frequent OOM trigger. - # - # The Triton kernel below writes directly from - # intermediate[layer, slot, accepted, ...] - # into - # current[layer, slot, ...] - # for decode requests only, with zero temporary allocations. + # Mamba speculative rewind: copy accepted intermediate states in-place. if context.is_hybrid_model: mamba_state_idx = context.mamba_metadata.request_to_mamba_state_idx[ active_request_slice @@ -883,14 +860,10 @@ def _compute_serial_mtp_and_sample(self): active_request_count = context.total_request_count - context.paused_request_count active_slice = slice(context.paused_request_count, context.total_request_count) - # Use cached values to avoid CPU overhead from unwrap_model, - # is_pipeline_last_stage, get_pg_size, etc. on every step. - unwrapped_model = self._serial_mtp_unwrapped_model - sp_enabled = self._serial_mtp_sp_enabled - num_depths = self._serial_mtp_num_depths + unwrapped_model = self._unwrapped_model # On non-last pipeline stages, the model won't have decoder hidden states. - has_mtp = self._serial_mtp_is_last_pp_stage and hasattr( + has_mtp = self._is_last_pp_stage and hasattr( unwrapped_model, '_decoder_hidden_states_cache' ) @@ -901,7 +874,7 @@ def _compute_serial_mtp_and_sample(self): # When SP is active the decoder output is in scattered format # [S/TP, B, H], but _last_accepted_seq_indices are indices into # the full (gathered) sequence. - if sp_enabled: + if self._sp_enabled: hidden_states = gather_from_sequence_parallel_region( hidden_states, group=self.inference_wrapped_model.tp_group ) @@ -916,8 +889,7 @@ def _compute_serial_mtp_and_sample(self): # The next position to predict starts at that cache length. adjusted_offsets = context.request_kv_length_offsets[active_slice] processed_tokens = context.request_query_lengths[active_slice] - # Cast to int64: context tensors are int32 but position_ids should be - # int64 to match CUDA graph capture dtype expectations. + # Cast to int64 to match CUDA graph capture dtype expectations. base_position = (adjusted_offsets + processed_tokens).to(torch.int64) # Start with the freshly sampled base token. @@ -926,17 +898,14 @@ def _compute_serial_mtp_and_sample(self): # Compute padding needed to make batch compatible with SP and CUDA graphs. if getattr(self, '_mtp_resolved_padded_count', None) is not None: - # CUDA-graph path: use the EP-synced padded count derived from the - # matched graph dimensions. + # CUDA-graph path: use the EP-synced padded count. padded_count = self._mtp_resolved_padded_count - assert not sp_enabled or padded_count % self._serial_mtp_tp_size == 0 + assert not self._sp_enabled or padded_count % self._tp_size == 0 elif has_mtp: - # Eager path (no CUDA graphs this step): pad only for SP alignment - # using the local request count. + # Eager path: pad only for SP alignment. padded_count = active_request_count - if sp_enabled: - tp_size = self._serial_mtp_tp_size - padded_count += (tp_size - padded_count % tp_size) % tp_size + if self._sp_enabled: + padded_count += (self._tp_size - padded_count % self._tp_size) % self._tp_size else: padded_count = active_request_count pad_count = padded_count - active_request_count @@ -945,22 +914,18 @@ def _compute_serial_mtp_and_sample(self): if has_mtp: if pad_count > 0: current_hidden = F.pad(current_hidden, (0, 0, 0, 0, 0, pad_count)) - if sp_enabled: + if self._sp_enabled: current_hidden = scatter_to_sequence_parallel_region( current_hidden, group=self.inference_wrapped_model.tp_group ) - # Prepare pre-allocated padded buffers for the depth loop so each - # iteration avoids unsqueeze + F.pad tensor creation overhead. token_ids_buf = self._mtp_token_ids_buf[:, :padded_count] position_ids_buf = self._mtp_position_ids_buf[:, :padded_count] nvtx_range_pop("mtp-spec-decoding/serial-mtp-init") - for depth in range(num_depths): + for depth in range(self._num_mtp_depths): nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") - # Write active region into pre-allocated buffers (padding region - # stays zero-filled from initialization / previous zero-fill). token_ids_buf[0, :active_request_count] = next_token_ids position_ids_buf[0, :active_request_count] = base_position + depth @@ -975,7 +940,7 @@ def _compute_serial_mtp_and_sample(self): ) nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/forward") - # Strip padding from logits only. Hidden states stay padded+SP + # Strip padding from logits only. Hidden states stay padded+SP # between depths to avoid redundant gather/scatter round-trips. if pad_count > 0: mtp_logits = mtp_logits[:active_request_count] @@ -1693,10 +1658,9 @@ def _dummy_serial_mtp_forward(self): if self.model_config.expert_model_parallel_size <= 1: return - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + unwrapped_model = self._unwrapped_model - is_last_stage = is_pipeline_last_stage(self.pp_group) - has_mtp = is_last_stage and hasattr(unwrapped_model, '_decoder_hidden_states_cache') + has_mtp = self._is_last_pp_stage and hasattr(unwrapped_model, '_decoder_hidden_states_cache') if not has_mtp and not self.model_is_pipeline_parallel: # No MTP on this rank and no PP broadcast to participate in. return @@ -1704,32 +1668,28 @@ def _dummy_serial_mtp_forward(self): device = torch.cuda.current_device() dtype = self.model_config.params_dtype hidden_size = self.model_config.hidden_size - num_depths = min(self.num_speculative_tokens, self.num_mtp_heads) # Use precomputed MTP CUDA graph batch size when available; # otherwise use minimal SP-compatible size. - tp_size = get_pg_size(self.inference_wrapped_model.tp_group) - sp_enabled = self.model_config.sequence_parallel and tp_size > 1 if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count - assert not sp_enabled or padded_count % tp_size == 0 + assert not self._sp_enabled or padded_count % self._tp_size == 0 else: assert not has_mtp dummy_hidden = None if has_mtp: - # Minimal dummy tensors — just enough to drive the MTP layer forward + # Minimal dummy tensors to drive the MTP layer forward # so that the MoE all-to-all collectives are issued. - # Depth 0 uses full-format hidden; subsequent depths use SP format. dummy_hidden = torch.zeros((padded_count, 1, hidden_size), device=device, dtype=dtype) - if sp_enabled: + if self._sp_enabled: dummy_hidden = scatter_to_sequence_parallel_region( dummy_hidden, group=self.inference_wrapped_model.tp_group ) dummy_token_ids = torch.zeros((1, padded_count), device=device, dtype=torch.long) dummy_position_ids = torch.zeros((1, padded_count), device=device, dtype=torch.long) - for depth in range(num_depths): + for depth in range(self._num_mtp_depths): nvtx_range_push(f"mtp-spec-decoding/dummy-depth-{depth}") mtp_logits_2d = None if has_mtp: @@ -1902,19 +1862,15 @@ async def async_generate_output_tokens_dynamic_batch( # are active (the graphs were captured with padding enabled). if self.model_config.moe_pad_experts_for_cuda_graph_inference: if not context.using_cuda_graph_this_step(): - set_decode_expert_padding(self._serial_mtp_unwrapped_model, False) + set_decode_expert_padding(self._unwrapped_model, False) # Phase 3: Compute MTP serially with correct (verified) inputs. nvtx_range_push("mtp-spec-decoding/serial-mtp") self._compute_serial_mtp_and_sample() nvtx_range_pop("mtp-spec-decoding/serial-mtp") - # Phase 4: Release freed blocks back to the allocator. - # Deferred from Phase 2 to avoid a GPU pipeline drain: - # blocks_to_release[remove_mask] is boolean-mask indexing whose - # output size is data-dependent, forcing a CUDA sync. By - # deferring to after serial MTP, the sync overlaps with - # already-completed GPU work instead of stalling before it. + # Phase 4: Release freed blocks. Deferred from Phase 2 so the + # data-dependent boolean-mask sync overlaps with MTP GPU work. context.kv_block_allocator.release_memory_blocks( blocks_to_release[remove_mask] ) diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py index e023e027fdc..0e3bb6ee0de 100644 --- a/megatron/core/inference/text_generation_controllers/triton_kernels.py +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -367,10 +367,7 @@ def _mamba_state_selective_copy_kernel( - dim 1: mamba layer index - dim 2: chunk of the flattened state vector - For prefill requests the program is a no-op. For decode requests it - copies ``intermediate[layer, slot, accepted, :]`` → - ``current[layer, slot, :]``, performing a direct in-place update with - **zero temporary allocations**. + No-op for prefill requests. """ pid_req = tl.program_id(0) pid_layer = tl.program_id(1) @@ -410,20 +407,10 @@ def mamba_state_selective_copy( ): """Copy accepted intermediate Mamba states to current states in-place. - For each **decode** request, copies + For each decode request, copies ``intermediate[layer, slot, accepted_count, ...]`` → ``current[layer, slot, ...]`` for every Mamba layer. - This replaces the original pattern of:: - - gathered = intermediate[:, idx, accepted] # large temporary COPY - current_val = current[:, idx] # large temporary COPY - result = torch.where(mask, gathered, current_val) # another allocation - current[:, idx] = result # scatter back - - …which allocated **three** full-sized temporaries per state type. - The Triton kernel writes directly in-place with zero temporaries. - Args: intermediate_states: ``(L, M, S+1, *state_shape)`` — intermediate buffer. current_states: ``(L, M, *state_shape)`` — current state buffer (updated in-place). diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index f4363947111..75ff640b1b9 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -353,7 +353,7 @@ def compute_mtp_single_step( embedding=self.embedding, ) # CudaGraphManager.replay_graph_capture always wraps outputs in a - # tuple. Unwrap when forward_single_position is CUDA-graphed. + # tuple. Unwrap when forward_single_position is CUDA-graphed. if isinstance(mtp_hidden, tuple): mtp_hidden = mtp_hidden[0] nvtx_range_pop(f"mtp-single-step/depth-{depth}/mtp-layer") diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 2bbcbaed91c..a124bc05af7 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -258,11 +258,7 @@ def _determine_if_first_last_layer_of_this_vp_chunk(base_module): if not hasattr(base_module, "layer_number"): return True, True - # MTP layers have their own numbering separate from the decoder stack. - # Treat each one as self-contained so the buffer-reuse logic does not - # try to chain them with decoder layers. Uses getattr rather than - # isinstance so it covers both the inner TransformerLayer (is_mtp_layer=True) - # and the outer MultiTokenPredictionLayer (is_mtp_layer=True). + # MTP layers are self-contained; don't chain them with decoder layers. if getattr(base_module, 'is_mtp_layer', False): return True, True @@ -1430,8 +1426,7 @@ def __init__( rng_tracker = get_cuda_rng_tracker() self.need_backward = need_backward - # is_mtp implies inference mode: MTP is only cuda-graphed for inference - # (forward_single_position), not for training which uses the regular forward path. + # MTP is only cuda-graphed for inference (forward_single_position). self.is_mtp = isinstance(base_module, MultiTokenPredictionLayer) if function_name is not None: @@ -1612,10 +1607,8 @@ def __call__(self, megatron_module, args, kwargs): out = runner.replay_graph_capture(self.is_first_microbatch, args, kwargs) else: if is_inference_mode: - # When the main model is in eager mode, MTP must also run - # eagerly so that all EP ranks take the same code path. - # Skip this guard during graph capturing (warmup) since the - # attribute is only set at runtime by the controller. + # MTP must match the main model's eager/graph mode so all EP + # ranks take the same code path. Skip during graph capture. if ( self.is_mtp and not getattr(megatron_module, 'use_mtp_cuda_graphs', False) @@ -1627,13 +1620,7 @@ def __call__(self, megatron_module, args, kwargs): runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) if not runner.fwd_graph_recorded and self.is_mtp and not is_graph_capturing(): - # No pre-warmed graph for this MTP batch size — run eagerly - # instead of attempting lazy capture. Lazy MTP graph capture - # would fail for models with MoE layers because the AlltoAll - # token dispatcher performs host synchronization - # (d2h_event.synchronize) that is illegal inside CUDA graph - # capture, and the graph-safe inference dispatcher is only - # enabled during explicit warmup. + # No pre-warmed graph for this batch size — run eagerly. return self.func(*args, **kwargs) if not runner.fwd_graph_recorded: From 9bd8aa4cfe5841275bd863c9c797bc2147e656eb Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 23:47:13 -0700 Subject: [PATCH 039/124] Add unit test Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 928 ++++++++++++++++++ 1 file changed, 928 insertions(+) create mode 100644 tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py new file mode 100644 index 00000000000..fd69464f366 --- /dev/null +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -0,0 +1,928 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Tests for CUDA-graphed MTP (Multi-Token Prediction) inference. + +Verifies that: +1. CUDA graph replay produces the same output as eager execution (no extra + padding in the CUDA graphed case). +2. CUDA graphs work correctly with sequence parallelism (padding is applied + to make batch sizes divisible by TP). +3. CUDA graphs work correctly with expert parallelism and dummy ranks. +""" + +import itertools +from unittest import mock + +import pytest +import torch +import torch.distributed as dist + +from megatron.core import parallel_state +from megatron.core.inference.batch_dimensions_utils import InferenceBatchDimensions +from megatron.core.inference.config import InferenceConfig +from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext +from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( + GPTInferenceWrapper, +) +from megatron.core.inference.text_generation_controllers.text_generation_controller import ( + TextGenerationController, +) +from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_layer_local_spec, + get_gpt_mtp_block_spec, +) +from megatron.core.models.gpt.gpt_model import GPTModel +from megatron.core.tensor_parallel.mappings import scatter_to_sequence_parallel_region +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer import TransformerConfig +from megatron.core.transformer.cuda_graphs import ( + _set_capture_end, + _set_capture_start, + delete_cuda_graphs, +) +from megatron.core.transformer.enums import AttnBackend +from megatron.core.utils import unwrap_model +from tests.unit_tests.test_utilities import Utils + + +# --------------------------------------------------------------------------- # +# TestMTPCudaGraphInference (TP = 2) +# --------------------------------------------------------------------------- # + + +class TestMTPCudaGraphInference: + """Tests for MTP CUDA-graphed inference with tensor parallelism. + + All tests require at least 2 GPUs (TP = 2). + """ + + HIDDEN_SIZE = 32 + VOCAB_SIZE = 100 + MAX_SEQ_LEN = 64 + NUM_LAYERS = 4 + NUM_ATTN_HEADS = 4 + TP_SIZE = 2 + + def setup_method(self, method): + if Utils.world_size < self.TP_SIZE: + pytest.skip(f"Need at least {self.TP_SIZE} GPUs") + Utils.initialize_model_parallel( + tensor_model_parallel_size=self.TP_SIZE, + pipeline_model_parallel_size=1, + ) + + def teardown_method(self, method): + delete_cuda_graphs() + Utils.destroy_model_parallel() + + # ---- helpers ---------------------------------------------------------- # + + def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): + """Build a GPT model with MTP layers and local CUDA graph support.""" + model_parallel_cuda_manual_seed(123, inference_rng_tracker=True, force_reset_rng=True) + config = TransformerConfig( + num_layers=self.NUM_LAYERS, + hidden_size=self.HIDDEN_SIZE, + num_attention_heads=self.NUM_ATTN_HEADS, + use_cpu_initialization=True, + attention_backend=AttnBackend.local, + params_dtype=torch.float32, + tensor_model_parallel_size=self.TP_SIZE, + pipeline_model_parallel_size=1, + pipeline_dtype=torch.float32, + mtp_num_layers=mtp_num_layers, + sequence_parallel=sequence_parallel, + cuda_graph_impl="local", + ) + layer_spec = get_gpt_layer_local_spec() + mtp_block_spec = get_gpt_mtp_block_spec( + config=config, + spec=layer_spec, + use_transformer_engine=False, + ) + model = GPTModel( + config=config, + transformer_layer_spec=layer_spec, + vocab_size=self.VOCAB_SIZE, + max_sequence_length=self.MAX_SEQ_LEN, + parallel_output=True, + pre_process=True, + post_process=True, + mtp_block_spec=mtp_block_spec, + ).cuda() + model.eval() + return model + + def _build_controller( + self, + *, + sequence_parallel=False, + mtp_num_layers=2, + num_speculative_tokens=2, + max_requests=None, + ): + """Build a model, DynamicInferenceContext, and TextGenerationController.""" + model = self._build_model( + sequence_parallel=sequence_parallel, + mtp_num_layers=mtp_num_layers, + ) + config = model.config + if max_requests is None: + max_requests = 16 + context = DynamicInferenceContext( + model_config=config, + inference_config=InferenceConfig( + max_sequence_length=self.MAX_SEQ_LEN * 2, + buffer_size_gb=0.2, + materialize_only_last_token_logits=False, + use_flashinfer_fused_rope=None, + unified_memory_level=0, + num_speculative_tokens=num_speculative_tokens, + block_size_tokens=256, + max_requests=max_requests, + ), + ) + wrapped = GPTInferenceWrapper(model, context) + wrapped.model_is_pipeline_parallel = False + mock_tokenizer = mock.Mock() + ctrl = TextGenerationController( + inference_wrapped_model=wrapped, + tokenizer=mock_tokenizer, + ) + return model, context, ctrl + + def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): + """Warm up MTP CUDA graphs for the given batch sizes. + + Replicates the warmup logic from ``DynamicEngine._warmup_mtp_cuda_graphs``. + """ + unwrapped = unwrap_model(model) + tp_group = parallel_state.get_tensor_model_parallel_group() + device = torch.cuda.current_device() + dtype = model.config.params_dtype + hidden_size = model.config.hidden_size + + _set_capture_start() + for bs in sorted(batch_sizes): + dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) + if sp_enabled: + dummy_hidden = scatter_to_sequence_parallel_region( + dummy_hidden, group=tp_group + ) + dummy_token_ids = torch.zeros((1, bs), device=device, dtype=torch.long) + dummy_position_ids = torch.zeros((1, bs), device=device, dtype=torch.int64) + unwrapped.compute_mtp_single_step( + hidden_states=dummy_hidden, + next_token_ids=dummy_token_ids, + position_ids=dummy_position_ids, + depth=0, + ) + _set_capture_end() + + @staticmethod + def _set_mtp_cuda_graph_flag(model, enabled): + """Set ``use_mtp_cuda_graphs`` on all MTP layers.""" + unwrapped = unwrap_model(model) + for layer in unwrapped.mtp.layers: + layer.use_mtp_cuda_graphs = enabled + + # ---- Test 1: graph output matches eager (no additional padding) ------- # + + @pytest.mark.parametrize("batch_size", [2, 4, 8]) + @torch.inference_mode() + def test_cuda_graph_output_matches_eager(self, batch_size): + """CUDA graph replay produces the same output as eager execution. + + The batch size exactly matches a warmed-up graph, so there is no + additional padding in the CUDA graphed case. Both paths must + produce identical hidden states and logits. + """ + model = self._build_model() + unwrapped = unwrap_model(model) + self._warmup_mtp_graphs(model, [batch_size]) + + # Create identical random inputs on all TP ranks. + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + # Graph path. + self._set_mtp_cuda_graph_flag(model, True) + h_graph, logits_graph = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + # Clone immediately — CUDA graph output buffers are reused on next call. + h_graph = h_graph.clone() + logits_graph = logits_graph.clone() + + # Eager path. + self._set_mtp_cuda_graph_flag(model, False) + h_eager, logits_eager = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + + torch.testing.assert_close(h_graph, h_eager) + torch.testing.assert_close(logits_graph, logits_eager) + + # ---- Test 2: graph matches eager with sequence parallelism ------------ # + + @pytest.mark.parametrize("batch_size", [2, 4]) + @torch.inference_mode() + def test_cuda_graph_output_matches_eager_with_sp(self, batch_size): + """CUDA graph replay matches eager with sequence parallelism. + + Hidden states are in scattered SP format ``[batch_size/TP, 1, H]``. + Token/position IDs remain at full ``[1, batch_size]``. Both paths + must produce identical outputs. + """ + model = self._build_model(sequence_parallel=True) + unwrapped = unwrap_model(model) + tp_group = parallel_state.get_tensor_model_parallel_group() + self._warmup_mtp_graphs(model, [batch_size], sp_enabled=True) + + # Create random inputs; scatter hidden for SP. + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + hidden_sp = scatter_to_sequence_parallel_region(hidden, group=tp_group) + + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + # Graph path. + self._set_mtp_cuda_graph_flag(model, True) + h_graph, logits_graph = unwrapped.compute_mtp_single_step( + hidden_states=hidden_sp.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + h_graph = h_graph.clone() + logits_graph = logits_graph.clone() + + # Eager path. + self._set_mtp_cuda_graph_flag(model, False) + h_eager, logits_eager = unwrapped.compute_mtp_single_step( + hidden_states=hidden_sp.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + + torch.testing.assert_close(h_graph, h_eager) + torch.testing.assert_close(logits_graph, logits_eager) + + # ---- Test 3: end-to-end _compute_serial_mtp_and_sample with SP ------- # + + @pytest.mark.parametrize("active_request_count", [2, 3, 4, 5]) + @torch.inference_mode() + def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): + """Full ``_compute_serial_mtp_and_sample`` with CUDA graphs and SP. + + Active request counts that are not multiples of TP are padded. + The MTP CUDA graph is pre-warmed for the padded batch size. + Verifies that padding, SP scatter/gather, and MTP forward all + work correctly through the CUDA graph path. + """ + tp_size = self.TP_SIZE + num_spec = 2 + # max_requests must accommodate the padded count. + max_requests = ((active_request_count + tp_size - 1) // tp_size) * tp_size * 2 + model, ctx, ctrl = self._build_controller( + sequence_parallel=True, + mtp_num_layers=num_spec, + num_speculative_tokens=num_spec, + max_requests=max_requests, + ) + unwrapped = unwrap_model(model) + + # Compute the padded batch size. + padded_count = active_request_count + padded_count += (tp_size - padded_count % tp_size) % tp_size + + # Warmup MTP CUDA graphs for the padded count. + self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) + ctrl._mtp_cuda_graph_batch_sizes = [padded_count] + + # Set up context state. + ctx.total_request_count = active_request_count + ctx.paused_request_count = 0 + ctx.request_kv_length_offsets[:active_request_count] = torch.arange( + active_request_count, dtype=torch.int32, device='cuda', + ) + ctx.request_query_lengths[:active_request_count] = torch.ones( + active_request_count, dtype=torch.int32, device='cuda', + ) + + ctrl.num_speculative_tokens = num_spec + ctrl.num_mtp_heads = num_spec + ctrl._init_mtp_sampling_tensor() + ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( + torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE, + ) + + # Build decoder hidden states cache in SP format. + tp_rank = parallel_state.get_tensor_model_parallel_rank() + tp_group = parallel_state.get_tensor_model_parallel_group() + pad = (tp_size - active_request_count % tp_size) % tp_size + s_total = active_request_count + pad + + torch.manual_seed(42) + full_hidden = torch.randn( + s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 + ) + dist.broadcast(full_hidden, src=0) + local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() + unwrapped._decoder_hidden_states_cache = local_hidden + + ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') + + # Enable CUDA graphs for MTP. + ctrl._mtp_resolved_padded_count = padded_count + self._set_mtp_cuda_graph_flag(model, True) + + # Greedy sampling: top_k=1 selects argmax deterministically. + ctrl._torch_sampling_buckets = [ + (list(range(active_request_count)), 1.0, 1, 0.0), + ] + ctrl._torch_sampling_bucket_index_tensors = [ + torch.arange(active_request_count, device='cuda', dtype=torch.long), + ] + + # Run MTP forward pass. + ctrl._compute_serial_mtp_and_sample() + + # Verify sampled MTP tokens. + for depth in range(num_spec): + sampled = ctrl._sampled_mtp_tokens_cuda[depth, :active_request_count] + assert sampled.shape == (active_request_count,) + assert sampled.dtype == torch.int64 + assert torch.all(sampled >= 0) and torch.all(sampled < self.VOCAB_SIZE) + + # Verify decoder hidden states cache was cleaned up. + assert not hasattr(unwrapped, '_decoder_hidden_states_cache') + + # ---- Test 4: SP padding graph vs eager produces same MTP tokens ------- # + + @pytest.mark.parametrize("active_request_count", [3, 5]) + @torch.inference_mode() + def test_cuda_graph_sp_padding_matches_eager(self, active_request_count): + """With SP padding, CUDA graph path produces the same MTP tokens as eager. + + Runs ``_compute_serial_mtp_and_sample`` twice — once through the + CUDA graph path and once through the eager path — with identical + inputs, and asserts the sampled MTP tokens match. + """ + tp_size = self.TP_SIZE + num_spec = 2 + padded_count = active_request_count + padded_count += (tp_size - padded_count % tp_size) % tp_size + max_requests = padded_count * 2 + + def _run_mtp(use_cuda_graph): + """Build fresh model+controller and run MTP, returning sampled tokens.""" + delete_cuda_graphs() + model, ctx, ctrl = self._build_controller( + sequence_parallel=True, + mtp_num_layers=num_spec, + num_speculative_tokens=num_spec, + max_requests=max_requests, + ) + unwrapped = unwrap_model(model) + + if use_cuda_graph: + self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) + ctrl._mtp_cuda_graph_batch_sizes = [padded_count] + ctrl._mtp_resolved_padded_count = padded_count + self._set_mtp_cuda_graph_flag(model, True) + else: + ctrl._mtp_resolved_padded_count = None + self._set_mtp_cuda_graph_flag(model, False) + + ctx.total_request_count = active_request_count + ctx.paused_request_count = 0 + ctx.request_kv_length_offsets[:active_request_count] = torch.arange( + active_request_count, dtype=torch.int32, device='cuda', + ) + ctx.request_query_lengths[:active_request_count] = torch.ones( + active_request_count, dtype=torch.int32, device='cuda', + ) + + ctrl.num_speculative_tokens = num_spec + ctrl.num_mtp_heads = num_spec + ctrl._init_mtp_sampling_tensor() + ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( + torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE, + ) + + tp_rank = parallel_state.get_tensor_model_parallel_rank() + tp_group = parallel_state.get_tensor_model_parallel_group() + pad = (tp_size - active_request_count % tp_size) % tp_size + s_total = active_request_count + pad + + torch.manual_seed(42) + full_hidden = torch.randn( + s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32, + ) + dist.broadcast(full_hidden, src=0) + local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() + unwrapped._decoder_hidden_states_cache = local_hidden + + ctrl._last_accepted_seq_indices = torch.arange( + active_request_count, device='cuda', + ) + ctrl._torch_sampling_buckets = [ + (list(range(active_request_count)), 1.0, 1, 0.0), + ] + ctrl._torch_sampling_bucket_index_tensors = [ + torch.arange(active_request_count, device='cuda', dtype=torch.long), + ] + + ctrl._compute_serial_mtp_and_sample() + + return [ + ctrl._sampled_mtp_tokens_cuda[d, :active_request_count].clone() + for d in range(num_spec) + ] + + graph_tokens = _run_mtp(use_cuda_graph=True) + eager_tokens = _run_mtp(use_cuda_graph=False) + + for depth in range(num_spec): + assert torch.equal(graph_tokens[depth], eager_tokens[depth]), ( + f"Depth {depth}: graph tokens {graph_tokens[depth].tolist()} != " + f"eager tokens {eager_tokens[depth].tolist()}" + ) + + # ---- Test 5: multiple MTP depths with CUDA graphs --------------------- # + + @torch.inference_mode() + def test_cuda_graph_multi_depth(self): + """Run multiple MTP depths with CUDA graphs enabled. + + Verifies that the hidden output from one depth feeds correctly into + the next depth through the same CUDA graph, producing valid outputs + at every depth. + """ + batch_size = 4 + num_depths = 2 + model = self._build_model(mtp_num_layers=num_depths) + unwrapped = unwrap_model(model) + self._warmup_mtp_graphs(model, [batch_size]) + self._set_mtp_cuda_graph_flag(model, True) + + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + current_hidden = hidden.clone() + for depth in range(num_depths): + current_hidden, logits = unwrapped.compute_mtp_single_step( + hidden_states=current_hidden, + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=depth, + ) + # Clone — graph output buffers are reused. + current_hidden = current_hidden.clone() + + assert current_hidden.shape == (batch_size, 1, self.HIDDEN_SIZE), ( + f"Depth {depth}: expected hidden shape ({batch_size}, 1, {self.HIDDEN_SIZE}), " + f"got {current_hidden.shape}" + ) + assert logits.shape == (batch_size, 1, self.VOCAB_SIZE), ( + f"Depth {depth}: expected logits shape ({batch_size}, 1, {self.VOCAB_SIZE}), " + f"got {logits.shape}" + ) + assert torch.all(torch.isfinite(logits)), ( + f"Depth {depth}: logits contain non-finite values" + ) + + # ---- Test 6: eager fallback when no matching graph exists ------------- # + + @torch.inference_mode() + def test_eager_fallback_no_matching_graph(self): + """When ``use_mtp_cuda_graphs`` is True but no warmed graph matches the + batch size, ``forward_single_position`` falls back to eager execution. + The system should produce valid outputs without errors. + """ + model = self._build_model() + unwrapped = unwrap_model(model) + # Warmup for batch_size=4 only. + self._warmup_mtp_graphs(model, [4]) + self._set_mtp_cuda_graph_flag(model, True) + + # Run with batch_size=6 — no matching graph exists. + batch_size = 6 + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + h_out, logits = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + + assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) + assert logits.shape == (batch_size, 1, self.VOCAB_SIZE) + assert torch.all(torch.isfinite(logits)) + + # ---- Test 7: graph flag propagation matches main model ---------------- # + + @torch.inference_mode() + def test_mtp_graph_flag_propagation(self): + """``use_mtp_cuda_graphs`` is correctly toggled via the helper and + every MTP layer sees the same value. + """ + model = self._build_model(mtp_num_layers=2) + unwrapped = unwrap_model(model) + + self._set_mtp_cuda_graph_flag(model, True) + for layer in unwrapped.mtp.layers: + assert layer.use_mtp_cuda_graphs is True + + self._set_mtp_cuda_graph_flag(model, False) + for layer in unwrapped.mtp.layers: + assert layer.use_mtp_cuda_graphs is False + + +# --------------------------------------------------------------------------- # +# TestMTPCudaGraphExpertParallel (EP = 2) +# --------------------------------------------------------------------------- # + +_EP_SIZE = 2 + +# Request state constants for parametrized tests. +NONE = "none" +DECODE = "decode" +PREFILL = "prefill" +MIXED = "mixed" + +ALL_STATES = [NONE, DECODE, PREFILL, MIXED] + +# Combinatorial sweep: C(4+2-1, 2) = 10 test cases. +_STATE_COMBOS = list(itertools.combinations_with_replacement(ALL_STATES, _EP_SIZE)) + +# Batch dimensions for each non-dummy state. +_STATE_DIMS = { + DECODE: InferenceBatchDimensions(token_count=2, prefill_req_count=0, decode_req_count=2), + PREFILL: InferenceBatchDimensions(token_count=16, prefill_req_count=2, decode_req_count=0), + MIXED: InferenceBatchDimensions(token_count=32, prefill_req_count=1, decode_req_count=2), +} + + +@pytest.mark.internal +class TestMTPCudaGraphExpertParallel: + """Tests for MTP CUDA-graphed inference with expert parallelism. + + Follows the test pattern from ``test_mamba_model_expert_parallel_inference.py``. + All tests require at least ``_EP_SIZE`` GPUs. + """ + + HIDDEN_SIZE = 32 + VOCAB_SIZE = 100 + MAX_SEQ_LEN = 128 + NUM_LAYERS = 2 + NUM_ATTN_HEADS = 4 + NUM_MOE_EXPERTS = 2 + + def setup_method(self, method): + if Utils.world_size < _EP_SIZE: + pytest.skip(f"EP test requires at least {_EP_SIZE} GPUs") + if Utils.world_size % _EP_SIZE != 0: + pytest.skip( + f"world_size ({Utils.world_size}) must be divisible by EP size ({_EP_SIZE})" + ) + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=_EP_SIZE, + ) + + def teardown_method(self, method): + delete_cuda_graphs() + Utils.destroy_model_parallel() + + # ---- helpers ---------------------------------------------------------- # + + def _build_model(self): + """Build a GPT model with MTP + MoE + local CUDA graphs.""" + model_parallel_cuda_manual_seed(123, inference_rng_tracker=True, force_reset_rng=True) + config = TransformerConfig( + num_layers=self.NUM_LAYERS, + hidden_size=self.HIDDEN_SIZE, + num_attention_heads=self.NUM_ATTN_HEADS, + use_cpu_initialization=True, + attention_backend=AttnBackend.local, + params_dtype=torch.float32, + expert_model_parallel_size=_EP_SIZE, + num_moe_experts=self.NUM_MOE_EXPERTS, + moe_token_dispatcher_type="alltoall", + add_bias_linear=False, + mtp_num_layers=2, + cuda_graph_impl="local", + moe_pad_experts_for_cuda_graph_inference=True, + ) + layer_spec = get_gpt_layer_local_spec(num_experts=self.NUM_MOE_EXPERTS) + mtp_block_spec = get_gpt_mtp_block_spec( + config=config, + spec=layer_spec, + use_transformer_engine=False, + ) + model = GPTModel( + config=config, + transformer_layer_spec=layer_spec, + vocab_size=self.VOCAB_SIZE, + max_sequence_length=self.MAX_SEQ_LEN, + parallel_output=True, + pre_process=True, + post_process=True, + mtp_block_spec=mtp_block_spec, + ).cuda() + model.eval() + return model + + def _build_context( + self, + model, + *, + num_cuda_graphs=16, + use_cuda_graphs_for_non_decode_steps=True, + max_requests=None, + ): + """Build a DynamicInferenceContext for the model.""" + return DynamicInferenceContext( + model_config=model.config, + inference_config=InferenceConfig( + max_sequence_length=self.MAX_SEQ_LEN, + buffer_size_gb=0.5, + block_size_tokens=256, + materialize_only_last_token_logits=False, + num_cuda_graphs=num_cuda_graphs, + use_cuda_graphs_for_non_decode_steps=use_cuda_graphs_for_non_decode_steps, + max_requests=max_requests, + ), + ) + + def _warmup_mtp_graphs(self, model, batch_sizes): + """Warm up MTP CUDA graphs for the given batch sizes.""" + unwrapped = unwrap_model(model) + device = torch.cuda.current_device() + dtype = model.config.params_dtype + hidden_size = model.config.hidden_size + + _set_capture_start() + for bs in sorted(batch_sizes): + dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) + dummy_token_ids = torch.zeros((1, bs), device=device, dtype=torch.long) + dummy_position_ids = torch.zeros((1, bs), device=device, dtype=torch.int64) + unwrapped.compute_mtp_single_step( + hidden_states=dummy_hidden, + next_token_ids=dummy_token_ids, + position_ids=dummy_position_ids, + depth=0, + ) + _set_capture_end() + + @staticmethod + def _set_mtp_cuda_graph_flag(model, enabled): + unwrapped = unwrap_model(model) + for layer in unwrapped.mtp.layers: + layer.use_mtp_cuda_graphs = enabled + + # ---- Test 1: all EP ranks run MTP forward with CUDA graphs ------------ # + + @pytest.mark.parametrize("batch_size", [2, 4, 8]) + @pytest.mark.internal + @torch.inference_mode() + def test_ep_mtp_cuda_graph_forward(self, batch_size): + """All EP ranks can run MTP forward with CUDA graphs. + + The MoE all-to-all collectives must match across EP ranks. Verifies + that all ranks complete without hanging and produce valid shapes. + """ + model = self._build_model() + unwrapped = unwrap_model(model) + self._warmup_mtp_graphs(model, [batch_size]) + self._set_mtp_cuda_graph_flag(model, True) + + # Broadcast identical inputs so all EP ranks see the same data. + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + h_out, logits = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + depth=0, + ) + + assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) + assert logits.shape == (batch_size, 1, self.VOCAB_SIZE) + assert torch.all(torch.isfinite(logits)) + + # ---- Test 2: dummy ranks + real ranks with CUDA graphs ---------------- # + + @pytest.mark.internal + @torch.inference_mode() + def test_ep_mtp_cuda_graph_dummy_and_real_ranks(self): + """Even EP ranks run as dummy (with zeros), odd ranks run with real data. + + Both must issue matching MoE all-to-all collectives via the + CUDA-graphed MTP forward to avoid hangs. + """ + batch_size = 4 + model = self._build_model() + unwrapped = unwrap_model(model) + self._warmup_mtp_graphs(model, [batch_size]) + self._set_mtp_cuda_graph_flag(model, True) + + ep_rank = parallel_state.get_expert_model_parallel_rank() + is_dummy = ep_rank % 2 == 0 + + if is_dummy: + hidden = torch.zeros(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + token_ids = torch.zeros(1, batch_size, device='cuda', dtype=torch.long) + else: + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + + # All ranks must complete without hanging. + h_out, logits = unwrapped.compute_mtp_single_step( + hidden_states=hidden, + next_token_ids=token_ids, + position_ids=position_ids, + depth=0, + ) + + assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) + assert logits.shape == (batch_size, 1, self.VOCAB_SIZE) + + # ---- Test 3: EP state cross product with DynamicInferenceContext ------- # + + @pytest.mark.parametrize( + "rank_states", + _STATE_COMBOS, + ids=[",".join(s) for s in _STATE_COMBOS], + ) + @pytest.mark.internal + @torch.inference_mode() + def test_ep_state_cross_product(self, rank_states): + """Test combinatorial assignments of request states across EP ranks. + + Verifies that: + - All EP ranks agree on CUDA graph usage (on or off). + - When CUDA graphs are used, all ranks agree on the padded batch size. + - MTP ``compute_mtp_single_step`` completes on all ranks with the + EP-synced padded batch size. + """ + ep_rank = parallel_state.get_expert_model_parallel_rank() + my_state = rank_states[ep_rank] + is_dummy = my_state == NONE + + model = self._build_model() + ctx = self._build_context(model) + + # Phase 1: Set up each rank's request state. + if not is_dummy: + ctx.add_dummy_requests_for_cudagraph_capture(_STATE_DIMS[my_state]) + + # Phase 2: Initialize attention state (EP collective). + if is_dummy: + ctx.initialize_attention_state(is_expert_parallel_dummy_cuda_graph_step=True) + else: + ctx.initialize_attention_state() + + # Phase 3: Verify EP agreement on CUDA graph usage. + uses_graph = ctx.using_cuda_graph_this_step() + ep_group = parallel_state.get_expert_model_parallel_group() + uses_graph_t = torch.tensor([int(uses_graph)], device='cuda', dtype=torch.int32) + graph_min = uses_graph_t.clone() + graph_max = uses_graph_t.clone() + dist.all_reduce(graph_min, op=dist.ReduceOp.MIN, group=ep_group) + dist.all_reduce(graph_max, op=dist.ReduceOp.MAX, group=ep_group) + assert graph_min.item() == graph_max.item(), ( + f"CUDA graph usage disagrees across EP ranks: " + f"min={graph_min.item()}, max={graph_max.item()} " + f"(rank_states={rank_states})" + ) + + if not uses_graph: + # When no CUDA graph matches, skip the MTP forward test. + return + + # Phase 4: Derive MTP padded batch size from EP-synced dimensions. + mtp_padded = ctx.padded_batch_dimensions.req_count + + # Verify MTP padded count agrees across EP ranks. + padded_t = torch.tensor([mtp_padded], dtype=torch.int32, device='cuda') + padded_max = padded_t.clone() + padded_min = padded_t.clone() + dist.all_reduce(padded_max, op=dist.ReduceOp.MAX, group=ep_group) + dist.all_reduce(padded_min, op=dist.ReduceOp.MIN, group=ep_group) + assert padded_max.item() == padded_min.item(), ( + f"MTP padded batch size mismatch across EP ranks: " + f"min={padded_min.item()}, max={padded_max.item()} " + f"(rank_states={rank_states})" + ) + + # Phase 5: Warmup MTP CUDA graphs and run forward. + self._warmup_mtp_graphs(model, [mtp_padded]) + self._set_mtp_cuda_graph_flag(model, True) + + unwrapped = unwrap_model(model) + hidden = torch.randn(mtp_padded, 1, self.HIDDEN_SIZE, device='cuda') + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, mtp_padded), device='cuda') + position_ids = torch.arange(mtp_padded, device='cuda', dtype=torch.int64).unsqueeze(0) + + h_out, logits = unwrapped.compute_mtp_single_step( + hidden_states=hidden, + next_token_ids=token_ids, + position_ids=position_ids, + depth=0, + ) + + assert h_out.shape == (mtp_padded, 1, self.HIDDEN_SIZE), ( + f"EP rank {ep_rank} (state={my_state}): expected hidden shape " + f"({mtp_padded}, 1, {self.HIDDEN_SIZE}), got {h_out.shape}" + ) + assert logits.shape == (mtp_padded, 1, self.VOCAB_SIZE), ( + f"EP rank {ep_rank} (state={my_state}): expected logits shape " + f"({mtp_padded}, 1, {self.VOCAB_SIZE}), got {logits.shape}" + ) + + # ---- Test 4: dummy EP rank bail-out with decode-only CUDA graphs ------ # + + @pytest.mark.parametrize( + "peer_state", + [PREFILL, MIXED], + ids=[f"peer={s}" for s in [PREFILL, MIXED]], + ) + @pytest.mark.internal + @torch.inference_mode() + def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): + """Verify the dummy-rank bail-out path when only decode CUDA graphs + are available. + + With ``use_cuda_graphs_for_non_decode_steps=False``, only decode-only + graphs exist. When any EP rank has prefill requests, no graph matches + and all ranks fall back to eager mode. The MTP forward for the dummy + rank must use eager execution without hanging. + """ + ep_rank = parallel_state.get_expert_model_parallel_rank() + is_even = ep_rank % 2 == 0 + + model = self._build_model() + ctx = self._build_context(model, use_cuda_graphs_for_non_decode_steps=False) + + # Even ranks are dummy; odd ranks have the peer_state. + if not is_even: + ctx.add_dummy_requests_for_cudagraph_capture(_STATE_DIMS[peer_state]) + + if is_even: + ctx.initialize_attention_state(is_expert_parallel_dummy_cuda_graph_step=True) + else: + ctx.initialize_attention_state() + + # No rank should match a CUDA graph. + assert not ctx.using_cuda_graph_this_step(), ( + f"EP rank {ep_rank}: expected no CUDA graph match with " + f"decode-only graphs and peer_state={peer_state}" + ) + + # MTP eager forward should still work on all ranks. + unwrapped = unwrap_model(model) + self._set_mtp_cuda_graph_flag(model, False) + + tp_size = parallel_state.get_tensor_model_parallel_world_size() + dummy_hidden = torch.zeros((tp_size, 1, self.HIDDEN_SIZE), device='cuda') + dummy_tokens = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) + dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) + + h_out, logits = unwrapped.compute_mtp_single_step( + hidden_states=dummy_hidden, + next_token_ids=dummy_tokens, + position_ids=dummy_positions, + depth=0, + ) + + assert h_out.shape == (tp_size, 1, self.HIDDEN_SIZE) + assert logits.shape == (tp_size, 1, self.VOCAB_SIZE) From e8690f8cb7fb6d186d5336e30c1a2c39ef92edb1 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 23:48:29 -0700 Subject: [PATCH 040/124] Linting Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 13 ++- .../triton_kernels.py | 17 +--- .../test_mtp_cuda_graph_inference.py | 84 ++++++------------- 3 files changed, 34 insertions(+), 80 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index ef95acd34cc..724dc8ff88b 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -610,7 +610,6 @@ def _dynamic_step_context_init( is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, ) - # Derive the MTP padded batch size from the EP-synced graph dimensions. # In eager mode MTP uses locally SP-aligned batch size instead. if ( @@ -1137,9 +1136,7 @@ def _prepare_speculative_tokens_for_next_forward_pass( num_speculative_tokens=self.num_speculative_tokens, ) # Expose the active slice so downstream code sees the right length. - self._last_accepted_seq_indices = self._last_accepted_seq_indices_buf[ - :active_request_count - ] + self._last_accepted_seq_indices = self._last_accepted_seq_indices_buf[:active_request_count] def _dynamic_step_sample_logits(self, logits: Tensor): """Sample tokens from logits for dynamic batching. @@ -1660,7 +1657,9 @@ def _dummy_serial_mtp_forward(self): unwrapped_model = self._unwrapped_model - has_mtp = self._is_last_pp_stage and hasattr(unwrapped_model, '_decoder_hidden_states_cache') + has_mtp = self._is_last_pp_stage and hasattr( + unwrapped_model, '_decoder_hidden_states_cache' + ) if not has_mtp and not self.model_is_pipeline_parallel: # No MTP on this rank and no PP broadcast to participate in. return @@ -1871,9 +1870,7 @@ async def async_generate_output_tokens_dynamic_batch( # Phase 4: Release freed blocks. Deferred from Phase 2 so the # data-dependent boolean-mask sync overlaps with MTP GPU work. - context.kv_block_allocator.release_memory_blocks( - blocks_to_release[remove_mask] - ) + context.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) else: self._dynamic_step_sample_logits(logits) diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py index 0e3bb6ee0de..a27995f601b 100644 --- a/megatron/core/inference/text_generation_controllers/triton_kernels.py +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -198,11 +198,7 @@ def _verify_speculative_tokens_kernel( def verify_speculative_tokens( - input_tokens, - output_tokens, - num_decode_requests, - num_prefill_requests, - num_speculative_tokens, + input_tokens, output_tokens, num_decode_requests, num_prefill_requests, num_speculative_tokens ): """Launch the speculative-token verification Triton kernel. @@ -219,9 +215,7 @@ def verify_speculative_tokens( decode_len = num_decode_requests * stride accepted_tokens_mask = torch.zeros_like(input_tokens, dtype=torch.bool) - last_one_indices = torch.full( - (active_request_count,), -1, device=device, dtype=torch.long - ) + last_one_indices = torch.full((active_request_count,), -1, device=device, dtype=torch.long) if active_request_count > 0: block_size = triton.next_power_of_2(stride) @@ -398,12 +392,7 @@ def _mamba_state_selective_copy_kernel( def mamba_state_selective_copy( - intermediate_states, - current_states, - prefill_status, - state_idx, - accepted_counts, - num_layers, + intermediate_states, current_states, prefill_status, state_idx, accepted_counts, num_layers ): """Copy accepted intermediate Mamba states to current states in-place. diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index fd69464f366..fa22dd89eb9 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -44,7 +44,6 @@ from megatron.core.utils import unwrap_model from tests.unit_tests.test_utilities import Utils - # --------------------------------------------------------------------------- # # TestMTPCudaGraphInference (TP = 2) # --------------------------------------------------------------------------- # @@ -67,8 +66,7 @@ def setup_method(self, method): if Utils.world_size < self.TP_SIZE: pytest.skip(f"Need at least {self.TP_SIZE} GPUs") Utils.initialize_model_parallel( - tensor_model_parallel_size=self.TP_SIZE, - pipeline_model_parallel_size=1, + tensor_model_parallel_size=self.TP_SIZE, pipeline_model_parallel_size=1 ) def teardown_method(self, method): @@ -96,9 +94,7 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): ) layer_spec = get_gpt_layer_local_spec() mtp_block_spec = get_gpt_mtp_block_spec( - config=config, - spec=layer_spec, - use_transformer_engine=False, + config=config, spec=layer_spec, use_transformer_engine=False ) model = GPTModel( config=config, @@ -123,8 +119,7 @@ def _build_controller( ): """Build a model, DynamicInferenceContext, and TextGenerationController.""" model = self._build_model( - sequence_parallel=sequence_parallel, - mtp_num_layers=mtp_num_layers, + sequence_parallel=sequence_parallel, mtp_num_layers=mtp_num_layers ) config = model.config if max_requests is None: @@ -145,10 +140,7 @@ def _build_controller( wrapped = GPTInferenceWrapper(model, context) wrapped.model_is_pipeline_parallel = False mock_tokenizer = mock.Mock() - ctrl = TextGenerationController( - inference_wrapped_model=wrapped, - tokenizer=mock_tokenizer, - ) + ctrl = TextGenerationController(inference_wrapped_model=wrapped, tokenizer=mock_tokenizer) return model, context, ctrl def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): @@ -166,9 +158,7 @@ def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): for bs in sorted(batch_sizes): dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) if sp_enabled: - dummy_hidden = scatter_to_sequence_parallel_region( - dummy_hidden, group=tp_group - ) + dummy_hidden = scatter_to_sequence_parallel_region(dummy_hidden, group=tp_group) dummy_token_ids = torch.zeros((1, bs), device=device, dtype=torch.long) dummy_position_ids = torch.zeros((1, bs), device=device, dtype=torch.int64) unwrapped.compute_mtp_single_step( @@ -316,17 +306,17 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): ctx.total_request_count = active_request_count ctx.paused_request_count = 0 ctx.request_kv_length_offsets[:active_request_count] = torch.arange( - active_request_count, dtype=torch.int32, device='cuda', + active_request_count, dtype=torch.int32, device='cuda' ) ctx.request_query_lengths[:active_request_count] = torch.ones( - active_request_count, dtype=torch.int32, device='cuda', + active_request_count, dtype=torch.int32, device='cuda' ) ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec ctrl._init_mtp_sampling_tensor() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( - torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE, + torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE ) # Build decoder hidden states cache in SP format. @@ -336,9 +326,7 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): s_total = active_request_count + pad torch.manual_seed(42) - full_hidden = torch.randn( - s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 - ) + full_hidden = torch.randn(s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32) dist.broadcast(full_hidden, src=0) local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() unwrapped._decoder_hidden_states_cache = local_hidden @@ -350,11 +338,9 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): self._set_mtp_cuda_graph_flag(model, True) # Greedy sampling: top_k=1 selects argmax deterministically. - ctrl._torch_sampling_buckets = [ - (list(range(active_request_count)), 1.0, 1, 0.0), - ] + ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] ctrl._torch_sampling_bucket_index_tensors = [ - torch.arange(active_request_count, device='cuda', dtype=torch.long), + torch.arange(active_request_count, device='cuda', dtype=torch.long) ] # Run MTP forward pass. @@ -410,17 +396,17 @@ def _run_mtp(use_cuda_graph): ctx.total_request_count = active_request_count ctx.paused_request_count = 0 ctx.request_kv_length_offsets[:active_request_count] = torch.arange( - active_request_count, dtype=torch.int32, device='cuda', + active_request_count, dtype=torch.int32, device='cuda' ) ctx.request_query_lengths[:active_request_count] = torch.ones( - active_request_count, dtype=torch.int32, device='cuda', + active_request_count, dtype=torch.int32, device='cuda' ) ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec ctrl._init_mtp_sampling_tensor() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( - torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE, + torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE ) tp_rank = parallel_state.get_tensor_model_parallel_rank() @@ -430,20 +416,16 @@ def _run_mtp(use_cuda_graph): torch.manual_seed(42) full_hidden = torch.randn( - s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32, + s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 ) dist.broadcast(full_hidden, src=0) local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() unwrapped._decoder_hidden_states_cache = local_hidden - ctrl._last_accepted_seq_indices = torch.arange( - active_request_count, device='cuda', - ) - ctrl._torch_sampling_buckets = [ - (list(range(active_request_count)), 1.0, 1, 0.0), - ] + ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') + ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] ctrl._torch_sampling_bucket_index_tensors = [ - torch.arange(active_request_count, device='cuda', dtype=torch.long), + torch.arange(active_request_count, device='cuda', dtype=torch.long) ] ctrl._compute_serial_mtp_and_sample() @@ -504,9 +486,9 @@ def test_cuda_graph_multi_depth(self): f"Depth {depth}: expected logits shape ({batch_size}, 1, {self.VOCAB_SIZE}), " f"got {logits.shape}" ) - assert torch.all(torch.isfinite(logits)), ( - f"Depth {depth}: logits contain non-finite values" - ) + assert torch.all( + torch.isfinite(logits) + ), f"Depth {depth}: logits contain non-finite values" # ---- Test 6: eager fallback when no matching graph exists ------------- # @@ -639,9 +621,7 @@ def _build_model(self): ) layer_spec = get_gpt_layer_local_spec(num_experts=self.NUM_MOE_EXPERTS) mtp_block_spec = get_gpt_mtp_block_spec( - config=config, - spec=layer_spec, - use_transformer_engine=False, + config=config, spec=layer_spec, use_transformer_engine=False ) model = GPTModel( config=config, @@ -767,10 +747,7 @@ def test_ep_mtp_cuda_graph_dummy_and_real_ranks(self): # All ranks must complete without hanging. h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=hidden, - next_token_ids=token_ids, - position_ids=position_ids, - depth=0, + hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids, depth=0 ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -778,11 +755,7 @@ def test_ep_mtp_cuda_graph_dummy_and_real_ranks(self): # ---- Test 3: EP state cross product with DynamicInferenceContext ------- # - @pytest.mark.parametrize( - "rank_states", - _STATE_COMBOS, - ids=[",".join(s) for s in _STATE_COMBOS], - ) + @pytest.mark.parametrize("rank_states", _STATE_COMBOS, ids=[",".join(s) for s in _STATE_COMBOS]) @pytest.mark.internal @torch.inference_mode() def test_ep_state_cross_product(self, rank_states): @@ -854,10 +827,7 @@ def test_ep_state_cross_product(self, rank_states): position_ids = torch.arange(mtp_padded, device='cuda', dtype=torch.int64).unsqueeze(0) h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=hidden, - next_token_ids=token_ids, - position_ids=position_ids, - depth=0, + hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids, depth=0 ) assert h_out.shape == (mtp_padded, 1, self.HIDDEN_SIZE), ( @@ -872,9 +842,7 @@ def test_ep_state_cross_product(self, rank_states): # ---- Test 4: dummy EP rank bail-out with decode-only CUDA graphs ------ # @pytest.mark.parametrize( - "peer_state", - [PREFILL, MIXED], - ids=[f"peer={s}" for s in [PREFILL, MIXED]], + "peer_state", [PREFILL, MIXED], ids=[f"peer={s}" for s in [PREFILL, MIXED]] ) @pytest.mark.internal @torch.inference_mode() From 89c4cedcd3729b6c5de05f43a272c709153008f9 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 13 Apr 2026 23:58:56 -0700 Subject: [PATCH 041/124] Zero out padded values in test Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index fa22dd89eb9..95477f512e7 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -315,6 +315,10 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec ctrl._init_mtp_sampling_tensor() + # Zero out buffers allocated with torch.empty to avoid garbage values + # in padding positions causing out-of-bounds embedding lookups. + ctrl._mtp_token_ids_buf.zero_() + ctrl._mtp_position_ids_buf.zero_() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE ) @@ -405,6 +409,10 @@ def _run_mtp(use_cuda_graph): ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec ctrl._init_mtp_sampling_tensor() + # Zero out buffers allocated with torch.empty to avoid garbage values + # in padding positions causing out-of-bounds embedding lookups. + ctrl._mtp_token_ids_buf.zero_() + ctrl._mtp_position_ids_buf.zero_() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE ) From 610d4345cffc695a4a219cd9a7379b8b1dded7fc Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 00:05:06 -0700 Subject: [PATCH 042/124] Fix test Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index 95477f512e7..a9800cc9772 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -24,6 +24,7 @@ from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( GPTInferenceWrapper, ) +from megatron.core.inference.utils import set_decode_expert_padding from megatron.core.inference.text_generation_controllers.text_generation_controller import ( TextGenerationController, ) @@ -673,6 +674,13 @@ def _warmup_mtp_graphs(self, model, batch_sizes): dtype = model.config.params_dtype hidden_size = model.config.hidden_size + # Enable drop-and-pad for MoE during graph capture so the all-to-all + # dispatcher is replaced by CUDA graph-safe local operations. + has_ep = model.config.expert_model_parallel_size > 1 + if has_ep: + capacity_factor = model.config.num_moe_experts / model.config.moe_router_topk + set_decode_expert_padding(unwrapped, True, capacity_factor=capacity_factor) + _set_capture_start() for bs in sorted(batch_sizes): dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) @@ -686,6 +694,9 @@ def _warmup_mtp_graphs(self, model, batch_sizes): ) _set_capture_end() + if has_ep: + set_decode_expert_padding(unwrapped, False) + @staticmethod def _set_mtp_cuda_graph_flag(model, enabled): unwrapped = unwrap_model(model) From 065637313ac2ce31f3a90994e440c80d4130fd76 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 00:09:58 -0700 Subject: [PATCH 043/124] fix test Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index a9800cc9772..c605a63ee54 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -24,6 +24,7 @@ from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( GPTInferenceWrapper, ) +import megatron.core.inference.utils as _inference_utils from megatron.core.inference.utils import set_decode_expert_padding from megatron.core.inference.text_generation_controllers.text_generation_controller import ( TextGenerationController, @@ -606,6 +607,7 @@ def setup_method(self, method): def teardown_method(self, method): delete_cuda_graphs() + _inference_utils.moe_layer_cache = None Utils.destroy_model_parallel() # ---- helpers ---------------------------------------------------------- # @@ -676,8 +678,10 @@ def _warmup_mtp_graphs(self, model, batch_sizes): # Enable drop-and-pad for MoE during graph capture so the all-to-all # dispatcher is replaced by CUDA graph-safe local operations. + # Reset the global MoE layer cache so it discovers this model's layers. has_ep = model.config.expert_model_parallel_size > 1 if has_ep: + _inference_utils.moe_layer_cache = None capacity_factor = model.config.num_moe_experts / model.config.moe_router_topk set_decode_expert_padding(unwrapped, True, capacity_factor=capacity_factor) From 93f5a343821d6b410b87e047d6eddfa390ac0eb7 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 00:40:38 -0700 Subject: [PATCH 044/124] Fix test Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 86 ++----------------- 1 file changed, 8 insertions(+), 78 deletions(-) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index c605a63ee54..ec76471b9f1 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -24,8 +24,6 @@ from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( GPTInferenceWrapper, ) -import megatron.core.inference.utils as _inference_utils -from megatron.core.inference.utils import set_decode_expert_padding from megatron.core.inference.text_generation_controllers.text_generation_controller import ( TextGenerationController, ) @@ -607,7 +605,6 @@ def setup_method(self, method): def teardown_method(self, method): delete_cuda_graphs() - _inference_utils.moe_layer_cache = None Utils.destroy_model_parallel() # ---- helpers ---------------------------------------------------------- # @@ -669,59 +666,19 @@ def _build_context( ), ) - def _warmup_mtp_graphs(self, model, batch_sizes): - """Warm up MTP CUDA graphs for the given batch sizes.""" - unwrapped = unwrap_model(model) - device = torch.cuda.current_device() - dtype = model.config.params_dtype - hidden_size = model.config.hidden_size - - # Enable drop-and-pad for MoE during graph capture so the all-to-all - # dispatcher is replaced by CUDA graph-safe local operations. - # Reset the global MoE layer cache so it discovers this model's layers. - has_ep = model.config.expert_model_parallel_size > 1 - if has_ep: - _inference_utils.moe_layer_cache = None - capacity_factor = model.config.num_moe_experts / model.config.moe_router_topk - set_decode_expert_padding(unwrapped, True, capacity_factor=capacity_factor) - - _set_capture_start() - for bs in sorted(batch_sizes): - dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) - dummy_token_ids = torch.zeros((1, bs), device=device, dtype=torch.long) - dummy_position_ids = torch.zeros((1, bs), device=device, dtype=torch.int64) - unwrapped.compute_mtp_single_step( - hidden_states=dummy_hidden, - next_token_ids=dummy_token_ids, - position_ids=dummy_position_ids, - depth=0, - ) - _set_capture_end() - - if has_ep: - set_decode_expert_padding(unwrapped, False) - - @staticmethod - def _set_mtp_cuda_graph_flag(model, enabled): - unwrapped = unwrap_model(model) - for layer in unwrapped.mtp.layers: - layer.use_mtp_cuda_graphs = enabled - - # ---- Test 1: all EP ranks run MTP forward with CUDA graphs ------------ # + # ---- Test 1: all EP ranks run MTP eager forward ----------------------- # @pytest.mark.parametrize("batch_size", [2, 4, 8]) @pytest.mark.internal @torch.inference_mode() - def test_ep_mtp_cuda_graph_forward(self, batch_size): - """All EP ranks can run MTP forward with CUDA graphs. + def test_ep_mtp_eager_forward(self, batch_size): + """All EP ranks can run MTP forward in eager mode. The MoE all-to-all collectives must match across EP ranks. Verifies that all ranks complete without hanging and produce valid shapes. """ model = self._build_model() unwrapped = unwrap_model(model) - self._warmup_mtp_graphs(model, [batch_size]) - self._set_mtp_cuda_graph_flag(model, True) # Broadcast identical inputs so all EP ranks see the same data. hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') @@ -741,21 +698,19 @@ def test_ep_mtp_cuda_graph_forward(self, batch_size): assert logits.shape == (batch_size, 1, self.VOCAB_SIZE) assert torch.all(torch.isfinite(logits)) - # ---- Test 2: dummy ranks + real ranks with CUDA graphs ---------------- # + # ---- Test 2: dummy ranks + real ranks in eager mode ------------------- # @pytest.mark.internal @torch.inference_mode() - def test_ep_mtp_cuda_graph_dummy_and_real_ranks(self): + def test_ep_mtp_eager_dummy_and_real_ranks(self): """Even EP ranks run as dummy (with zeros), odd ranks run with real data. Both must issue matching MoE all-to-all collectives via the - CUDA-graphed MTP forward to avoid hangs. + MTP eager forward to avoid hangs. """ batch_size = 4 model = self._build_model() unwrapped = unwrap_model(model) - self._warmup_mtp_graphs(model, [batch_size]) - self._set_mtp_cuda_graph_flag(model, True) ep_rank = parallel_state.get_expert_model_parallel_rank() is_dummy = ep_rank % 2 == 0 @@ -786,9 +741,8 @@ def test_ep_state_cross_product(self, rank_states): Verifies that: - All EP ranks agree on CUDA graph usage (on or off). - - When CUDA graphs are used, all ranks agree on the padded batch size. - - MTP ``compute_mtp_single_step`` completes on all ranks with the - EP-synced padded batch size. + - When CUDA graphs are used, all ranks agree on the padded batch size + (which would be used as the MTP batch dimension). """ ep_rank = parallel_state.get_expert_model_parallel_rank() my_state = rank_states[ep_rank] @@ -822,7 +776,6 @@ def test_ep_state_cross_product(self, rank_states): ) if not uses_graph: - # When no CUDA graph matches, skip the MTP forward test. return # Phase 4: Derive MTP padded batch size from EP-synced dimensions. @@ -840,28 +793,6 @@ def test_ep_state_cross_product(self, rank_states): f"(rank_states={rank_states})" ) - # Phase 5: Warmup MTP CUDA graphs and run forward. - self._warmup_mtp_graphs(model, [mtp_padded]) - self._set_mtp_cuda_graph_flag(model, True) - - unwrapped = unwrap_model(model) - hidden = torch.randn(mtp_padded, 1, self.HIDDEN_SIZE, device='cuda') - token_ids = torch.randint(0, self.VOCAB_SIZE, (1, mtp_padded), device='cuda') - position_ids = torch.arange(mtp_padded, device='cuda', dtype=torch.int64).unsqueeze(0) - - h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids, depth=0 - ) - - assert h_out.shape == (mtp_padded, 1, self.HIDDEN_SIZE), ( - f"EP rank {ep_rank} (state={my_state}): expected hidden shape " - f"({mtp_padded}, 1, {self.HIDDEN_SIZE}), got {h_out.shape}" - ) - assert logits.shape == (mtp_padded, 1, self.VOCAB_SIZE), ( - f"EP rank {ep_rank} (state={my_state}): expected logits shape " - f"({mtp_padded}, 1, {self.VOCAB_SIZE}), got {logits.shape}" - ) - # ---- Test 4: dummy EP rank bail-out with decode-only CUDA graphs ------ # @pytest.mark.parametrize( @@ -901,7 +832,6 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): # MTP eager forward should still work on all ranks. unwrapped = unwrap_model(model) - self._set_mtp_cuda_graph_flag(model, False) tp_size = parallel_state.get_tensor_model_parallel_world_size() dummy_hidden = torch.zeros((tp_size, 1, self.HIDDEN_SIZE), device='cuda') From bbbb92d02705f110f0d9a006b62e7d412747a3c8 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 00:56:46 -0700 Subject: [PATCH 045/124] Fix test Signed-off-by: Keshav Santhanam --- .../test_text_generation_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index d1b6d59ac48..bef099d57a7 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1181,6 +1181,7 @@ def test_rewind_kv_cache(self, is_hybrid_model): if is_hybrid_model: ctx.is_hybrid_model = True + ctx.num_mamba_layers = 1 ctx.mamba_metadata = mock.MagicMock() ctx.mamba_metadata.request_to_mamba_state_idx = torch.tensor([0, 1], device='cuda') ctx.mamba_ssm_states = torch.zeros((1, 2, 16), device='cuda') From 24d481e69df4eea62103b5f272d921dfb39ccf90 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 01:15:07 -0700 Subject: [PATCH 046/124] Fix tests Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 10 ++- .../test_text_generation_controller.py | 85 +++++++++++++------ 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 724dc8ff88b..d583cbb00f4 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -911,8 +911,7 @@ def _compute_serial_mtp_and_sample(self): # Pad hidden states and scatter for sequence parallelism. if has_mtp: - if pad_count > 0: - current_hidden = F.pad(current_hidden, (0, 0, 0, 0, 0, pad_count)) + current_hidden = F.pad(current_hidden, (0, 0, 0, 0, 0, pad_count)) if self._sp_enabled: current_hidden = scatter_to_sequence_parallel_region( current_hidden, group=self.inference_wrapped_model.tp_group @@ -921,6 +920,10 @@ def _compute_serial_mtp_and_sample(self): token_ids_buf = self._mtp_token_ids_buf[:, :padded_count] position_ids_buf = self._mtp_position_ids_buf[:, :padded_count] + # Zero-fill padding slots so the embedding layer never sees out-of-range IDs. + token_ids_buf[0, active_request_count:] = 0 + position_ids_buf[0, active_request_count:] = 0 + nvtx_range_pop("mtp-spec-decoding/serial-mtp-init") for depth in range(self._num_mtp_depths): nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") @@ -941,8 +944,7 @@ def _compute_serial_mtp_and_sample(self): # Strip padding from logits only. Hidden states stay padded+SP # between depths to avoid redundant gather/scatter round-trips. - if pad_count > 0: - mtp_logits = mtp_logits[:active_request_count] + mtp_logits = mtp_logits[:active_request_count] # mtp_logits: [active_request_count, 1, vocab_size] mtp_logits_2d = mtp_logits.squeeze(1) # [active_request_count, vocab_size] diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index bef099d57a7..d77efb5c268 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -14,7 +14,7 @@ from transformer_engine.pytorch.fp8 import check_fp8_support from megatron.core import parallel_state -from megatron.core.inference.config import InferenceConfig +from megatron.core.inference.config import InferenceConfig, MambaInferenceStateConfig from megatron.core.inference.contexts import DynamicInferenceContext, StaticInferenceContext from megatron.core.inference.contexts.dynamic_context import MaxSequenceLengthOverflowError from megatron.core.inference.inference_request import ( @@ -34,6 +34,8 @@ get_gpt_mtp_block_spec, ) from megatron.core.models.gpt.gpt_model import GPTModel +from megatron.core.models.mamba.mamba_layer_specs import mamba_stack_spec +from megatron.core.models.mamba.mamba_model import MambaModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.module import Float16Module @@ -64,6 +66,7 @@ def setup_model( sequence_parallel: bool = False, expert_model_parallel_size: int = 1, num_moe_experts: int = None, + hybrid_layer_pattern: str = None, ): Utils.initialize_model_parallel( tensor_model_parallel_size=tensor_model_parallel_size, @@ -98,31 +101,56 @@ def setup_model( expert_model_parallel_size=expert_model_parallel_size, num_moe_experts=num_moe_experts, add_bias_linear=num_moe_experts is None, + **( + dict( + is_hybrid_model=True, + mamba_num_heads=2, + mamba_head_dim=16, + mamba_num_groups=2, + ) + if hybrid_layer_pattern + else {} + ), ) if dtype == torch.bfloat16: transformer_config.bf16 = True - layer_spec = get_gpt_layer_local_spec() + mamba_inference_state_config = None + if hybrid_layer_pattern: + model = MambaModel( + config=transformer_config, + mamba_stack_spec=mamba_stack_spec, + vocab_size=self.vocab_size, + max_sequence_length=self.sequence_length, + parallel_output=True, + hybrid_layer_pattern=hybrid_layer_pattern, + pre_process=parallel_state.is_pipeline_first_stage(), + post_process=parallel_state.is_pipeline_last_stage(), + ).cuda() + mamba_inference_state_config = MambaInferenceStateConfig.from_model(model) + else: + layer_spec = get_gpt_layer_local_spec() - mtp_block_spec = None - if mtp_num_layers > 0: - mtp_block_spec = get_gpt_mtp_block_spec( - config=transformer_config, spec=layer_spec, use_transformer_engine=False - ) + mtp_block_spec = None + if mtp_num_layers > 0: + mtp_block_spec = get_gpt_mtp_block_spec( + config=transformer_config, spec=layer_spec, use_transformer_engine=False + ) - gpt_model = GPTModel( - config=transformer_config, - transformer_layer_spec=layer_spec, - vocab_size=self.vocab_size, - max_sequence_length=self.sequence_length, - parallel_output=True, - pre_process=parallel_state.is_pipeline_first_stage(), - post_process=parallel_state.is_pipeline_last_stage(), - mtp_block_spec=mtp_block_spec, - ).cuda() - gpt_model.eval() + model = GPTModel( + config=transformer_config, + transformer_layer_spec=layer_spec, + vocab_size=self.vocab_size, + max_sequence_length=self.sequence_length, + parallel_output=True, + pre_process=parallel_state.is_pipeline_first_stage(), + post_process=parallel_state.is_pipeline_last_stage(), + mtp_block_spec=mtp_block_spec, + ).cuda() + + model.eval() if dtype == torch.bfloat16: - gpt_model = Float16Module(gpt_model.config, gpt_model) + model = Float16Module(model.config, model) if static: inference_context = StaticInferenceContext( @@ -142,10 +170,11 @@ def setup_model( block_size_tokens=block_size_tokens, enable_prefix_caching=enable_prefix_caching, max_requests=max_requests, + mamba_inference_state_config=mamba_inference_state_config, ), ) - inference_wrapped_model = GPTInferenceWrapper(gpt_model, inference_context) + inference_wrapped_model = GPTInferenceWrapper(model, inference_context) inference_wrapped_model.model_is_pipeline_parallel = not ( parallel_state.is_pipeline_first_stage() and parallel_state.is_pipeline_last_stage() @@ -1159,6 +1188,7 @@ def test_rewind_kv_cache(self, is_hybrid_model): num_speculative_tokens=3, block_size_tokens=4, max_requests=16, + hybrid_layer_pattern="***M" if is_hybrid_model else None, ) self.text_generation_controller.num_speculative_tokens = 3 ctx = self.text_generation_controller.inference_wrapped_model.inference_context @@ -1180,14 +1210,13 @@ def test_rewind_kv_cache(self, is_hybrid_model): ) if is_hybrid_model: - ctx.is_hybrid_model = True - ctx.num_mamba_layers = 1 - ctx.mamba_metadata = mock.MagicMock() - ctx.mamba_metadata.request_to_mamba_state_idx = torch.tensor([0, 1], device='cuda') - ctx.mamba_ssm_states = torch.zeros((1, 2, 16), device='cuda') - ctx.mamba_intermediate_ssm_states = torch.ones((1, 2, 4, 16), device='cuda') * 99 - ctx.mamba_conv_states = torch.zeros((1, 2, 8), device='cuda') - ctx.mamba_intermediate_conv_states = torch.ones((1, 2, 4, 8), device='cuda') * 77 + ctx.mamba_metadata.request_to_mamba_state_idx[:2] = torch.tensor( + [0, 1], dtype=torch.int32, device='cuda' + ) + ctx.mamba_ssm_states.zero_() + ctx.mamba_intermediate_ssm_states.fill_(99) + ctx.mamba_conv_states.zero_() + ctx.mamba_intermediate_conv_states.fill_(77) # Mock accepted token counts: Req 0 accepts 1 (rejects 2), Req 1 accepts 0 (rejects 3) self.text_generation_controller._init_mtp_sampling_tensor() From e872366bc15a2c35cb31cc35863e0eca85d37605 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 01:23:00 -0700 Subject: [PATCH 047/124] Fix tests Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 5 +++-- .../text_generation_controller.py | 9 +++++---- .../test_mtp_cuda_graph_inference.py | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 9e828f113d2..9b9cc8108ec 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -465,8 +465,9 @@ def _create_mtp_cuda_graphs(self, controller, context): if not mtp_batch_sizes: return - # Store sorted batch sizes on the controller for runtime padding lookup. - controller._mtp_cuda_graph_batch_sizes = sorted(mtp_batch_sizes) + # Flag that MTP CUDA graphs are available. The actual padded count is + # re-derived at runtime from padded_batch_dimensions.req_count. + controller._has_mtp_cuda_graphs = True device = torch.cuda.current_device() dtype = model_config.params_dtype diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index d583cbb00f4..47ea719e570 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -613,7 +613,7 @@ def _dynamic_step_context_init( # Derive the MTP padded batch size from the EP-synced graph dimensions. # In eager mode MTP uses locally SP-aligned batch size instead. if ( - getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None + getattr(self, '_has_mtp_cuda_graphs', False) and context.using_cuda_graph_this_step() ): self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count @@ -628,7 +628,7 @@ def _dynamic_step_context_init( # model falls back to eager mode, MTP must also run eagerly across all # EP ranks — otherwise some ranks may replay a captured graph while # others run eagerly, causing EP collectives to hang. - if getattr(self, '_mtp_cuda_graph_batch_sizes', None) is not None: + if getattr(self, '_has_mtp_cuda_graphs', False): use_mtp_graphs = context.using_cuda_graph_this_step() if hasattr(unwrapped_model, 'mtp'): for layer in unwrapped_model.mtp.layers: @@ -1675,8 +1675,9 @@ def _dummy_serial_mtp_forward(self): if getattr(self, '_mtp_resolved_padded_count', None) is not None: padded_count = self._mtp_resolved_padded_count assert not self._sp_enabled or padded_count % self._tp_size == 0 - else: - assert not has_mtp + elif has_mtp: + # Eager path: use TP-aligned minimum size for dummy tensors. + padded_count = self._tp_size if self._sp_enabled else 1 dummy_hidden = None if has_mtp: diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index ec76471b9f1..0fa47344457 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -300,7 +300,7 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): # Warmup MTP CUDA graphs for the padded count. self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) - ctrl._mtp_cuda_graph_batch_sizes = [padded_count] + ctrl._has_mtp_cuda_graphs = True # Set up context state. ctx.total_request_count = active_request_count @@ -390,7 +390,7 @@ def _run_mtp(use_cuda_graph): if use_cuda_graph: self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) - ctrl._mtp_cuda_graph_batch_sizes = [padded_count] + ctrl._has_mtp_cuda_graphs = True ctrl._mtp_resolved_padded_count = padded_count self._set_mtp_cuda_graph_flag(model, True) else: From aacefb42be697ea3a1e123bb80d2417021647fb2 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 01:32:01 -0700 Subject: [PATCH 048/124] Linting Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 5 +---- .../test_text_generation_controller.py | 7 +------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 47ea719e570..c57fb1b34d0 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -612,10 +612,7 @@ def _dynamic_step_context_init( # Derive the MTP padded batch size from the EP-synced graph dimensions. # In eager mode MTP uses locally SP-aligned batch size instead. - if ( - getattr(self, '_has_mtp_cuda_graphs', False) - and context.using_cuda_graph_this_step() - ): + if getattr(self, '_has_mtp_cuda_graphs', False) and context.using_cuda_graph_this_step(): self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count if self._sp_enabled: self._mtp_resolved_padded_count += ( diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index d77efb5c268..6bd96d5dae5 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -102,12 +102,7 @@ def setup_model( num_moe_experts=num_moe_experts, add_bias_linear=num_moe_experts is None, **( - dict( - is_hybrid_model=True, - mamba_num_heads=2, - mamba_head_dim=16, - mamba_num_groups=2, - ) + dict(is_hybrid_model=True, mamba_num_heads=2, mamba_head_dim=16, mamba_num_groups=2) if hybrid_layer_pattern else {} ), From 13122e7300fc0285979a88b09c6624b75bbc1109 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Apr 2026 15:24:27 -0700 Subject: [PATCH 049/124] Mark flaky tests Signed-off-by: Keshav Santhanam --- .../test_attention_variant_dsa.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py b/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py index 45470d4dd6c..47011c7a42a 100644 --- a/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py +++ b/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py @@ -67,6 +67,7 @@ def setup_method(self): yield Utils.destroy_model_parallel() + @pytest.mark.flaky_in_dev def test_rotate_activation_shape(self): """Test that rotate_activation preserves shape.""" batch_size = 2 @@ -79,6 +80,7 @@ def test_rotate_activation_shape(self): assert output.shape == x.shape assert output.dtype == torch.bfloat16 + @pytest.mark.flaky_in_dev def test_rotate_activation_dtype_check(self): """Test that rotate_activation only accepts bfloat16.""" x = torch.randn(16, 2, 128, dtype=torch.float32).cuda() From 8d3a063fa55b715c7f927103fdfdbcd07eb45b98 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 11:03:16 -0700 Subject: [PATCH 050/124] Fix rewind_kv_cache_bug Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 1 + .../triton_kernels.py | 23 +++- .../test_mtp_cuda_graph_inference.py | 24 +++-- .../test_text_generation_controller.py | 102 ++++++++++++++++++ 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index c57fb1b34d0..3685fcb6a2e 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -791,6 +791,7 @@ def _rewind_kv_cache(self) -> tuple: kv_block_ids=request_to_kv_block_ids, num_speculative_tokens=self.num_speculative_tokens, block_size_tokens=context.block_size_tokens, + num_active_requests=active_request_count, ) # Mamba speculative rewind: copy accepted intermediate states in-place. diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py index a27995f601b..97d49611ba4 100644 --- a/megatron/core/inference/text_generation_controllers/triton_kernels.py +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -41,17 +41,25 @@ def _rewind_kv_cache_kernel( # Strides / limits kv_block_ids_stride, max_blocks_minus_1, + num_active_requests, # Compile-time constants NUM_SPEC_TOKENS: tl.constexpr, BLOCK_SIZE_TOKENS: tl.constexpr, ): """Rewind KV-cache bookkeeping for one request after speculative verification. - Grid: (active_request_count,) - Each program handles exactly one request. + Grid: may be padded beyond active requests for CUDA-graph compatibility. + Each program handles exactly one request. Programs with + ``pid >= num_active_requests`` are padding and produce safe no-op outputs. """ pid = tl.program_id(0) + # Padding programs: write safe defaults and skip all state mutation. + if pid >= num_active_requests: + tl.store(BLOCKS_TO_RELEASE_PTR + pid, 0) + tl.store(REMOVE_MASK_PTR + pid, False) + return + # --- Load per-request scalars --- accepted = tl.load(ACCEPTED_COUNTS_PTR + pid) prefill = tl.load(PREFILL_STATUS_PTR + pid) @@ -104,15 +112,25 @@ def rewind_kv_cache( kv_block_ids, num_speculative_tokens, block_size_tokens, + num_active_requests=None, ): """Launch the KV-cache rewind Triton kernel. + Args: + num_active_requests: Number of real (non-padding) requests. When the + grid is padded beyond this count, the kernel skips padding + programs so stale data in padding slots cannot corrupt + bookkeeping. Defaults to ``accepted_counts.shape[0]`` (no + padding). + Returns: (blocks_to_release, remove_mask) — same semantics as the original torch.compile'd ``_rewind_kv_cache`` (KV-cache portion only; Mamba state updates are handled separately by the caller). """ N = accepted_counts.shape[0] + if num_active_requests is None: + num_active_requests = N if N == 0: return ( torch.empty(0, device=accepted_counts.device, dtype=last_kv_block_id.dtype), @@ -134,6 +152,7 @@ def rewind_kv_cache( remove_mask, kv_block_ids_stride=kv_block_ids.stride(0), max_blocks_minus_1=kv_block_ids.shape[1] - 1, + num_active_requests=num_active_requests, NUM_SPEC_TOKENS=num_speculative_tokens, BLOCK_SIZE_TOKENS=block_size_tokens, ) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index 0fa47344457..c964df11bed 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -62,17 +62,22 @@ class TestMTPCudaGraphInference: NUM_ATTN_HEADS = 4 TP_SIZE = 2 - def setup_method(self, method): - if Utils.world_size < self.TP_SIZE: - pytest.skip(f"Need at least {self.TP_SIZE} GPUs") + @classmethod + def setup_class(cls): + if Utils.world_size < cls.TP_SIZE: + pytest.skip(f"Need at least {cls.TP_SIZE} GPUs") Utils.initialize_model_parallel( - tensor_model_parallel_size=self.TP_SIZE, pipeline_model_parallel_size=1 + tensor_model_parallel_size=cls.TP_SIZE, pipeline_model_parallel_size=1 ) - def teardown_method(self, method): + @classmethod + def teardown_class(cls): delete_cuda_graphs() Utils.destroy_model_parallel() + def teardown_method(self): + delete_cuda_graphs() + # ---- helpers ---------------------------------------------------------- # def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): @@ -590,7 +595,8 @@ class TestMTPCudaGraphExpertParallel: NUM_ATTN_HEADS = 4 NUM_MOE_EXPERTS = 2 - def setup_method(self, method): + @classmethod + def setup_class(cls): if Utils.world_size < _EP_SIZE: pytest.skip(f"EP test requires at least {_EP_SIZE} GPUs") if Utils.world_size % _EP_SIZE != 0: @@ -603,10 +609,14 @@ def setup_method(self, method): expert_model_parallel_size=_EP_SIZE, ) - def teardown_method(self, method): + @classmethod + def teardown_class(cls): delete_cuda_graphs() Utils.destroy_model_parallel() + def teardown_method(self): + delete_cuda_graphs() + # ---- helpers ---------------------------------------------------------- # def _build_model(self): diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 6bd96d5dae5..53b7ef19ba4 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1250,6 +1250,108 @@ def test_rewind_kv_cache(self, is_hybrid_model): assert torch.all(ctx.mamba_conv_states[:, 0] == 77) # Req 0 accepted 1, loaded index 1 assert torch.all(ctx.mamba_conv_states[:, 1] == 77) # Req 1 accepted 0, loaded index 0 + @pytest.mark.internal + def test_rewind_kv_cache_stale_padding_is_safe(self): + """Padding slots with stale data must not corrupt active requests or + release junk blocks when the rewind kernel grid is padded beyond the + active request count. + + Without the num_active_requests guard in the kernel, padding slots + whose stale request_last_kv_block_offset < num_speculative_tokens + would produce remove_mask=True, causing the block allocator to free + block IDs that belong to other active requests. + """ + from megatron.core.inference.text_generation_controllers.triton_kernels import ( + rewind_kv_cache, + ) + + num_spec = 3 + block_size = 4 + active = 2 + padded = 4 + max_blocks = 10 + dev = 'cuda' + + # --- Active requests (slots 0-1): identical to test_rewind_kv_cache --- + # Req 0: accepted 1, last_offset 2 → rewind 2 → offset 0, no release + # Req 1: accepted 0, last_offset 1 → rewind 3 → crosses block, release block 60 + accepted = torch.zeros(padded, device=dev, dtype=torch.int64) + accepted[0] = 1 + accepted[1] = 0 + + prefill = torch.zeros(padded, device=dev, dtype=torch.int64) + + last_offset = torch.zeros(padded, device=dev, dtype=torch.int64) + last_offset[0] = 2 + last_offset[1] = 1 + + kv_length = torch.zeros(padded, device=dev, dtype=torch.int64) + kv_length[0] = 10 + kv_length[1] = 15 + + block_counts = torch.zeros(padded, device=dev, dtype=torch.int64) + block_counts[0] = 3 + block_counts[1] = 4 + + last_block_id = torch.zeros(padded, device=dev, dtype=torch.int64) + last_block_id[0] = 50 + last_block_id[1] = 60 + + block_ids = torch.full((padded, max_blocks), -1, device=dev, dtype=torch.int64) + block_ids[0, :3] = torch.tensor([48, 49, 50]) + block_ids[1, :4] = torch.tensor([57, 58, 59, 60]) + + # --- Padding slots (2-3): stale data from completed requests --- + # Crucially, last_offset values < num_spec would trigger remove=True + # without the kernel guard, releasing stale block IDs. + last_offset[2] = 1 + last_offset[3] = 2 + kv_length[2] = 9999 + kv_length[3] = 9999 + block_counts[2] = 5 + block_counts[3] = 7 + last_block_id[2] = 777 + last_block_id[3] = 888 + block_ids[2, :5] = torch.arange(100, 105, device=dev) + block_ids[3, :5] = torch.arange(200, 205, device=dev) + + blocks_to_release, remove_mask = rewind_kv_cache( + accepted_counts=accepted, + prefill_status=prefill, + last_kv_block_offset=last_offset, + kv_length_offsets=kv_length, + kv_block_counts=block_counts, + last_kv_block_id=last_block_id, + kv_block_ids=block_ids, + num_speculative_tokens=num_spec, + block_size_tokens=block_size, + num_active_requests=active, + ) + + # --- Active request 0: rewind 2, no block release --- + assert remove_mask[0].item() is False + assert last_offset[0].item() == 0 + assert kv_length[0].item() == 8 + assert block_counts[0].item() == 3 + assert last_block_id[0].item() == 50 + + # --- Active request 1: rewind 3, crosses block boundary --- + assert remove_mask[1].item() is True + assert last_offset[1].item() == 2 # (1 - 3) % 4 = 2 + assert kv_length[1].item() == 12 + assert block_counts[1].item() == 3 + assert last_block_id[1].item() == 59 + assert blocks_to_release[1].item() == 60 + + # --- Padding slots 2-3: must be no-ops, no blocks released --- + assert remove_mask[2].item() is False + assert remove_mask[3].item() is False + # Stale state must be untouched (kernel skipped these programs). + assert kv_length[2].item() == 9999 + assert kv_length[3].item() == 9999 + assert block_counts[2].item() == 5 + assert block_counts[3].item() == 7 + @pytest.mark.internal def test_speculative_multinomial_sampling(self): """Test that speculative decoding can successfully use non-greedy sampling From 210415ace220162c87655eae16bb5e069a87054c Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 11:26:09 -0700 Subject: [PATCH 051/124] Move MTP cuda graph warmup inline Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 135 ++++++------------ megatron/core/transformer/cuda_graphs.py | 12 +- 2 files changed, 53 insertions(+), 94 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 9b9cc8108ec..c13b947a324 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -50,7 +50,7 @@ unset_inference_cuda_graphed_iteration_for_ep_inference, ) from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.transformer.cuda_graphs import delete_cuda_graphs +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs, graph_capture from megatron.core.transformer.enums import CudaGraphScope from megatron.core.transformer.moe.router_replay import RouterReplay, RouterReplayAction from megatron.core.utils import ( @@ -366,6 +366,19 @@ def create_cuda_graphs(self, reset_context: bool = True): unwrapped_model = controller.inference_wrapped_model.model set_inference_cuda_graphed_iteration_for_ep_inference(unwrapped_model) + # MTP warmup preparation: capture MTP CUDA graphs alongside the + # decoder graphs within the same loop rather than in a separate pass. + unwrapped = unwrap_model(controller.inference_wrapped_model.model) + mtp_warmup_enabled = ( + controller.num_mtp_heads > 0 + and (controller.num_speculative_tokens or 0) > 0 + and hasattr(unwrapped, 'mtp') + ) + if mtp_warmup_enabled: + tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) + sp_enabled = model_config.sequence_parallel and tp_size > 1 + mtp_seen_batch_sizes = set() + tbar = enumerate(context.cuda_graph_batch_dimensions_list) if HAVE_TQDM: tbar = tqdm(tbar, total=len(context.cuda_graph_batch_dimensions_list)) @@ -391,16 +404,40 @@ def create_cuda_graphs(self, reset_context: bool = True): # Forward pass -> logits. controller._dynamic_step_forward_logits(input_ids, position_ids) + # MTP CUDA graph warmup for this batch dimension. + if mtp_warmup_enabled: + n = cuda_graph_batch_dimension.req_count + if sp_enabled: + n += (tp_size - n % tp_size) % tp_size + if n > 0 and n not in mtp_seen_batch_sizes: + mtp_seen_batch_sizes.add(n) + device = torch.cuda.current_device() + batch_dim = n // tp_size if sp_enabled else n + with graph_capture(): + unwrapped.compute_mtp_single_step( + hidden_states=torch.empty( + (batch_dim, 1, model_config.hidden_size), + device=device, + dtype=model_config.params_dtype, + ), + next_token_ids=torch.empty( + (1, n), device=device, dtype=torch.long, + ), + position_ids=torch.empty( + (1, n), device=device, dtype=torch.int64, + ), + depth=0, + ) + context.reset() # Disable inference dispatcher after graph capture if is_inference_optimized_ep: unset_inference_cuda_graphed_iteration_for_ep_inference(unwrapped_model) - # MTP CUDA graph warmup: capture graphs for the MTP TransformerLayers - # used during speculative decoding. This must happen after decoder graph - # warmup so that the MTP graphs are captured independently. - self._create_mtp_cuda_graphs(controller, context) + if mtp_warmup_enabled and mtp_seen_batch_sizes: + controller._has_mtp_cuda_graphs = True + logging.info("> MTP CUDA graph warmup: %d batch size(s)", len(mtp_seen_batch_sizes)) # Memory usage. time_end = time.time() @@ -426,94 +463,6 @@ def create_cuda_graphs(self, reset_context: bool = True): self.capture_stats = capture_stats - def _create_mtp_cuda_graphs(self, controller, context): - """Capture CUDA graphs for MTP layers used in speculative decoding. - - Derives the set of MTP batch sizes from the decoder CUDA graph batch - dimensions, then runs ``compute_mtp_single_step`` per batch size to - trigger graph capture. With ``mtp_use_repeated_layer`` one call covers - every depth; with unique layers the remaining depths capture lazily. - """ - num_mtp_heads = controller.num_mtp_heads - num_spec_tokens = controller.num_speculative_tokens or 0 - if num_mtp_heads == 0 or num_spec_tokens == 0: - return - - model = controller.inference_wrapped_model.model - unwrapped = unwrap_model(model) - if not hasattr(unwrapped, 'mtp'): - return - - model_config = model.config - - # Only proceed when local CUDA graphs are enabled. - if model_config.cuda_graph_impl != "local": - return - - # Collect batch sizes from all graph dimensions. MTP serial forward - # runs on all active requests (decode + prefill), so we need graphs - # for total request counts, not just decode-only counts. - tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) - sp_enabled = model_config.sequence_parallel and tp_size > 1 - mtp_batch_sizes = set() - for dim in context.cuda_graph_batch_dimensions_list: - n = dim.req_count - if n > 0: - if sp_enabled: - n += (tp_size - n % tp_size) % tp_size - mtp_batch_sizes.add(n) - if not mtp_batch_sizes: - return - - # Flag that MTP CUDA graphs are available. The actual padded count is - # re-derived at runtime from padded_batch_dimensions.req_count. - controller._has_mtp_cuda_graphs = True - - device = torch.cuda.current_device() - dtype = model_config.params_dtype - hidden_size = model_config.hidden_size - - # Enable inference dispatcher for EP during MTP graph capture. - is_inference_optimized_ep = ( - model_config.transformer_impl == "inference_optimized" - and model_config.expert_model_parallel_size > 1 - ) - if is_inference_optimized_ep: - set_inference_cuda_graphed_iteration_for_ep_inference(model) - - logging.info("> MTP CUDA graph warmup: %d batch size(s)", len(mtp_batch_sizes)) - - from megatron.core.transformer.cuda_graphs import _set_capture_end, _set_capture_start - - _set_capture_start() - for batch_size in sorted(mtp_batch_sizes): - dummy_hidden = torch.zeros((batch_size, 1, hidden_size), device=device, dtype=dtype) - if sp_enabled: - from megatron.core.tensor_parallel.mappings import ( - scatter_to_sequence_parallel_region, - ) - - dummy_hidden = scatter_to_sequence_parallel_region( - dummy_hidden, group=controller.inference_wrapped_model.tp_group - ) - dummy_token_ids = torch.zeros((1, batch_size), device=device, dtype=torch.long) - dummy_position_ids = torch.zeros((1, batch_size), device=device, dtype=torch.int64) - - # One call per batch size; depth=0 warms the shared layer (repeated - # mode) or the first unique layer (non-repeated mode). - unwrapped.compute_mtp_single_step( - hidden_states=dummy_hidden, - next_token_ids=dummy_token_ids, - position_ids=dummy_position_ids, - depth=0, - ) - _set_capture_end() - - if is_inference_optimized_ep: - unset_inference_cuda_graphed_iteration_for_ep_inference(model) - - logging.info("> MTP CUDA graph warmup complete") - @internal_api async def start_listening_to_data_parallel_coordinator( self, diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 946653ef5f8..ca0a2aa35f5 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -8,7 +8,7 @@ import os import time from collections import defaultdict -from contextlib import nullcontext +from contextlib import contextmanager, nullcontext from copy import deepcopy from dataclasses import dataclass, is_dataclass from enum import Enum @@ -98,6 +98,16 @@ def _set_capture_end(): _IS_GRAPH_CAPTURING = False +@contextmanager +def graph_capture(): + """Context manager that brackets a graph-capture region.""" + _set_capture_start() + try: + yield + finally: + _set_capture_end() + + def is_graph_warmup(): """Query if currently warming up for graph capture.""" return _IS_GRAPH_WARMUP From bcebf41d0218733bc904a94116418b43b3a30f38 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 12:28:06 -0700 Subject: [PATCH 052/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 8 ++------ .../test_attention_variant_dsa.py | 2 -- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index c13b947a324..31056ab5c5a 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -420,12 +420,8 @@ def create_cuda_graphs(self, reset_context: bool = True): device=device, dtype=model_config.params_dtype, ), - next_token_ids=torch.empty( - (1, n), device=device, dtype=torch.long, - ), - position_ids=torch.empty( - (1, n), device=device, dtype=torch.int64, - ), + next_token_ids=torch.empty((1, n), device=device, dtype=torch.long), + position_ids=torch.empty((1, n), device=device, dtype=torch.int64), depth=0, ) diff --git a/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py b/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py index 70a8fd8b4ee..757b9dd283a 100644 --- a/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py +++ b/tests/unit_tests/transformer/experimental_attention_variant/test_attention_variant_dsa.py @@ -72,7 +72,6 @@ def setup_method(self): yield Utils.destroy_model_parallel() - @pytest.mark.flaky_in_dev def test_rotate_activation_shape(self): """Test that rotate_activation preserves shape.""" batch_size = 2 @@ -85,7 +84,6 @@ def test_rotate_activation_shape(self): assert output.shape == x.shape assert output.dtype == torch.bfloat16 - @pytest.mark.flaky_in_dev def test_rotate_activation_dtype_check(self): """Test that rotate_activation only accepts bfloat16.""" x = torch.randn(16, 2, 128, dtype=torch.float32).cuda() From c82b70a90beae7d7682182d98d3be10ebfcff7bd Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 12:46:32 -0700 Subject: [PATCH 053/124] Add Triton tests Signed-off-by: Keshav Santhanam --- .../test_triton_kernels.py | 805 ++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py new file mode 100644 index 00000000000..be884075c9f --- /dev/null +++ b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py @@ -0,0 +1,805 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for MTP Triton kernels. + +Each test provides a pure-PyTorch reference implementation of the operation, +runs both the reference and the Triton kernel on the same inputs, and asserts +that the outputs match exactly. +""" + +import math + +import pytest +import torch + +from megatron.core.inference.text_generation_controllers.triton_kernels import ( + mamba_state_selective_copy, + prepare_next_forward_pass, + rewind_kv_cache, + verify_speculative_tokens, +) + + +# --------------------------------------------------------------------------- +# PyTorch reference implementations +# --------------------------------------------------------------------------- + + +def rewind_kv_cache_pytorch( + accepted_counts, + prefill_status, + last_kv_block_offset, + kv_length_offsets, + kv_block_counts, + last_kv_block_id, + kv_block_ids, + num_speculative_tokens, + block_size_tokens, + num_active_requests=None, +): + """Pure-PyTorch reference for the KV-cache rewind operation. + + Mirrors the original ``TextGenerationController._rewind_kv_cache`` logic + (KV-cache portion only, no Mamba state updates). Mutates the input tensors + in-place, just like the Triton kernel. + + Returns (blocks_to_release, remove_mask). + """ + N = accepted_counts.shape[0] + if num_active_requests is None: + num_active_requests = N + + blocks_to_release = torch.empty_like(last_kv_block_id) + remove_mask = torch.empty(N, device=accepted_counts.device, dtype=torch.bool) + + for i in range(N): + if i >= num_active_requests: + blocks_to_release[i] = 0 + remove_mask[i] = False + continue + + accepted = accepted_counts[i].item() + prefill = prefill_status[i].item() + last_offset = last_kv_block_offset[i].item() + kv_length = kv_length_offsets[i].item() + block_count = kv_block_counts[i].item() + last_block = last_kv_block_id[i].item() + + num_to_rewind = 0 if prefill == 1 else num_speculative_tokens - accepted + diff = last_offset - num_to_rewind + remove = diff < 0 + + new_offset = diff % block_size_tokens + last_kv_block_offset[i] = new_offset + kv_length_offsets[i] = kv_length - num_to_rewind + + blocks_to_release[i] = last_block + + new_block_count = block_count - 1 if remove else block_count + kv_block_counts[i] = new_block_count + + prev_idx = max(new_block_count - 1, 0) + prev_block_id = kv_block_ids[i, prev_idx].item() + + last_kv_block_id[i] = prev_block_id if remove else last_block + + scatter_idx = min(new_block_count, kv_block_ids.shape[1] - 1) + if remove: + kv_block_ids[i, scatter_idx] = -1 + + remove_mask[i] = remove + + return blocks_to_release, remove_mask + + +def verify_speculative_tokens_pytorch( + input_tokens, output_tokens, num_decode_requests, num_prefill_requests, num_speculative_tokens +): + """Pure-PyTorch reference for speculative token verification. + + Mirrors the original ``TextGenerationController._verify_speculative_tokens`` + logic. + """ + if input_tokens.ndim == 2: + input_tokens = input_tokens.squeeze(0) + + stride = num_speculative_tokens + 1 + active_request_count = num_decode_requests + num_prefill_requests + decode_len = num_decode_requests * stride + + accepted_tokens_mask = torch.zeros_like(input_tokens, dtype=torch.bool) + + decode_mask_2d = None + if num_decode_requests > 0: + decode_inputs = input_tokens[:decode_len].reshape(num_decode_requests, stride) + decode_outputs = output_tokens[:decode_len].reshape(num_decode_requests, stride) + + decode_outputs_shifted = decode_outputs.roll(1, dims=1) + decode_mask_2d = decode_inputs == decode_outputs_shifted + decode_mask_2d[:, 0] = True + decode_mask_2d = decode_mask_2d.cummin(dim=1).values + accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() + + if num_prefill_requests > 0: + accepted_tokens_mask[decode_len:] = True + + last_one_indices = torch.full( + (active_request_count,), -1, device=input_tokens.device, dtype=torch.long + ) + + if num_decode_requests > 0: + local_last_indices = decode_mask_2d.sum(dim=1) - 1 + row_offsets = torch.arange(num_decode_requests, device=input_tokens.device) * stride + last_one_indices[:num_decode_requests] = row_offsets + local_last_indices + + if num_prefill_requests > 0: + prefill_valid = torch.nonzero(accepted_tokens_mask[decode_len:]).squeeze(-1) + decode_len + last_one_indices[num_decode_requests:] = prefill_valid + + return last_one_indices, accepted_tokens_mask, input_tokens + + +def prepare_next_forward_pass_pytorch( + num_decode_requests, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_tokens_mask, + input_tokens, + sampled_tokens_buf, + last_accepted_seq_buf, + accepted_tokens_per_request, + accepted_token_counts, + num_speculative_tokens, +): + """Pure-PyTorch reference for preparing the next forward pass. + + Mirrors the original ``_dynamic_step_sample_logits_and_verify_tokens`` + post-verification logic. + """ + active_request_count = last_one_indices.shape[0] + stride = num_speculative_tokens + 1 + + for pid in range(active_request_count): + idx = last_one_indices[pid].item() + sampled_tokens_buf[pid] = output_tokens[idx] + last_accepted_seq_buf[pid] = required_logit_indices[idx] + + if pid < num_decode_requests: + base = pid * stride + for s in range(num_speculative_tokens): + pos = base + 1 + s + if accepted_tokens_mask[pos]: + accepted_tokens_per_request[pid, s] = input_tokens[pos] + else: + accepted_tokens_per_request[pid, s] = -1 + + count = 0 + for s in range(num_speculative_tokens): + if accepted_tokens_per_request[pid, s].item() != -1: + count += 1 + accepted_token_counts[pid] = count + + +def mamba_state_selective_copy_pytorch( + intermediate_states, current_states, prefill_status, state_idx, accepted_counts, num_layers +): + """Pure-PyTorch reference for Mamba state selective copy. + + For each decode request, copies + ``intermediate[layer, slot, accepted_count, ...]`` → + ``current[layer, slot, ...]`` for every Mamba layer. + """ + N = prefill_status.shape[0] + for i in range(N): + if prefill_status[i].item() == 1: + continue + slot = state_idx[i].item() + accepted = accepted_counts[i].item() + for layer in range(num_layers): + current_states[layer, slot] = intermediate_states[layer, slot, accepted] + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + +DEVICE = "cuda" + + +def _clone_tensors(*tensors): + """Return a tuple of cloned tensors (for running reference vs kernel on the same data).""" + return tuple(t.clone() for t in tensors) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestRewindKvCache: + """Tests for the rewind_kv_cache Triton kernel.""" + + @pytest.mark.parametrize("num_requests", [1, 4, 16]) + @pytest.mark.parametrize("num_speculative_tokens", [1, 2, 4]) + @pytest.mark.parametrize("block_size_tokens", [8, 16, 64]) + def test_basic(self, num_requests, num_speculative_tokens, block_size_tokens): + N = num_requests + max_blocks = 8 + + accepted_counts = torch.randint(0, num_speculative_tokens + 1, (N,), device=DEVICE) + prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) + + last_kv_block_offset = torch.randint(0, block_size_tokens, (N,), device=DEVICE) + kv_length_offsets = torch.randint(block_size_tokens, block_size_tokens * 4, (N,), device=DEVICE) + kv_block_counts = torch.randint(2, max_blocks, (N,), device=DEVICE) + last_kv_block_id = torch.randint(0, 100, (N,), device=DEVICE) + kv_block_ids = torch.randint(0, 100, (N, max_blocks), device=DEVICE) + + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + + ref_release, ref_mask = rewind_kv_cache_pytorch( + accepted_counts.clone(), prefill_status.clone(), + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, + num_speculative_tokens, block_size_tokens, + ) + + tri_release, tri_mask = rewind_kv_cache( + accepted_counts.clone(), prefill_status.clone(), + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, + num_speculative_tokens, block_size_tokens, + ) + + torch.testing.assert_close(tri_offset, ref_offset) + torch.testing.assert_close(tri_kv_len, ref_kv_len) + torch.testing.assert_close(tri_block_counts, ref_block_counts) + torch.testing.assert_close(tri_last_block, ref_last_block) + torch.testing.assert_close(tri_block_ids, ref_block_ids) + torch.testing.assert_close(tri_release, ref_release) + torch.testing.assert_close(tri_mask, ref_mask) + + def test_prefill_requests_skip_rewind(self): + N = 4 + num_spec = 3 + block_size = 16 + + accepted_counts = torch.tensor([1, 0, 2, 0], device=DEVICE) + prefill_status = torch.tensor([0, 1, 0, 1], dtype=torch.int32, device=DEVICE) + last_kv_block_offset = torch.tensor([5, 10, 2, 7], device=DEVICE) + kv_length_offsets = torch.tensor([100, 200, 300, 400], device=DEVICE) + kv_block_counts = torch.tensor([3, 4, 2, 5], device=DEVICE) + last_kv_block_id = torch.tensor([10, 20, 30, 40], device=DEVICE) + kv_block_ids = torch.randint(0, 50, (N, 8), device=DEVICE) + + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + + ref_release, ref_mask = rewind_kv_cache_pytorch( + accepted_counts.clone(), prefill_status.clone(), + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, + num_spec, block_size, + ) + tri_release, tri_mask = rewind_kv_cache( + accepted_counts.clone(), prefill_status.clone(), + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, + num_spec, block_size, + ) + + # Prefill requests (indices 1, 3) should be unchanged. + for idx in [1, 3]: + assert ref_kv_len[idx] == kv_length_offsets[idx] + assert ref_offset[idx] == last_kv_block_offset[idx] + + torch.testing.assert_close(tri_offset, ref_offset) + torch.testing.assert_close(tri_kv_len, ref_kv_len) + torch.testing.assert_close(tri_block_counts, ref_block_counts) + torch.testing.assert_close(tri_last_block, ref_last_block) + torch.testing.assert_close(tri_block_ids, ref_block_ids) + torch.testing.assert_close(tri_mask, ref_mask) + + def test_block_boundary_crossing(self): + """When offset - rewind < 0, a block boundary is crossed.""" + N = 2 + num_spec = 3 + block_size = 16 + + accepted_counts = torch.tensor([0, 0], device=DEVICE) + prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) + last_kv_block_offset = torch.tensor([1, 10], device=DEVICE) + kv_length_offsets = torch.tensor([100, 200], device=DEVICE) + kv_block_counts = torch.tensor([3, 4], device=DEVICE) + last_kv_block_id = torch.tensor([50, 60], device=DEVICE) + kv_block_ids = torch.tensor( + [[10, 20, 50, -1, -1, -1, -1, -1], [15, 25, 35, 60, -1, -1, -1, -1]], device=DEVICE + ) + + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + + rewind_kv_cache_pytorch( + accepted_counts.clone(), prefill_status.clone(), + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, + num_spec, block_size, + ) + rewind_kv_cache( + accepted_counts.clone(), prefill_status.clone(), + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, + num_spec, block_size, + ) + + # Request 0: offset 1 - 3 = -2 → crosses boundary. + assert ref_block_counts[0] == 2 + assert tri_block_counts[0] == 2 + assert ref_last_block[0] == 20 # previous block + assert tri_last_block[0] == 20 + + # Request 1: offset 10 - 3 = 7 → no crossing. + assert ref_block_counts[1] == 4 + assert tri_block_counts[1] == 4 + + torch.testing.assert_close(tri_offset, ref_offset) + torch.testing.assert_close(tri_kv_len, ref_kv_len) + torch.testing.assert_close(tri_block_ids, ref_block_ids) + + def test_padding_programs(self): + """Padding slots (pid >= num_active_requests) must produce safe no-ops.""" + N = 8 # grid size + active = 3 + num_spec = 2 + block_size = 16 + + accepted_counts = torch.randint(0, num_spec + 1, (N,), device=DEVICE) + prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) + last_kv_block_offset = torch.randint(0, block_size, (N,), device=DEVICE) + kv_length_offsets = torch.randint(block_size, block_size * 4, (N,), device=DEVICE) + kv_block_counts = torch.randint(2, 6, (N,), device=DEVICE) + last_kv_block_id = torch.randint(0, 100, (N,), device=DEVICE) + kv_block_ids = torch.randint(0, 100, (N, 8), device=DEVICE) + + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + + rewind_kv_cache_pytorch( + accepted_counts.clone(), prefill_status.clone(), + ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, + num_spec, block_size, num_active_requests=active, + ) + tri_release, tri_mask = rewind_kv_cache( + accepted_counts.clone(), prefill_status.clone(), + tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, + num_spec, block_size, num_active_requests=active, + ) + + # Active slots should match. + torch.testing.assert_close(tri_offset[:active], ref_offset[:active]) + torch.testing.assert_close(tri_kv_len[:active], ref_kv_len[:active]) + torch.testing.assert_close(tri_block_counts[:active], ref_block_counts[:active]) + torch.testing.assert_close(tri_last_block[:active], ref_last_block[:active]) + torch.testing.assert_close(tri_block_ids[:active], ref_block_ids[:active]) + + # Padding slots: release=0, mask=False. + assert (tri_release[active:] == 0).all() + assert (~tri_mask[active:]).all() + + def test_empty(self): + N = 0 + blocks_to_release, remove_mask = rewind_kv_cache( + torch.empty(0, device=DEVICE, dtype=torch.int64), + torch.empty(0, device=DEVICE, dtype=torch.int32), + torch.empty(0, device=DEVICE, dtype=torch.int64), + torch.empty(0, device=DEVICE, dtype=torch.int64), + torch.empty(0, device=DEVICE, dtype=torch.int64), + torch.empty(0, device=DEVICE, dtype=torch.int64), + torch.empty(0, 8, device=DEVICE, dtype=torch.int64), + num_speculative_tokens=2, + block_size_tokens=16, + ) + assert blocks_to_release.shape[0] == 0 + assert remove_mask.shape[0] == 0 + + +class TestVerifySpeculativeTokens: + """Tests for the verify_speculative_tokens Triton kernel.""" + + def _make_scenario(self, num_decode, num_prefill, num_spec, *, match_pattern=None): + """Build input/output token tensors for testing. + + Args: + match_pattern: list of ints per decode request indicating how many + speculative tokens should match (0 means only base accepted). + If None, generates random matches. + """ + stride = num_spec + 1 + decode_len = num_decode * stride + total_len = decode_len + num_prefill + + input_tokens = torch.randint(1, 1000, (total_len,), device=DEVICE) + output_tokens = torch.randint(1, 1000, (total_len,), device=DEVICE) + + if match_pattern is not None: + assert len(match_pattern) == num_decode + for req_idx, num_match in enumerate(match_pattern): + base = req_idx * stride + for s in range(num_match): + output_tokens[base + s] = input_tokens[base + s + 1] + + return input_tokens, output_tokens + + @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ + (1, 0, 2), + (3, 0, 2), + (3, 2, 2), + (0, 3, 2), + (5, 3, 4), + ]) + def test_basic(self, num_decode, num_prefill, num_spec): + input_tokens, output_tokens = self._make_scenario(num_decode, num_prefill, num_spec) + + ref_last, ref_mask, ref_input = verify_speculative_tokens_pytorch( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, tri_input = verify_speculative_tokens( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) + + def test_all_accepted(self): + """All speculative tokens match → all accepted.""" + num_decode, num_prefill, num_spec = 3, 0, 3 + input_tokens, output_tokens = self._make_scenario( + num_decode, num_prefill, num_spec, match_pattern=[3, 3, 3] + ) + + ref_last, ref_mask, _ = verify_speculative_tokens_pytorch( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, _ = verify_speculative_tokens( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + assert ref_mask.all() + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) + + def test_none_accepted(self): + """No speculative tokens match → only base tokens accepted.""" + num_decode, num_prefill, num_spec = 3, 0, 3 + input_tokens, output_tokens = self._make_scenario( + num_decode, num_prefill, num_spec, match_pattern=[0, 0, 0] + ) + + ref_last, ref_mask, _ = verify_speculative_tokens_pytorch( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, _ = verify_speculative_tokens( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + stride = num_spec + 1 + for req in range(num_decode): + base = req * stride + assert ref_mask[base].item() is True + assert not ref_mask[base + 1 : base + stride].any() + + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) + + def test_mixed_match_pattern(self): + """Different acceptance counts per request.""" + num_decode, num_prefill, num_spec = 3, 1, 3 + input_tokens, output_tokens = self._make_scenario( + num_decode, num_prefill, num_spec, match_pattern=[1, 3, 0] + ) + + ref_last, ref_mask, _ = verify_speculative_tokens_pytorch( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, _ = verify_speculative_tokens( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) + + def test_2d_input(self): + """Input tokens with shape [1, total_len] should be squeezed.""" + num_decode, num_prefill, num_spec = 2, 1, 2 + input_tokens, output_tokens = self._make_scenario(num_decode, num_prefill, num_spec) + input_2d = input_tokens.unsqueeze(0) + + ref_last, ref_mask, _ = verify_speculative_tokens_pytorch( + input_2d.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, _ = verify_speculative_tokens( + input_2d.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) + + +class TestPrepareNextForwardPass: + """Tests for the prepare_next_forward_pass Triton kernel.""" + + def _setup(self, num_decode, num_prefill, num_spec): + stride = num_spec + 1 + active = num_decode + num_prefill + decode_len = num_decode * stride + total_len = decode_len + num_prefill + + output_tokens = torch.randint(1, 1000, (total_len,), device=DEVICE, dtype=torch.int64) + required_logit_indices = torch.arange(total_len, device=DEVICE, dtype=torch.int64) + input_tokens = torch.randint(1, 1000, (total_len,), device=DEVICE, dtype=torch.int64) + + accepted_mask = torch.zeros(total_len, device=DEVICE, dtype=torch.bool) + last_one_indices = torch.empty(active, device=DEVICE, dtype=torch.int64) + + for req in range(num_decode): + base = req * stride + num_match = torch.randint(0, num_spec + 1, (1,)).item() + for j in range(stride): + if j <= num_match: + accepted_mask[base + j] = True + last_one_indices[req] = base + num_match + + for p in range(num_prefill): + idx = decode_len + p + accepted_mask[idx] = True + last_one_indices[num_decode + p] = idx + + return output_tokens, required_logit_indices, input_tokens, accepted_mask, last_one_indices + + @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ + (1, 0, 2), + (3, 0, 2), + (3, 2, 2), + (0, 3, 2), + (5, 3, 4), + ]) + def test_basic(self, num_decode, num_prefill, num_spec): + ( + output_tokens, required_logit_indices, input_tokens, + accepted_mask, last_one_indices, + ) = self._setup(num_decode, num_prefill, num_spec) + + active = num_decode + num_prefill + + ref_sampled = torch.zeros(active, device=DEVICE, dtype=torch.int64) + ref_last_seq = torch.zeros(active, device=DEVICE, dtype=torch.int64) + ref_accepted = torch.full((num_decode, num_spec), -1, device=DEVICE, dtype=torch.int64) + ref_counts = torch.zeros(num_decode, device=DEVICE, dtype=torch.int64) + + tri_sampled = torch.zeros(active, device=DEVICE, dtype=torch.int64) + tri_last_seq = torch.zeros(active, device=DEVICE, dtype=torch.int64) + tri_accepted = torch.full((max(num_decode, 1), num_spec), -1, device=DEVICE, dtype=torch.int64) + tri_counts = torch.zeros(max(num_decode, 1), device=DEVICE, dtype=torch.int64) + + prepare_next_forward_pass_pytorch( + num_decode, output_tokens, required_logit_indices, + last_one_indices, accepted_mask, input_tokens, + ref_sampled, ref_last_seq, ref_accepted, ref_counts, num_spec, + ) + + prepare_next_forward_pass( + num_decode, output_tokens, required_logit_indices, + last_one_indices, accepted_mask, input_tokens, + tri_sampled, tri_last_seq, tri_accepted, tri_counts, num_spec, + ) + + torch.testing.assert_close(tri_sampled, ref_sampled) + torch.testing.assert_close(tri_last_seq, ref_last_seq) + if num_decode > 0: + torch.testing.assert_close(tri_accepted[:num_decode], ref_accepted[:num_decode]) + torch.testing.assert_close(tri_counts[:num_decode], ref_counts[:num_decode]) + + def test_empty(self): + """Zero active requests should be a no-op.""" + last_one_indices = torch.empty(0, device=DEVICE, dtype=torch.int64) + prepare_next_forward_pass( + num_decode_requests=0, + output_tokens=torch.empty(0, device=DEVICE, dtype=torch.int64), + required_logit_indices=torch.empty(0, device=DEVICE, dtype=torch.int64), + last_one_indices=last_one_indices, + accepted_tokens_mask=torch.empty(0, device=DEVICE, dtype=torch.bool), + input_tokens=torch.empty(0, device=DEVICE, dtype=torch.int64), + sampled_tokens_buf=torch.empty(0, device=DEVICE, dtype=torch.int64), + last_accepted_seq_buf=torch.empty(0, device=DEVICE, dtype=torch.int64), + accepted_tokens_per_request=torch.empty(0, 2, device=DEVICE, dtype=torch.int64), + accepted_token_counts=torch.empty(0, device=DEVICE, dtype=torch.int64), + num_speculative_tokens=2, + ) + + +class TestMambaStateSelectiveCopy: + """Tests for the mamba_state_selective_copy Triton kernel.""" + + @pytest.mark.parametrize("num_requests", [1, 4, 8]) + @pytest.mark.parametrize("num_layers", [1, 3]) + def test_basic(self, num_requests, num_layers): + N = num_requests + M = N # 1:1 request-to-slot mapping for simplicity + S = 4 # speculative tokens + 1 + state_shape = (16, 32) # arbitrary state dimensions + + intermediate = torch.randn(num_layers, M, S, *state_shape, device=DEVICE) + current_ref = torch.randn(num_layers, M, *state_shape, device=DEVICE) + current_tri = current_ref.clone() + + prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) + state_idx = torch.arange(N, device=DEVICE, dtype=torch.int64) + accepted_counts = torch.randint(0, S, (N,), device=DEVICE, dtype=torch.int64) + + mamba_state_selective_copy_pytorch( + intermediate, current_ref, prefill_status, state_idx, accepted_counts, num_layers + ) + mamba_state_selective_copy( + intermediate, current_tri, prefill_status, state_idx, accepted_counts, num_layers + ) + + torch.testing.assert_close(current_tri, current_ref) + + def test_prefill_skipped(self): + N = 4 + num_layers = 2 + M = N + S = 3 + state_shape = (8,) + + intermediate = torch.randn(num_layers, M, S, *state_shape, device=DEVICE) + current_ref = torch.randn(num_layers, M, *state_shape, device=DEVICE) + current_tri = current_ref.clone() + current_orig = current_ref.clone() + + prefill_status = torch.tensor([0, 1, 0, 1], dtype=torch.int32, device=DEVICE) + state_idx = torch.arange(N, device=DEVICE, dtype=torch.int64) + accepted_counts = torch.tensor([1, 0, 2, 0], device=DEVICE, dtype=torch.int64) + + mamba_state_selective_copy_pytorch( + intermediate, current_ref, prefill_status, state_idx, accepted_counts, num_layers + ) + mamba_state_selective_copy( + intermediate, current_tri, prefill_status, state_idx, accepted_counts, num_layers + ) + + # Prefill slots should be unchanged from original. + for layer in range(num_layers): + for slot in [1, 3]: + torch.testing.assert_close(current_ref[layer, slot], current_orig[layer, slot]) + torch.testing.assert_close(current_tri[layer, slot], current_orig[layer, slot]) + + torch.testing.assert_close(current_tri, current_ref) + + def test_noncontiguous_state_idx(self): + """state_idx does not have to be a simple arange.""" + N = 3 + num_layers = 2 + M = 6 # more slots than requests + S = 3 + state_shape = (8, 4) + + intermediate = torch.randn(num_layers, M, S, *state_shape, device=DEVICE) + current_ref = torch.randn(num_layers, M, *state_shape, device=DEVICE) + current_tri = current_ref.clone() + + prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) + state_idx = torch.tensor([1, 4, 0], device=DEVICE, dtype=torch.int64) + accepted_counts = torch.tensor([2, 0, 1], device=DEVICE, dtype=torch.int64) + + mamba_state_selective_copy_pytorch( + intermediate, current_ref, prefill_status, state_idx, accepted_counts, num_layers + ) + mamba_state_selective_copy( + intermediate, current_tri, prefill_status, state_idx, accepted_counts, num_layers + ) + + torch.testing.assert_close(current_tri, current_ref) + + def test_empty(self): + """Zero requests should be a no-op.""" + num_layers = 2 + state_shape = (8,) + intermediate = torch.randn(num_layers, 4, 3, *state_shape, device=DEVICE) + current = torch.randn(num_layers, 4, *state_shape, device=DEVICE) + current_before = current.clone() + + mamba_state_selective_copy( + intermediate, current, + torch.empty(0, dtype=torch.int32, device=DEVICE), + torch.empty(0, dtype=torch.int64, device=DEVICE), + torch.empty(0, dtype=torch.int64, device=DEVICE), + num_layers, + ) + + torch.testing.assert_close(current, current_before) + + +class TestStressRandom: + """Randomized stress tests running all four kernels with varied inputs.""" + + @pytest.mark.parametrize("trial", range(5)) + def test_rewind_random(self, trial): + torch.manual_seed(42 + trial) + N = torch.randint(1, 32, (1,)).item() + num_spec = torch.randint(1, 6, (1,)).item() + block_size = 2 ** torch.randint(3, 7, (1,)).item() + max_blocks = torch.randint(4, 16, (1,)).item() + + accepted_counts = torch.randint(0, num_spec + 1, (N,), device=DEVICE) + prefill_status = (torch.rand(N, device=DEVICE) > 0.7).to(torch.int32) + last_kv_block_offset = torch.randint(0, block_size, (N,), device=DEVICE) + kv_length_offsets = torch.randint(block_size, block_size * 4, (N,), device=DEVICE) + kv_block_counts = torch.randint(2, max_blocks, (N,), device=DEVICE) + last_kv_block_id = torch.randint(0, 200, (N,), device=DEVICE) + kv_block_ids = torch.randint(0, 200, (N, max_blocks), device=DEVICE) + + ref_args = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + tri_args = _clone_tensors( + last_kv_block_offset, kv_length_offsets, kv_block_counts, last_kv_block_id, kv_block_ids + ) + + ref_release, ref_mask = rewind_kv_cache_pytorch( + accepted_counts.clone(), prefill_status.clone(), *ref_args, + num_spec, block_size, + ) + tri_release, tri_mask = rewind_kv_cache( + accepted_counts.clone(), prefill_status.clone(), *tri_args, + num_spec, block_size, + ) + + for r, t in zip(ref_args, tri_args): + torch.testing.assert_close(t, r) + torch.testing.assert_close(tri_release, ref_release) + torch.testing.assert_close(tri_mask, ref_mask) + + @pytest.mark.parametrize("trial", range(5)) + def test_verify_random(self, trial): + torch.manual_seed(42 + trial) + num_decode = torch.randint(0, 16, (1,)).item() + num_prefill = torch.randint(0, 8, (1,)).item() + if num_decode == 0 and num_prefill == 0: + num_prefill = 1 + num_spec = torch.randint(1, 6, (1,)).item() + + stride = num_spec + 1 + total_len = num_decode * stride + num_prefill + + input_tokens = torch.randint(1, 500, (total_len,), device=DEVICE) + output_tokens = torch.randint(1, 500, (total_len,), device=DEVICE) + + # Randomly make some speculative tokens match. + for req in range(num_decode): + base = req * stride + num_match = torch.randint(0, num_spec + 1, (1,)).item() + for s in range(num_match): + output_tokens[base + s] = input_tokens[base + s + 1] + + ref_last, ref_mask, _ = verify_speculative_tokens_pytorch( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + tri_last, tri_mask, _ = verify_speculative_tokens( + input_tokens.clone(), output_tokens.clone(), num_decode, num_prefill, num_spec + ) + + torch.testing.assert_close(tri_mask, ref_mask) + torch.testing.assert_close(tri_last, ref_last) From 3a3bbdc58f9954a8a1f296e2436f22349150dd09 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 12:48:07 -0700 Subject: [PATCH 054/124] Linting Signed-off-by: Keshav Santhanam --- .../test_triton_kernels.py | 171 ++++++++++++------ 1 file changed, 115 insertions(+), 56 deletions(-) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py index be884075c9f..005dcab7815 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py @@ -19,7 +19,6 @@ verify_speculative_tokens, ) - # --------------------------------------------------------------------------- # PyTorch reference implementations # --------------------------------------------------------------------------- @@ -231,7 +230,9 @@ def test_basic(self, num_requests, num_speculative_tokens, block_size_tokens): prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) last_kv_block_offset = torch.randint(0, block_size_tokens, (N,), device=DEVICE) - kv_length_offsets = torch.randint(block_size_tokens, block_size_tokens * 4, (N,), device=DEVICE) + kv_length_offsets = torch.randint( + block_size_tokens, block_size_tokens * 4, (N,), device=DEVICE + ) kv_block_counts = torch.randint(2, max_blocks, (N,), device=DEVICE) last_kv_block_id = torch.randint(0, 100, (N,), device=DEVICE) kv_block_ids = torch.randint(0, 100, (N, max_blocks), device=DEVICE) @@ -244,15 +245,27 @@ def test_basic(self, num_requests, num_speculative_tokens, block_size_tokens): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_speculative_tokens, block_size_tokens, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_speculative_tokens, + block_size_tokens, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_speculative_tokens, block_size_tokens, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_speculative_tokens, + block_size_tokens, ) torch.testing.assert_close(tri_offset, ref_offset) @@ -284,14 +297,26 @@ def test_prefill_requests_skip_rewind(self): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, ) # Prefill requests (indices 1, 3) should be unchanged. @@ -330,14 +355,26 @@ def test_block_boundary_crossing(self): ) rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, ) rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, ) # Request 0: offset 1 - 3 = -2 → crosses boundary. @@ -377,14 +414,28 @@ def test_padding_programs(self): ) rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, num_active_requests=active, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, + num_active_requests=active, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, num_active_requests=active, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, + num_active_requests=active, ) # Active slots should match. @@ -442,13 +493,9 @@ def _make_scenario(self, num_decode, num_prefill, num_spec, *, match_pattern=Non return input_tokens, output_tokens - @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ - (1, 0, 2), - (3, 0, 2), - (3, 2, 2), - (0, 3, 2), - (5, 3, 4), - ]) + @pytest.mark.parametrize( + "num_decode,num_prefill,num_spec", [(1, 0, 2), (3, 0, 2), (3, 2, 2), (0, 3, 2), (5, 3, 4)] + ) def test_basic(self, num_decode, num_prefill, num_spec): input_tokens, output_tokens = self._make_scenario(num_decode, num_prefill, num_spec) @@ -568,18 +615,13 @@ def _setup(self, num_decode, num_prefill, num_spec): return output_tokens, required_logit_indices, input_tokens, accepted_mask, last_one_indices - @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ - (1, 0, 2), - (3, 0, 2), - (3, 2, 2), - (0, 3, 2), - (5, 3, 4), - ]) + @pytest.mark.parametrize( + "num_decode,num_prefill,num_spec", [(1, 0, 2), (3, 0, 2), (3, 2, 2), (0, 3, 2), (5, 3, 4)] + ) def test_basic(self, num_decode, num_prefill, num_spec): - ( - output_tokens, required_logit_indices, input_tokens, - accepted_mask, last_one_indices, - ) = self._setup(num_decode, num_prefill, num_spec) + (output_tokens, required_logit_indices, input_tokens, accepted_mask, last_one_indices) = ( + self._setup(num_decode, num_prefill, num_spec) + ) active = num_decode + num_prefill @@ -590,19 +632,37 @@ def test_basic(self, num_decode, num_prefill, num_spec): tri_sampled = torch.zeros(active, device=DEVICE, dtype=torch.int64) tri_last_seq = torch.zeros(active, device=DEVICE, dtype=torch.int64) - tri_accepted = torch.full((max(num_decode, 1), num_spec), -1, device=DEVICE, dtype=torch.int64) + tri_accepted = torch.full( + (max(num_decode, 1), num_spec), -1, device=DEVICE, dtype=torch.int64 + ) tri_counts = torch.zeros(max(num_decode, 1), device=DEVICE, dtype=torch.int64) prepare_next_forward_pass_pytorch( - num_decode, output_tokens, required_logit_indices, - last_one_indices, accepted_mask, input_tokens, - ref_sampled, ref_last_seq, ref_accepted, ref_counts, num_spec, + num_decode, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_mask, + input_tokens, + ref_sampled, + ref_last_seq, + ref_accepted, + ref_counts, + num_spec, ) prepare_next_forward_pass( - num_decode, output_tokens, required_logit_indices, - last_one_indices, accepted_mask, input_tokens, - tri_sampled, tri_last_seq, tri_accepted, tri_counts, num_spec, + num_decode, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_mask, + input_tokens, + tri_sampled, + tri_last_seq, + tri_accepted, + tri_counts, + num_spec, ) torch.testing.assert_close(tri_sampled, ref_sampled) @@ -722,7 +782,8 @@ def test_empty(self): current_before = current.clone() mamba_state_selective_copy( - intermediate, current, + intermediate, + current, torch.empty(0, dtype=torch.int32, device=DEVICE), torch.empty(0, dtype=torch.int64, device=DEVICE), torch.empty(0, dtype=torch.int64, device=DEVICE), @@ -759,12 +820,10 @@ def test_rewind_random(self, trial): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), *ref_args, - num_spec, block_size, + accepted_counts.clone(), prefill_status.clone(), *ref_args, num_spec, block_size ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), *tri_args, - num_spec, block_size, + accepted_counts.clone(), prefill_status.clone(), *tri_args, num_spec, block_size ) for r, t in zip(ref_args, tri_args): From 43de7aab0e4208475e075822257ee916c9e2ad83 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 14:39:41 -0700 Subject: [PATCH 055/124] NVLS all gathers Signed-off-by: Keshav Santhanam --- megatron/core/models/gpt/gpt_model.py | 54 +++++++---- megatron/core/models/hybrid/hybrid_model.py | 50 +++++++---- .../core/tensor_parallel/inference_layers.py | 89 +++++++++++++++++-- .../transformer/multi_token_prediction.py | 15 ++-- 4 files changed, 162 insertions(+), 46 deletions(-) diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index 4fe641bb17b..e17be6e3839 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -241,24 +241,44 @@ def __init__( self.embedding_activation_buffer = None self.grad_output_buffer = None - self.output_layer = tensor_parallel.ColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=( - config.embedding_init_method - if config.use_mup and not self.share_embeddings_and_output_weights - else config.init_method - ), - bias=False, - skip_bias_add=False, - gather_output=not self.parallel_output, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - embedding_activation_buffer=self.embedding_activation_buffer, - grad_output_buffer=self.grad_output_buffer, - tp_group=self.pg_collection.tp, + output_init_method = ( + config.embedding_init_method + if config.use_mup and not self.share_embeddings_and_output_weights + else config.init_method ) + if config.transformer_impl == "inference_optimized": + from megatron.core.tensor_parallel.inference_layers import ( + InferenceColumnParallelLinear, + ) + + self.output_layer = InferenceColumnParallelLinear( + config.hidden_size, + self.vocab_size, + config=config, + init_method=output_init_method, + gather_output=not self.parallel_output, + bias=False, + skip_bias_add=False, + is_expert=False, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + tp_group=self.pg_collection.tp, + ) + else: + self.output_layer = tensor_parallel.ColumnParallelLinear( + config.hidden_size, + self.vocab_size, + config=config, + init_method=output_init_method, + bias=False, + skip_bias_add=False, + gather_output=not self.parallel_output, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + embedding_activation_buffer=self.embedding_activation_buffer, + grad_output_buffer=self.grad_output_buffer, + tp_group=self.pg_collection.tp, + ) if self.pre_process or self.post_process or self.mtp_process: self.setup_embeddings_and_output_layer() diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index f1b3c102634..2f79ea29193 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -261,22 +261,42 @@ def __init__( # Output if post_process or self.mtp_process: - self.output_layer = tensor_parallel.ColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=( - config.embedding_init_method - if config.use_mup and not self.share_embeddings_and_output_weights - else config.init_method - ), - bias=False, - skip_bias_add=False, - gather_output=not self.parallel_output, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - tp_group=self.pg_collection.tp, + output_init_method = ( + config.embedding_init_method + if config.use_mup and not self.share_embeddings_and_output_weights + else config.init_method ) + if config.transformer_impl == "inference_optimized": + from megatron.core.tensor_parallel.inference_layers import ( + InferenceColumnParallelLinear, + ) + + self.output_layer = InferenceColumnParallelLinear( + config.hidden_size, + self.vocab_size, + config=config, + init_method=output_init_method, + gather_output=not self.parallel_output, + bias=False, + skip_bias_add=False, + is_expert=False, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + tp_group=self.pg_collection.tp, + ) + else: + self.output_layer = tensor_parallel.ColumnParallelLinear( + config.hidden_size, + self.vocab_size, + config=config, + init_method=output_init_method, + bias=False, + skip_bias_add=False, + gather_output=not self.parallel_output, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + tp_group=self.pg_collection.tp, + ) if self.pre_process or self.post_process or self.mtp_process: self.setup_embeddings_and_output_layer() diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 80aa754dd50..b5d81407f90 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -21,6 +21,7 @@ from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.model_parallel_config import ModelParallelConfig from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.tensor_parallel.mappings import gather_from_tensor_model_parallel_region from megatron.core.utils import get_tensor_model_parallel_group_if_none try: @@ -312,20 +313,53 @@ def _all_gather(self, x: torch.Tensor, symm_mem_buffer: dict) -> None: x, _ = gather_along_first_dim(x, process_group=self.tp_group) return x - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, None]: - """ - Forward pass. - """ + def _nvls_gather_last_dim(self, x: torch.Tensor) -> torch.Tensor: + """NVLS all-gather along last dim, with NCCL fallback.""" + ag_buffer_dims = list(x.size()) + ag_buffer_dims[0] *= self.tp_size + buf = SymmetricMemoryManager.get_buffer("tp", process_group=self.tp_group) + symm_mem_buffer = buf.maybe_get_tensor(ag_buffer_dims, dtype=x.dtype) + + can_use_nvls = ( + self.triton_nvls_kernels_allowed + and are_tensors_nvls_eligible(x) + and symm_mem_buffer["handle"] is not None + ) + if can_use_nvls: + multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) + tensor_list = symm_mem_buffer["tensor"].chunk(self.tp_size, dim=0) + return torch.cat(tensor_list, dim=-1).contiguous() + + return gather_from_tensor_model_parallel_region(x, group=self.tp_group) + + def forward( + self, + x: torch.Tensor, + weight: Optional[torch.Tensor] = None, + runtime_gather_output: Optional[bool] = None, + ) -> Tuple[torch.Tensor, None]: + """Forward pass.""" if self.training: - return super().forward(x) + return super().forward(x, weight=weight, runtime_gather_output=runtime_gather_output) + + if weight is None: + weight = self.weight if self.tp_size == 1: - x = _apply_linear(x, self.weight, self.config) + x = _apply_linear(x, weight, self.config) return x, None - symm_mem_buffer = self._maybe_allocate_symmetric_buffer(x) - x = self._all_gather(x, symm_mem_buffer) - x = _apply_linear(x, self.weight, self.config) + if self.sequence_parallel: + symm_mem_buffer = self._maybe_allocate_symmetric_buffer(x) + x = self._all_gather(x, symm_mem_buffer) + + x = _apply_linear(x, weight, self.config) + + gather_output = self.gather_output + if runtime_gather_output is not None: + gather_output = runtime_gather_output + if gather_output: + x = self._nvls_gather_last_dim(x) return x, None @@ -473,3 +507,40 @@ def forward( else: x = self._matmul_reduce_scatter(x) return x, None + + +def inference_all_gather_last_dim( + x: torch.Tensor, + tp_group: torch.distributed.ProcessGroup, + config: TransformerConfig, +) -> torch.Tensor: + """NVLS-optimized all-gather along the last dimension, with NCCL fallback. + + Replaces ``gather_from_tensor_model_parallel_region`` in inference paths + where autograd is not needed and NVLS symmetric-memory is available. + + The NVLS path performs a flat all-gather into symmetric memory (concatenating + along dim-0), then rearranges the result to the last dimension — the same + semantics as ``_gather_along_last_dim`` but using hardware multicast when + possible. + """ + tp_size = dist.get_world_size(tp_group) + if tp_size == 1: + return x + + triton_nvls_kernels_allowed = not getattr( + config, 'inference_disable_triton_nvls_kernels', False + ) + + if triton_nvls_kernels_allowed and SymmetricMemoryManager.is_initialized("tp"): + ag_buffer_dims = list(x.size()) + ag_buffer_dims[0] *= tp_size + buf = SymmetricMemoryManager.get_buffer("tp", process_group=tp_group) + symm_mem_buffer = buf.maybe_get_tensor(ag_buffer_dims, dtype=x.dtype) + + if are_tensors_nvls_eligible(x) and symm_mem_buffer["handle"] is not None: + multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) + tensor_list = symm_mem_buffer["tensor"].chunk(tp_size, dim=0) + return torch.cat(tensor_list, dim=-1).contiguous() + + return gather_from_tensor_model_parallel_region(x, group=tp_group) diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 5fcb9d5710a..8c79bed5595 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -22,6 +22,7 @@ gather_from_tensor_model_parallel_region, scatter_to_sequence_parallel_region, ) +from megatron.core.tensor_parallel.inference_layers import inference_all_gather_last_dim from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module @@ -932,11 +933,15 @@ def _concat_embeddings(self, hidden_states: torch.Tensor, decoder_input: torch.T hidden_states = torch.cat((decoder_input, hidden_states), -1) hidden_states, _ = self.eh_proj(hidden_states) # For tensor parallel we need to gather the tensor across the model-parallel - # ranks after the linear projection. This used to call - # `all_gather_last_dim_from_tensor_parallel_region`, but that utility reduces - # the gradient in backward pass and was therefore incorrect in this context. - # It has been replaced with the correct `gather_from_tensor_model_parallel_region`. - hidden_states = gather_from_tensor_model_parallel_region(hidden_states, group=self.tp_group) + # ranks after the linear projection. + if not self.training: + hidden_states = inference_all_gather_last_dim( + hidden_states, self.tp_group, self.config + ) + else: + hidden_states = gather_from_tensor_model_parallel_region( + hidden_states, group=self.tp_group + ) # For sequence parallel, scatter after linear_fc and before transformer layer. if self.sequence_parallel: hidden_states = scatter_to_sequence_parallel_region(hidden_states, group=self.tp_group) From 78fba30b0490d5580175d20a1afb012d928151d8 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 14:59:28 -0700 Subject: [PATCH 056/124] Fix gather_output Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index b5d81407f90..b6ca8cf0519 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -255,12 +255,14 @@ def __init__( tp_group: Optional[torch.distributed.ProcessGroup] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" + # TEColumnParallelLinear rejects gather_output=True, so always pass + # False and handle output gathering ourselves in forward(). super().__init__( input_size, output_size, config=config, init_method=init_method, - gather_output=gather_output, + gather_output=False, bias=bias, skip_bias_add=skip_bias_add, is_expert=is_expert, @@ -269,6 +271,7 @@ def __init__( tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, ) + self.gather_output = gather_output self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) @@ -339,9 +342,6 @@ def forward( runtime_gather_output: Optional[bool] = None, ) -> Tuple[torch.Tensor, None]: """Forward pass.""" - if self.training: - return super().forward(x, weight=weight, runtime_gather_output=runtime_gather_output) - if weight is None: weight = self.weight From 4c0ac1ab13b0e3ed65a7ae4098c71719bf809a63 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 15:36:51 -0700 Subject: [PATCH 057/124] Expand graph scope Signed-off-by: Keshav Santhanam --- .../text_generation_controller.py | 11 ++++------ .../common/language_module/language_module.py | 4 ---- megatron/core/models/gpt/gpt_model.py | 10 +++++++++ megatron/core/transformer/cuda_graphs.py | 6 +----- .../transformer/multi_token_prediction.py | 21 ------------------- 5 files changed, 15 insertions(+), 37 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 3685fcb6a2e..6621f02693c 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -621,15 +621,12 @@ def _dynamic_step_context_init( else: self._mtp_resolved_padded_count = None - # Tell MTP layers whether to use CUDA graphs this step. When the main - # model falls back to eager mode, MTP must also run eagerly across all - # EP ranks — otherwise some ranks may replay a captured graph while + # Tell the model whether to use MTP CUDA graphs this step. When the + # main model falls back to eager mode, MTP must also run eagerly across + # all EP ranks — otherwise some ranks may replay a captured graph while # others run eagerly, causing EP collectives to hang. if getattr(self, '_has_mtp_cuda_graphs', False): - use_mtp_graphs = context.using_cuda_graph_this_step() - if hasattr(unwrapped_model, 'mtp'): - for layer in unwrapped_model.mtp.layers: - layer.use_mtp_cuda_graphs = use_mtp_graphs + unwrapped_model.use_mtp_cuda_graphs = context.using_cuda_graph_this_step() # If using symmetric kernels and we are using using nccl # for prefill turn off symmetric kernels diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 75ff640b1b9..f39274e945a 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -352,10 +352,6 @@ def compute_mtp_single_step( position_ids=position_ids, embedding=self.embedding, ) - # CudaGraphManager.replay_graph_capture always wraps outputs in a - # tuple. Unwrap when forward_single_position is CUDA-graphed. - if isinstance(mtp_hidden, tuple): - mtp_hidden = mtp_hidden[0] nvtx_range_pop(f"mtp-single-step/depth-{depth}/mtp-layer") output_weight = None diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index e17be6e3839..5b292f75150 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -223,6 +223,16 @@ def __init__( pg_collection=self.pg_collection, ) + if self.config.cuda_graph_impl == "local" and not self.config.cuda_graph_scope: + from megatron.core.transformer.cuda_graphs import CudaGraphManager + + self._mtp_cudagraph_manager = CudaGraphManager( + self.config, + base_module=self, + function_name="compute_mtp_single_step", + need_backward=False, + ) + # Output if self.post_process: diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index ca0a2aa35f5..1497ac2b7ca 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1437,15 +1437,12 @@ def __init__( config: TransformerConfig object containing CUDA graph settings for memory pooling, graph retention, gradient accumulation, FP8/FP4, and warmup steps. """ - from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer - if pg_collection is None: pg_collection = ProcessGroupCollection.use_mpu_process_groups() self.pg_collection = pg_collection rng_tracker = get_cuda_rng_tracker() self.need_backward = need_backward - # MTP is only cuda-graphed for inference (forward_single_position). - self.is_mtp = isinstance(base_module, MultiTokenPredictionLayer) + self.is_mtp = function_name == "compute_mtp_single_step" if function_name is not None: func = getattr(base_module, function_name) @@ -1531,7 +1528,6 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): padded_batch_dimensions = kwargs['inference_context'].padded_batch_dimensions runner = self.inference_cudagraphs_lookup_table[padded_batch_dimensions] elif is_mtp_inference: - # MTP layers have no inference_context; key by hidden_states shape. mtp_key = ('mtp', kwargs['hidden_states'].shape) runner = self.inference_cudagraphs_lookup_table.get(mtp_key) else: diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 8c79bed5595..dfe110dfa2a 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -862,19 +862,6 @@ def __init__( ) self.offload_context = nullcontext() - # Create cuda graph manager for forward_single_position so that - # the full MTP forward (embedding, projection, transformer, layernorm) - # is captured in a single graph. - if config.cuda_graph_impl == "local" and not config.cuda_graph_scope: - from megatron.core.transformer.cuda_graphs import CudaGraphManager - - self.cudagraph_manager = CudaGraphManager( - config, - base_module=self, - function_name="forward_single_position", - need_backward=False, - ) - def _get_embeddings( self, input_ids: torch.Tensor, @@ -1030,14 +1017,6 @@ def _postprocess(self, hidden_states: torch.Tensor): return hidden_states - def _should_call_local_cudagraph(self, *args, **kwargs): - """MTP cuda-graphs forward_single_position, not forward. - - Disable the MegatronModule.__call__ interceptor so the training forward - path is not routed through the cuda graph manager. - """ - return False - def forward_single_position( self, hidden_states: Tensor, From 03d52aba671a2a877ad65fc13509a4cca460cafe Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 15:39:26 -0700 Subject: [PATCH 058/124] Update tests Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index c964df11bed..d2436b3f088 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -176,10 +176,9 @@ def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): @staticmethod def _set_mtp_cuda_graph_flag(model, enabled): - """Set ``use_mtp_cuda_graphs`` on all MTP layers.""" + """Set ``use_mtp_cuda_graphs`` on the model.""" unwrapped = unwrap_model(model) - for layer in unwrapped.mtp.layers: - layer.use_mtp_cuda_graphs = enabled + unwrapped.use_mtp_cuda_graphs = enabled # ---- Test 1: graph output matches eager (no additional padding) ------- # @@ -540,19 +539,15 @@ def test_eager_fallback_no_matching_graph(self): @torch.inference_mode() def test_mtp_graph_flag_propagation(self): - """``use_mtp_cuda_graphs`` is correctly toggled via the helper and - every MTP layer sees the same value. - """ + """``use_mtp_cuda_graphs`` is correctly toggled via the helper.""" model = self._build_model(mtp_num_layers=2) unwrapped = unwrap_model(model) self._set_mtp_cuda_graph_flag(model, True) - for layer in unwrapped.mtp.layers: - assert layer.use_mtp_cuda_graphs is True + assert unwrapped.use_mtp_cuda_graphs is True self._set_mtp_cuda_graph_flag(model, False) - for layer in unwrapped.mtp.layers: - assert layer.use_mtp_cuda_graphs is False + assert unwrapped.use_mtp_cuda_graphs is False # --------------------------------------------------------------------------- # From 4abc5735a2f814500e2caf8aabc77d1ed166433e Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 15:59:56 -0700 Subject: [PATCH 059/124] Fix graph manager Signed-off-by: Keshav Santhanam --- .../common/language_module/language_module.py | 15 +++++++++++++++ megatron/core/models/gpt/gpt_model.py | 10 +--------- megatron/core/models/hybrid/hybrid_model.py | 1 + 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index f39274e945a..45d75a91501 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -65,6 +65,21 @@ def __init__( self.vp_stage = None self.vp_size = self.config.virtual_pipeline_model_parallel_size + def _setup_mtp_cuda_graphs(self): + """Wrap ``compute_mtp_single_step`` with a CudaGraphManager. + + Must be called by subclasses after ``self.mtp`` is created. + """ + if self.config.cuda_graph_impl == "local" and not self.config.cuda_graph_scope: + from megatron.core.transformer.cuda_graphs import CudaGraphManager + + self._mtp_cudagraph_manager = CudaGraphManager( + self.config, + base_module=self, + function_name="compute_mtp_single_step", + need_backward=False, + ) + def _is_in_embd_group(self): if self.embd_group is None: return False diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index 5b292f75150..d4574952353 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -223,15 +223,7 @@ def __init__( pg_collection=self.pg_collection, ) - if self.config.cuda_graph_impl == "local" and not self.config.cuda_graph_scope: - from megatron.core.transformer.cuda_graphs import CudaGraphManager - - self._mtp_cudagraph_manager = CudaGraphManager( - self.config, - base_module=self, - function_name="compute_mtp_single_step", - need_backward=False, - ) + self._setup_mtp_cuda_graphs() # Output if self.post_process: diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index 2f79ea29193..6e739a7bf9e 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -258,6 +258,7 @@ def __init__( mtp_num_depths=self.mtp_num_depths, hybrid_submodules=hybrid_submodules, ) + self._setup_mtp_cuda_graphs() # Output if post_process or self.mtp_process: From d8b08ac7c514e57a42d41affd826d4728e0cc115 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 16:04:55 -0700 Subject: [PATCH 060/124] Fix chaining Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 1497ac2b7ca..ecc55cf3b09 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1668,10 +1668,14 @@ def __call__(self, megatron_module, args, kwargs): runner.cudagraph_created = True runner = runner.eval() - # Record this to the global execution record - _CudagraphGlobalRecord.cudagraph_inference_record.append( - (runner, "fwd", args, kwargs) - ) + # Record this to the global execution record. + # MTP runners are self-contained and don't chain with + # decoder layers, so skip the record to avoid polluting + # the previous-layer lookup (which expects layer_number). + if not self.is_mtp: + _CudagraphGlobalRecord.cudagraph_inference_record.append( + (runner, "fwd", args, kwargs) + ) # Now replay the graph out = runner.replay_graph_capture(self.is_first_microbatch, args, kwargs) From 836a914d47006cfd233004f48093955de1ba92f1 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 16:20:11 -0700 Subject: [PATCH 061/124] Fix keys Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 31 ++++++++++++------- .../text_generation_controller.py | 6 ++-- .../common/language_module/language_module.py | 21 ++++++------- megatron/core/transformer/cuda_graphs.py | 2 +- .../inference/engines/test_dynamic_engine.py | 18 +++++------ .../test_mtp_cuda_graph_inference.py | 11 +------ .../test_text_generation_controller.py | 4 +-- 7 files changed, 46 insertions(+), 47 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 31056ab5c5a..263ae6fada8 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -377,6 +377,10 @@ def create_cuda_graphs(self, reset_context: bool = True): if mtp_warmup_enabled: tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) sp_enabled = model_config.sequence_parallel and tp_size > 1 + mtp_pass_depth = not unwrapped.mtp.mtp_use_repeated_layer + mtp_warmup_depths = ( + range(controller._num_mtp_depths) if mtp_pass_depth else [None] + ) mtp_seen_batch_sizes = set() tbar = enumerate(context.cuda_graph_batch_dimensions_list) @@ -413,17 +417,22 @@ def create_cuda_graphs(self, reset_context: bool = True): mtp_seen_batch_sizes.add(n) device = torch.cuda.current_device() batch_dim = n // tp_size if sp_enabled else n - with graph_capture(): - unwrapped.compute_mtp_single_step( - hidden_states=torch.empty( - (batch_dim, 1, model_config.hidden_size), - device=device, - dtype=model_config.params_dtype, - ), - next_token_ids=torch.empty((1, n), device=device, dtype=torch.long), - position_ids=torch.empty((1, n), device=device, dtype=torch.int64), - depth=0, - ) + for depth in mtp_warmup_depths: + with graph_capture(): + unwrapped.compute_mtp_single_step( + hidden_states=torch.empty( + (batch_dim, 1, model_config.hidden_size), + device=device, + dtype=model_config.params_dtype, + ), + next_token_ids=torch.empty( + (1, n), device=device, dtype=torch.long + ), + position_ids=torch.empty( + (1, n), device=device, dtype=torch.int64 + ), + depth=depth, + ) context.reset() diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 6621f02693c..3a136e06b89 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -929,11 +929,12 @@ def _compute_serial_mtp_and_sample(self): mtp_logits_2d = None if has_mtp: nvtx_range_push(f"mtp-spec-decoding/depth-{depth}/forward") + mtp_depth = None if unwrapped_model.mtp.mtp_use_repeated_layer else depth current_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=current_hidden, next_token_ids=token_ids_buf, position_ids=position_ids_buf, - depth=depth, + depth=mtp_depth, ) nvtx_range_pop(f"mtp-spec-decoding/depth-{depth}/forward") @@ -1690,11 +1691,12 @@ def _dummy_serial_mtp_forward(self): nvtx_range_push(f"mtp-spec-decoding/dummy-depth-{depth}") mtp_logits_2d = None if has_mtp: + mtp_depth = None if unwrapped_model.mtp.mtp_use_repeated_layer else depth dummy_hidden, mtp_logits = unwrapped_model.compute_mtp_single_step( hidden_states=dummy_hidden, next_token_ids=dummy_token_ids, position_ids=dummy_position_ids, - depth=depth, + depth=mtp_depth, ) mtp_logits_2d = mtp_logits.squeeze(1) # [padded_count, vocab_size] diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 45d75a91501..7552f489d2b 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -342,41 +342,40 @@ def shared_embedding_or_output_weight(self) -> Tensor: @torch.inference_mode() def compute_mtp_single_step( - self, hidden_states: Tensor, next_token_ids: Tensor, position_ids: Tensor, depth: int + self, + hidden_states: Tensor, + next_token_ids: Tensor, + position_ids: Tensor, + depth: Optional[int] = None, ) -> tuple: """Compute a single MTP depth for speculative decoding. - This is called after speculative token verification to compute MTP - predictions conditioned on verified tokens only. - Args: hidden_states (Tensor): Hidden states at last accepted positions. next_token_ids (Tensor): Correct next token IDs [1, N]. position_ids (Tensor): Position IDs for the next tokens [1, N]. - depth (int): MTP depth index (0-indexed). + depth (int, optional): MTP depth index. Only needed when + ``mtp_use_repeated_layer`` is False (each depth uses a + distinct layer). Omit for repeated-layer models so that a + single CUDA graph can serve all depths. Returns: tuple: (new_hidden_states, logits [N, 1, vocab_size]). """ - layer_idx = 0 if self.mtp.mtp_use_repeated_layer else depth - - nvtx_range_push(f"mtp-single-step/depth-{depth}/mtp-layer") + layer_idx = 0 if depth is None else depth mtp_hidden = self.mtp.layers[layer_idx].forward_single_position( hidden_states=hidden_states, next_token_ids=next_token_ids, position_ids=position_ids, embedding=self.embedding, ) - nvtx_range_pop(f"mtp-single-step/depth-{depth}/mtp-layer") output_weight = None if self.share_embeddings_and_output_weights: output_weight = self.shared_embedding_or_output_weight() - nvtx_range_push(f"mtp-single-step/depth-{depth}/output-layer") logits, _ = self.output_layer(mtp_hidden, weight=output_weight, runtime_gather_output=True) logits = self._scale_logits(logits) - nvtx_range_pop(f"mtp-single-step/depth-{depth}/output-layer") return mtp_hidden, logits diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index ecc55cf3b09..4dd8ccb44ea 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1528,7 +1528,7 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): padded_batch_dimensions = kwargs['inference_context'].padded_batch_dimensions runner = self.inference_cudagraphs_lookup_table[padded_batch_dimensions] elif is_mtp_inference: - mtp_key = ('mtp', kwargs['hidden_states'].shape) + mtp_key = ('mtp', kwargs['hidden_states'].shape, kwargs.get('depth')) runner = self.inference_cudagraphs_lookup_table.get(mtp_key) else: # Todo: For training, we could also cache runners based on input shape. diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index fe2b8fc5802..bbcf6a95282 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -2361,7 +2361,7 @@ def mock_mtp_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) logits = torch.zeros( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -2484,7 +2484,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -2568,7 +2568,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -2653,7 +2653,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -3007,7 +3007,7 @@ def mock_safe_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) logits = torch.zeros( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3220,7 +3220,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3340,7 +3340,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3470,7 +3470,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3819,7 +3819,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): n = hidden_states.size(0) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) logits = torch.zeros( diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index d2436b3f088..acc482b5655 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -170,7 +170,6 @@ def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): hidden_states=dummy_hidden, next_token_ids=dummy_token_ids, position_ids=dummy_position_ids, - depth=0, ) _set_capture_end() @@ -208,7 +207,6 @@ def test_cuda_graph_output_matches_eager(self, batch_size): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) # Clone immediately — CUDA graph output buffers are reused on next call. h_graph = h_graph.clone() @@ -220,7 +218,6 @@ def test_cuda_graph_output_matches_eager(self, batch_size): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) torch.testing.assert_close(h_graph, h_eager) @@ -257,7 +254,6 @@ def test_cuda_graph_output_matches_eager_with_sp(self, batch_size): hidden_states=hidden_sp.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) h_graph = h_graph.clone() logits_graph = logits_graph.clone() @@ -268,7 +264,6 @@ def test_cuda_graph_output_matches_eager_with_sp(self, batch_size): hidden_states=hidden_sp.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) torch.testing.assert_close(h_graph, h_eager) @@ -485,7 +480,6 @@ def test_cuda_graph_multi_depth(self): hidden_states=current_hidden, next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=depth, ) # Clone — graph output buffers are reused. current_hidden = current_hidden.clone() @@ -528,7 +522,6 @@ def test_eager_fallback_no_matching_graph(self): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -696,7 +689,6 @@ def test_ep_mtp_eager_forward(self, batch_size): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), - depth=0, ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -730,7 +722,7 @@ def test_ep_mtp_eager_dummy_and_real_ranks(self): # All ranks must complete without hanging. h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids, depth=0 + hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -847,7 +839,6 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): hidden_states=dummy_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions, - depth=0, ) assert h_out.shape == (tp_size, 1, self.HIDDEN_SIZE) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 53b7ef19ba4..74134c9b014 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1539,7 +1539,7 @@ def test_speculative_mtp_position_ids_with_prefill(self): captured_position_ids = [] - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): captured_position_ids.append(position_ids.clone()) return hidden_states, torch.randn(2, 1, self.vocab_size, device='cuda') @@ -1681,7 +1681,6 @@ def test_mtp_sp_padding_dummy_ranks(self): hidden_states=dummy_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions, - depth=0, ) # Hidden output is in SP format: [padded_count/tp_size, 1, H] = [1, 1, H]. @@ -1724,7 +1723,6 @@ def test_mtp_sp_dummy_hidden_uses_full_seq_len(self): hidden_states=current_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions, - depth=depth, ) # Hidden stays in SP format across all depths. From bb597ca8b2a62648511f6c2d0f0f176fc10f5182 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 16:42:57 -0700 Subject: [PATCH 062/124] Inference RS Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/layers.py | 45 +++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 610700f0a95..86aa24e7c3f 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -236,6 +236,14 @@ def __init__( self.num_embeddings_per_partition = self.vocab_end_index - self.vocab_start_index self.deterministic_mode = config.deterministic_mode + self.use_inference_optimized_reduce_scatter = getattr( + config, 'use_inference_optimized_layers', False + ) + self.triton_nvls_kernels_allowed = ( + self.use_inference_optimized_reduce_scatter + and not getattr(config, 'inference_disable_triton_nvls_kernels', False) + ) + # Allocate weights and initialize. if config.use_cpu_initialization: self.weight = Parameter( @@ -302,14 +310,45 @@ def forward(self, input_): if self.reduce_scatter_embeddings: # Data format change to avoid explicit tranposes : [b s h] --> [s b h]. output_parallel = output_parallel.transpose(0, 1).contiguous() - output = reduce_scatter_to_sequence_parallel_region( - output_parallel, group=self.tp_group - ) + if self.use_inference_optimized_reduce_scatter and not self.training: + output = self._inference_reduce_scatter(output_parallel) + else: + output = reduce_scatter_to_sequence_parallel_region( + output_parallel, group=self.tp_group + ) else: # Reduce across all the model parallel GPUs. output = reduce_from_tensor_model_parallel_region(output_parallel, group=self.tp_group) return output + def _inference_reduce_scatter(self, input_: torch.Tensor) -> torch.Tensor: + """NVLS-optimized reduce scatter with NCCL fallback for inference.""" + from megatron.core.inference.communication.torch_symm_triton import ( + are_tensors_nvls_eligible, + multimem_reduce_scatter, + ) + from megatron.core.inference.symmetric_memory import SymmetricMemoryManager + + buf = SymmetricMemoryManager.get_buffer("tp", process_group=self.tp_group) + symm_mem_buffer = buf.maybe_get_tensor(list(input_.size()), dtype=input_.dtype) + + can_use_nvls = ( + self.triton_nvls_kernels_allowed + and input_.dtype == torch.bfloat16 + and are_tensors_nvls_eligible(input_) + and symm_mem_buffer["handle"] is not None + ) + + if can_use_nvls: + symm_mem_buffer["tensor"].copy_(input_) + output_dims = list(input_.size()) + output_dims[0] = input_.size(0) // self.tp_group.size() + output = torch.empty(output_dims, dtype=input_.dtype, device=input_.device) + multimem_reduce_scatter(output, symm_mem_buffer["tensor"], symm_mem_buffer["handle"]) + return output + else: + return reduce_scatter_to_sequence_parallel_region(input_, group=self.tp_group) + def sharded_state_dict( self, prefix: str = "", From 3c3b23b12db0d8338f8bc7f6c3dc4fb656464684 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 16:53:24 -0700 Subject: [PATCH 063/124] Fix flag Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/layers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 86aa24e7c3f..ec011da7845 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -236,8 +236,8 @@ def __init__( self.num_embeddings_per_partition = self.vocab_end_index - self.vocab_start_index self.deterministic_mode = config.deterministic_mode - self.use_inference_optimized_reduce_scatter = getattr( - config, 'use_inference_optimized_layers', False + self.use_inference_optimized_reduce_scatter = ( + getattr(config, 'transformer_impl', None) == 'inference_optimized' ) self.triton_nvls_kernels_allowed = ( self.use_inference_optimized_reduce_scatter From bc214cf88732999f652c5b36265c26b7b92419ed Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 17:14:09 -0700 Subject: [PATCH 064/124] Formatting Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 12 +- .../common/language_module/language_module.py | 2 - .../core/tensor_parallel/inference_layers.py | 6 +- .../transformer/multi_token_prediction.py | 4 +- .../test_mtp_cuda_graph_inference.py | 4 +- .../test_text_generation_controller.py | 4 +- .../test_triton_kernels.py | 171 ++++++++++++------ 7 files changed, 123 insertions(+), 80 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 263ae6fada8..a8113a2abcc 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -378,9 +378,7 @@ def create_cuda_graphs(self, reset_context: bool = True): tp_size = get_pg_size(controller.inference_wrapped_model.tp_group) sp_enabled = model_config.sequence_parallel and tp_size > 1 mtp_pass_depth = not unwrapped.mtp.mtp_use_repeated_layer - mtp_warmup_depths = ( - range(controller._num_mtp_depths) if mtp_pass_depth else [None] - ) + mtp_warmup_depths = range(controller._num_mtp_depths) if mtp_pass_depth else [None] mtp_seen_batch_sizes = set() tbar = enumerate(context.cuda_graph_batch_dimensions_list) @@ -425,12 +423,8 @@ def create_cuda_graphs(self, reset_context: bool = True): device=device, dtype=model_config.params_dtype, ), - next_token_ids=torch.empty( - (1, n), device=device, dtype=torch.long - ), - position_ids=torch.empty( - (1, n), device=device, dtype=torch.int64 - ), + next_token_ids=torch.empty((1, n), device=device, dtype=torch.long), + position_ids=torch.empty((1, n), device=device, dtype=torch.int64), depth=depth, ) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 7552f489d2b..84f4cdf5c31 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -30,8 +30,6 @@ get_tensor_model_parallel_group_if_none, is_te_min_version, make_tp_sharded_tensor_for_checkpoint, - nvtx_range_pop, - nvtx_range_push, ) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index b6ca8cf0519..1307ba15873 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -20,8 +20,8 @@ from megatron.core.inference.quantization.utils import mm_mxfp8 from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.model_parallel_config import ModelParallelConfig -from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.tensor_parallel.mappings import gather_from_tensor_model_parallel_region +from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import get_tensor_model_parallel_group_if_none try: @@ -510,9 +510,7 @@ def forward( def inference_all_gather_last_dim( - x: torch.Tensor, - tp_group: torch.distributed.ProcessGroup, - config: TransformerConfig, + x: torch.Tensor, tp_group: torch.distributed.ProcessGroup, config: TransformerConfig ) -> torch.Tensor: """NVLS-optimized all-gather along the last dimension, with NCCL fallback. diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index dfe110dfa2a..86ef8cd5dfc 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -922,9 +922,7 @@ def _concat_embeddings(self, hidden_states: torch.Tensor, decoder_input: torch.T # For tensor parallel we need to gather the tensor across the model-parallel # ranks after the linear projection. if not self.training: - hidden_states = inference_all_gather_last_dim( - hidden_states, self.tp_group, self.config - ) + hidden_states = inference_all_gather_last_dim(hidden_states, self.tp_group, self.config) else: hidden_states = gather_from_tensor_model_parallel_region( hidden_states, group=self.tp_group diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index acc482b5655..5b2fae4fb8c 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -836,9 +836,7 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=dummy_hidden, - next_token_ids=dummy_tokens, - position_ids=dummy_positions, + hidden_states=dummy_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions ) assert h_out.shape == (tp_size, 1, self.HIDDEN_SIZE) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 74134c9b014..d3174ed2835 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1678,9 +1678,7 @@ def test_mtp_sp_padding_dummy_ranks(self): dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) hidden_out, logits_out = unwrapped_model.compute_mtp_single_step( - hidden_states=dummy_hidden, - next_token_ids=dummy_tokens, - position_ids=dummy_positions, + hidden_states=dummy_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions ) # Hidden output is in SP format: [padded_count/tp_size, 1, H] = [1, 1, H]. diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py index be884075c9f..005dcab7815 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py @@ -19,7 +19,6 @@ verify_speculative_tokens, ) - # --------------------------------------------------------------------------- # PyTorch reference implementations # --------------------------------------------------------------------------- @@ -231,7 +230,9 @@ def test_basic(self, num_requests, num_speculative_tokens, block_size_tokens): prefill_status = torch.zeros(N, dtype=torch.int32, device=DEVICE) last_kv_block_offset = torch.randint(0, block_size_tokens, (N,), device=DEVICE) - kv_length_offsets = torch.randint(block_size_tokens, block_size_tokens * 4, (N,), device=DEVICE) + kv_length_offsets = torch.randint( + block_size_tokens, block_size_tokens * 4, (N,), device=DEVICE + ) kv_block_counts = torch.randint(2, max_blocks, (N,), device=DEVICE) last_kv_block_id = torch.randint(0, 100, (N,), device=DEVICE) kv_block_ids = torch.randint(0, 100, (N, max_blocks), device=DEVICE) @@ -244,15 +245,27 @@ def test_basic(self, num_requests, num_speculative_tokens, block_size_tokens): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_speculative_tokens, block_size_tokens, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_speculative_tokens, + block_size_tokens, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_speculative_tokens, block_size_tokens, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_speculative_tokens, + block_size_tokens, ) torch.testing.assert_close(tri_offset, ref_offset) @@ -284,14 +297,26 @@ def test_prefill_requests_skip_rewind(self): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, ) # Prefill requests (indices 1, 3) should be unchanged. @@ -330,14 +355,26 @@ def test_block_boundary_crossing(self): ) rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, ) rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, ) # Request 0: offset 1 - 3 = -2 → crosses boundary. @@ -377,14 +414,28 @@ def test_padding_programs(self): ) rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), - ref_offset, ref_kv_len, ref_block_counts, ref_last_block, ref_block_ids, - num_spec, block_size, num_active_requests=active, + accepted_counts.clone(), + prefill_status.clone(), + ref_offset, + ref_kv_len, + ref_block_counts, + ref_last_block, + ref_block_ids, + num_spec, + block_size, + num_active_requests=active, ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), - tri_offset, tri_kv_len, tri_block_counts, tri_last_block, tri_block_ids, - num_spec, block_size, num_active_requests=active, + accepted_counts.clone(), + prefill_status.clone(), + tri_offset, + tri_kv_len, + tri_block_counts, + tri_last_block, + tri_block_ids, + num_spec, + block_size, + num_active_requests=active, ) # Active slots should match. @@ -442,13 +493,9 @@ def _make_scenario(self, num_decode, num_prefill, num_spec, *, match_pattern=Non return input_tokens, output_tokens - @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ - (1, 0, 2), - (3, 0, 2), - (3, 2, 2), - (0, 3, 2), - (5, 3, 4), - ]) + @pytest.mark.parametrize( + "num_decode,num_prefill,num_spec", [(1, 0, 2), (3, 0, 2), (3, 2, 2), (0, 3, 2), (5, 3, 4)] + ) def test_basic(self, num_decode, num_prefill, num_spec): input_tokens, output_tokens = self._make_scenario(num_decode, num_prefill, num_spec) @@ -568,18 +615,13 @@ def _setup(self, num_decode, num_prefill, num_spec): return output_tokens, required_logit_indices, input_tokens, accepted_mask, last_one_indices - @pytest.mark.parametrize("num_decode,num_prefill,num_spec", [ - (1, 0, 2), - (3, 0, 2), - (3, 2, 2), - (0, 3, 2), - (5, 3, 4), - ]) + @pytest.mark.parametrize( + "num_decode,num_prefill,num_spec", [(1, 0, 2), (3, 0, 2), (3, 2, 2), (0, 3, 2), (5, 3, 4)] + ) def test_basic(self, num_decode, num_prefill, num_spec): - ( - output_tokens, required_logit_indices, input_tokens, - accepted_mask, last_one_indices, - ) = self._setup(num_decode, num_prefill, num_spec) + (output_tokens, required_logit_indices, input_tokens, accepted_mask, last_one_indices) = ( + self._setup(num_decode, num_prefill, num_spec) + ) active = num_decode + num_prefill @@ -590,19 +632,37 @@ def test_basic(self, num_decode, num_prefill, num_spec): tri_sampled = torch.zeros(active, device=DEVICE, dtype=torch.int64) tri_last_seq = torch.zeros(active, device=DEVICE, dtype=torch.int64) - tri_accepted = torch.full((max(num_decode, 1), num_spec), -1, device=DEVICE, dtype=torch.int64) + tri_accepted = torch.full( + (max(num_decode, 1), num_spec), -1, device=DEVICE, dtype=torch.int64 + ) tri_counts = torch.zeros(max(num_decode, 1), device=DEVICE, dtype=torch.int64) prepare_next_forward_pass_pytorch( - num_decode, output_tokens, required_logit_indices, - last_one_indices, accepted_mask, input_tokens, - ref_sampled, ref_last_seq, ref_accepted, ref_counts, num_spec, + num_decode, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_mask, + input_tokens, + ref_sampled, + ref_last_seq, + ref_accepted, + ref_counts, + num_spec, ) prepare_next_forward_pass( - num_decode, output_tokens, required_logit_indices, - last_one_indices, accepted_mask, input_tokens, - tri_sampled, tri_last_seq, tri_accepted, tri_counts, num_spec, + num_decode, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_mask, + input_tokens, + tri_sampled, + tri_last_seq, + tri_accepted, + tri_counts, + num_spec, ) torch.testing.assert_close(tri_sampled, ref_sampled) @@ -722,7 +782,8 @@ def test_empty(self): current_before = current.clone() mamba_state_selective_copy( - intermediate, current, + intermediate, + current, torch.empty(0, dtype=torch.int32, device=DEVICE), torch.empty(0, dtype=torch.int64, device=DEVICE), torch.empty(0, dtype=torch.int64, device=DEVICE), @@ -759,12 +820,10 @@ def test_rewind_random(self, trial): ) ref_release, ref_mask = rewind_kv_cache_pytorch( - accepted_counts.clone(), prefill_status.clone(), *ref_args, - num_spec, block_size, + accepted_counts.clone(), prefill_status.clone(), *ref_args, num_spec, block_size ) tri_release, tri_mask = rewind_kv_cache( - accepted_counts.clone(), prefill_status.clone(), *tri_args, - num_spec, block_size, + accepted_counts.clone(), prefill_status.clone(), *tri_args, num_spec, block_size ) for r, t in zip(ref_args, tri_args): From c0f4fffaa56db5c0ed592314f8e17b9b28056c49 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 17:28:23 -0700 Subject: [PATCH 065/124] Clean up and fixes Signed-off-by: Keshav Santhanam --- megatron/core/models/gpt/gpt_model.py | 52 +++++++------------ .../core/tensor_parallel/inference_layers.py | 3 ++ .../test_text_generation_controller.py | 8 ++- 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index d4574952353..f42f4bcadb9 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -243,44 +243,32 @@ def __init__( self.embedding_activation_buffer = None self.grad_output_buffer = None - output_init_method = ( - config.embedding_init_method - if config.use_mup and not self.share_embeddings_and_output_weights - else config.init_method - ) if config.transformer_impl == "inference_optimized": from megatron.core.tensor_parallel.inference_layers import ( InferenceColumnParallelLinear, ) - self.output_layer = InferenceColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=output_init_method, - gather_output=not self.parallel_output, - bias=False, - skip_bias_add=False, - is_expert=False, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - tp_group=self.pg_collection.tp, - ) + output_layer_cls = InferenceColumnParallelLinear else: - self.output_layer = tensor_parallel.ColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=output_init_method, - bias=False, - skip_bias_add=False, - gather_output=not self.parallel_output, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - embedding_activation_buffer=self.embedding_activation_buffer, - grad_output_buffer=self.grad_output_buffer, - tp_group=self.pg_collection.tp, - ) + output_layer_cls = tensor_parallel.ColumnParallelLinear + self.output_layer = output_layer_cls( + config.hidden_size, + self.vocab_size, + config=config, + init_method=( + config.embedding_init_method + if config.use_mup and not self.share_embeddings_and_output_weights + else config.init_method + ), + bias=False, + skip_bias_add=False, + gather_output=not self.parallel_output, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + embedding_activation_buffer=self.embedding_activation_buffer, + grad_output_buffer=self.grad_output_buffer, + tp_group=self.pg_collection.tp, + ) if self.pre_process or self.post_process or self.mtp_process: self.setup_embeddings_and_output_layer() diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 1307ba15873..f8eceede94f 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -251,6 +251,9 @@ def __init__( is_expert: bool, stride: int = 1, skip_weight_param_allocation: bool = False, + # Accepted for signature compatibility with ColumnParallelLinear but unused at inference. + embedding_activation_buffer: Optional[list] = None, + grad_output_buffer: Optional[list] = None, tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, ): diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index d3174ed2835..a43ae68a645 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1112,7 +1112,9 @@ def test_sampled_tokens_match_with_parallelism(self, static, tp_size, pp_size): @pytest.mark.internal def test_speculative_verify_tokens(self): """Test consecutive token acceptance logic for speculative decoding.""" - self.setup_model(torch.float32, static=False, num_speculative_tokens=2, max_requests=2) + self.setup_model( + torch.float32, static=False, num_speculative_tokens=2, max_requests=2, mtp_num_layers=2 + ) # Enable speculative decoding self.text_generation_controller.num_speculative_tokens = 2 @@ -1508,7 +1510,9 @@ def test_rewind_kv_cache_does_not_release_shared_prefix_blocks(self): def test_speculative_mtp_position_ids_with_prefill(self): """Test that _compute_serial_mtp_and_sample uses the correct position IDs for a mixed batch of prefill and decode requests.""" - self.setup_model(torch.float32, static=False, num_speculative_tokens=2, max_requests=2) + self.setup_model( + torch.float32, static=False, num_speculative_tokens=2, max_requests=2, mtp_num_layers=2 + ) self.text_generation_controller.num_speculative_tokens = 2 self.text_generation_controller.num_mtp_heads = 2 From c39c8e52c87e8ae7a6ebdeb5513492c08dea30f6 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 17:36:04 -0700 Subject: [PATCH 066/124] Fix tests Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 39 +++++++++++++++++++ .../test_text_generation_controller.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py index 5b2fae4fb8c..7be473be543 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py @@ -179,6 +179,39 @@ def _set_mtp_cuda_graph_flag(model, enabled): unwrapped = unwrap_model(model) unwrapped.use_mtp_cuda_graphs = enabled + @staticmethod + def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): + """Assert that MTP CUDA graphs were (or were not) replayed. + + MTP runners are stored in the CudaGraphManager's lookup table + rather than the global inference record. A runner with + ``fwd_graph_recorded=True`` confirms the graph was captured and + replayed. + """ + unwrapped = unwrap_model(model) + manager = getattr(unwrapped, '_mtp_cudagraph_manager', None) + if manager is None: + assert not expect_replayed, "No MTP CudaGraphManager found on the model" + return + table = manager.inference_cudagraphs_lookup_table + mtp_runners = [v for k, v in table.items() if isinstance(k, tuple) and k[0] == 'mtp'] + if expect_replayed: + assert len(mtp_runners) > 0, ( + "Expected MTP CUDA graphs to be replayed, but no MTP runners found" + ) + for runner in mtp_runners: + assert runner.fwd_graph_recorded, ( + "Expected MTP CUDA graph to be recorded and replayed, " + f"but runner for {runner.base_module.__class__.__name__} " + "has fwd_graph_recorded=False" + ) + else: + recorded = [r for r in mtp_runners if r.fwd_graph_recorded] + assert len(recorded) == 0, ( + f"Expected no MTP CUDA graph replay, but {len(recorded)} " + "runners have fwd_graph_recorded=True" + ) + # ---- Test 1: graph output matches eager (no additional padding) ------- # @pytest.mark.parametrize("batch_size", [2, 4, 8]) @@ -222,6 +255,7 @@ def test_cuda_graph_output_matches_eager(self, batch_size): torch.testing.assert_close(h_graph, h_eager) torch.testing.assert_close(logits_graph, logits_eager) + self._assert_mtp_cuda_graphs_were_replayed(model, True) # ---- Test 2: graph matches eager with sequence parallelism ------------ # @@ -268,6 +302,7 @@ def test_cuda_graph_output_matches_eager_with_sp(self, batch_size): torch.testing.assert_close(h_graph, h_eager) torch.testing.assert_close(logits_graph, logits_eager) + self._assert_mtp_cuda_graphs_were_replayed(model, True) # ---- Test 3: end-to-end _compute_serial_mtp_and_sample with SP ------- # @@ -358,6 +393,7 @@ def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): # Verify decoder hidden states cache was cleaned up. assert not hasattr(unwrapped, '_decoder_hidden_states_cache') + self._assert_mtp_cuda_graphs_were_replayed(model, True) # ---- Test 4: SP padding graph vs eager produces same MTP tokens ------- # @@ -436,6 +472,7 @@ def _run_mtp(use_cuda_graph): ] ctrl._compute_serial_mtp_and_sample() + self._assert_mtp_cuda_graphs_were_replayed(model, use_cuda_graph) return [ ctrl._sampled_mtp_tokens_cuda[d, :active_request_count].clone() @@ -496,6 +533,8 @@ def test_cuda_graph_multi_depth(self): torch.isfinite(logits) ), f"Depth {depth}: logits contain non-finite values" + self._assert_mtp_cuda_graphs_were_replayed(model, True) + # ---- Test 6: eager fallback when no matching graph exists ------------- # @torch.inference_mode() diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index a43ae68a645..90510b260dd 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1543,7 +1543,7 @@ def test_speculative_mtp_position_ids_with_prefill(self): captured_position_ids = [] - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): captured_position_ids.append(position_ids.clone()) return hidden_states, torch.randn(2, 1, self.vocab_size, device='cuda') From 084e6056e901fae7f5b6eb17eb375ac252e27b94 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 18:47:22 -0700 Subject: [PATCH 067/124] Make MTP cuda graphs test engine level Signed-off-by: Keshav Santhanam --- .../test_mtp_cuda_graph_inference.py | 524 ++++++++++-------- 1 file changed, 281 insertions(+), 243 deletions(-) rename tests/unit_tests/inference/{text_generation_controllers => engines}/test_mtp_cuda_graph_inference.py (65%) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py similarity index 65% rename from tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py rename to tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 7be473be543..382316346c6 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -8,6 +8,9 @@ 2. CUDA graphs work correctly with sequence parallelism (padding is applied to make batch sizes divisible by TP). 3. CUDA graphs work correctly with expert parallelism and dummy ranks. + +Uses DynamicInferenceEngine for CUDA graph warmup so MTP graph capture +logic matches production code exactly. """ import itertools @@ -21,6 +24,7 @@ from megatron.core.inference.batch_dimensions_utils import InferenceBatchDimensions from megatron.core.inference.config import InferenceConfig from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext +from megatron.core.inference.engines.dynamic_engine import DynamicInferenceEngine from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( GPTInferenceWrapper, ) @@ -35,11 +39,7 @@ from megatron.core.tensor_parallel.mappings import scatter_to_sequence_parallel_region from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import TransformerConfig -from megatron.core.transformer.cuda_graphs import ( - _set_capture_end, - _set_capture_start, - delete_cuda_graphs, -) +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.enums import AttnBackend from megatron.core.utils import unwrap_model from tests.unit_tests.test_utilities import Utils @@ -52,7 +52,8 @@ class TestMTPCudaGraphInference: """Tests for MTP CUDA-graphed inference with tensor parallelism. - All tests require at least 2 GPUs (TP = 2). + All tests require at least 2 GPUs (TP = 2). Uses DynamicInferenceEngine + for CUDA graph warmup so MTP graph capture matches production code. """ HIDDEN_SIZE = 32 @@ -114,21 +115,23 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): model.eval() return model - def _build_controller( + def _build_engine( self, *, sequence_parallel=False, mtp_num_layers=2, num_speculative_tokens=2, - max_requests=None, + max_requests=16, ): - """Build a model, DynamicInferenceContext, and TextGenerationController.""" + """Build a DynamicInferenceEngine with automatic MTP CUDA graph warmup. + + The engine's ``__init__`` calls ``create_cuda_graphs()`` which captures + both decoder and MTP CUDA graphs, matching production warmup exactly. + """ model = self._build_model( sequence_parallel=sequence_parallel, mtp_num_layers=mtp_num_layers ) config = model.config - if max_requests is None: - max_requests = 16 context = DynamicInferenceContext( model_config=config, inference_config=InferenceConfig( @@ -140,38 +143,39 @@ def _build_controller( num_speculative_tokens=num_speculative_tokens, block_size_tokens=256, max_requests=max_requests, + num_cuda_graphs=-1, ), ) wrapped = GPTInferenceWrapper(model, context) wrapped.model_is_pipeline_parallel = False mock_tokenizer = mock.Mock() - ctrl = TextGenerationController(inference_wrapped_model=wrapped, tokenizer=mock_tokenizer) - return model, context, ctrl + ctrl = TextGenerationController( + inference_wrapped_model=wrapped, tokenizer=mock_tokenizer + ) + delete_cuda_graphs() + engine = DynamicInferenceEngine(ctrl, context) + return engine - def _warmup_mtp_graphs(self, model, batch_sizes, *, sp_enabled=False): - """Warm up MTP CUDA graphs for the given batch sizes. + @staticmethod + def _get_mtp_warmed_batch_sizes(engine): + """Return the MTP batch sizes (padded req_counts) warmed by the engine. - Replicates the warmup logic from ``DynamicEngine._warmup_mtp_cuda_graphs``. + These are the ``n`` values for which MTP CUDA graphs were captured. + Hidden states shape is ``[n // tp, 1, H]`` with SP, ``[n, 1, H]`` without. + Token/position IDs are always ``[1, n]``. """ - unwrapped = unwrap_model(model) - tp_group = parallel_state.get_tensor_model_parallel_group() - device = torch.cuda.current_device() - dtype = model.config.params_dtype - hidden_size = model.config.hidden_size - - _set_capture_start() - for bs in sorted(batch_sizes): - dummy_hidden = torch.zeros((bs, 1, hidden_size), device=device, dtype=dtype) + context = engine.context + model_config = engine.controller.inference_wrapped_model.model.config + tp_size = parallel_state.get_tensor_model_parallel_world_size() + sp_enabled = model_config.sequence_parallel and tp_size > 1 + sizes = set() + for dim in context.cuda_graph_batch_dimensions_list: + n = dim.req_count if sp_enabled: - dummy_hidden = scatter_to_sequence_parallel_region(dummy_hidden, group=tp_group) - dummy_token_ids = torch.zeros((1, bs), device=device, dtype=torch.long) - dummy_position_ids = torch.zeros((1, bs), device=device, dtype=torch.int64) - unwrapped.compute_mtp_single_step( - hidden_states=dummy_hidden, - next_token_ids=dummy_token_ids, - position_ids=dummy_position_ids, - ) - _set_capture_end() + n += (tp_size - n % tp_size) % tp_size + if n > 0: + sizes.add(n) + return sorted(sizes) @staticmethod def _set_mtp_cuda_graph_flag(model, enabled): @@ -214,238 +218,158 @@ def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): # ---- Test 1: graph output matches eager (no additional padding) ------- # - @pytest.mark.parametrize("batch_size", [2, 4, 8]) @torch.inference_mode() - def test_cuda_graph_output_matches_eager(self, batch_size): + def test_cuda_graph_output_matches_eager(self): """CUDA graph replay produces the same output as eager execution. - The batch size exactly matches a warmed-up graph, so there is no - additional padding in the CUDA graphed case. Both paths must - produce identical hidden states and logits. + The batch sizes exactly match warmed-up graphs (from the engine's + CUDA graph warmup), so there is no additional padding. Both paths + must produce identical hidden states and logits. """ - model = self._build_model() + engine = self._build_engine() + model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) - self._warmup_mtp_graphs(model, [batch_size]) + batch_sizes = self._get_mtp_warmed_batch_sizes(engine) + assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" - # Create identical random inputs on all TP ranks. - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') - dist.broadcast(hidden, src=0) - token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') - dist.broadcast(token_ids, src=0) - position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + for batch_size in batch_sizes[:3]: + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) - # Graph path. - self._set_mtp_cuda_graph_flag(model, True) - h_graph, logits_graph = unwrapped.compute_mtp_single_step( - hidden_states=hidden.clone(), - next_token_ids=token_ids.clone(), - position_ids=position_ids.clone(), - ) - # Clone immediately — CUDA graph output buffers are reused on next call. - h_graph = h_graph.clone() - logits_graph = logits_graph.clone() + self._set_mtp_cuda_graph_flag(model, True) + h_graph, logits_graph = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + ) + h_graph = h_graph.clone() + logits_graph = logits_graph.clone() - # Eager path. - self._set_mtp_cuda_graph_flag(model, False) - h_eager, logits_eager = unwrapped.compute_mtp_single_step( - hidden_states=hidden.clone(), - next_token_ids=token_ids.clone(), - position_ids=position_ids.clone(), - ) + self._set_mtp_cuda_graph_flag(model, False) + h_eager, logits_eager = unwrapped.compute_mtp_single_step( + hidden_states=hidden.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + ) + + torch.testing.assert_close( + h_graph, h_eager, msg=f"Hidden mismatch at batch_size={batch_size}" + ) + torch.testing.assert_close( + logits_graph, logits_eager, msg=f"Logits mismatch at batch_size={batch_size}" + ) - torch.testing.assert_close(h_graph, h_eager) - torch.testing.assert_close(logits_graph, logits_eager) self._assert_mtp_cuda_graphs_were_replayed(model, True) # ---- Test 2: graph matches eager with sequence parallelism ------------ # - @pytest.mark.parametrize("batch_size", [2, 4]) @torch.inference_mode() - def test_cuda_graph_output_matches_eager_with_sp(self, batch_size): + def test_cuda_graph_output_matches_eager_with_sp(self): """CUDA graph replay matches eager with sequence parallelism. Hidden states are in scattered SP format ``[batch_size/TP, 1, H]``. Token/position IDs remain at full ``[1, batch_size]``. Both paths must produce identical outputs. """ - model = self._build_model(sequence_parallel=True) + engine = self._build_engine(sequence_parallel=True) + model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) tp_group = parallel_state.get_tensor_model_parallel_group() - self._warmup_mtp_graphs(model, [batch_size], sp_enabled=True) + batch_sizes = self._get_mtp_warmed_batch_sizes(engine) + assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" - # Create random inputs; scatter hidden for SP. - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') - dist.broadcast(hidden, src=0) - hidden_sp = scatter_to_sequence_parallel_region(hidden, group=tp_group) + for batch_size in batch_sizes[:3]: + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + dist.broadcast(hidden, src=0) + hidden_sp = scatter_to_sequence_parallel_region(hidden, group=tp_group) - token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') - dist.broadcast(token_ids, src=0) - position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + dist.broadcast(token_ids, src=0) + position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) - # Graph path. - self._set_mtp_cuda_graph_flag(model, True) - h_graph, logits_graph = unwrapped.compute_mtp_single_step( - hidden_states=hidden_sp.clone(), - next_token_ids=token_ids.clone(), - position_ids=position_ids.clone(), - ) - h_graph = h_graph.clone() - logits_graph = logits_graph.clone() + self._set_mtp_cuda_graph_flag(model, True) + h_graph, logits_graph = unwrapped.compute_mtp_single_step( + hidden_states=hidden_sp.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + ) + h_graph = h_graph.clone() + logits_graph = logits_graph.clone() - # Eager path. - self._set_mtp_cuda_graph_flag(model, False) - h_eager, logits_eager = unwrapped.compute_mtp_single_step( - hidden_states=hidden_sp.clone(), - next_token_ids=token_ids.clone(), - position_ids=position_ids.clone(), - ) + self._set_mtp_cuda_graph_flag(model, False) + h_eager, logits_eager = unwrapped.compute_mtp_single_step( + hidden_states=hidden_sp.clone(), + next_token_ids=token_ids.clone(), + position_ids=position_ids.clone(), + ) + + torch.testing.assert_close( + h_graph, h_eager, msg=f"Hidden mismatch at batch_size={batch_size}" + ) + torch.testing.assert_close( + logits_graph, logits_eager, msg=f"Logits mismatch at batch_size={batch_size}" + ) - torch.testing.assert_close(h_graph, h_eager) - torch.testing.assert_close(logits_graph, logits_eager) self._assert_mtp_cuda_graphs_were_replayed(model, True) # ---- Test 3: end-to-end _compute_serial_mtp_and_sample with SP ------- # - @pytest.mark.parametrize("active_request_count", [2, 3, 4, 5]) @torch.inference_mode() - def test_cuda_graph_sp_padding_end_to_end(self, active_request_count): + def test_cuda_graph_sp_padding_end_to_end(self): """Full ``_compute_serial_mtp_and_sample`` with CUDA graphs and SP. Active request counts that are not multiples of TP are padded. - The MTP CUDA graph is pre-warmed for the padded batch size. - Verifies that padding, SP scatter/gather, and MTP forward all - work correctly through the CUDA graph path. + The engine's CUDA graph warmup pre-captures MTP graphs for the + padded batch sizes. Verifies that padding, SP scatter/gather, and + MTP forward all work correctly through the CUDA graph path. """ tp_size = self.TP_SIZE num_spec = 2 - # max_requests must accommodate the padded count. - max_requests = ((active_request_count + tp_size - 1) // tp_size) * tp_size * 2 - model, ctx, ctrl = self._build_controller( + max_requests = 16 + engine = self._build_engine( sequence_parallel=True, mtp_num_layers=num_spec, num_speculative_tokens=num_spec, max_requests=max_requests, ) + ctrl = engine.controller + context = engine.context + model = ctrl.inference_wrapped_model.model unwrapped = unwrap_model(model) - # Compute the padded batch size. - padded_count = active_request_count - padded_count += (tp_size - padded_count % tp_size) % tp_size - - # Warmup MTP CUDA graphs for the padded count. - self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) - ctrl._has_mtp_cuda_graphs = True - - # Set up context state. - ctx.total_request_count = active_request_count - ctx.paused_request_count = 0 - ctx.request_kv_length_offsets[:active_request_count] = torch.arange( - active_request_count, dtype=torch.int32, device='cuda' - ) - ctx.request_query_lengths[:active_request_count] = torch.ones( - active_request_count, dtype=torch.int32, device='cuda' - ) - - ctrl.num_speculative_tokens = num_spec - ctrl.num_mtp_heads = num_spec - ctrl._init_mtp_sampling_tensor() - # Zero out buffers allocated with torch.empty to avoid garbage values - # in padding positions causing out-of-bounds embedding lookups. - ctrl._mtp_token_ids_buf.zero_() - ctrl._mtp_position_ids_buf.zero_() - ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( - torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE - ) - - # Build decoder hidden states cache in SP format. - tp_rank = parallel_state.get_tensor_model_parallel_rank() - tp_group = parallel_state.get_tensor_model_parallel_group() - pad = (tp_size - active_request_count % tp_size) % tp_size - s_total = active_request_count + pad - - torch.manual_seed(42) - full_hidden = torch.randn(s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32) - dist.broadcast(full_hidden, src=0) - local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() - unwrapped._decoder_hidden_states_cache = local_hidden - - ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') - - # Enable CUDA graphs for MTP. - ctrl._mtp_resolved_padded_count = padded_count - self._set_mtp_cuda_graph_flag(model, True) - - # Greedy sampling: top_k=1 selects argmax deterministically. - ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] - ctrl._torch_sampling_bucket_index_tensors = [ - torch.arange(active_request_count, device='cuda', dtype=torch.long) - ] - - # Run MTP forward pass. - ctrl._compute_serial_mtp_and_sample() - - # Verify sampled MTP tokens. - for depth in range(num_spec): - sampled = ctrl._sampled_mtp_tokens_cuda[depth, :active_request_count] - assert sampled.shape == (active_request_count,) - assert sampled.dtype == torch.int64 - assert torch.all(sampled >= 0) and torch.all(sampled < self.VOCAB_SIZE) - - # Verify decoder hidden states cache was cleaned up. - assert not hasattr(unwrapped, '_decoder_hidden_states_cache') - self._assert_mtp_cuda_graphs_were_replayed(model, True) - - # ---- Test 4: SP padding graph vs eager produces same MTP tokens ------- # - - @pytest.mark.parametrize("active_request_count", [3, 5]) - @torch.inference_mode() - def test_cuda_graph_sp_padding_matches_eager(self, active_request_count): - """With SP padding, CUDA graph path produces the same MTP tokens as eager. - - Runs ``_compute_serial_mtp_and_sample`` twice — once through the - CUDA graph path and once through the eager path — with identical - inputs, and asserts the sampled MTP tokens match. - """ - tp_size = self.TP_SIZE - num_spec = 2 - padded_count = active_request_count - padded_count += (tp_size - padded_count % tp_size) % tp_size - max_requests = padded_count * 2 - - def _run_mtp(use_cuda_graph): - """Build fresh model+controller and run MTP, returning sampled tokens.""" - delete_cuda_graphs() - model, ctx, ctrl = self._build_controller( - sequence_parallel=True, - mtp_num_layers=num_spec, - num_speculative_tokens=num_spec, - max_requests=max_requests, + mtp_sizes = self._get_mtp_warmed_batch_sizes(engine) + + # Find active_request_counts whose TP-padded values match warmed MTP sizes. + active_counts = [] + for n in mtp_sizes: + for active in range(n, 0, -1): + padded = active + (tp_size - active % tp_size) % tp_size + if padded == n and active <= max_requests: + active_counts.append(active) + break + assert len(active_counts) > 0, "No valid active request counts found" + + for active_request_count in active_counts[:4]: + padded_count = ( + active_request_count + (tp_size - active_request_count % tp_size) % tp_size ) - unwrapped = unwrap_model(model) - - if use_cuda_graph: - self._warmup_mtp_graphs(model, [padded_count], sp_enabled=True) - ctrl._has_mtp_cuda_graphs = True - ctrl._mtp_resolved_padded_count = padded_count - self._set_mtp_cuda_graph_flag(model, True) - else: - ctrl._mtp_resolved_padded_count = None - self._set_mtp_cuda_graph_flag(model, False) - - ctx.total_request_count = active_request_count - ctx.paused_request_count = 0 - ctx.request_kv_length_offsets[:active_request_count] = torch.arange( + + context.reset() + context.total_request_count = active_request_count + context.paused_request_count = 0 + context.request_kv_length_offsets[:active_request_count] = torch.arange( active_request_count, dtype=torch.int32, device='cuda' ) - ctx.request_query_lengths[:active_request_count] = torch.ones( + context.request_query_lengths[:active_request_count] = torch.ones( active_request_count, dtype=torch.int32, device='cuda' ) ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec ctrl._init_mtp_sampling_tensor() - # Zero out buffers allocated with torch.empty to avoid garbage values - # in padding positions causing out-of-bounds embedding lookups. ctrl._mtp_token_ids_buf.zero_() ctrl._mtp_position_ids_buf.zero_() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( @@ -453,41 +377,147 @@ def _run_mtp(use_cuda_graph): ) tp_rank = parallel_state.get_tensor_model_parallel_rank() - tp_group = parallel_state.get_tensor_model_parallel_group() - pad = (tp_size - active_request_count % tp_size) % tp_size - s_total = active_request_count + pad torch.manual_seed(42) full_hidden = torch.randn( - s_total, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 + padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 ) dist.broadcast(full_hidden, src=0) local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() unwrapped._decoder_hidden_states_cache = local_hidden ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') + ctrl._mtp_resolved_padded_count = padded_count + self._set_mtp_cuda_graph_flag(model, True) + ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] ctrl._torch_sampling_bucket_index_tensors = [ torch.arange(active_request_count, device='cuda', dtype=torch.long) ] ctrl._compute_serial_mtp_and_sample() - self._assert_mtp_cuda_graphs_were_replayed(model, use_cuda_graph) - return [ - ctrl._sampled_mtp_tokens_cuda[d, :active_request_count].clone() - for d in range(num_spec) - ] + for depth in range(num_spec): + sampled = ctrl._sampled_mtp_tokens_cuda[depth, :active_request_count] + assert sampled.shape == (active_request_count,), ( + f"active={active_request_count}, depth={depth}" + ) + assert sampled.dtype == torch.int64 + assert torch.all(sampled >= 0) and torch.all(sampled < self.VOCAB_SIZE) + + assert not hasattr(unwrapped, '_decoder_hidden_states_cache') - graph_tokens = _run_mtp(use_cuda_graph=True) - eager_tokens = _run_mtp(use_cuda_graph=False) + self._assert_mtp_cuda_graphs_were_replayed(model, True) + + # ---- Test 4: SP padding graph vs eager produces same MTP tokens ------- # - for depth in range(num_spec): - assert torch.equal(graph_tokens[depth], eager_tokens[depth]), ( - f"Depth {depth}: graph tokens {graph_tokens[depth].tolist()} != " - f"eager tokens {eager_tokens[depth].tolist()}" + @torch.inference_mode() + def test_cuda_graph_sp_padding_matches_eager(self): + """With SP padding, CUDA graph path produces the same MTP tokens as eager. + + Uses a single engine (shared model weights) and toggles the CUDA + graph flag between runs. Both paths receive identical inputs and + must produce the same sampled MTP tokens. + """ + tp_size = self.TP_SIZE + num_spec = 2 + max_requests = 16 + engine = self._build_engine( + sequence_parallel=True, + mtp_num_layers=num_spec, + num_speculative_tokens=num_spec, + max_requests=max_requests, + ) + ctrl = engine.controller + context = engine.context + model = ctrl.inference_wrapped_model.model + + mtp_sizes = self._get_mtp_warmed_batch_sizes(engine) + + # Find active counts that require TP padding (active % tp != 0). + active_counts = [] + for n in mtp_sizes: + for active in range(n, 0, -1): + padded = active + (tp_size - active % tp_size) % tp_size + if padded == n and active % tp_size != 0 and active <= max_requests: + active_counts.append(active) + break + assert len(active_counts) > 0, "No active counts with TP padding found" + + for active_request_count in active_counts[:2]: + padded_count = ( + active_request_count + (tp_size - active_request_count % tp_size) % tp_size ) + def _run_mtp(use_cuda_graph): + """Set up state and run MTP, returning sampled tokens.""" + unwrapped = unwrap_model(model) + context.reset() + context.total_request_count = active_request_count + context.paused_request_count = 0 + context.request_kv_length_offsets[:active_request_count] = torch.arange( + active_request_count, dtype=torch.int32, device='cuda' + ) + context.request_query_lengths[:active_request_count] = torch.ones( + active_request_count, dtype=torch.int32, device='cuda' + ) + + ctrl.num_speculative_tokens = num_spec + ctrl.num_mtp_heads = num_spec + ctrl._init_mtp_sampling_tensor() + ctrl._mtp_token_ids_buf.zero_() + ctrl._mtp_position_ids_buf.zero_() + ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( + torch.arange(active_request_count, device='cuda'), self.VOCAB_SIZE + ) + + if use_cuda_graph: + ctrl._has_mtp_cuda_graphs = True + ctrl._mtp_resolved_padded_count = padded_count + self._set_mtp_cuda_graph_flag(model, True) + else: + ctrl._has_mtp_cuda_graphs = False + ctrl._mtp_resolved_padded_count = None + self._set_mtp_cuda_graph_flag(model, False) + + tp_rank = parallel_state.get_tensor_model_parallel_rank() + + torch.manual_seed(42) + full_hidden = torch.randn( + padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 + ) + dist.broadcast(full_hidden, src=0) + local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() + unwrapped._decoder_hidden_states_cache = local_hidden + + ctrl._last_accepted_seq_indices = torch.arange( + active_request_count, device='cuda' + ) + ctrl._torch_sampling_buckets = [ + (list(range(active_request_count)), 1.0, 1, 0.0) + ] + ctrl._torch_sampling_bucket_index_tensors = [ + torch.arange(active_request_count, device='cuda', dtype=torch.long) + ] + + ctrl._compute_serial_mtp_and_sample() + self._assert_mtp_cuda_graphs_were_replayed(model, use_cuda_graph) + + return [ + ctrl._sampled_mtp_tokens_cuda[d, :active_request_count].clone() + for d in range(num_spec) + ] + + graph_tokens = _run_mtp(use_cuda_graph=True) + eager_tokens = _run_mtp(use_cuda_graph=False) + + for depth in range(num_spec): + assert torch.equal(graph_tokens[depth], eager_tokens[depth]), ( + f"active={active_request_count}, depth={depth}: " + f"graph tokens {graph_tokens[depth].tolist()} != " + f"eager tokens {eager_tokens[depth].tolist()}" + ) + # ---- Test 5: multiple MTP depths with CUDA graphs --------------------- # @torch.inference_mode() @@ -498,11 +528,14 @@ def test_cuda_graph_multi_depth(self): the next depth through the same CUDA graph, producing valid outputs at every depth. """ - batch_size = 4 num_depths = 2 - model = self._build_model(mtp_num_layers=num_depths) + engine = self._build_engine(mtp_num_layers=num_depths) + model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) - self._warmup_mtp_graphs(model, [batch_size]) + batch_sizes = self._get_mtp_warmed_batch_sizes(engine) + assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" + + batch_size = batch_sizes[0] self._set_mtp_cuda_graph_flag(model, True) hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') @@ -518,7 +551,6 @@ def test_cuda_graph_multi_depth(self): next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), ) - # Clone — graph output buffers are reused. current_hidden = current_hidden.clone() assert current_hidden.shape == (batch_size, 1, self.HIDDEN_SIZE), ( @@ -540,22 +572,28 @@ def test_cuda_graph_multi_depth(self): @torch.inference_mode() def test_eager_fallback_no_matching_graph(self): """When ``use_mtp_cuda_graphs`` is True but no warmed graph matches the - batch size, ``forward_single_position`` falls back to eager execution. + batch size, ``compute_mtp_single_step`` falls back to eager execution. The system should produce valid outputs without errors. """ - model = self._build_model() + engine = self._build_engine() + model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) - # Warmup for batch_size=4 only. - self._warmup_mtp_graphs(model, [4]) - self._set_mtp_cuda_graph_flag(model, True) + warmed_sizes = set(self._get_mtp_warmed_batch_sizes(engine)) - # Run with batch_size=6 — no matching graph exists. - batch_size = 6 - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + # Find a batch size with no matching CUDA graph. + fallback_size = None + for candidate in range(1, 32): + if candidate not in warmed_sizes: + fallback_size = candidate + break + assert fallback_size is not None, "Could not find a non-warmed batch size" + + self._set_mtp_cuda_graph_flag(model, True) + hidden = torch.randn(fallback_size, 1, self.HIDDEN_SIZE, device='cuda') dist.broadcast(hidden, src=0) - token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') + token_ids = torch.randint(0, self.VOCAB_SIZE, (1, fallback_size), device='cuda') dist.broadcast(token_ids, src=0) - position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) + position_ids = torch.arange(fallback_size, device='cuda', dtype=torch.int64).unsqueeze(0) h_out, logits = unwrapped.compute_mtp_single_step( hidden_states=hidden.clone(), @@ -563,8 +601,8 @@ def test_eager_fallback_no_matching_graph(self): position_ids=position_ids.clone(), ) - assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) - assert logits.shape == (batch_size, 1, self.VOCAB_SIZE) + assert h_out.shape == (fallback_size, 1, self.HIDDEN_SIZE) + assert logits.shape == (fallback_size, 1, self.VOCAB_SIZE) assert torch.all(torch.isfinite(logits)) # ---- Test 7: graph flag propagation matches main model ---------------- # From 432722d9393d9b53a04af7148c5d17ed856a3118 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 18:50:21 -0700 Subject: [PATCH 068/124] Fix dtype Signed-off-by: Keshav Santhanam --- .../inference/engines/test_mtp_cuda_graph_inference.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 382316346c6..047796c3034 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -90,10 +90,10 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): num_attention_heads=self.NUM_ATTN_HEADS, use_cpu_initialization=True, attention_backend=AttnBackend.local, - params_dtype=torch.float32, + params_dtype=torch.bfloat16, tensor_model_parallel_size=self.TP_SIZE, pipeline_model_parallel_size=1, - pipeline_dtype=torch.float32, + pipeline_dtype=torch.bfloat16, mtp_num_layers=mtp_num_layers, sequence_parallel=sequence_parallel, cuda_graph_impl="local", @@ -380,7 +380,7 @@ def test_cuda_graph_sp_padding_end_to_end(self): torch.manual_seed(42) full_hidden = torch.randn( - padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 + padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 ) dist.broadcast(full_hidden, src=0) local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() @@ -484,7 +484,7 @@ def _run_mtp(use_cuda_graph): torch.manual_seed(42) full_hidden = torch.randn( - padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.float32 + padded_count, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 ) dist.broadcast(full_hidden, src=0) local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() @@ -693,7 +693,7 @@ def _build_model(self): num_attention_heads=self.NUM_ATTN_HEADS, use_cpu_initialization=True, attention_backend=AttnBackend.local, - params_dtype=torch.float32, + params_dtype=torch.bfloat16, expert_model_parallel_size=_EP_SIZE, num_moe_experts=self.NUM_MOE_EXPERTS, moe_token_dispatcher_type="alltoall", From 1a59fccaf234c027dc7ed6772bdf94f82011e988 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 18:53:15 -0700 Subject: [PATCH 069/124] Cast model to params_dtype Signed-off-by: Keshav Santhanam --- .../inference/engines/test_mtp_cuda_graph_inference.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 047796c3034..61195a223df 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -112,6 +112,7 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): post_process=True, mtp_block_spec=mtp_block_spec, ).cuda() + model = model.to(config.params_dtype) model.eval() return model @@ -716,6 +717,7 @@ def _build_model(self): post_process=True, mtp_block_spec=mtp_block_spec, ).cuda() + model = model.to(config.params_dtype) model.eval() return model From 2f8e0c8a0daf9b4d4f39aad7c9e2e82e03b34489 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 18:57:10 -0700 Subject: [PATCH 070/124] Fixes Signed-off-by: Keshav Santhanam --- .../inference/engines/test_mtp_cuda_graph_inference.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 61195a223df..5a02c7d4cbb 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -136,11 +136,9 @@ def _build_engine( context = DynamicInferenceContext( model_config=config, inference_config=InferenceConfig( - max_sequence_length=self.MAX_SEQ_LEN * 2, - buffer_size_gb=0.2, + max_sequence_length=self.MAX_SEQ_LEN, + buffer_size_gb=0.5, materialize_only_last_token_logits=False, - use_flashinfer_fused_rope=None, - unified_memory_level=0, num_speculative_tokens=num_speculative_tokens, block_size_tokens=256, max_requests=max_requests, From 57771f9e9de809c8418883baa28ff780866c394c Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:12:11 -0700 Subject: [PATCH 071/124] Try again to fix tests Signed-off-by: Keshav Santhanam --- megatron/core/inference/engines/dynamic_engine.py | 7 ++++--- .../core/models/common/language_module/language_module.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index a8113a2abcc..5e51e42be33 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -415,16 +415,17 @@ def create_cuda_graphs(self, reset_context: bool = True): mtp_seen_batch_sizes.add(n) device = torch.cuda.current_device() batch_dim = n // tp_size if sp_enabled else n + # Use zeros (not empty) — garbage token IDs cause OOB embedding lookups during graph capture/replay. for depth in mtp_warmup_depths: with graph_capture(): unwrapped.compute_mtp_single_step( - hidden_states=torch.empty( + hidden_states=torch.zeros( (batch_dim, 1, model_config.hidden_size), device=device, dtype=model_config.params_dtype, ), - next_token_ids=torch.empty((1, n), device=device, dtype=torch.long), - position_ids=torch.empty((1, n), device=device, dtype=torch.int64), + next_token_ids=torch.zeros((1, n), device=device, dtype=torch.long), + position_ids=torch.zeros((1, n), device=device, dtype=torch.int64), depth=depth, ) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 84f4cdf5c31..e29b4271467 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -68,7 +68,7 @@ def _setup_mtp_cuda_graphs(self): Must be called by subclasses after ``self.mtp`` is created. """ - if self.config.cuda_graph_impl == "local" and not self.config.cuda_graph_scope: + if self.config.cuda_graph_impl == "local": from megatron.core.transformer.cuda_graphs import CudaGraphManager self._mtp_cudagraph_manager = CudaGraphManager( From 314a2eee01a3cff5f3ff251318769ee0e51a818f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:21:33 -0700 Subject: [PATCH 072/124] Fix 0 token count graph error Signed-off-by: Keshav Santhanam --- megatron/core/inference/batch_dimensions_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/megatron/core/inference/batch_dimensions_utils.py b/megatron/core/inference/batch_dimensions_utils.py index 4e23151c533..2d54ae4090f 100644 --- a/megatron/core/inference/batch_dimensions_utils.py +++ b/megatron/core/inference/batch_dimensions_utils.py @@ -85,6 +85,10 @@ def is_valid( Returns: True if the config is valid, False otherwise """ + # A dimension with no tokens serves no requests. + if self.token_count <= 0: + return False + # Check if total requests exceed maximum if self.prefill_req_count + self.decode_req_count > max_requests: return False From f0a87e058b09de288310bf84041f39fe1cb72198 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:39:06 -0700 Subject: [PATCH 073/124] Fix casting Signed-off-by: Keshav Santhanam --- .../inference/engines/test_mtp_cuda_graph_inference.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 5a02c7d4cbb..76ec077f58b 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -112,7 +112,8 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): post_process=True, mtp_block_spec=mtp_block_spec, ).cuda() - model = model.to(config.params_dtype) + for param in model.parameters(): + param.data = param.data.to(config.params_dtype) model.eval() return model @@ -715,7 +716,8 @@ def _build_model(self): post_process=True, mtp_block_spec=mtp_block_spec, ).cuda() - model = model.to(config.params_dtype) + for param in model.parameters(): + param.data = param.data.to(config.params_dtype) model.eval() return model From 168028b2d7b0a494e3a52c6ef4de0b8b3b0c1d54 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:45:01 -0700 Subject: [PATCH 074/124] Fix input dtype Signed-off-by: Keshav Santhanam --- .../engines/test_mtp_cuda_graph_inference.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 76ec077f58b..829d8cc76b0 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -233,7 +233,7 @@ def test_cuda_graph_output_matches_eager(self): assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" for batch_size in batch_sizes[:3]: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -282,7 +282,7 @@ def test_cuda_graph_output_matches_eager_with_sp(self): assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" for batch_size in batch_sizes[:3]: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) hidden_sp = scatter_to_sequence_parallel_region(hidden, group=tp_group) @@ -538,7 +538,7 @@ def test_cuda_graph_multi_depth(self): batch_size = batch_sizes[0] self._set_mtp_cuda_graph_flag(model, True) - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -589,7 +589,7 @@ def test_eager_fallback_no_matching_graph(self): assert fallback_size is not None, "Could not find a non-warmed batch size" self._set_mtp_cuda_graph_flag(model, True) - hidden = torch.randn(fallback_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(fallback_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, fallback_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -758,7 +758,7 @@ def test_ep_mtp_eager_forward(self, batch_size): unwrapped = unwrap_model(model) # Broadcast identical inputs so all EP ranks see the same data. - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -792,10 +792,10 @@ def test_ep_mtp_eager_dummy_and_real_ranks(self): is_dummy = ep_rank % 2 == 0 if is_dummy: - hidden = torch.zeros(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.zeros(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) token_ids = torch.zeros(1, batch_size, device='cuda', dtype=torch.long) else: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda') + hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) @@ -910,7 +910,7 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): unwrapped = unwrap_model(model) tp_size = parallel_state.get_tensor_model_parallel_world_size() - dummy_hidden = torch.zeros((tp_size, 1, self.HIDDEN_SIZE), device='cuda') + dummy_hidden = torch.zeros((tp_size, 1, self.HIDDEN_SIZE), device='cuda', dtype=torch.bfloat16) dummy_tokens = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) From 3d61c13196f9e4027fbb9f72fd08ca224a311c5f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:53:09 -0700 Subject: [PATCH 075/124] Add depth Signed-off-by: Keshav Santhanam --- .../engines/test_mtp_cuda_graph_inference.py | 65 +++++++++++++++---- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 829d8cc76b0..e0d8cd5c8c2 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -81,7 +81,9 @@ def teardown_method(self): # ---- helpers ---------------------------------------------------------- # - def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): + def _build_model( + self, *, sequence_parallel=False, mtp_num_layers=2, mtp_use_repeated_layer=False + ): """Build a GPT model with MTP layers and local CUDA graph support.""" model_parallel_cuda_manual_seed(123, inference_rng_tracker=True, force_reset_rng=True) config = TransformerConfig( @@ -95,6 +97,7 @@ def _build_model(self, *, sequence_parallel=False, mtp_num_layers=2): pipeline_model_parallel_size=1, pipeline_dtype=torch.bfloat16, mtp_num_layers=mtp_num_layers, + mtp_use_repeated_layer=mtp_use_repeated_layer, sequence_parallel=sequence_parallel, cuda_graph_impl="local", ) @@ -122,6 +125,7 @@ def _build_engine( *, sequence_parallel=False, mtp_num_layers=2, + mtp_use_repeated_layer=False, num_speculative_tokens=2, max_requests=16, ): @@ -131,7 +135,9 @@ def _build_engine( both decoder and MTP CUDA graphs, matching production warmup exactly. """ model = self._build_model( - sequence_parallel=sequence_parallel, mtp_num_layers=mtp_num_layers + sequence_parallel=sequence_parallel, + mtp_num_layers=mtp_num_layers, + mtp_use_repeated_layer=mtp_use_repeated_layer, ) config = model.config context = DynamicInferenceContext( @@ -218,20 +224,23 @@ def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): # ---- Test 1: graph output matches eager (no additional padding) ------- # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_cuda_graph_output_matches_eager(self): + def test_cuda_graph_output_matches_eager(self, mtp_use_repeated_layer): """CUDA graph replay produces the same output as eager execution. The batch sizes exactly match warmed-up graphs (from the engine's CUDA graph warmup), so there is no additional padding. Both paths must produce identical hidden states and logits. """ - engine = self._build_engine() + engine = self._build_engine(mtp_use_repeated_layer=mtp_use_repeated_layer) model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) batch_sizes = self._get_mtp_warmed_batch_sizes(engine) assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" + mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 + for batch_size in batch_sizes[:3]: hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) @@ -244,6 +253,7 @@ def test_cuda_graph_output_matches_eager(self): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) h_graph = h_graph.clone() logits_graph = logits_graph.clone() @@ -253,6 +263,7 @@ def test_cuda_graph_output_matches_eager(self): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) torch.testing.assert_close( @@ -266,21 +277,26 @@ def test_cuda_graph_output_matches_eager(self): # ---- Test 2: graph matches eager with sequence parallelism ------------ # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_cuda_graph_output_matches_eager_with_sp(self): + def test_cuda_graph_output_matches_eager_with_sp(self, mtp_use_repeated_layer): """CUDA graph replay matches eager with sequence parallelism. Hidden states are in scattered SP format ``[batch_size/TP, 1, H]``. Token/position IDs remain at full ``[1, batch_size]``. Both paths must produce identical outputs. """ - engine = self._build_engine(sequence_parallel=True) + engine = self._build_engine( + sequence_parallel=True, mtp_use_repeated_layer=mtp_use_repeated_layer + ) model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) tp_group = parallel_state.get_tensor_model_parallel_group() batch_sizes = self._get_mtp_warmed_batch_sizes(engine) assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" + mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 + for batch_size in batch_sizes[:3]: hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) @@ -295,6 +311,7 @@ def test_cuda_graph_output_matches_eager_with_sp(self): hidden_states=hidden_sp.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) h_graph = h_graph.clone() logits_graph = logits_graph.clone() @@ -304,6 +321,7 @@ def test_cuda_graph_output_matches_eager_with_sp(self): hidden_states=hidden_sp.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) torch.testing.assert_close( @@ -317,8 +335,9 @@ def test_cuda_graph_output_matches_eager_with_sp(self): # ---- Test 3: end-to-end _compute_serial_mtp_and_sample with SP ------- # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_cuda_graph_sp_padding_end_to_end(self): + def test_cuda_graph_sp_padding_end_to_end(self, mtp_use_repeated_layer): """Full ``_compute_serial_mtp_and_sample`` with CUDA graphs and SP. Active request counts that are not multiples of TP are padded. @@ -332,6 +351,7 @@ def test_cuda_graph_sp_padding_end_to_end(self): engine = self._build_engine( sequence_parallel=True, mtp_num_layers=num_spec, + mtp_use_repeated_layer=mtp_use_repeated_layer, num_speculative_tokens=num_spec, max_requests=max_requests, ) @@ -411,8 +431,9 @@ def test_cuda_graph_sp_padding_end_to_end(self): # ---- Test 4: SP padding graph vs eager produces same MTP tokens ------- # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_cuda_graph_sp_padding_matches_eager(self): + def test_cuda_graph_sp_padding_matches_eager(self, mtp_use_repeated_layer): """With SP padding, CUDA graph path produces the same MTP tokens as eager. Uses a single engine (shared model weights) and toggles the CUDA @@ -425,6 +446,7 @@ def test_cuda_graph_sp_padding_matches_eager(self): engine = self._build_engine( sequence_parallel=True, mtp_num_layers=num_spec, + mtp_use_repeated_layer=mtp_use_repeated_layer, num_speculative_tokens=num_spec, max_requests=max_requests, ) @@ -520,8 +542,9 @@ def _run_mtp(use_cuda_graph): # ---- Test 5: multiple MTP depths with CUDA graphs --------------------- # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_cuda_graph_multi_depth(self): + def test_cuda_graph_multi_depth(self, mtp_use_repeated_layer): """Run multiple MTP depths with CUDA graphs enabled. Verifies that the hidden output from one depth feeds correctly into @@ -529,12 +552,16 @@ def test_cuda_graph_multi_depth(self): at every depth. """ num_depths = 2 - engine = self._build_engine(mtp_num_layers=num_depths) + engine = self._build_engine( + mtp_num_layers=num_depths, mtp_use_repeated_layer=mtp_use_repeated_layer + ) model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) batch_sizes = self._get_mtp_warmed_batch_sizes(engine) assert len(batch_sizes) > 0, "Engine did not warm up any MTP CUDA graphs" + use_repeated = unwrapped.mtp.mtp_use_repeated_layer + batch_size = batch_sizes[0] self._set_mtp_cuda_graph_flag(model, True) @@ -546,10 +573,12 @@ def test_cuda_graph_multi_depth(self): current_hidden = hidden.clone() for depth in range(num_depths): + mtp_depth = None if use_repeated else depth current_hidden, logits = unwrapped.compute_mtp_single_step( hidden_states=current_hidden, next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) current_hidden = current_hidden.clone() @@ -569,13 +598,14 @@ def test_cuda_graph_multi_depth(self): # ---- Test 6: eager fallback when no matching graph exists ------------- # + @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() - def test_eager_fallback_no_matching_graph(self): + def test_eager_fallback_no_matching_graph(self, mtp_use_repeated_layer): """When ``use_mtp_cuda_graphs`` is True but no warmed graph matches the batch size, ``compute_mtp_single_step`` falls back to eager execution. The system should produce valid outputs without errors. """ - engine = self._build_engine() + engine = self._build_engine(mtp_use_repeated_layer=mtp_use_repeated_layer) model = engine.controller.inference_wrapped_model.model unwrapped = unwrap_model(model) warmed_sizes = set(self._get_mtp_warmed_batch_sizes(engine)) @@ -588,6 +618,8 @@ def test_eager_fallback_no_matching_graph(self): break assert fallback_size is not None, "Could not find a non-warmed batch size" + mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 + self._set_mtp_cuda_graph_flag(model, True) hidden = torch.randn(fallback_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) dist.broadcast(hidden, src=0) @@ -599,6 +631,7 @@ def test_eager_fallback_no_matching_graph(self): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=mtp_depth, ) assert h_out.shape == (fallback_size, 1, self.HIDDEN_SIZE) @@ -768,6 +801,7 @@ def test_ep_mtp_eager_forward(self, batch_size): hidden_states=hidden.clone(), next_token_ids=token_ids.clone(), position_ids=position_ids.clone(), + depth=0, ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -801,7 +835,7 @@ def test_ep_mtp_eager_dummy_and_real_ranks(self): # All ranks must complete without hanging. h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids + hidden_states=hidden, next_token_ids=token_ids, position_ids=position_ids, depth=0 ) assert h_out.shape == (batch_size, 1, self.HIDDEN_SIZE) @@ -915,7 +949,10 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) h_out, logits = unwrapped.compute_mtp_single_step( - hidden_states=dummy_hidden, next_token_ids=dummy_tokens, position_ids=dummy_positions + hidden_states=dummy_hidden, + next_token_ids=dummy_tokens, + position_ids=dummy_positions, + depth=0, ) assert h_out.shape == (tp_size, 1, self.HIDDEN_SIZE) From 43176d1ca7ca14c6d2519f712d17909d629d29c3 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:56:06 -0700 Subject: [PATCH 076/124] Delete cuda graphs for eager Signed-off-by: Keshav Santhanam --- .../inference/engines/test_mtp_cuda_graph_inference.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index e0d8cd5c8c2..e015415413c 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -531,6 +531,7 @@ def _run_mtp(use_cuda_graph): ] graph_tokens = _run_mtp(use_cuda_graph=True) + delete_cuda_graphs() eager_tokens = _run_mtp(use_cuda_graph=False) for depth in range(num_spec): From d76da599c433be739ae9550de40a2990dbc574da Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 19:58:45 -0700 Subject: [PATCH 077/124] Fix delete cuda graphs Signed-off-by: Keshav Santhanam --- .../engines/test_mtp_cuda_graph_inference.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index e015415413c..71f6448c311 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -189,6 +189,23 @@ def _set_mtp_cuda_graph_flag(model, enabled): unwrapped = unwrap_model(model) unwrapped.use_mtp_cuda_graphs = enabled + @staticmethod + def _delete_mtp_cuda_graphs(model): + """Reset MTP CUDA graph runners on the model. + + MTP runners are excluded from the global record (``if not self.is_mtp`` + guard in CudaGraphManager), so ``delete_cuda_graphs()`` does not touch + them. This helper resets them directly. + """ + unwrapped = unwrap_model(model) + mgr = getattr(unwrapped, '_mtp_cudagraph_manager', None) + if mgr is None: + return + for runner in mgr.inference_cudagraphs_lookup_table.values(): + if runner is not None: + runner.fwd_graph_recorded = False + runner.fwd_graph = None + @staticmethod def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): """Assert that MTP CUDA graphs were (or were not) replayed. @@ -531,7 +548,7 @@ def _run_mtp(use_cuda_graph): ] graph_tokens = _run_mtp(use_cuda_graph=True) - delete_cuda_graphs() + self._delete_mtp_cuda_graphs(model) eager_tokens = _run_mtp(use_cuda_graph=False) for depth in range(num_spec): From 248d7618ea933a7e579aa45c8a15c0f1144dae01 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 20:04:07 -0700 Subject: [PATCH 078/124] Fix deletion path Signed-off-by: Keshav Santhanam --- .../common/language_module/language_module.py | 6 ++- megatron/core/transformer/cuda_graphs.py | 11 +++++ .../engines/test_mtp_cuda_graph_inference.py | 42 ++++++++++--------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index e29b4271467..12aa2d46de0 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -69,7 +69,10 @@ def _setup_mtp_cuda_graphs(self): Must be called by subclasses after ``self.mtp`` is created. """ if self.config.cuda_graph_impl == "local": - from megatron.core.transformer.cuda_graphs import CudaGraphManager + from megatron.core.transformer.cuda_graphs import ( + CudaGraphManager, + _CudagraphGlobalRecord, + ) self._mtp_cudagraph_manager = CudaGraphManager( self.config, @@ -77,6 +80,7 @@ def _setup_mtp_cuda_graphs(self): function_name="compute_mtp_single_step", need_backward=False, ) + _CudagraphGlobalRecord.mtp_cudagraph_managers.append(self._mtp_cudagraph_manager) def _is_in_embd_group(self): if self.embd_group is None: diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 4dd8ccb44ea..fd8af37e15d 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -344,6 +344,7 @@ class _CudagraphGlobalRecord: 'record_bwd_graph.""" cudagraph_record: list[tuple] = [] cudagraph_inference_record: list[tuple] = [] + mtp_cudagraph_managers: list = [] """A pool-like data structure to reuse input and output buffers across cudagraph.""" tensor_reuse_pool = TensorReusePool() @@ -520,6 +521,16 @@ def delete_cuda_graphs(): runner.bwd_graph = None runner.mempool = None + # Reset MTP runners (excluded from the global inference record). + for mgr in _CudagraphGlobalRecord.mtp_cudagraph_managers: + for runner in mgr.inference_cudagraphs_lookup_table.values(): + if runner is not None: + runner.cudagraph_created = False + runner.fwd_graph_recorded = False + runner.fwd_graph = None + runner.mempool = None + mgr.inference_cudagraphs_lookup_table.clear() + # Reset global tracking state _CudagraphGlobalRecord.cudagraph_created = False _CudagraphGlobalRecord.cudagraph_record = [] diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 71f6448c311..e6b3909dccb 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -189,23 +189,6 @@ def _set_mtp_cuda_graph_flag(model, enabled): unwrapped = unwrap_model(model) unwrapped.use_mtp_cuda_graphs = enabled - @staticmethod - def _delete_mtp_cuda_graphs(model): - """Reset MTP CUDA graph runners on the model. - - MTP runners are excluded from the global record (``if not self.is_mtp`` - guard in CudaGraphManager), so ``delete_cuda_graphs()`` does not touch - them. This helper resets them directly. - """ - unwrapped = unwrap_model(model) - mgr = getattr(unwrapped, '_mtp_cudagraph_manager', None) - if mgr is None: - return - for runner in mgr.inference_cudagraphs_lookup_table.values(): - if runner is not None: - runner.fwd_graph_recorded = False - runner.fwd_graph = None - @staticmethod def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): """Assert that MTP CUDA graphs were (or were not) replayed. @@ -540,7 +523,6 @@ def _run_mtp(use_cuda_graph): ] ctrl._compute_serial_mtp_and_sample() - self._assert_mtp_cuda_graphs_were_replayed(model, use_cuda_graph) return [ ctrl._sampled_mtp_tokens_cuda[d, :active_request_count].clone() @@ -548,7 +530,7 @@ def _run_mtp(use_cuda_graph): ] graph_tokens = _run_mtp(use_cuda_graph=True) - self._delete_mtp_cuda_graphs(model) + self._assert_mtp_cuda_graphs_were_replayed(model, True) eager_tokens = _run_mtp(use_cuda_graph=False) for depth in range(num_spec): @@ -670,6 +652,28 @@ def test_mtp_graph_flag_propagation(self): self._set_mtp_cuda_graph_flag(model, False) assert unwrapped.use_mtp_cuda_graphs is False + # ---- Test 8: delete_cuda_graphs resets MTP runners -------------------- # + + @torch.inference_mode() + def test_delete_cuda_graphs_resets_mtp_runners(self): + """``delete_cuda_graphs()`` resets MTP CUDA graph runners. + + MTP runners are excluded from the global inference record, so they + require special handling in ``delete_cuda_graphs()``. After deletion, + no MTP runners should have ``fwd_graph_recorded=True``. + """ + engine = self._build_engine() + model = engine.controller.inference_wrapped_model.model + + self._assert_mtp_cuda_graphs_were_replayed(model, True) + + delete_cuda_graphs() + + unwrapped = unwrap_model(model) + manager = getattr(unwrapped, '_mtp_cudagraph_manager', None) + assert manager is not None + assert len(manager.inference_cudagraphs_lookup_table) == 0 + # --------------------------------------------------------------------------- # # TestMTPCudaGraphExpertParallel (EP = 2) From 187b00d526678a1cd65863469e69d6cdf978b7de Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 20:05:21 -0700 Subject: [PATCH 079/124] Linting Signed-off-by: Keshav Santhanam --- .../engines/test_mtp_cuda_graph_inference.py | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index e6b3909dccb..064d88a2bd0 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -155,9 +155,7 @@ def _build_engine( wrapped = GPTInferenceWrapper(model, context) wrapped.model_is_pipeline_parallel = False mock_tokenizer = mock.Mock() - ctrl = TextGenerationController( - inference_wrapped_model=wrapped, tokenizer=mock_tokenizer - ) + ctrl = TextGenerationController(inference_wrapped_model=wrapped, tokenizer=mock_tokenizer) delete_cuda_graphs() engine = DynamicInferenceEngine(ctrl, context) return engine @@ -206,9 +204,9 @@ def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): table = manager.inference_cudagraphs_lookup_table mtp_runners = [v for k, v in table.items() if isinstance(k, tuple) and k[0] == 'mtp'] if expect_replayed: - assert len(mtp_runners) > 0, ( - "Expected MTP CUDA graphs to be replayed, but no MTP runners found" - ) + assert ( + len(mtp_runners) > 0 + ), "Expected MTP CUDA graphs to be replayed, but no MTP runners found" for runner in mtp_runners: assert runner.fwd_graph_recorded, ( "Expected MTP CUDA graph to be recorded and replayed, " @@ -242,7 +240,9 @@ def test_cuda_graph_output_matches_eager(self, mtp_use_repeated_layer): mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 for batch_size in batch_sizes[:3]: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) + hidden = torch.randn( + batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 + ) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -298,7 +298,9 @@ def test_cuda_graph_output_matches_eager_with_sp(self, mtp_use_repeated_layer): mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 for batch_size in batch_sizes[:3]: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) + hidden = torch.randn( + batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 + ) dist.broadcast(hidden, src=0) hidden_sp = scatter_to_sequence_parallel_region(hidden, group=tp_group) @@ -419,9 +421,9 @@ def test_cuda_graph_sp_padding_end_to_end(self, mtp_use_repeated_layer): for depth in range(num_spec): sampled = ctrl._sampled_mtp_tokens_cuda[depth, :active_request_count] - assert sampled.shape == (active_request_count,), ( - f"active={active_request_count}, depth={depth}" - ) + assert sampled.shape == ( + active_request_count, + ), f"active={active_request_count}, depth={depth}" assert sampled.dtype == torch.int64 assert torch.all(sampled >= 0) and torch.all(sampled < self.VOCAB_SIZE) @@ -512,12 +514,8 @@ def _run_mtp(use_cuda_graph): local_hidden = full_hidden.chunk(tp_size)[tp_rank].contiguous() unwrapped._decoder_hidden_states_cache = local_hidden - ctrl._last_accepted_seq_indices = torch.arange( - active_request_count, device='cuda' - ) - ctrl._torch_sampling_buckets = [ - (list(range(active_request_count)), 1.0, 1, 0.0) - ] + ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') + ctrl._torch_sampling_buckets = [(list(range(active_request_count)), 1.0, 1, 0.0)] ctrl._torch_sampling_bucket_index_tensors = [ torch.arange(active_request_count, device='cuda', dtype=torch.long) ] @@ -621,7 +619,9 @@ def test_eager_fallback_no_matching_graph(self, mtp_use_repeated_layer): mtp_depth = None if unwrapped.mtp.mtp_use_repeated_layer else 0 self._set_mtp_cuda_graph_flag(model, True) - hidden = torch.randn(fallback_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) + hidden = torch.randn( + fallback_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 + ) dist.broadcast(hidden, src=0) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, fallback_size), device='cuda') dist.broadcast(token_ids, src=0) @@ -848,10 +848,14 @@ def test_ep_mtp_eager_dummy_and_real_ranks(self): is_dummy = ep_rank % 2 == 0 if is_dummy: - hidden = torch.zeros(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) + hidden = torch.zeros( + batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 + ) token_ids = torch.zeros(1, batch_size, device='cuda', dtype=torch.long) else: - hidden = torch.randn(batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16) + hidden = torch.randn( + batch_size, 1, self.HIDDEN_SIZE, device='cuda', dtype=torch.bfloat16 + ) token_ids = torch.randint(0, self.VOCAB_SIZE, (1, batch_size), device='cuda') position_ids = torch.arange(batch_size, device='cuda', dtype=torch.int64).unsqueeze(0) @@ -966,7 +970,9 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): unwrapped = unwrap_model(model) tp_size = parallel_state.get_tensor_model_parallel_world_size() - dummy_hidden = torch.zeros((tp_size, 1, self.HIDDEN_SIZE), device='cuda', dtype=torch.bfloat16) + dummy_hidden = torch.zeros( + (tp_size, 1, self.HIDDEN_SIZE), device='cuda', dtype=torch.bfloat16 + ) dummy_tokens = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) dummy_positions = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) From aa1e8f67a9731449fd29bf2e486f0e9fa32f0bd4 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 20:12:53 -0700 Subject: [PATCH 080/124] Cleanup Signed-off-by: Keshav Santhanam --- megatron/core/models/hybrid/hybrid_model.py | 48 +++++++------------ .../core/tensor_parallel/inference_layers.py | 5 +- megatron/core/transformer/cuda_graphs.py | 2 +- .../transformer/multi_token_prediction.py | 1 - 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index 6e739a7bf9e..8871b19666e 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -262,42 +262,30 @@ def __init__( # Output if post_process or self.mtp_process: - output_init_method = ( - config.embedding_init_method - if config.use_mup and not self.share_embeddings_and_output_weights - else config.init_method - ) if config.transformer_impl == "inference_optimized": from megatron.core.tensor_parallel.inference_layers import ( InferenceColumnParallelLinear, ) - self.output_layer = InferenceColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=output_init_method, - gather_output=not self.parallel_output, - bias=False, - skip_bias_add=False, - is_expert=False, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - tp_group=self.pg_collection.tp, - ) + output_layer_cls = InferenceColumnParallelLinear else: - self.output_layer = tensor_parallel.ColumnParallelLinear( - config.hidden_size, - self.vocab_size, - config=config, - init_method=output_init_method, - bias=False, - skip_bias_add=False, - gather_output=not self.parallel_output, - skip_weight_param_allocation=self.pre_process - and self.share_embeddings_and_output_weights, - tp_group=self.pg_collection.tp, - ) + output_layer_cls = tensor_parallel.ColumnParallelLinear + self.output_layer = output_layer_cls( + config.hidden_size, + self.vocab_size, + config=config, + init_method=( + config.embedding_init_method + if config.use_mup and not self.share_embeddings_and_output_weights + else config.init_method + ), + bias=False, + skip_bias_add=False, + gather_output=not self.parallel_output, + skip_weight_param_allocation=self.pre_process + and self.share_embeddings_and_output_weights, + tp_group=self.pg_collection.tp, + ) if self.pre_process or self.post_process or self.mtp_process: self.setup_embeddings_and_output_layer() diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index f8eceede94f..f407269436e 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -341,13 +341,10 @@ def _nvls_gather_last_dim(self, x: torch.Tensor) -> torch.Tensor: def forward( self, x: torch.Tensor, - weight: Optional[torch.Tensor] = None, + weight: torch.Tensor, runtime_gather_output: Optional[bool] = None, ) -> Tuple[torch.Tensor, None]: """Forward pass.""" - if weight is None: - weight = self.weight - if self.tp_size == 1: x = _apply_linear(x, weight, self.config) return x, None diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index fd8af37e15d..a5ff4f43d5f 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -252,7 +252,7 @@ def _check_supported_type(meta): ArgMetadata, } assert ( - meta.type in _SUPPORTED_TYPES or is_dataclass(meta.value) or callable(meta.value) + meta.type in _SUPPORTED_TYPES or is_dataclass(meta.value) ), f"Cudagraphs received an arg of type {meta.type} which is not supported." diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index 86ef8cd5dfc..f185693ce3f 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -755,7 +755,6 @@ def __init__( stacklevel=2, ) hybrid_submodules = mamba_submodules - self.is_mtp_layer = True self.sequence_parallel = config.sequence_parallel self.submodules = submodules self.layer_number = layer_number + get_mtp_layer_offset(self.config, vp_stage) From 1bc4a87112c1d1d260d84068ec23fbd0fe70a0a7 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 20:13:20 -0700 Subject: [PATCH 081/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 5 +---- megatron/core/transformer/cuda_graphs.py | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index f407269436e..66b6bb852b5 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -339,10 +339,7 @@ def _nvls_gather_last_dim(self, x: torch.Tensor) -> torch.Tensor: return gather_from_tensor_model_parallel_region(x, group=self.tp_group) def forward( - self, - x: torch.Tensor, - weight: torch.Tensor, - runtime_gather_output: Optional[bool] = None, + self, x: torch.Tensor, weight: torch.Tensor, runtime_gather_output: Optional[bool] = None ) -> Tuple[torch.Tensor, None]: """Forward pass.""" if self.tp_size == 1: diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index a5ff4f43d5f..a663914be85 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -251,8 +251,8 @@ def _check_supported_type(meta): DynamicInferenceContext, ArgMetadata, } - assert ( - meta.type in _SUPPORTED_TYPES or is_dataclass(meta.value) + assert meta.type in _SUPPORTED_TYPES or is_dataclass( + meta.value ), f"Cudagraphs received an arg of type {meta.type} which is not supported." From f97e3f6215c1ab6f3dbb99e331cfa9e24f2a7b62 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Mon, 20 Apr 2026 20:16:44 -0700 Subject: [PATCH 082/124] Remove dead code Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index a663914be85..f67427fae35 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -268,10 +268,6 @@ def _determine_if_first_last_layer_of_this_vp_chunk(base_module): if not hasattr(base_module, "layer_number"): return True, True - # MTP layers are self-contained; don't chain them with decoder layers. - if getattr(base_module, 'is_mtp_layer', False): - return True, True - # find all first/last layers of this PP stage first_layer_numbers = [] last_layer_numbers = [] From fcb2ec3e7f2bc3d8503376f720a365bade81c927 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 09:54:05 -0700 Subject: [PATCH 083/124] Clean up resources for test_parallel_inference Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_dynamic_engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index bbcf6a95282..65740a86857 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -1146,6 +1146,11 @@ def test_parallel_inference( transformer_impl=transformer_impl, ) + # Free NCCL communicators between parametrized runs to avoid resource exhaustion. + del env + gc.collect() + torch.cuda.empty_cache() + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" From f2504c55fb09c71a2340dc4fad45310088344a5d Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 10:18:15 -0700 Subject: [PATCH 084/124] Fix EP in test Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_dynamic_engine.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 65740a86857..d0a2d97202c 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -284,6 +284,8 @@ def _build_test_env(cls, test_config): Utils.initialize_model_parallel( tensor_model_parallel_size=test_config.tensor_model_parallel_size, pipeline_model_parallel_size=test_config.pipeline_model_parallel_size, + expert_model_parallel_size=test_config.expert_model_parallel_size, + expert_tensor_parallel_size=1, ) set_rounder(4) @@ -1146,11 +1148,6 @@ def test_parallel_inference( transformer_impl=transformer_impl, ) - # Free NCCL communicators between parametrized runs to avoid resource exhaustion. - del env - gc.collect() - torch.cuda.empty_cache() - @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" From 3d21b5dda14a4f33707c4b8cf830fd6d2e805850 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 10:27:29 -0700 Subject: [PATCH 085/124] Fix is_expert arg Signed-off-by: Keshav Santhanam --- megatron/core/models/gpt/gpt_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index f42f4bcadb9..255d6fa09a8 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -262,6 +262,7 @@ def __init__( ), bias=False, skip_bias_add=False, + is_expert=False, gather_output=not self.parallel_output, skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, From fa2220263a1fdc41d85938dc4b60fc7a67f471be Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 10:33:31 -0700 Subject: [PATCH 086/124] Fix case where weight is None Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 66b6bb852b5..f12f7dcd16a 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -339,9 +339,16 @@ def _nvls_gather_last_dim(self, x: torch.Tensor) -> torch.Tensor: return gather_from_tensor_model_parallel_region(x, group=self.tp_group) def forward( - self, x: torch.Tensor, weight: torch.Tensor, runtime_gather_output: Optional[bool] = None + self, + x: torch.Tensor, + weight: Optional[torch.Tensor] = None, + runtime_gather_output: Optional[bool] = None, ) -> Tuple[torch.Tensor, None]: """Forward pass.""" + # Fall back to self.weight when caller passes None (e.g. output layer + # without shared embedding weights), matching ColumnParallelLinear. + if weight is None: + weight = self.weight if self.tp_size == 1: x = _apply_linear(x, weight, self.config) return x, None From 389d83e9c72c4b10592b558d9ffa896f3138cf68 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 10:39:40 -0700 Subject: [PATCH 087/124] Add depth kwarg Signed-off-by: Keshav Santhanam --- .../inference/engines/test_dynamic_engine.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index d0a2d97202c..a374c3b36d0 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -2363,7 +2363,7 @@ def mock_mtp_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) logits = torch.zeros( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -2486,7 +2486,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -2570,7 +2570,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -2655,7 +2655,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) # Predict next_token_ids + 1 (continuing the ascending sequence) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) @@ -3009,7 +3009,7 @@ def mock_safe_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) logits = torch.zeros( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3222,7 +3222,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3342,7 +3342,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3472,7 +3472,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) logits = torch.randn( n, 1, test_config.vocab_size, device=hidden_states.device, dtype=torch.bfloat16 @@ -3821,7 +3821,7 @@ def mock_deterministic_forward(*args, **kwargs): ) return base_logits - def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids): + def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, depth=None): n = hidden_states.size(0) pred_toks = (next_token_ids + 1).clamp(max=test_config.vocab_size - 1) logits = torch.zeros( From 0825420e48cd02e0beb3a4a2fa2041d88d96b275 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 11:37:58 -0700 Subject: [PATCH 088/124] Nits Signed-off-by: Keshav Santhanam --- .../inference/text_generation_controllers/triton_kernels.py | 2 +- megatron/core/models/hybrid/hybrid_model.py | 1 + tests/unit_tests/inference/engines/test_dynamic_engine.py | 4 ---- .../text_generation_controllers/test_triton_kernels.py | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py index 97d49611ba4..fa3fa7ee68c 100644 --- a/megatron/core/inference/text_generation_controllers/triton_kernels.py +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import math diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index 8871b19666e..aa99d8c1de4 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -281,6 +281,7 @@ def __init__( ), bias=False, skip_bias_add=False, + is_expert=False, gather_output=not self.parallel_output, skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index a374c3b36d0..7cd5b15e55d 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -1133,10 +1133,6 @@ def test_parallel_inference( "when tp_size > 1." ) ) - if model_provider == "mamba": - pytest.skip( - reason="Mamba model is not supported with the inference optimized transformer." - ) env = self._run_test( model_provider=model_provider, diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py index 005dcab7815..24b32fc42b9 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. """Unit tests for MTP Triton kernels. From ffdefc5f114b9fd03ba8cb859b56bd08454113e3 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 11:43:53 -0700 Subject: [PATCH 089/124] Fix normalization in unit test Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_dynamic_engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 7cd5b15e55d..79f7d07c31c 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -406,6 +406,11 @@ def _build_test_env(cls, test_config): inference_sampling_seed=test_config.random_seed, cuda_graph_scope=test_config.cuda_graph_scope, transformer_impl=test_config.transformer_impl, + normalization=( + "RMSNorm" + if test_config.transformer_impl == "inference_optimized" + else "LayerNorm" + ), is_hybrid_model=True, # Needs to be set for correct out_proj init ) From dbd9f20818ea722792db4702f60ba810f75599c7 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 11:49:43 -0700 Subject: [PATCH 090/124] Fix bias in unit test Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_dynamic_engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 79f7d07c31c..02a177c413c 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -400,7 +400,8 @@ def _build_test_env(cls, test_config): ), sequence_parallel=test_config.sequence_parallel, pipeline_dtype=torch.bfloat16, - add_bias_linear=test_config.expert_model_parallel_size == 1, + add_bias_linear=test_config.expert_model_parallel_size == 1 + and not (test_config.transformer_impl == "inference_optimized"), fp8="hybrid" if test_config.fp8 else None, fp8_recipe="tensorwise" if test_config.fp8 else None, inference_sampling_seed=test_config.random_seed, From 9b6a2c4a877178c28a9fe2d59059c22cf91b6f5f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 14:08:34 -0700 Subject: [PATCH 091/124] Add no-op get_extra_state function Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index f12f7dcd16a..92fa56ca36d 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -289,6 +289,10 @@ def __init__( self.triton_nvls_kernels_allowed = not config.inference_disable_triton_nvls_kernels + def get_extra_state(self) -> None: + """Suppress TE's FP8 extra state; this layer bypasses TE's forward and uses _apply_linear.""" + return None + def _maybe_allocate_symmetric_buffer(self, x: torch.Tensor): """ Attempt to allocate symmetric memory buffer for all-gather. From 2d8b102d6b84caee4cc41b9b53461210e386cd13 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 21 Apr 2026 14:11:05 -0700 Subject: [PATCH 092/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 92fa56ca36d..9634af16e02 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -290,7 +290,10 @@ def __init__( self.triton_nvls_kernels_allowed = not config.inference_disable_triton_nvls_kernels def get_extra_state(self) -> None: - """Suppress TE's FP8 extra state; this layer bypasses TE's forward and uses _apply_linear.""" + """ + Suppress TE's FP8 extra state. + This layer bypasses TE's forward and uses _apply_linear. + """ return None def _maybe_allocate_symmetric_buffer(self, x: torch.Tensor): From 6d2ace55da7c170ef95fa6e41cb3e52ed4c67b9f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 09:55:47 -0700 Subject: [PATCH 093/124] Address review comments Signed-off-by: Keshav Santhanam --- .../triton_kernels.py | 22 ++++++------ .../common/language_module/language_module.py | 10 ++---- .../core/tensor_parallel/inference_layers.py | 4 +-- megatron/core/transformer/cuda_graphs.py | 27 ++++++-------- .../engines/test_mtp_cuda_graph_inference.py | 36 +++++++++---------- .../test_triton_kernels.py | 10 +++--- 6 files changed, 50 insertions(+), 59 deletions(-) diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/triton_kernels.py index fa3fa7ee68c..37ff55c1e99 100644 --- a/megatron/core/inference/text_generation_controllers/triton_kernels.py +++ b/megatron/core/inference/text_generation_controllers/triton_kernels.py @@ -50,7 +50,7 @@ def _rewind_kv_cache_kernel( Grid: may be padded beyond active requests for CUDA-graph compatibility. Each program handles exactly one request. Programs with - ``pid >= num_active_requests`` are padding and produce safe no-op outputs. + `pid >= num_active_requests` are padding and produce safe no-op outputs. """ pid = tl.program_id(0) @@ -120,12 +120,12 @@ def rewind_kv_cache( num_active_requests: Number of real (non-padding) requests. When the grid is padded beyond this count, the kernel skips padding programs so stale data in padding slots cannot corrupt - bookkeeping. Defaults to ``accepted_counts.shape[0]`` (no + bookkeeping. Defaults to `accepted_counts.shape[0]` (no padding). Returns: (blocks_to_release, remove_mask) — same semantics as the original - torch.compile'd ``_rewind_kv_cache`` (KV-cache portion only; Mamba + torch.compile'd `_rewind_kv_cache` (KV-cache portion only; Mamba state updates are handled separately by the caller). """ N = accepted_counts.shape[0] @@ -223,7 +223,7 @@ def verify_speculative_tokens( Returns: (last_one_indices, accepted_tokens_mask, input_tokens) - matching the original ``_verify_speculative_tokens`` signature. + matching the original `_verify_speculative_tokens` signature. """ if input_tokens.ndim == 2: input_tokens = input_tokens.squeeze(0) @@ -416,15 +416,15 @@ def mamba_state_selective_copy( """Copy accepted intermediate Mamba states to current states in-place. For each decode request, copies - ``intermediate[layer, slot, accepted_count, ...]`` → - ``current[layer, slot, ...]`` for every Mamba layer. + `intermediate[layer, slot, accepted_count, ...]` → + `current[layer, slot, ...]` for every Mamba layer. Args: - intermediate_states: ``(L, M, S+1, *state_shape)`` — intermediate buffer. - current_states: ``(L, M, *state_shape)`` — current state buffer (updated in-place). - prefill_status: ``(N,)`` int tensor — 0 for decode, 1 for prefill. - state_idx: ``(N,)`` int tensor — mamba state slot index per request. - accepted_counts: ``(N,)`` int tensor — accepted token index per request. + intermediate_states: `(L, M, S+1, *state_shape)` — intermediate buffer. + current_states: `(L, M, *state_shape)` — current state buffer (updated in-place). + prefill_status: `(N,)` int tensor — 0 for decode, 1 for prefill. + state_idx: `(N,)` int tensor — mamba state slot index per request. + accepted_counts: `(N,)` int tensor — accepted token index per request. num_layers: number of Mamba layers (first dim of the state tensors). """ N = prefill_status.shape[0] diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 12aa2d46de0..14ea1dd6d73 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -64,15 +64,12 @@ def __init__( self.vp_size = self.config.virtual_pipeline_model_parallel_size def _setup_mtp_cuda_graphs(self): - """Wrap ``compute_mtp_single_step`` with a CudaGraphManager. + """Wrap `compute_mtp_single_step` with a CudaGraphManager. - Must be called by subclasses after ``self.mtp`` is created. + Must be called by subclasses after `self.mtp` is created. """ if self.config.cuda_graph_impl == "local": - from megatron.core.transformer.cuda_graphs import ( - CudaGraphManager, - _CudagraphGlobalRecord, - ) + from megatron.core.transformer.cuda_graphs import CudaGraphManager self._mtp_cudagraph_manager = CudaGraphManager( self.config, @@ -80,7 +77,6 @@ def _setup_mtp_cuda_graphs(self): function_name="compute_mtp_single_step", need_backward=False, ) - _CudagraphGlobalRecord.mtp_cudagraph_managers.append(self._mtp_cudagraph_manager) def _is_in_embd_group(self): if self.embd_group is None: diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 9634af16e02..8f6c864e3ae 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -525,12 +525,12 @@ def inference_all_gather_last_dim( ) -> torch.Tensor: """NVLS-optimized all-gather along the last dimension, with NCCL fallback. - Replaces ``gather_from_tensor_model_parallel_region`` in inference paths + Replaces `gather_from_tensor_model_parallel_region` in inference paths where autograd is not needed and NVLS symmetric-memory is available. The NVLS path performs a flat all-gather into symmetric memory (concatenating along dim-0), then rearranges the result to the last dimension — the same - semantics as ``_gather_along_last_dim`` but using hardware multicast when + semantics as `_gather_along_last_dim` but using hardware multicast when possible. """ tp_size = dist.get_world_size(tp_group) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index f67427fae35..14bc7f9fd84 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -340,7 +340,7 @@ class _CudagraphGlobalRecord: 'record_bwd_graph.""" cudagraph_record: list[tuple] = [] cudagraph_inference_record: list[tuple] = [] - mtp_cudagraph_managers: list = [] + mtp_cudagraph_inference_record: list[tuple] = [] """A pool-like data structure to reuse input and output buffers across cudagraph.""" tensor_reuse_pool = TensorReusePool() @@ -506,6 +506,7 @@ def delete_cuda_graphs(): for record in [ *_CudagraphGlobalRecord.cudagraph_record, *_CudagraphGlobalRecord.cudagraph_inference_record, + *_CudagraphGlobalRecord.mtp_cudagraph_inference_record, ]: runner = record[0] assert isinstance(runner, _CudaGraphRunner) @@ -517,20 +518,11 @@ def delete_cuda_graphs(): runner.bwd_graph = None runner.mempool = None - # Reset MTP runners (excluded from the global inference record). - for mgr in _CudagraphGlobalRecord.mtp_cudagraph_managers: - for runner in mgr.inference_cudagraphs_lookup_table.values(): - if runner is not None: - runner.cudagraph_created = False - runner.fwd_graph_recorded = False - runner.fwd_graph = None - runner.mempool = None - mgr.inference_cudagraphs_lookup_table.clear() - # Reset global tracking state _CudagraphGlobalRecord.cudagraph_created = False _CudagraphGlobalRecord.cudagraph_record = [] _CudagraphGlobalRecord.cudagraph_inference_record = [] + _CudagraphGlobalRecord.mtp_cudagraph_inference_record = [] # TODO: Optional?: Force garbage collection to clean up memory gc.collect() @@ -1675,11 +1667,14 @@ def __call__(self, megatron_module, args, kwargs): runner.cudagraph_created = True runner = runner.eval() - # Record this to the global execution record. - # MTP runners are self-contained and don't chain with - # decoder layers, so skip the record to avoid polluting - # the previous-layer lookup (which expects layer_number). - if not self.is_mtp: + # Record to the global execution record. MTP runners use a + # separate ledger since they don't chain with decoder layers + # (the previous-layer lookup expects layer_number). + if self.is_mtp: + _CudagraphGlobalRecord.mtp_cudagraph_inference_record.append( + (runner, "fwd", args, kwargs) + ) + else: _CudagraphGlobalRecord.cudagraph_inference_record.append( (runner, "fwd", args, kwargs) ) diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 064d88a2bd0..f8b998d94f8 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -131,7 +131,7 @@ def _build_engine( ): """Build a DynamicInferenceEngine with automatic MTP CUDA graph warmup. - The engine's ``__init__`` calls ``create_cuda_graphs()`` which captures + The engine's `__init__` calls `create_cuda_graphs()` which captures both decoder and MTP CUDA graphs, matching production warmup exactly. """ model = self._build_model( @@ -164,9 +164,9 @@ def _build_engine( def _get_mtp_warmed_batch_sizes(engine): """Return the MTP batch sizes (padded req_counts) warmed by the engine. - These are the ``n`` values for which MTP CUDA graphs were captured. - Hidden states shape is ``[n // tp, 1, H]`` with SP, ``[n, 1, H]`` without. - Token/position IDs are always ``[1, n]``. + These are the `n` values for which MTP CUDA graphs were captured. + Hidden states shape is `[n // tp, 1, H]` with SP, `[n, 1, H]` without. + Token/position IDs are always `[1, n]`. """ context = engine.context model_config = engine.controller.inference_wrapped_model.model.config @@ -183,7 +183,7 @@ def _get_mtp_warmed_batch_sizes(engine): @staticmethod def _set_mtp_cuda_graph_flag(model, enabled): - """Set ``use_mtp_cuda_graphs`` on the model.""" + """Set `use_mtp_cuda_graphs` on the model.""" unwrapped = unwrap_model(model) unwrapped.use_mtp_cuda_graphs = enabled @@ -193,7 +193,7 @@ def _assert_mtp_cuda_graphs_were_replayed(model, expect_replayed): MTP runners are stored in the CudaGraphManager's lookup table rather than the global inference record. A runner with - ``fwd_graph_recorded=True`` confirms the graph was captured and + `fwd_graph_recorded=True` confirms the graph was captured and replayed. """ unwrapped = unwrap_model(model) @@ -282,8 +282,8 @@ def test_cuda_graph_output_matches_eager(self, mtp_use_repeated_layer): def test_cuda_graph_output_matches_eager_with_sp(self, mtp_use_repeated_layer): """CUDA graph replay matches eager with sequence parallelism. - Hidden states are in scattered SP format ``[batch_size/TP, 1, H]``. - Token/position IDs remain at full ``[1, batch_size]``. Both paths + Hidden states are in scattered SP format `[batch_size/TP, 1, H]`. + Token/position IDs remain at full `[1, batch_size]`. Both paths must produce identical outputs. """ engine = self._build_engine( @@ -340,7 +340,7 @@ def test_cuda_graph_output_matches_eager_with_sp(self, mtp_use_repeated_layer): @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() def test_cuda_graph_sp_padding_end_to_end(self, mtp_use_repeated_layer): - """Full ``_compute_serial_mtp_and_sample`` with CUDA graphs and SP. + """Full `_compute_serial_mtp_and_sample` with CUDA graphs and SP. Active request counts that are not multiples of TP are padded. The engine's CUDA graph warmup pre-captures MTP graphs for the @@ -599,8 +599,8 @@ def test_cuda_graph_multi_depth(self, mtp_use_repeated_layer): @pytest.mark.parametrize("mtp_use_repeated_layer", [False, True]) @torch.inference_mode() def test_eager_fallback_no_matching_graph(self, mtp_use_repeated_layer): - """When ``use_mtp_cuda_graphs`` is True but no warmed graph matches the - batch size, ``compute_mtp_single_step`` falls back to eager execution. + """When `use_mtp_cuda_graphs` is True but no warmed graph matches the + batch size, `compute_mtp_single_step` falls back to eager execution. The system should produce valid outputs without errors. """ engine = self._build_engine(mtp_use_repeated_layer=mtp_use_repeated_layer) @@ -642,7 +642,7 @@ def test_eager_fallback_no_matching_graph(self, mtp_use_repeated_layer): @torch.inference_mode() def test_mtp_graph_flag_propagation(self): - """``use_mtp_cuda_graphs`` is correctly toggled via the helper.""" + """`use_mtp_cuda_graphs` is correctly toggled via the helper.""" model = self._build_model(mtp_num_layers=2) unwrapped = unwrap_model(model) @@ -656,11 +656,11 @@ def test_mtp_graph_flag_propagation(self): @torch.inference_mode() def test_delete_cuda_graphs_resets_mtp_runners(self): - """``delete_cuda_graphs()`` resets MTP CUDA graph runners. + """`delete_cuda_graphs()` resets MTP CUDA graph runners. MTP runners are excluded from the global inference record, so they - require special handling in ``delete_cuda_graphs()``. After deletion, - no MTP runners should have ``fwd_graph_recorded=True``. + require special handling in `delete_cuda_graphs()`. After deletion, + no MTP runners should have `fwd_graph_recorded=True`. """ engine = self._build_engine() model = engine.controller.inference_wrapped_model.model @@ -704,8 +704,8 @@ def test_delete_cuda_graphs_resets_mtp_runners(self): class TestMTPCudaGraphExpertParallel: """Tests for MTP CUDA-graphed inference with expert parallelism. - Follows the test pattern from ``test_mamba_model_expert_parallel_inference.py``. - All tests require at least ``_EP_SIZE`` GPUs. + Follows the test pattern from `test_mamba_model_expert_parallel_inference.py`. + All tests require at least `_EP_SIZE` GPUs. """ HIDDEN_SIZE = 32 @@ -940,7 +940,7 @@ def test_ep_dummy_bailout_with_decode_only_cuda_graphs(self, peer_state): """Verify the dummy-rank bail-out path when only decode CUDA graphs are available. - With ``use_cuda_graphs_for_non_decode_steps=False``, only decode-only + With `use_cuda_graphs_for_non_decode_steps=False`, only decode-only graphs exist. When any EP rank has prefill requests, no graph matches and all ranks fall back to eager mode. The MTP forward for the dummy rank must use eager execution without hanging. diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py index 24b32fc42b9..ab4a5a22f0b 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py @@ -38,7 +38,7 @@ def rewind_kv_cache_pytorch( ): """Pure-PyTorch reference for the KV-cache rewind operation. - Mirrors the original ``TextGenerationController._rewind_kv_cache`` logic + Mirrors the original `TextGenerationController._rewind_kv_cache` logic (KV-cache portion only, no Mamba state updates). Mutates the input tensors in-place, just like the Triton kernel. @@ -96,7 +96,7 @@ def verify_speculative_tokens_pytorch( ): """Pure-PyTorch reference for speculative token verification. - Mirrors the original ``TextGenerationController._verify_speculative_tokens`` + Mirrors the original `TextGenerationController._verify_speculative_tokens` logic. """ if input_tokens.ndim == 2: @@ -153,7 +153,7 @@ def prepare_next_forward_pass_pytorch( ): """Pure-PyTorch reference for preparing the next forward pass. - Mirrors the original ``_dynamic_step_sample_logits_and_verify_tokens`` + Mirrors the original `_dynamic_step_sample_logits_and_verify_tokens` post-verification logic. """ active_request_count = last_one_indices.shape[0] @@ -186,8 +186,8 @@ def mamba_state_selective_copy_pytorch( """Pure-PyTorch reference for Mamba state selective copy. For each decode request, copies - ``intermediate[layer, slot, accepted_count, ...]`` → - ``current[layer, slot, ...]`` for every Mamba layer. + `intermediate[layer, slot, accepted_count, ...]` → + `current[layer, slot, ...]` for every Mamba layer. """ N = prefill_status.shape[0] for i in range(N): From 53bd51d6eb35dacf8357a918e6ee70688de88b18 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:14:23 -0700 Subject: [PATCH 094/124] Address reviewer comments Signed-off-by: Keshav Santhanam --- .../core/inference/batch_dimensions_utils.py | 6 +- .../core/inference/engines/dynamic_engine.py | 5 +- .../text_generation_controller.py | 100 +++++++++--------- .../common/language_module/language_module.py | 12 ++- megatron/core/models/gpt/gpt_model.py | 10 +- megatron/core/models/hybrid/hybrid_model.py | 10 +- .../core/tensor_parallel/inference_layers.py | 21 +--- megatron/core/transformer/cuda_graphs.py | 17 +-- megatron/core/utils.py | 5 + .../engines/test_mtp_cuda_graph_inference.py | 8 +- .../test_text_generation_controller.py | 12 +-- 11 files changed, 95 insertions(+), 111 deletions(-) diff --git a/megatron/core/inference/batch_dimensions_utils.py b/megatron/core/inference/batch_dimensions_utils.py index 2d54ae4090f..4d7e4a8b881 100644 --- a/megatron/core/inference/batch_dimensions_utils.py +++ b/megatron/core/inference/batch_dimensions_utils.py @@ -14,7 +14,7 @@ import torch -from megatron.core.utils import get_pg_size +from megatron.core.utils import get_pg_size, round_up_to_nearest_multiple @dataclass(order=True, frozen=True) @@ -273,7 +273,7 @@ def _calculate_cuda_graph_token_counts( ) # Align each entry to TP size cuda_graph_token_counts = list( - dict.fromkeys(math.ceil(s / tp_size) * tp_size for s in cuda_graph_token_counts) + dict.fromkeys(round_up_to_nearest_multiple(s, tp_size) for s in cuda_graph_token_counts) ) # Clamp to max tokens cuda_graph_token_counts = [ @@ -295,7 +295,7 @@ def _calculate_cuda_graph_token_counts( math.ceil(int(cuda_graph_step_size) / CUDAGraphBatchDimensionBuilder.CUDA_GRAPH_ROUNDER) ) # Make sure divisible by TP size - cuda_graph_step_size = math.ceil(cuda_graph_step_size / tp_size) * tp_size + cuda_graph_step_size = round_up_to_nearest_multiple(cuda_graph_step_size, tp_size) # round down cuda graph max tokens to be multiple of TP size cuda_graph_max_tokens = (cuda_graph_max_tokens // tp_size) * tp_size diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 5e51e42be33..2d4725a75b8 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -63,6 +63,7 @@ internal_api, nvtx_range_pop, nvtx_range_push, + round_up_to_nearest_multiple, trace_async_exceptions, unwrap_model, ) @@ -410,7 +411,7 @@ def create_cuda_graphs(self, reset_context: bool = True): if mtp_warmup_enabled: n = cuda_graph_batch_dimension.req_count if sp_enabled: - n += (tp_size - n % tp_size) % tp_size + n = round_up_to_nearest_multiple(n, tp_size) if n > 0 and n not in mtp_seen_batch_sizes: mtp_seen_batch_sizes.add(n) device = torch.cuda.current_device() @@ -436,7 +437,7 @@ def create_cuda_graphs(self, reset_context: bool = True): unset_inference_cuda_graphed_iteration_for_ep_inference(unwrapped_model) if mtp_warmup_enabled and mtp_seen_batch_sizes: - controller._has_mtp_cuda_graphs = True + controller.has_mtp_cuda_graphs = True logging.info("> MTP CUDA graph warmup: %d batch size(s)", len(mtp_seen_batch_sizes)) # Memory usage. diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 3a136e06b89..57532e80d27 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -41,6 +41,7 @@ get_pg_size, nvtx_range_pop, nvtx_range_push, + round_up_to_nearest_multiple, unwrap_model, ) @@ -98,6 +99,7 @@ def __init__(self, inference_wrapped_model: AbstractModelInferenceWrapper, token self.sampling_rng = torch.Generator(device=torch.cuda.current_device()) self.num_mtp_heads = self._get_mtp_num_heads() + self.has_mtp_cuda_graphs = False self.sampling_rng.manual_seed(self.model_config.inference_sampling_seed) if ( @@ -144,12 +146,6 @@ def _init_dynamic_sampling_tensors(self): self._sampling_backend = "torch" self._sampled_tokens_cuda = torch.empty(max_requests, dtype=torch.int64, device=device) - # Speculative tokens tensor will be allocated later when num_speculative_tokens is set by the engine - self._accepted_tokens_per_request = None - # MTP tensor will be allocated later when num_speculative_tokens is set by the engine - self._sampled_mtp_tokens_cuda = None - # Last accepted sequence indices for serial MTP computation - self._last_accepted_seq_indices = None # Keep track of request metadata. self._request_metadata: Dict[str, Tensor] = {} @@ -165,44 +161,51 @@ def _init_dynamic_sampling_tensors(self): if self._sampling_backend == "torch": self._torch_sampling_buckets: List[Tuple] = [] - self._init_mtp_sampling_tensor() + # Cache values that are constant across inference steps. + self._unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + self._is_last_pp_stage = is_pipeline_last_stage(self.pp_group) + self._tp_size = get_pg_size(self.inference_wrapped_model.tp_group) + self._sp_enabled = self.model_config.sequence_parallel and self._tp_size > 1 - def _init_mtp_sampling_tensor(self): - """Initialize the MTP sampling tensor after num_speculative_tokens is set.""" - if self.num_speculative_tokens is not None and self.num_speculative_tokens > 0: - context = self.inference_wrapped_model.inference_context - max_requests = context.max_requests - device = torch.cuda.current_device() - self._sampled_mtp_tokens_cuda = torch.empty( - [self.num_speculative_tokens, max_requests], dtype=torch.int64, device=device - ) - self._accepted_tokens_per_request = ( - torch.ones( - [max_requests, self.num_speculative_tokens], dtype=torch.int64, device=device - ) - * -1 - ) - self._accepted_token_counts_per_request = torch.zeros( - max_requests, dtype=torch.int64, device=device - ) - self._last_accepted_seq_indices_buf = torch.empty( - max_requests, dtype=torch.int64, device=device - ) + self._init_mtp_sampling_tensors() - # Cache values that are constant across inference steps. - self._unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - self._is_last_pp_stage = is_pipeline_last_stage(self.pp_group) - self._tp_size = get_pg_size(self.inference_wrapped_model.tp_group) - self._sp_enabled = self.model_config.sequence_parallel and self._tp_size > 1 - self._num_mtp_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + def _init_mtp_sampling_tensors(self): + """Pre-allocate MTP sampling tensors. - # Pre-allocate padded buffers for per-depth token/position IDs. - self._mtp_token_ids_buf = torch.empty( - [1, max_requests], dtype=torch.int64, device=device - ) - self._mtp_position_ids_buf = torch.empty( - [1, max_requests], dtype=torch.int64, device=device + Addresses must be stable across steps for CUDA graph capture. + """ + if not self.num_speculative_tokens: + self._sampled_mtp_tokens_cuda = None + self._accepted_tokens_per_request = None + self._last_accepted_seq_indices = None + return + + context = self.inference_wrapped_model.inference_context + max_requests = context.max_requests + device = torch.cuda.current_device() + self._sampled_mtp_tokens_cuda = torch.empty( + [self.num_speculative_tokens, max_requests], dtype=torch.int64, device=device + ) + self._accepted_tokens_per_request = ( + torch.ones( + [max_requests, self.num_speculative_tokens], dtype=torch.int64, device=device ) + * -1 + ) + self._accepted_token_counts_per_request = torch.zeros( + max_requests, dtype=torch.int64, device=device + ) + self._last_accepted_seq_indices_buf = torch.empty( + max_requests, dtype=torch.int64, device=device + ) + self._last_accepted_seq_indices = None + self._num_mtp_depths = min(self.num_speculative_tokens, self.num_mtp_heads) + self._mtp_token_ids_buf = torch.empty( + [1, max_requests], dtype=torch.int64, device=device + ) + self._mtp_position_ids_buf = torch.empty( + [1, max_requests], dtype=torch.int64, device=device + ) @staticmethod def tokenize_prompt(tokenizer, prompt: str, add_BOS: bool = False) -> List[int]: @@ -610,14 +613,15 @@ def _dynamic_step_context_init( is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, ) - # Derive the MTP padded batch size from the EP-synced graph dimensions. - # In eager mode MTP uses locally SP-aligned batch size instead. - if getattr(self, '_has_mtp_cuda_graphs', False) and context.using_cuda_graph_this_step(): + # Derive the MTP padded batch size from the existing padded graph dimensions. + # For MoE models this is post EP sync. In eager mode MTP uses locally SP-aligned + # batch size instead. + if self.has_mtp_cuda_graphs and context.using_cuda_graph_this_step(): self._mtp_resolved_padded_count = context.padded_batch_dimensions.req_count if self._sp_enabled: - self._mtp_resolved_padded_count += ( - self._tp_size - self._mtp_resolved_padded_count % self._tp_size - ) % self._tp_size + self._mtp_resolved_padded_count = round_up_to_nearest_multiple( + self._mtp_resolved_padded_count, self._tp_size + ) else: self._mtp_resolved_padded_count = None @@ -625,7 +629,7 @@ def _dynamic_step_context_init( # main model falls back to eager mode, MTP must also run eagerly across # all EP ranks — otherwise some ranks may replay a captured graph while # others run eagerly, causing EP collectives to hang. - if getattr(self, '_has_mtp_cuda_graphs', False): + if self.has_mtp_cuda_graphs: unwrapped_model.use_mtp_cuda_graphs = context.using_cuda_graph_this_step() # If using symmetric kernels and we are using using nccl @@ -899,7 +903,7 @@ def _compute_serial_mtp_and_sample(self): # Eager path: pad only for SP alignment. padded_count = active_request_count if self._sp_enabled: - padded_count += (self._tp_size - padded_count % self._tp_size) % self._tp_size + padded_count = round_up_to_nearest_multiple(padded_count, self._tp_size) else: padded_count = active_request_count pad_count = padded_count - active_request_count diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 14ea1dd6d73..650fa8f70a1 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -8,6 +8,8 @@ from megatron.core import parallel_state, tensor_parallel from megatron.core.dist_checkpointing.mapping import ShardedStateDict +from megatron.core.tensor_parallel.inference_layers import InferenceColumnParallelLinear +from megatron.core.transformer.cuda_graphs import CudaGraphManager try: from megatron.core.extensions.transformer_engine import te_parallel_cross_entropy @@ -63,19 +65,25 @@ def __init__( self.vp_stage = None self.vp_size = self.config.virtual_pipeline_model_parallel_size + @staticmethod + def _get_output_layer_cls(config: TransformerConfig): + """Return the column-parallel class for the output projection layer.""" + if config.transformer_impl == "inference_optimized": + return InferenceColumnParallelLinear + return tensor_parallel.ColumnParallelLinear + def _setup_mtp_cuda_graphs(self): """Wrap `compute_mtp_single_step` with a CudaGraphManager. Must be called by subclasses after `self.mtp` is created. """ if self.config.cuda_graph_impl == "local": - from megatron.core.transformer.cuda_graphs import CudaGraphManager - self._mtp_cudagraph_manager = CudaGraphManager( self.config, base_module=self, function_name="compute_mtp_single_step", need_backward=False, + is_mtp_inference=True, ) def _is_in_embd_group(self): diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index 255d6fa09a8..b8b6b8b64fb 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -243,15 +243,7 @@ def __init__( self.embedding_activation_buffer = None self.grad_output_buffer = None - if config.transformer_impl == "inference_optimized": - from megatron.core.tensor_parallel.inference_layers import ( - InferenceColumnParallelLinear, - ) - - output_layer_cls = InferenceColumnParallelLinear - else: - output_layer_cls = tensor_parallel.ColumnParallelLinear - self.output_layer = output_layer_cls( + self.output_layer = self._get_output_layer_cls(config)( config.hidden_size, self.vocab_size, config=config, diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index aa99d8c1de4..b68e2c0d70d 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -262,15 +262,7 @@ def __init__( # Output if post_process or self.mtp_process: - if config.transformer_impl == "inference_optimized": - from megatron.core.tensor_parallel.inference_layers import ( - InferenceColumnParallelLinear, - ) - - output_layer_cls = InferenceColumnParallelLinear - else: - output_layer_cls = tensor_parallel.ColumnParallelLinear - self.output_layer = output_layer_cls( + self.output_layer = self._get_output_layer_cls(config)( config.hidden_size, self.vocab_size, config=config, diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 8f6c864e3ae..504becb6353 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -326,25 +326,6 @@ def _all_gather(self, x: torch.Tensor, symm_mem_buffer: dict) -> None: x, _ = gather_along_first_dim(x, process_group=self.tp_group) return x - def _nvls_gather_last_dim(self, x: torch.Tensor) -> torch.Tensor: - """NVLS all-gather along last dim, with NCCL fallback.""" - ag_buffer_dims = list(x.size()) - ag_buffer_dims[0] *= self.tp_size - buf = SymmetricMemoryManager.get_buffer("tp", process_group=self.tp_group) - symm_mem_buffer = buf.maybe_get_tensor(ag_buffer_dims, dtype=x.dtype) - - can_use_nvls = ( - self.triton_nvls_kernels_allowed - and are_tensors_nvls_eligible(x) - and symm_mem_buffer["handle"] is not None - ) - if can_use_nvls: - multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) - tensor_list = symm_mem_buffer["tensor"].chunk(self.tp_size, dim=0) - return torch.cat(tensor_list, dim=-1).contiguous() - - return gather_from_tensor_model_parallel_region(x, group=self.tp_group) - def forward( self, x: torch.Tensor, @@ -370,7 +351,7 @@ def forward( if runtime_gather_output is not None: gather_output = runtime_gather_output if gather_output: - x = self._nvls_gather_last_dim(x) + x = inference_all_gather_last_dim(x, self.tp_group, self.config) return x, None diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 14bc7f9fd84..bbc0fb14d04 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1428,6 +1428,7 @@ def __init__( function_name=None, need_backward=True, pg_collection=None, + is_mtp_inference=False, ): super().__init__() """Creates a CudaGraphManager to manage CUDA graphs for a Megatron module. @@ -1435,13 +1436,14 @@ def __init__( Args: config: TransformerConfig object containing CUDA graph settings for memory pooling, graph retention, gradient accumulation, FP8/FP4, and warmup steps. + is_mtp_inference: Whether this manager wraps an MTP inference forward pass. """ if pg_collection is None: pg_collection = ProcessGroupCollection.use_mpu_process_groups() self.pg_collection = pg_collection rng_tracker = get_cuda_rng_tracker() self.need_backward = need_backward - self.is_mtp = function_name == "compute_mtp_single_step" + self.is_mtp_inference = is_mtp_inference if function_name is not None: func = getattr(base_module, function_name) @@ -1516,7 +1518,6 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): over different microbatches by tracking their respective fwd and bwd passes.''' if reuse_cudagraphs: is_inference_mode = 'inference_context' in kwargs.keys() and kwargs['inference_context'] - is_mtp_inference = self.is_mtp if is_inference_mode: is_static_batching = kwargs['inference_context'].is_static_batching() if is_static_batching: @@ -1526,7 +1527,7 @@ def get_cudagraph_runner(self, megatron_module, args, kwargs, reuse_cudagraphs): else: padded_batch_dimensions = kwargs['inference_context'].padded_batch_dimensions runner = self.inference_cudagraphs_lookup_table[padded_batch_dimensions] - elif is_mtp_inference: + elif self.is_mtp_inference: mtp_key = ('mtp', kwargs['hidden_states'].shape, kwargs.get('depth')) runner = self.inference_cudagraphs_lookup_table.get(mtp_key) else: @@ -1571,7 +1572,7 @@ def is_valid(r): ) else: self.inference_cudagraphs_lookup_table[padded_batch_dimensions] = runner - elif is_mtp_inference: + elif self.is_mtp_inference: self.inference_cudagraphs_lookup_table[mtp_key] = runner else: # Create cudagraphs for every microbatch @@ -1604,7 +1605,7 @@ def __call__(self, megatron_module, args, kwargs): """ is_inference_mode = ( 'inference_context' in kwargs.keys() and kwargs['inference_context'] - ) or self.is_mtp + ) or self.is_mtp_inference is_in_checkpoint_fwd = is_checkpointing() if HAVE_TE_GRAPHS: is_in_checkpoint_fwd = is_in_checkpoint_fwd or is_fp8_activation_recompute_enabled() @@ -1623,7 +1624,7 @@ def __call__(self, megatron_module, args, kwargs): # MTP must match the main model's eager/graph mode so all EP # ranks take the same code path. Skip during graph capture. if ( - self.is_mtp + self.is_mtp_inference and not getattr(megatron_module, 'use_mtp_cuda_graphs', False) and not is_graph_capturing() ): @@ -1632,7 +1633,7 @@ def __call__(self, megatron_module, args, kwargs): # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) - if not runner.fwd_graph_recorded and self.is_mtp and not is_graph_capturing(): + if not runner.fwd_graph_recorded and self.is_mtp_inference and not is_graph_capturing(): # No pre-warmed graph for this batch size — run eagerly. return self.func(*args, **kwargs) @@ -1670,7 +1671,7 @@ def __call__(self, megatron_module, args, kwargs): # Record to the global execution record. MTP runners use a # separate ledger since they don't chain with decoder layers # (the previous-layer lookup expects layer_number). - if self.is_mtp: + if self.is_mtp_inference: _CudagraphGlobalRecord.mtp_cudagraph_inference_record.append( (runner, "fwd", args, kwargs) ) diff --git a/megatron/core/utils.py b/megatron/core/utils.py index 3fac8fdafff..0da1c34a70d 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -513,6 +513,11 @@ def divide(numerator, denominator): return numerator // denominator +def round_up_to_nearest_multiple(value: int, multiple: int) -> int: + """Round *value* up to the nearest multiple of *multiple*.""" + return math.ceil(value / multiple) * multiple + + def get_tensor_model_parallel_group_if_none(tp_group, is_expert=False, check_initialized=True): """Issue a deprecation warning if tp_group is None and return the default tp group.""" # TODO(zijiey): remove this function later. diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index f8b998d94f8..53f7c46bd56 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -391,7 +391,7 @@ def test_cuda_graph_sp_padding_end_to_end(self, mtp_use_repeated_layer): ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec - ctrl._init_mtp_sampling_tensor() + ctrl._init_mtp_sampling_tensors() ctrl._mtp_token_ids_buf.zero_() ctrl._mtp_position_ids_buf.zero_() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( @@ -488,7 +488,7 @@ def _run_mtp(use_cuda_graph): ctrl.num_speculative_tokens = num_spec ctrl.num_mtp_heads = num_spec - ctrl._init_mtp_sampling_tensor() + ctrl._init_mtp_sampling_tensors() ctrl._mtp_token_ids_buf.zero_() ctrl._mtp_position_ids_buf.zero_() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( @@ -496,11 +496,11 @@ def _run_mtp(use_cuda_graph): ) if use_cuda_graph: - ctrl._has_mtp_cuda_graphs = True + ctrl.has_mtp_cuda_graphs = True ctrl._mtp_resolved_padded_count = padded_count self._set_mtp_cuda_graph_flag(model, True) else: - ctrl._has_mtp_cuda_graphs = False + ctrl.has_mtp_cuda_graphs = False ctrl._mtp_resolved_padded_count = None self._set_mtp_cuda_graph_flag(model, False) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 90510b260dd..b37d3add10e 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1129,7 +1129,7 @@ def test_speculative_verify_tokens(self): ) # 1 sampled + 2 spec # Init accepted tokens tensors - self.text_generation_controller._init_mtp_sampling_tensor() + self.text_generation_controller._init_mtp_sampling_tensors() # Mock inputs: [Req 1 sampled, Req 1 spec1, Req 1 spec2, Req 2 sampled, Req 2 spec1, Req 2 spec2] # Target tokens (what the model was fed): [T0, T1, T2, T3, T4, T5] @@ -1216,7 +1216,7 @@ def test_rewind_kv_cache(self, is_hybrid_model): ctx.mamba_intermediate_conv_states.fill_(77) # Mock accepted token counts: Req 0 accepts 1 (rejects 2), Req 1 accepts 0 (rejects 3) - self.text_generation_controller._init_mtp_sampling_tensor() + self.text_generation_controller._init_mtp_sampling_tensors() self.text_generation_controller._accepted_token_counts_per_request = torch.tensor( [1, 0], device='cuda' ) @@ -1446,7 +1446,7 @@ def test_rewind_kv_cache_with_prefix_caching_ref_counts(self): initial_avail = ctx.kv_block_allocator.total_avail # Req 0 accepts 1 (rewinds 1), Req 1 accepts 0 (rewinds 2, crosses boundary). - self.text_generation_controller._init_mtp_sampling_tensor() + self.text_generation_controller._init_mtp_sampling_tensors() self.text_generation_controller._accepted_token_counts_per_request = torch.tensor( [1, 0], device='cuda' ) @@ -1487,7 +1487,7 @@ def test_rewind_kv_cache_does_not_release_shared_prefix_blocks(self): # Blocks 10, 20 are shared prefix blocks. Block 30, 40 are exclusive. ctx.kv_block_allocator.total_avail = 50 - self.text_generation_controller._init_mtp_sampling_tensor() + self.text_generation_controller._init_mtp_sampling_tensors() self.text_generation_controller._accepted_token_counts_per_request = torch.tensor( [0], device='cuda' ) @@ -1528,7 +1528,7 @@ def test_speculative_mtp_position_ids_with_prefill(self): ctx.request_kv_length_offsets[:2] = torch.tensor([10, 0], dtype=torch.int32, device='cuda') ctx.request_query_lengths[:2] = torch.tensor([3, 15], dtype=torch.int32, device='cuda') - self.text_generation_controller._init_mtp_sampling_tensor() + self.text_generation_controller._init_mtp_sampling_tensors() # Mock base token sampling (the first tokens fed into MTP) self.text_generation_controller._sampled_tokens_cuda[:2] = torch.tensor( [100, 200], device='cuda' @@ -1603,7 +1603,7 @@ def test_mtp_sp_padding_real_ranks(self, active_request_count): active_request_count, dtype=torch.int32, device='cuda' ) - ctrl._init_mtp_sampling_tensor() + ctrl._init_mtp_sampling_tensors() ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( torch.arange(active_request_count, device='cuda'), self.vocab_size ) From 8f5e7d3d74805013d95f9a9d4b41e7db2fbcbaa2 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:16:15 -0700 Subject: [PATCH 095/124] Revert unnecessary changes Signed-off-by: Keshav Santhanam --- .../inference/gpt/gpt_dynamic_inference.py | 37 +++++-------------- .../gpt/gpt_dynamic_inference_12b.sh | 1 + .../gpt/gpt_dynamic_inference_357m.sh | 1 + examples/inference/gpt/utils.py | 17 ++++++--- .../cuda_graphs.sh | 1 + 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/examples/inference/gpt/gpt_dynamic_inference.py b/examples/inference/gpt/gpt_dynamic_inference.py index 969552b05c5..02a257c1b46 100644 --- a/examples/inference/gpt/gpt_dynamic_inference.py +++ b/examples/inference/gpt/gpt_dynamic_inference.py @@ -286,6 +286,10 @@ def main(): ) initialize_megatron() + # Start Nsight profiler. + if os.environ.get("NSIGHT_PREFIX"): + torch.cuda.cudart().cudaProfilerStart() + level_str = os.getenv("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_str, logging.INFO) logging.basicConfig(level=level, force=True) @@ -346,23 +350,8 @@ def main(): print(setup_prefix) print("~~~") - # Warmup: run one untimed iteration so CUDA caches, JIT kernels, and - # allocator pools are ready before the measured runs. - if args.inference_repeat_n > 1: - print("Running warmup iteration ...") - engine.reset() - run_inference(requests, engine) - torch.cuda.synchronize() - engine.reset() - - # Start CUDA profiler after warmup so nsys traces only the measured runs. - if os.environ.get("NSIGHT_PREFIX"): - torch.cuda.cudart().cudaProfilerStart() - # Run and time test, optionally `args.inference_repeat_n` times. throughputs = [] - cuda_start_event = torch.cuda.Event(enable_timing=True) - cuda_end_event = torch.cuda.Event(enable_timing=True) for _ in range(args.inference_repeat_n): # Reset engine. @@ -370,29 +359,19 @@ def main(): torch.cuda.reset_peak_memory_stats() - # Synchronize before starting the timer to avoid measuring stale GPU work. - torch.cuda.synchronize() - - # Trial — use both wall-clock and CUDA events for accurate GPU timing. + # Trial. t = get_curr_time() - cuda_start_event.record() result = run_inference(requests, engine) - cuda_end_event.record() step_times = result["step_times"] add_times = result["add_times"] output_times = result["output_times"] total_output_tokens = result["total_output_tokens"] torch.cuda.synchronize() total_time = get_curr_time() - t - cuda_elapsed_ms = cuda_start_event.elapsed_time(cuda_end_event) stats = torch.cuda.memory_stats() throughput = total_output_tokens / total_time throughputs.append(throughput) - # Stop CUDA profiler after measured runs. - if os.environ.get("NSIGHT_PREFIX"): - torch.cuda.cudart().cudaProfilerStop() - # Validate all requests finished. for request in requests: assert request.state == "finished", f"request.state == '{request.state}' != 'finished'." @@ -526,17 +505,19 @@ def escape_str(s): # f"count [ p {p_count}, d {d_count} ]." # ) capture_str = f"{engine.capture_stats['time']:.2f} sec" if engine.capture_stats else "--" - cuda_throughput = total_output_tokens / (cuda_elapsed_ms / 1000.0) print( f"{setup_prefix} … " f"throughput: {throughput:.3f} tok/s … ", f"total time: {total_time:.3f}s … " - f"cuda time: {cuda_elapsed_ms:.1f}ms ({cuda_throughput:.3f} tok/s) … " f"mem {peak_alloc_gb:.1f}/{peak_resvd_gb:.1f} GB … " f"steps: {engine.context.step_count:d} … " f"capture {capture_str}", ) print("~~~") + # Stop Nsight profiler. + if os.environ.get("NSIGHT_PREFIX"): + torch.cuda.cudart().cudaProfilerStop() + if __name__ == "__main__": main() diff --git a/examples/inference/gpt/gpt_dynamic_inference_12b.sh b/examples/inference/gpt/gpt_dynamic_inference_12b.sh index d848fdb51b7..ca21bb170a5 100644 --- a/examples/inference/gpt/gpt_dynamic_inference_12b.sh +++ b/examples/inference/gpt/gpt_dynamic_inference_12b.sh @@ -5,6 +5,7 @@ set -u # Libraries. +pip install simpy pip install sentencepiece pip install tiktoken diff --git a/examples/inference/gpt/gpt_dynamic_inference_357m.sh b/examples/inference/gpt/gpt_dynamic_inference_357m.sh index d0c126cd191..cc99bdddec1 100644 --- a/examples/inference/gpt/gpt_dynamic_inference_357m.sh +++ b/examples/inference/gpt/gpt_dynamic_inference_357m.sh @@ -5,6 +5,7 @@ set -u # Libraries. +pip install simpy pip install sentencepiece pip install tiktoken diff --git a/examples/inference/gpt/utils.py b/examples/inference/gpt/utils.py index ca26985e046..c9b1c05c544 100644 --- a/examples/inference/gpt/utils.py +++ b/examples/inference/gpt/utils.py @@ -106,13 +106,18 @@ def get_time_offsets( random.seed(seed) - # Generate Poisson arrival times by accumulating exponential inter-arrival intervals. + import simpy # Guard against this import in test case + + # Generate random time offsets. + def arrival(r): + while True: + yield env.timeout(random.expovariate(r)) + time_offsets.append(env.now) + time_offsets = [] - current_time = 0.0 - while current_time < incoming_requests_duration: - current_time += random.expovariate(incoming_requests_per_sec) - if current_time < incoming_requests_duration: - time_offsets.append(current_time) + env = simpy.Environment() + env.process(arrival(incoming_requests_per_sec)) + env.run(incoming_requests_duration) # Ensure at least a single request. if len(time_offsets) == 0: diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh index ed0a5a622c2..641019c9750 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/cuda_graphs.sh @@ -3,6 +3,7 @@ set -u # Libraries. +uv pip install simpy uv pip install tiktoken # Environment variables. From 6d1f0b6dae07435577c9b5ac2ef6f0b4b22e3a3d Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:18:19 -0700 Subject: [PATCH 096/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/inference/batch_dimensions_utils.py | 4 +++- .../text_generation_controller.py | 4 +--- megatron/core/models/hybrid/hybrid_model.py | 1 - megatron/core/transformer/cuda_graphs.py | 6 +++++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/batch_dimensions_utils.py b/megatron/core/inference/batch_dimensions_utils.py index 4d7e4a8b881..c9474eac5a6 100644 --- a/megatron/core/inference/batch_dimensions_utils.py +++ b/megatron/core/inference/batch_dimensions_utils.py @@ -273,7 +273,9 @@ def _calculate_cuda_graph_token_counts( ) # Align each entry to TP size cuda_graph_token_counts = list( - dict.fromkeys(round_up_to_nearest_multiple(s, tp_size) for s in cuda_graph_token_counts) + dict.fromkeys( + round_up_to_nearest_multiple(s, tp_size) for s in cuda_graph_token_counts + ) ) # Clamp to max tokens cuda_graph_token_counts = [ diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 57532e80d27..83f825afa1d 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -200,9 +200,7 @@ def _init_mtp_sampling_tensors(self): ) self._last_accepted_seq_indices = None self._num_mtp_depths = min(self.num_speculative_tokens, self.num_mtp_heads) - self._mtp_token_ids_buf = torch.empty( - [1, max_requests], dtype=torch.int64, device=device - ) + self._mtp_token_ids_buf = torch.empty([1, max_requests], dtype=torch.int64, device=device) self._mtp_position_ids_buf = torch.empty( [1, max_requests], dtype=torch.int64, device=device ) diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index b68e2c0d70d..77be15c9529 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -5,7 +5,6 @@ from torch import Tensor -from megatron.core import tensor_parallel from megatron.core.config_logger import has_config_logger_enabled, log_config_to_disk from megatron.core.inference.contexts import BaseInferenceContext from megatron.core.models.common.embeddings.language_model_embedding import LanguageModelEmbedding diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index bbc0fb14d04..a64efcd94ac 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -1633,7 +1633,11 @@ def __call__(self, megatron_module, args, kwargs): # Inference generation mode creates graphs immediately runner = self.get_cudagraph_runner(megatron_module, args, kwargs, True) - if not runner.fwd_graph_recorded and self.is_mtp_inference and not is_graph_capturing(): + if ( + not runner.fwd_graph_recorded + and self.is_mtp_inference + and not is_graph_capturing() + ): # No pre-warmed graph for this batch size — run eagerly. return self.func(*args, **kwargs) From c5a2d5f4b4a2bb58648daab5e7709fa97d5657d1 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:34:44 -0700 Subject: [PATCH 097/124] Clean up MTP graph manager tracking Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 37 +++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index a64efcd94ac..6ad60ff3550 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -340,7 +340,10 @@ class _CudagraphGlobalRecord: 'record_bwd_graph.""" cudagraph_record: list[tuple] = [] cudagraph_inference_record: list[tuple] = [] - mtp_cudagraph_inference_record: list[tuple] = [] + + # MTP CudaGraphManagers registered at construction time so that + # delete_cuda_graphs() can clear their lookup tables. + mtp_cudagraph_managers: list = [] """A pool-like data structure to reuse input and output buffers across cudagraph.""" tensor_reuse_pool = TensorReusePool() @@ -506,7 +509,6 @@ def delete_cuda_graphs(): for record in [ *_CudagraphGlobalRecord.cudagraph_record, *_CudagraphGlobalRecord.cudagraph_inference_record, - *_CudagraphGlobalRecord.mtp_cudagraph_inference_record, ]: runner = record[0] assert isinstance(runner, _CudaGraphRunner) @@ -518,11 +520,23 @@ def delete_cuda_graphs(): runner.bwd_graph = None runner.mempool = None + # Reset MTP runners (excluded from the global inference record). + for mgr in _CudagraphGlobalRecord.mtp_cudagraph_managers: + for runner in mgr.cudagraph_runners: + runner.cudagraph_created = False + runner.fwd_graph_recorded = False + runner.bwd_graph_recorded = False + runner.fwd_graph = None + runner.bwd_graph = None + runner.mempool = None + mgr.cudagraph_runners.clear() + mgr.inference_cudagraphs_lookup_table.clear() + # Reset global tracking state _CudagraphGlobalRecord.cudagraph_created = False _CudagraphGlobalRecord.cudagraph_record = [] _CudagraphGlobalRecord.cudagraph_inference_record = [] - _CudagraphGlobalRecord.mtp_cudagraph_inference_record = [] + _CudagraphGlobalRecord.mtp_cudagraph_managers = [] # TODO: Optional?: Force garbage collection to clean up memory gc.collect() @@ -1484,6 +1498,10 @@ def wrapped_func(*args, **kwargs): self.inference_cudagraphs_lookup_table: dict = defaultdict(lambda: None) self.is_first_microbatch = False + if is_mtp_inference: + # Registered so delete_cuda_graphs() can clear the lookup table. + _CudagraphGlobalRecord.mtp_cudagraph_managers.append(self) + # Without pipeline parallelism, microbatches execute one at a time. # Therefore modules will always execute in the same order, so cudagraphs # can both be reused and share a single mempool. @@ -1672,14 +1690,11 @@ def __call__(self, megatron_module, args, kwargs): runner.cudagraph_created = True runner = runner.eval() - # Record to the global execution record. MTP runners use a - # separate ledger since they don't chain with decoder layers - # (the previous-layer lookup expects layer_number). - if self.is_mtp_inference: - _CudagraphGlobalRecord.mtp_cudagraph_inference_record.append( - (runner, "fwd", args, kwargs) - ) - else: + # Record to the global execution record. MTP runners are + # excluded — they don't chain with decoder layers (the + # previous-layer lookup expects layer_number) and are + # cleaned up via mtp_cudagraph_managers instead. + if not self.is_mtp_inference: _CudagraphGlobalRecord.cudagraph_inference_record.append( (runner, "fwd", args, kwargs) ) From b074789444df294dab3801f32cf41b8d69d124ca Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:40:35 -0700 Subject: [PATCH 098/124] More cleanup Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 6ad60ff3550..8d1972ff175 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -536,7 +536,6 @@ def delete_cuda_graphs(): _CudagraphGlobalRecord.cudagraph_created = False _CudagraphGlobalRecord.cudagraph_record = [] _CudagraphGlobalRecord.cudagraph_inference_record = [] - _CudagraphGlobalRecord.mtp_cudagraph_managers = [] # TODO: Optional?: Force garbage collection to clean up memory gc.collect() From ed0e0289acf61f519ebb05165f06840e050cc949 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 13:48:28 -0700 Subject: [PATCH 099/124] Restore docstring Signed-off-by: Keshav Santhanam --- megatron/core/models/common/language_module/language_module.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 650fa8f70a1..2bc24948c59 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -356,6 +356,9 @@ def compute_mtp_single_step( ) -> tuple: """Compute a single MTP depth for speculative decoding. + This is called after speculative token verification to compute MTP + predictions conditioned on verified tokens only. + Args: hidden_states (Tensor): Hidden states at last accepted positions. next_token_ids (Tensor): Correct next token IDs [1, N]. From ffe4780f0cc2ab76bb9f916effc54900798b5037 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 14:40:39 -0700 Subject: [PATCH 100/124] Fix skip_weight_param_allocation Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/inference_layers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 504becb6353..6e952e29a78 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -260,6 +260,8 @@ def __init__( assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" # TEColumnParallelLinear rejects gather_output=True, so always pass # False and handle output gathering ourselves in forward(). + # TE also does not support skip_weight_param_allocation, so we always + # let TE allocate the weight and remove it afterwards when sharing. super().__init__( input_size, output_size, @@ -270,10 +272,12 @@ def __init__( skip_bias_add=skip_bias_add, is_expert=is_expert, stride=stride, - skip_weight_param_allocation=skip_weight_param_allocation, + skip_weight_param_allocation=False, tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, ) + if skip_weight_param_allocation: + self.weight = None self.gather_output = gather_output self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) From 3119052b3602333491c4362db84fabde7d7f42c1 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 15:30:37 -0700 Subject: [PATCH 101/124] Revert InferenceColumnParallelLinear changes Signed-off-by: Keshav Santhanam --- .../common/language_module/language_module.py | 8 --- megatron/core/models/gpt/gpt_model.py | 2 +- megatron/core/models/hybrid/hybrid_model.py | 3 +- .../core/tensor_parallel/inference_layers.py | 55 +++++-------------- megatron/core/tensor_parallel/layers.py | 20 ++++++- 5 files changed, 35 insertions(+), 53 deletions(-) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 2bc24948c59..85870726269 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -8,7 +8,6 @@ from megatron.core import parallel_state, tensor_parallel from megatron.core.dist_checkpointing.mapping import ShardedStateDict -from megatron.core.tensor_parallel.inference_layers import InferenceColumnParallelLinear from megatron.core.transformer.cuda_graphs import CudaGraphManager try: @@ -65,13 +64,6 @@ def __init__( self.vp_stage = None self.vp_size = self.config.virtual_pipeline_model_parallel_size - @staticmethod - def _get_output_layer_cls(config: TransformerConfig): - """Return the column-parallel class for the output projection layer.""" - if config.transformer_impl == "inference_optimized": - return InferenceColumnParallelLinear - return tensor_parallel.ColumnParallelLinear - def _setup_mtp_cuda_graphs(self): """Wrap `compute_mtp_single_step` with a CudaGraphManager. diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index b8b6b8b64fb..a74e94e0052 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -243,7 +243,7 @@ def __init__( self.embedding_activation_buffer = None self.grad_output_buffer = None - self.output_layer = self._get_output_layer_cls(config)( + self.output_layer = tensor_parallel.ColumnParallelLinear( config.hidden_size, self.vocab_size, config=config, diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index 77be15c9529..c1afbc4b856 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -5,6 +5,7 @@ from torch import Tensor +from megatron.core import tensor_parallel from megatron.core.config_logger import has_config_logger_enabled, log_config_to_disk from megatron.core.inference.contexts import BaseInferenceContext from megatron.core.models.common.embeddings.language_model_embedding import LanguageModelEmbedding @@ -261,7 +262,7 @@ def __init__( # Output if post_process or self.mtp_process: - self.output_layer = self._get_output_layer_cls(config)( + self.output_layer = tensor_parallel.ColumnParallelLinear( config.hidden_size, self.vocab_size, config=config, diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 6e952e29a78..8cb36955776 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -251,34 +251,24 @@ def __init__( is_expert: bool, stride: int = 1, skip_weight_param_allocation: bool = False, - # Accepted for signature compatibility with ColumnParallelLinear but unused at inference. - embedding_activation_buffer: Optional[list] = None, - grad_output_buffer: Optional[list] = None, tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" - # TEColumnParallelLinear rejects gather_output=True, so always pass - # False and handle output gathering ourselves in forward(). - # TE also does not support skip_weight_param_allocation, so we always - # let TE allocate the weight and remove it afterwards when sharing. super().__init__( input_size, output_size, config=config, init_method=init_method, - gather_output=False, + gather_output=gather_output, bias=bias, skip_bias_add=skip_bias_add, is_expert=is_expert, stride=stride, - skip_weight_param_allocation=False, + skip_weight_param_allocation=skip_weight_param_allocation, tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, ) - if skip_weight_param_allocation: - self.weight = None - self.gather_output = gather_output self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) @@ -293,13 +283,6 @@ def __init__( self.triton_nvls_kernels_allowed = not config.inference_disable_triton_nvls_kernels - def get_extra_state(self) -> None: - """ - Suppress TE's FP8 extra state. - This layer bypasses TE's forward and uses _apply_linear. - """ - return None - def _maybe_allocate_symmetric_buffer(self, x: torch.Tensor): """ Attempt to allocate symmetric memory buffer for all-gather. @@ -330,32 +313,20 @@ def _all_gather(self, x: torch.Tensor, symm_mem_buffer: dict) -> None: x, _ = gather_along_first_dim(x, process_group=self.tp_group) return x - def forward( - self, - x: torch.Tensor, - weight: Optional[torch.Tensor] = None, - runtime_gather_output: Optional[bool] = None, - ) -> Tuple[torch.Tensor, None]: - """Forward pass.""" - # Fall back to self.weight when caller passes None (e.g. output layer - # without shared embedding weights), matching ColumnParallelLinear. - if weight is None: - weight = self.weight + def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, None]: + """ + Forward pass. + """ + if self.training: + return super().forward(x) + if self.tp_size == 1: - x = _apply_linear(x, weight, self.config) + x = _apply_linear(x, self.weight, self.config) return x, None - if self.sequence_parallel: - symm_mem_buffer = self._maybe_allocate_symmetric_buffer(x) - x = self._all_gather(x, symm_mem_buffer) - - x = _apply_linear(x, weight, self.config) - - gather_output = self.gather_output - if runtime_gather_output is not None: - gather_output = runtime_gather_output - if gather_output: - x = inference_all_gather_last_dim(x, self.tp_group, self.config) + symm_mem_buffer = self._maybe_allocate_symmetric_buffer(x) + x = self._all_gather(x, symm_mem_buffer) + x = _apply_linear(x, self.weight, self.config) return x, None diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index ec011da7845..fb8e00fe476 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -960,6 +960,14 @@ def __init__( else: self.register_parameter("bias", None) + self.use_inference_optimized_all_gather = ( + getattr(config, 'transformer_impl', None) == 'inference_optimized' + ) + self.triton_nvls_kernels_allowed = ( + self.use_inference_optimized_all_gather + and not getattr(config, 'inference_disable_triton_nvls_kernels', False) + ) + self.sequence_parallel = config.sequence_parallel if self.sequence_parallel and world_size <= 1: warnings.warn( @@ -1095,7 +1103,17 @@ def forward( if gather_output: # All-gather across the partitions. - output = gather_from_tensor_model_parallel_region(output_parallel, group=self.tp_group) + if self.use_inference_optimized_all_gather and not self.training: + # Deferred to avoid circular import: inference_layers → TE → layers. + from .inference_layers import inference_all_gather_last_dim + + output = inference_all_gather_last_dim( + output_parallel, self.tp_group, self.config + ) + else: + output = gather_from_tensor_model_parallel_region( + output_parallel, group=self.tp_group + ) else: output = output_parallel output_bias = self.bias if self.skip_bias_add else None From d991265bd584f2ec5c3b3fa4b355544dbc5387db Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 15:32:05 -0700 Subject: [PATCH 102/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/layers.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index fb8e00fe476..06cb2048a80 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -963,9 +963,8 @@ def __init__( self.use_inference_optimized_all_gather = ( getattr(config, 'transformer_impl', None) == 'inference_optimized' ) - self.triton_nvls_kernels_allowed = ( - self.use_inference_optimized_all_gather - and not getattr(config, 'inference_disable_triton_nvls_kernels', False) + self.triton_nvls_kernels_allowed = self.use_inference_optimized_all_gather and not getattr( + config, 'inference_disable_triton_nvls_kernels', False ) self.sequence_parallel = config.sequence_parallel @@ -1107,9 +1106,7 @@ def forward( # Deferred to avoid circular import: inference_layers → TE → layers. from .inference_layers import inference_all_gather_last_dim - output = inference_all_gather_last_dim( - output_parallel, self.tp_group, self.config - ) + output = inference_all_gather_last_dim(output_parallel, self.tp_group, self.config) else: output = gather_from_tensor_model_parallel_region( output_parallel, group=self.tp_group From 9a72316fe7f0379637ab9c8e4811d55539c2a92d Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Apr 2026 15:35:50 -0700 Subject: [PATCH 103/124] Standardize inference_reduce_scatter_first_dim API Signed-off-by: Keshav Santhanam --- .../core/tensor_parallel/inference_layers.py | 40 ++++++++++++++++- megatron/core/tensor_parallel/layers.py | 44 +++---------------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 8cb36955776..7f7e9058160 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -20,7 +20,10 @@ from megatron.core.inference.quantization.utils import mm_mxfp8 from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.model_parallel_config import ModelParallelConfig -from megatron.core.tensor_parallel.mappings import gather_from_tensor_model_parallel_region +from megatron.core.tensor_parallel.mappings import ( + gather_from_tensor_model_parallel_region, + reduce_scatter_to_sequence_parallel_region, +) from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import get_tensor_model_parallel_group_if_none @@ -509,3 +512,38 @@ def inference_all_gather_last_dim( return torch.cat(tensor_list, dim=-1).contiguous() return gather_from_tensor_model_parallel_region(x, group=tp_group) + + +def inference_reduce_scatter_first_dim( + x: torch.Tensor, tp_group: torch.distributed.ProcessGroup, config: TransformerConfig +) -> torch.Tensor: + """NVLS-optimized reduce-scatter along the first dimension, with NCCL fallback. + + Replaces `reduce_scatter_to_sequence_parallel_region` in inference paths + where autograd is not needed and NVLS symmetric-memory is available. + """ + tp_size = dist.get_world_size(tp_group) + if tp_size == 1: + return x + + triton_nvls_kernels_allowed = not getattr( + config, 'inference_disable_triton_nvls_kernels', False + ) + + if triton_nvls_kernels_allowed and SymmetricMemoryManager.is_initialized("tp"): + buf = SymmetricMemoryManager.get_buffer("tp", process_group=tp_group) + symm_mem_buffer = buf.maybe_get_tensor(list(x.size()), dtype=x.dtype) + + if ( + x.dtype == torch.bfloat16 + and are_tensors_nvls_eligible(x) + and symm_mem_buffer["handle"] is not None + ): + symm_mem_buffer["tensor"].copy_(x) + output_dims = list(x.size()) + output_dims[0] = x.size(0) // tp_size + output = torch.empty(output_dims, dtype=x.dtype, device=x.device) + multimem_reduce_scatter(output, symm_mem_buffer["tensor"], symm_mem_buffer["handle"]) + return output + + return reduce_scatter_to_sequence_parallel_region(x, group=tp_group) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index fb8e00fe476..165c499bd43 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -235,14 +235,11 @@ def __init__( ) self.num_embeddings_per_partition = self.vocab_end_index - self.vocab_start_index self.deterministic_mode = config.deterministic_mode + self.config = config self.use_inference_optimized_reduce_scatter = ( getattr(config, 'transformer_impl', None) == 'inference_optimized' ) - self.triton_nvls_kernels_allowed = ( - self.use_inference_optimized_reduce_scatter - and not getattr(config, 'inference_disable_triton_nvls_kernels', False) - ) # Allocate weights and initialize. if config.use_cpu_initialization: @@ -311,7 +308,12 @@ def forward(self, input_): # Data format change to avoid explicit tranposes : [b s h] --> [s b h]. output_parallel = output_parallel.transpose(0, 1).contiguous() if self.use_inference_optimized_reduce_scatter and not self.training: - output = self._inference_reduce_scatter(output_parallel) + # Deferred to avoid circular import: inference_layers → TE → layers. + from .inference_layers import inference_reduce_scatter_first_dim + + output = inference_reduce_scatter_first_dim( + output_parallel, self.tp_group, self.config + ) else: output = reduce_scatter_to_sequence_parallel_region( output_parallel, group=self.tp_group @@ -321,34 +323,6 @@ def forward(self, input_): output = reduce_from_tensor_model_parallel_region(output_parallel, group=self.tp_group) return output - def _inference_reduce_scatter(self, input_: torch.Tensor) -> torch.Tensor: - """NVLS-optimized reduce scatter with NCCL fallback for inference.""" - from megatron.core.inference.communication.torch_symm_triton import ( - are_tensors_nvls_eligible, - multimem_reduce_scatter, - ) - from megatron.core.inference.symmetric_memory import SymmetricMemoryManager - - buf = SymmetricMemoryManager.get_buffer("tp", process_group=self.tp_group) - symm_mem_buffer = buf.maybe_get_tensor(list(input_.size()), dtype=input_.dtype) - - can_use_nvls = ( - self.triton_nvls_kernels_allowed - and input_.dtype == torch.bfloat16 - and are_tensors_nvls_eligible(input_) - and symm_mem_buffer["handle"] is not None - ) - - if can_use_nvls: - symm_mem_buffer["tensor"].copy_(input_) - output_dims = list(input_.size()) - output_dims[0] = input_.size(0) // self.tp_group.size() - output = torch.empty(output_dims, dtype=input_.dtype, device=input_.device) - multimem_reduce_scatter(output, symm_mem_buffer["tensor"], symm_mem_buffer["handle"]) - return output - else: - return reduce_scatter_to_sequence_parallel_region(input_, group=self.tp_group) - def sharded_state_dict( self, prefix: str = "", @@ -963,10 +937,6 @@ def __init__( self.use_inference_optimized_all_gather = ( getattr(config, 'transformer_impl', None) == 'inference_optimized' ) - self.triton_nvls_kernels_allowed = ( - self.use_inference_optimized_all_gather - and not getattr(config, 'inference_disable_triton_nvls_kernels', False) - ) self.sequence_parallel = config.sequence_parallel if self.sequence_parallel and world_size <= 1: From 4e56aeee1c9526b25ca5fb73ed416646e99a34f4 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 09:21:25 -0700 Subject: [PATCH 104/124] Remove is_expert setting Signed-off-by: Keshav Santhanam --- megatron/core/models/gpt/gpt_model.py | 1 - megatron/core/models/hybrid/hybrid_model.py | 1 - 2 files changed, 2 deletions(-) diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index a74e94e0052..dedada837b7 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -254,7 +254,6 @@ def __init__( ), bias=False, skip_bias_add=False, - is_expert=False, gather_output=not self.parallel_output, skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index c1afbc4b856..768d4a138f2 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -273,7 +273,6 @@ def __init__( ), bias=False, skip_bias_add=False, - is_expert=False, gather_output=not self.parallel_output, skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, From 9d906397c4c4b92aad471d80aa4c583611eb80db Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 11:26:04 -0700 Subject: [PATCH 105/124] Address reviewer comments Signed-off-by: Keshav Santhanam --- ...{triton_kernels.py => mtp_utils_triton.py} | 0 .../text_generation_controller.py | 2 +- .../core/tensor_parallel/inference_layers.py | 6 +- megatron/core/tensor_parallel/layers.py | 8 +- .../transformer/multi_token_prediction.py | 4 +- ...st_triton_kernels.py => test_mtp_utils.py} | 193 +----------------- .../test_text_generation_controller.py | 2 +- 7 files changed, 21 insertions(+), 194 deletions(-) rename megatron/core/inference/text_generation_controllers/{triton_kernels.py => mtp_utils_triton.py} (100%) rename tests/unit_tests/inference/text_generation_controllers/{test_triton_kernels.py => test_mtp_utils.py} (81%) diff --git a/megatron/core/inference/text_generation_controllers/triton_kernels.py b/megatron/core/inference/text_generation_controllers/mtp_utils_triton.py similarity index 100% rename from megatron/core/inference/text_generation_controllers/triton_kernels.py rename to megatron/core/inference/text_generation_controllers/mtp_utils_triton.py diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 83f825afa1d..6a1c028350c 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -54,7 +54,7 @@ HAVE_TE = False from megatron.core.inference.batch_dimensions_utils import InferenceBatchDimensions -from megatron.core.inference.text_generation_controllers.triton_kernels import ( +from megatron.core.inference.text_generation_controllers.mtp_utils_triton import ( mamba_state_selective_copy, prepare_next_forward_pass, rewind_kv_cache, diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 7f7e9058160..14ac28fbefa 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -479,7 +479,7 @@ def forward( return x, None -def inference_all_gather_last_dim( +def inference_all_gather_from_tensor_model_parallel_region( x: torch.Tensor, tp_group: torch.distributed.ProcessGroup, config: TransformerConfig ) -> torch.Tensor: """NVLS-optimized all-gather along the last dimension, with NCCL fallback. @@ -514,7 +514,7 @@ def inference_all_gather_last_dim( return gather_from_tensor_model_parallel_region(x, group=tp_group) -def inference_reduce_scatter_first_dim( +def inference_reduce_scatter_to_sequence_parallel_region( x: torch.Tensor, tp_group: torch.distributed.ProcessGroup, config: TransformerConfig ) -> torch.Tensor: """NVLS-optimized reduce-scatter along the first dimension, with NCCL fallback. @@ -522,6 +522,8 @@ def inference_reduce_scatter_first_dim( Replaces `reduce_scatter_to_sequence_parallel_region` in inference paths where autograd is not needed and NVLS symmetric-memory is available. """ + # TODO(ksanthanam): Refactor InferenceRowParallelLinear._matmul_reduce_scatter + # to use this function for its non-fused NVLS reduce-scatter path. tp_size = dist.get_world_size(tp_group) if tp_size == 1: return x diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 274b78b9edf..2712937360b 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -309,9 +309,9 @@ def forward(self, input_): output_parallel = output_parallel.transpose(0, 1).contiguous() if self.use_inference_optimized_reduce_scatter and not self.training: # Deferred to avoid circular import: inference_layers → TE → layers. - from .inference_layers import inference_reduce_scatter_first_dim + from .inference_layers import inference_reduce_scatter_to_sequence_parallel_region - output = inference_reduce_scatter_first_dim( + output = inference_reduce_scatter_to_sequence_parallel_region( output_parallel, self.tp_group, self.config ) else: @@ -1075,9 +1075,9 @@ def forward( # All-gather across the partitions. if self.use_inference_optimized_all_gather and not self.training: # Deferred to avoid circular import: inference_layers → TE → layers. - from .inference_layers import inference_all_gather_last_dim + from .inference_layers import inference_all_gather_from_tensor_model_parallel_region - output = inference_all_gather_last_dim(output_parallel, self.tp_group, self.config) + output = inference_all_gather_from_tensor_model_parallel_region(output_parallel, self.tp_group, self.config) else: output = gather_from_tensor_model_parallel_region( output_parallel, group=self.tp_group diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index f185693ce3f..dbaecf7720a 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -22,7 +22,7 @@ gather_from_tensor_model_parallel_region, scatter_to_sequence_parallel_region, ) -from megatron.core.tensor_parallel.inference_layers import inference_all_gather_last_dim +from megatron.core.tensor_parallel.inference_layers import inference_all_gather_from_tensor_model_parallel_region from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module @@ -921,7 +921,7 @@ def _concat_embeddings(self, hidden_states: torch.Tensor, decoder_input: torch.T # For tensor parallel we need to gather the tensor across the model-parallel # ranks after the linear projection. if not self.training: - hidden_states = inference_all_gather_last_dim(hidden_states, self.tp_group, self.config) + hidden_states = inference_all_gather_from_tensor_model_parallel_region(hidden_states, self.tp_group, self.config) else: hidden_states = gather_from_tensor_model_parallel_region( hidden_states, group=self.tp_group diff --git a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py similarity index 81% rename from tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py rename to tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py index ab4a5a22f0b..4a2895229cf 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_triton_kernels.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py @@ -2,202 +2,27 @@ """Unit tests for MTP Triton kernels. -Each test provides a pure-PyTorch reference implementation of the operation, -runs both the reference and the Triton kernel on the same inputs, and asserts +Each test runs both the pure-PyTorch reference (from mtp_utils_pytorch) and +the Triton kernel (from mtp_utils_triton) on the same inputs, and asserts that the outputs match exactly. """ -import math - import pytest import torch -from megatron.core.inference.text_generation_controllers.triton_kernels import ( +from megatron.core.inference.text_generation_controllers.mtp_utils_pytorch import ( + mamba_state_selective_copy as mamba_state_selective_copy_pytorch, + prepare_next_forward_pass as prepare_next_forward_pass_pytorch, + rewind_kv_cache as rewind_kv_cache_pytorch, + verify_speculative_tokens as verify_speculative_tokens_pytorch, +) +from megatron.core.inference.text_generation_controllers.mtp_utils_triton import ( mamba_state_selective_copy, prepare_next_forward_pass, rewind_kv_cache, verify_speculative_tokens, ) -# --------------------------------------------------------------------------- -# PyTorch reference implementations -# --------------------------------------------------------------------------- - - -def rewind_kv_cache_pytorch( - accepted_counts, - prefill_status, - last_kv_block_offset, - kv_length_offsets, - kv_block_counts, - last_kv_block_id, - kv_block_ids, - num_speculative_tokens, - block_size_tokens, - num_active_requests=None, -): - """Pure-PyTorch reference for the KV-cache rewind operation. - - Mirrors the original `TextGenerationController._rewind_kv_cache` logic - (KV-cache portion only, no Mamba state updates). Mutates the input tensors - in-place, just like the Triton kernel. - - Returns (blocks_to_release, remove_mask). - """ - N = accepted_counts.shape[0] - if num_active_requests is None: - num_active_requests = N - - blocks_to_release = torch.empty_like(last_kv_block_id) - remove_mask = torch.empty(N, device=accepted_counts.device, dtype=torch.bool) - - for i in range(N): - if i >= num_active_requests: - blocks_to_release[i] = 0 - remove_mask[i] = False - continue - - accepted = accepted_counts[i].item() - prefill = prefill_status[i].item() - last_offset = last_kv_block_offset[i].item() - kv_length = kv_length_offsets[i].item() - block_count = kv_block_counts[i].item() - last_block = last_kv_block_id[i].item() - - num_to_rewind = 0 if prefill == 1 else num_speculative_tokens - accepted - diff = last_offset - num_to_rewind - remove = diff < 0 - - new_offset = diff % block_size_tokens - last_kv_block_offset[i] = new_offset - kv_length_offsets[i] = kv_length - num_to_rewind - - blocks_to_release[i] = last_block - - new_block_count = block_count - 1 if remove else block_count - kv_block_counts[i] = new_block_count - - prev_idx = max(new_block_count - 1, 0) - prev_block_id = kv_block_ids[i, prev_idx].item() - - last_kv_block_id[i] = prev_block_id if remove else last_block - - scatter_idx = min(new_block_count, kv_block_ids.shape[1] - 1) - if remove: - kv_block_ids[i, scatter_idx] = -1 - - remove_mask[i] = remove - - return blocks_to_release, remove_mask - - -def verify_speculative_tokens_pytorch( - input_tokens, output_tokens, num_decode_requests, num_prefill_requests, num_speculative_tokens -): - """Pure-PyTorch reference for speculative token verification. - - Mirrors the original `TextGenerationController._verify_speculative_tokens` - logic. - """ - if input_tokens.ndim == 2: - input_tokens = input_tokens.squeeze(0) - - stride = num_speculative_tokens + 1 - active_request_count = num_decode_requests + num_prefill_requests - decode_len = num_decode_requests * stride - - accepted_tokens_mask = torch.zeros_like(input_tokens, dtype=torch.bool) - - decode_mask_2d = None - if num_decode_requests > 0: - decode_inputs = input_tokens[:decode_len].reshape(num_decode_requests, stride) - decode_outputs = output_tokens[:decode_len].reshape(num_decode_requests, stride) - - decode_outputs_shifted = decode_outputs.roll(1, dims=1) - decode_mask_2d = decode_inputs == decode_outputs_shifted - decode_mask_2d[:, 0] = True - decode_mask_2d = decode_mask_2d.cummin(dim=1).values - accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() - - if num_prefill_requests > 0: - accepted_tokens_mask[decode_len:] = True - - last_one_indices = torch.full( - (active_request_count,), -1, device=input_tokens.device, dtype=torch.long - ) - - if num_decode_requests > 0: - local_last_indices = decode_mask_2d.sum(dim=1) - 1 - row_offsets = torch.arange(num_decode_requests, device=input_tokens.device) * stride - last_one_indices[:num_decode_requests] = row_offsets + local_last_indices - - if num_prefill_requests > 0: - prefill_valid = torch.nonzero(accepted_tokens_mask[decode_len:]).squeeze(-1) + decode_len - last_one_indices[num_decode_requests:] = prefill_valid - - return last_one_indices, accepted_tokens_mask, input_tokens - - -def prepare_next_forward_pass_pytorch( - num_decode_requests, - output_tokens, - required_logit_indices, - last_one_indices, - accepted_tokens_mask, - input_tokens, - sampled_tokens_buf, - last_accepted_seq_buf, - accepted_tokens_per_request, - accepted_token_counts, - num_speculative_tokens, -): - """Pure-PyTorch reference for preparing the next forward pass. - - Mirrors the original `_dynamic_step_sample_logits_and_verify_tokens` - post-verification logic. - """ - active_request_count = last_one_indices.shape[0] - stride = num_speculative_tokens + 1 - - for pid in range(active_request_count): - idx = last_one_indices[pid].item() - sampled_tokens_buf[pid] = output_tokens[idx] - last_accepted_seq_buf[pid] = required_logit_indices[idx] - - if pid < num_decode_requests: - base = pid * stride - for s in range(num_speculative_tokens): - pos = base + 1 + s - if accepted_tokens_mask[pos]: - accepted_tokens_per_request[pid, s] = input_tokens[pos] - else: - accepted_tokens_per_request[pid, s] = -1 - - count = 0 - for s in range(num_speculative_tokens): - if accepted_tokens_per_request[pid, s].item() != -1: - count += 1 - accepted_token_counts[pid] = count - - -def mamba_state_selective_copy_pytorch( - intermediate_states, current_states, prefill_status, state_idx, accepted_counts, num_layers -): - """Pure-PyTorch reference for Mamba state selective copy. - - For each decode request, copies - `intermediate[layer, slot, accepted_count, ...]` → - `current[layer, slot, ...]` for every Mamba layer. - """ - N = prefill_status.shape[0] - for i in range(N): - if prefill_status[i].item() == 1: - continue - slot = state_idx[i].item() - accepted = accepted_counts[i].item() - for layer in range(num_layers): - current_states[layer, slot] = intermediate_states[layer, slot, accepted] - # --------------------------------------------------------------------------- # Test helpers diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index b37d3add10e..da740e30d23 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1263,7 +1263,7 @@ def test_rewind_kv_cache_stale_padding_is_safe(self): would produce remove_mask=True, causing the block allocator to free block IDs that belong to other active requests. """ - from megatron.core.inference.text_generation_controllers.triton_kernels import ( + from megatron.core.inference.text_generation_controllers.mtp_utils_triton import ( rewind_kv_cache, ) From adafdd13388e18ad523573f5dd48ef3b4700e45f Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 11:27:23 -0700 Subject: [PATCH 106/124] Linting Signed-off-by: Keshav Santhanam --- megatron/core/tensor_parallel/layers.py | 4 +++- megatron/core/transformer/multi_token_prediction.py | 8 ++++++-- .../text_generation_controllers/test_mtp_utils.py | 7 ++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 2712937360b..4ab2aa0f639 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -1077,7 +1077,9 @@ def forward( # Deferred to avoid circular import: inference_layers → TE → layers. from .inference_layers import inference_all_gather_from_tensor_model_parallel_region - output = inference_all_gather_from_tensor_model_parallel_region(output_parallel, self.tp_group, self.config) + output = inference_all_gather_from_tensor_model_parallel_region( + output_parallel, self.tp_group, self.config + ) else: output = gather_from_tensor_model_parallel_region( output_parallel, group=self.tp_group diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index dbaecf7720a..2e0461e365c 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -22,7 +22,9 @@ gather_from_tensor_model_parallel_region, scatter_to_sequence_parallel_region, ) -from megatron.core.tensor_parallel.inference_layers import inference_all_gather_from_tensor_model_parallel_region +from megatron.core.tensor_parallel.inference_layers import ( + inference_all_gather_from_tensor_model_parallel_region, +) from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module @@ -921,7 +923,9 @@ def _concat_embeddings(self, hidden_states: torch.Tensor, decoder_input: torch.T # For tensor parallel we need to gather the tensor across the model-parallel # ranks after the linear projection. if not self.training: - hidden_states = inference_all_gather_from_tensor_model_parallel_region(hidden_states, self.tp_group, self.config) + hidden_states = inference_all_gather_from_tensor_model_parallel_region( + hidden_states, self.tp_group, self.config + ) else: hidden_states = gather_from_tensor_model_parallel_region( hidden_states, group=self.tp_group diff --git a/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py b/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py index 4a2895229cf..16d9d901624 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_mtp_utils.py @@ -12,8 +12,14 @@ from megatron.core.inference.text_generation_controllers.mtp_utils_pytorch import ( mamba_state_selective_copy as mamba_state_selective_copy_pytorch, +) +from megatron.core.inference.text_generation_controllers.mtp_utils_pytorch import ( prepare_next_forward_pass as prepare_next_forward_pass_pytorch, +) +from megatron.core.inference.text_generation_controllers.mtp_utils_pytorch import ( rewind_kv_cache as rewind_kv_cache_pytorch, +) +from megatron.core.inference.text_generation_controllers.mtp_utils_pytorch import ( verify_speculative_tokens as verify_speculative_tokens_pytorch, ) from megatron.core.inference.text_generation_controllers.mtp_utils_triton import ( @@ -23,7 +29,6 @@ verify_speculative_tokens, ) - # --------------------------------------------------------------------------- # Test helpers # --------------------------------------------------------------------------- From 1700d02186e94fb1620ba1a6398eac5767b80748 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 11:30:22 -0700 Subject: [PATCH 107/124] Add mtp_utils_pytorch.py Signed-off-by: Keshav Santhanam --- .../mtp_utils_pytorch.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py diff --git a/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py b/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py new file mode 100644 index 00000000000..226078b7a35 --- /dev/null +++ b/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py @@ -0,0 +1,242 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import torch + + +def rewind_kv_cache( + accepted_counts, + prefill_status, + last_kv_block_offset, + kv_length_offsets, + kv_block_counts, + last_kv_block_id, + kv_block_ids, + num_speculative_tokens, + block_size_tokens, + num_active_requests=None, +): + """Update the KV cache bookkeeping for speculative decoding. + + After forward pass with speculative tokens, some tokens may be rejected. + This function "rewinds" the KV cache bookkeeping to reflect only the accepted tokens. + + When speculative tokens are rejected, we need to: + 1. Update kv_length_offsets (total sequence length) + 2. Update last_kv_block_offset (position within last block) + 3. If rewinding crosses a block boundary: + - Reduce kv_block_counts + - Update last_kv_block_id to point to the previous block + - Clear the entry in kv_block_ids for the released block + + Mutates the input tensors in-place. + + Returns (blocks_to_release, remove_mask). + """ + N = accepted_counts.shape[0] + if num_active_requests is None: + num_active_requests = N + + blocks_to_release = torch.empty_like(last_kv_block_id) + remove_mask = torch.empty(N, device=accepted_counts.device, dtype=torch.bool) + + for i in range(N): + if i >= num_active_requests: + blocks_to_release[i] = 0 + remove_mask[i] = False + continue + + accepted = accepted_counts[i].item() + prefill = prefill_status[i].item() + last_offset = last_kv_block_offset[i].item() + kv_length = kv_length_offsets[i].item() + block_count = kv_block_counts[i].item() + last_block = last_kv_block_id[i].item() + + # Number of tokens to rewind (rejected speculative tokens). + # For prefill requests, no speculative tokens were forwarded through the model, + # so there is nothing to rewind. + num_to_rewind = 0 if prefill == 1 else num_speculative_tokens - accepted + + # Save the original offset BEFORE modifying to correctly detect block boundary crossing. + # A request crosses back to a previous block if: original_offset - num_to_rewind < 0 + diff = last_offset - num_to_rewind + remove = diff < 0 + + # Update the offsets + new_offset = diff % block_size_tokens + last_kv_block_offset[i] = new_offset + kv_length_offsets[i] = kv_length - num_to_rewind + + # For requests that crossed back to a previous block, we need to: + # 1. Reduce the block count by 1 + # 2. Get the block ID to release (current last_kv_block_id) + # 3. Update last_kv_block_id to point to the previous block + # 4. Clear the entry in kv_block_ids for the released block + # 5. Release the block back to the allocator + blocks_to_release[i] = last_block + + # Reduce block counts for requests that crossed back + new_block_count = block_count - 1 if remove else block_count + kv_block_counts[i] = new_block_count + + # Update last_kv_block_id to point to the previous block (at index new_count - 1) + prev_idx = max(new_block_count - 1, 0) + prev_block_id = kv_block_ids[i, prev_idx].item() + last_kv_block_id[i] = prev_block_id if remove else last_block + + # Clear the released block entry (at index new_count, which was the old last block) + scatter_idx = min(new_block_count, kv_block_ids.shape[1] - 1) + if remove: + kv_block_ids[i, scatter_idx] = -1 + + remove_mask[i] = remove + + return blocks_to_release, remove_mask + + +def verify_speculative_tokens( + input_tokens, output_tokens, num_decode_requests, num_prefill_requests, num_speculative_tokens +): + """Verify speculative tokens against input tokens and compute acceptance. + + Creates an accepted tokens mask where: + - For prefill requests, the token is always accepted. + - For decode requests, the first token (base token) is always accepted, then we compare + sampled tokens with input tokens and accept consecutive matches. + Then finds the index of the last accepted token per request. + + Example (assume 1, 2, and 0 spec tokens are accepted in the first 3 decode requests): + input_tokens_required: [ a5 a6s a7s | b3 b4s b5s | c6 c7s c8s | d2 | e4 ] # Size 11 + Output tokens [ a6o a7o a8o | b40 b5o b6o | c7o c8o c9o | d3o | e5o ] + Output tokens right shift [ d3o a6o a7o | a8o b40 b5o | b6o c7o c8o | c9o | d3o ] + Accepted tokens mask [ 1 1 0 | 1 1 1 | 1 0 0 | 1 | 1 ] + Last one indices [ 1 | 5 | 6 | 9 | 10 ] + + Returns: + tuple: (last_one_indices, accepted_tokens_mask, input_tokens) where + last_one_indices contains the index of the last accepted token per request. + """ + if input_tokens.ndim == 2: + input_tokens = input_tokens.squeeze(0) + + stride = num_speculative_tokens + 1 + active_request_count = num_decode_requests + num_prefill_requests + decode_len = num_decode_requests * stride + + # Initialize mask with False to prevent boundary bleed + accepted_tokens_mask = torch.zeros_like(input_tokens, dtype=torch.bool) + + # Safe decode token verification without cross-batch boundary contamination + decode_mask_2d = None + if num_decode_requests > 0: + decode_inputs = input_tokens[:decode_len].reshape(num_decode_requests, stride) + decode_outputs = output_tokens[:decode_len].reshape(num_decode_requests, stride) + + # Shift outputs right by 1 *within* each request to align sampled tokens with input targets + decode_outputs_shifted = decode_outputs.roll(1, dims=1) + decode_mask_2d = decode_inputs == decode_outputs_shifted + # The first token (base token) is always accepted + decode_mask_2d[:, 0] = True + # Enforce consecutive acceptance: cummin propagates False to the right + decode_mask_2d = decode_mask_2d.cummin(dim=1).values + accepted_tokens_mask[:decode_len] = decode_mask_2d.flatten() + + # Make all prefill tokens accepted + if num_prefill_requests > 0: + accepted_tokens_mask[decode_len:] = True + + last_one_indices = torch.full( + (active_request_count,), -1, device=input_tokens.device, dtype=torch.long + ) + + if num_decode_requests > 0: + # Summing the consecutive mask gives the count; subtract 1 for the local index + local_last_indices = decode_mask_2d.sum(dim=1) - 1 + row_offsets = torch.arange(num_decode_requests, device=input_tokens.device) * stride + last_one_indices[:num_decode_requests] = row_offsets + local_last_indices + + if num_prefill_requests > 0: + prefill_valid = torch.nonzero(accepted_tokens_mask[decode_len:]).squeeze(-1) + decode_len + last_one_indices[num_decode_requests:] = prefill_valid + + return last_one_indices, accepted_tokens_mask, input_tokens + + +def prepare_next_forward_pass( + num_decode_requests, + output_tokens, + required_logit_indices, + last_one_indices, + accepted_tokens_mask, + input_tokens, + sampled_tokens_buf, + last_accepted_seq_buf, + accepted_tokens_per_request, + accepted_token_counts, + num_speculative_tokens, +): + """Prepare data for the next forward pass after speculative token verification. + + For each active request: + - Store the final sampled tokens for the next forward pass. + - Store the last accepted positions in the packed sequence for serial + MTP computation after verification. + + For decode requests, extract accepted tokens and counts: + input_tokens_required: [ a5 a6s a7s | b3 b4s b5s | c6 c7s c8s | d2 | e4 ] + Accepted tokens mask [ 1 1 0 | 1 1 1 | 1 0 0 | 1 | 1 ] + Accepted tokens [ [a6s -1] | [b4s b5s] | [-1 -1] ] # Only decode requests (prefill defaults to -1) + Accepted token counts [ 1 | 2 | 0 ] # Prefill defaults to 0 + + Writes results into the pre-allocated buffers provided by the caller. + """ + active_request_count = last_one_indices.shape[0] + stride = num_speculative_tokens + 1 + + for pid in range(active_request_count): + idx = last_one_indices[pid].item() + + # Store the final sampled tokens for the next forward pass. + sampled_tokens_buf[pid] = output_tokens[idx] + + # Store the last accepted positions in the packed sequence for serial + # MTP computation after verification. + last_accepted_seq_buf[pid] = required_logit_indices[idx] + + # Extract accepted tokens and counts for decode requests. + # For prefill it is always set to 1. For decode, the first token is always accepted, + # then we compare with input tokens and accept the next tokens if its a match. + if pid < num_decode_requests: + base = pid * stride + # Skip the first token of every decode request (i.e a5, b3, c6) + for s in range(num_speculative_tokens): + pos = base + 1 + s + if accepted_tokens_mask[pos]: + accepted_tokens_per_request[pid, s] = input_tokens[pos] + else: + accepted_tokens_per_request[pid, s] = -1 + + count = 0 + for s in range(num_speculative_tokens): + if accepted_tokens_per_request[pid, s].item() != -1: + count += 1 + accepted_token_counts[pid] = count + + +def mamba_state_selective_copy( + intermediate_states, current_states, prefill_status, state_idx, accepted_counts, num_layers +): + """Mamba speculative rewind state update. + + For each decode request, copies + `intermediate[layer, slot, accepted_count, ...]` → + `current[layer, slot, ...]` for every Mamba layer. + """ + N = prefill_status.shape[0] + for i in range(N): + if prefill_status[i].item() == 1: + continue + slot = state_idx[i].item() + accepted = accepted_counts[i].item() + for layer in range(num_layers): + current_states[layer, slot] = intermediate_states[layer, slot, accepted] From d75ed23bfcf293c5fcd9fe154e610c41309f8eff Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 11:38:28 -0700 Subject: [PATCH 108/124] Linting Signed-off-by: Keshav Santhanam --- .../inference/text_generation_controllers/mtp_utils_pytorch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py b/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py index 226078b7a35..59bad67d70a 100644 --- a/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py +++ b/megatron/core/inference/text_generation_controllers/mtp_utils_pytorch.py @@ -94,6 +94,7 @@ def rewind_kv_cache( return blocks_to_release, remove_mask +# pylint: disable=line-too-long def verify_speculative_tokens( input_tokens, output_tokens, num_decode_requests, num_prefill_requests, num_speculative_tokens ): @@ -162,6 +163,7 @@ def verify_speculative_tokens( return last_one_indices, accepted_tokens_mask, input_tokens +# pylint: disable=line-too-long def prepare_next_forward_pass( num_decode_requests, output_tokens, From 4bab4bec8ff42e9de21b5c73a57991f14f6b1efd Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 15:06:49 -0700 Subject: [PATCH 109/124] setup_method -> setup_cls Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/test_batch_dimension_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/inference/test_batch_dimension_utils.py b/tests/unit_tests/inference/test_batch_dimension_utils.py index f520c2441d7..3264d1c5171 100644 --- a/tests/unit_tests/inference/test_batch_dimension_utils.py +++ b/tests/unit_tests/inference/test_batch_dimension_utils.py @@ -352,14 +352,16 @@ def test_one_rank_oversized_forces_no_match(self, num_cuda_graphs): class TestSpeculativeDecodingBatchDimensions: """Tests for batch dimensions specifically handling speculative decoding.""" - def setup_method(self, method): + @classmethod + def setup_class(cls): Utils.initialize_model_parallel( tensor_model_parallel_size=1, pipeline_model_parallel_size=1, expert_model_parallel_size=Utils.world_size, ) - def teardown_method(self, method): + @classmethod + def teardown_class(cls): Utils.destroy_model_parallel() @staticmethod From 61bde087d0ae18ad1c6cae9e749674cb3766fb07 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 15:21:20 -0700 Subject: [PATCH 110/124] Add classmethod decorator Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/test_batch_dimension_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/inference/test_batch_dimension_utils.py b/tests/unit_tests/inference/test_batch_dimension_utils.py index 3264d1c5171..f35be897e3a 100644 --- a/tests/unit_tests/inference/test_batch_dimension_utils.py +++ b/tests/unit_tests/inference/test_batch_dimension_utils.py @@ -122,6 +122,7 @@ class TestMatchGraphConfigWithEP: Uses the world group as the EP group (all 8 GPUs form one EP group). """ + @classmethod def setup_class(cls): Utils.initialize_model_parallel( tensor_model_parallel_size=1, @@ -129,6 +130,7 @@ def setup_class(cls): expert_model_parallel_size=Utils.world_size, ) + @classmethod def teardown_class(cls): Utils.destroy_model_parallel() From 652c9b578617f1474b8ae721f1250dc1a6021b67 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 15:44:47 -0700 Subject: [PATCH 111/124] More test NCCL cleanup Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/test_communication_utils.py | 5 +++-- .../test_text_generation_controller.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/inference/test_communication_utils.py b/tests/unit_tests/inference/test_communication_utils.py index 95de6c70560..515800c355b 100644 --- a/tests/unit_tests/inference/test_communication_utils.py +++ b/tests/unit_tests/inference/test_communication_utils.py @@ -22,6 +22,9 @@ def setup(self): self.size = [16, 8] self.dtype = torch.float32 + def teardown_method(self, method): + Utils.destroy_model_parallel() + @pytest.mark.skipif( not is_torch_min_version("2.4.0"), reason="torch.distributed.init_device_mesh requires torch >= 2.4.0", @@ -65,7 +68,6 @@ def test_broadcast_comparison(self, tp_size, pp_size): assert torch.allclose( tensor_received_global, tensor_received_custom ), "broadcast_from_last_pipeline_stage should be the same with or without custom pp_group" - Utils.destroy_model_parallel() @pytest.mark.skipif( not is_torch_min_version("2.4.0"), @@ -126,4 +128,3 @@ def test_send_recv(self, tp_size, pp_size): assert torch.allclose( local_recv_buffer_global, local_recv_buffer_custom ), "Custom and global recv buffers should be the same." - Utils.destroy_model_parallel() diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index da740e30d23..97794abb9a8 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -181,8 +181,7 @@ def setup_model( inference_wrapped_model=inference_wrapped_model, tokenizer=self.mock_tokenizer ) - @classmethod - def teardown_class(cls): + def teardown_method(self, method): Utils.destroy_model_parallel() def test_sample_from_logits(self): From 06e6907138a9bdab9f32a93057363739e53a89ce Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 16:28:51 -0700 Subject: [PATCH 112/124] Refactor test_dynamic_engine.py Signed-off-by: Keshav Santhanam --- .../inference/engines/test_dynamic_engine.py | 268 ++++++++++-------- 1 file changed, 148 insertions(+), 120 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 17840af9216..941f3ec24de 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -178,7 +178,7 @@ class DynamicEngineTestEnv: ) -class TestDynamicInferenceEngine: +class DynamicInferenceEngineTestBase: @classmethod def _build_requests(cls, test_config: DynamicEngineTestConfig) -> List[DynamicInferenceRequest]: @@ -281,13 +281,6 @@ def _build_inference_context( @classmethod @torch.inference_mode() def _build_test_env(cls, test_config): - Utils.initialize_model_parallel( - tensor_model_parallel_size=test_config.tensor_model_parallel_size, - pipeline_model_parallel_size=test_config.pipeline_model_parallel_size, - expert_model_parallel_size=test_config.expert_model_parallel_size, - expert_tensor_parallel_size=1, - ) - set_rounder(4) # Random state. @@ -573,6 +566,18 @@ def _run_test(cls, **test_config_kwargs): return env + +class TestDynamicInferenceEngine(DynamicInferenceEngineTestBase): + + @classmethod + def setup_class(cls): + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=1, + expert_tensor_parallel_size=1, + ) + @classmethod def teardown_class(cls): set_rounder(64) @@ -1091,84 +1096,6 @@ def test_log_probs_token_correspondence(self): assert not math.isnan(log_prob) and not math.isinf(log_prob) assert -100.0 <= log_prob <= 0.0 - @pytest.mark.internal - @pytest.mark.skipif( - not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" - ) - @pytest.mark.parametrize("materialize_only_last_token_logits", [False, True]) - @pytest.mark.parametrize("sequence_parallel", [False, True]) - @pytest.mark.parametrize("ep_size", [1, 2]) - @pytest.mark.parametrize("pp_size", [1, 2]) - @pytest.mark.parametrize("tp_size", [1, 2]) - @pytest.mark.parametrize("model_provider", ["gpt", "hybrid"]) - @pytest.mark.parametrize("transformer_impl", ["local", "inference_optimized"]) - @torch.inference_mode() - def test_parallel_inference( - self, - model_provider, - tp_size, - pp_size, - ep_size, - sequence_parallel, - materialize_only_last_token_logits, - transformer_impl, - ): - skip_if_mamba_sequence_packing_not_available(model_provider) - - if tp_size == 1 and pp_size == 1 and ep_size == 1: - pytest.skip(reason="Test requires tp_size > 1 or pp_size > 1 or ep_size > 1") - elif not torch.distributed.is_initialized(): - pytest.skip("Distributed not initialized") - world_size = torch.distributed.get_world_size() - min_world_size = tp_size * pp_size * ep_size - if world_size < min_world_size: - pytest.skip(f"Test requires at least {min_world_size} GPUs") - elif tp_size == 1 and sequence_parallel: - pytest.skip(reason="Sequence parallelism requires tp_size > 1") - elif tp_size > 1 and ep_size > 1 and not sequence_parallel: - pytest.skip(reason="Sequence parallelism must be used with tp_size > 1 and ep_size > 1") - elif transformer_impl == "inference_optimized": - if ep_size > 1: - pytest.skip( - reason="MoE models are not supported with the inference optimized transformer." - ) - if tp_size > 1 and not sequence_parallel: - pytest.skip( - reason=( - "The inference optimized transformer requires sequence parallelism " - "when tp_size > 1." - ) - ) - - env = self._run_test( - model_provider=model_provider, - tensor_model_parallel_size=tp_size, - pipeline_model_parallel_size=pp_size, - expert_model_parallel_size=ep_size, - sequence_parallel=sequence_parallel, - materialize_only_last_token_logits=materialize_only_last_token_logits, - transformer_impl=transformer_impl, - ) - - @pytest.mark.internal - @pytest.mark.skipif( - not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" - ) - @pytest.mark.parametrize("materialize_only_last_token_logits", [False, True]) - def test_sequence_parallel_fp8_inference(self, materialize_only_last_token_logits: bool): - fp8_available, reason_for_no_fp8 = check_fp8_support() - if not fp8_available: - pytest.skip(reason_for_no_fp8) - - self._run_test( - min_prompt_length=19, - max_prompt_length=19, - tensor_model_parallel_size=4, - sequence_parallel=True, - materialize_only_last_token_logits=True, - fp8=True, - ) - @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" @@ -4261,40 +4188,6 @@ def deterministic_mtp(hidden_states, next_token_ids, position_ids, depth): assert isinstance(lp, float) assert -0.1 < lp <= 0.0, f"Token {j}: expected log prob near 0.0, got {lp}" - @pytest.mark.internal - @pytest.mark.skipif( - not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" - ) - @torch.inference_mode() - def test_speculative_decoding_pipeline_parallel(self): - """Test speculative decoding with pipeline parallelism (pp_size=2). - - Verifies that MTP logit broadcasts across pipeline stages don't hang - or produce incorrect results. Each PP stage must participate in the - same number of MTP broadcast rounds. - """ - if not torch.distributed.is_initialized(): - pytest.skip("Distributed not initialized") - world_size = torch.distributed.get_world_size() - pp_size = 2 - if world_size < pp_size: - pytest.skip(f"Test requires at least {pp_size} GPUs") - - env = self._run_test( - model_provider="gpt", - pipeline_model_parallel_size=pp_size, - num_speculative_tokens=2, - num_tokens_to_generate=6, - materialize_only_last_token_logits=False, - ) - - for request in env.requests: - assert ( - request.status == Status.COMPLETED - ), f"Request {request.request_id}: status={request.status}" - num_expected = request.sampling_params.num_tokens_to_generate - assert len(request.generated_tokens) <= num_expected - @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" @@ -4414,6 +4307,141 @@ def mtp_with_rejection(hidden_states, next_token_ids, position_ids, depth): assert env.engine.context.total_request_count == 0 +class TestDynamicInferenceEngineParallel(DynamicInferenceEngineTestBase): + """Tests that require non-default parallel configs (tp>1, pp>1, or ep>1). + + Each test initializes its own parallel state and tears it down afterward, + so these are separated from TestDynamicInferenceEngine to avoid accumulating + NCCL communicator memory from repeated init/destroy cycles. + """ + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @classmethod + def teardown_class(cls): + set_rounder(64) + Utils.destroy_model_parallel() + + @classmethod + @torch.inference_mode() + def _build_test_env(cls, test_config): + Utils.initialize_model_parallel( + tensor_model_parallel_size=test_config.tensor_model_parallel_size, + pipeline_model_parallel_size=test_config.pipeline_model_parallel_size, + expert_model_parallel_size=test_config.expert_model_parallel_size, + expert_tensor_parallel_size=1, + ) + return super()._build_test_env(test_config) + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize("materialize_only_last_token_logits", [False, True]) + @pytest.mark.parametrize("sequence_parallel", [False, True]) + @pytest.mark.parametrize("ep_size", [1, 2]) + @pytest.mark.parametrize("pp_size", [1, 2]) + @pytest.mark.parametrize("tp_size", [1, 2]) + @pytest.mark.parametrize("model_provider", ["gpt", "hybrid"]) + @pytest.mark.parametrize("transformer_impl", ["local", "inference_optimized"]) + @torch.inference_mode() + def test_parallel_inference( + self, + model_provider, + tp_size, + pp_size, + ep_size, + sequence_parallel, + materialize_only_last_token_logits, + transformer_impl, + ): + skip_if_mamba_sequence_packing_not_available(model_provider) + + if tp_size == 1 and pp_size == 1 and ep_size == 1: + pytest.skip(reason="Test requires tp_size > 1 or pp_size > 1 or ep_size > 1") + elif not torch.distributed.is_initialized(): + pytest.skip("Distributed not initialized") + world_size = torch.distributed.get_world_size() + min_world_size = tp_size * pp_size * ep_size + if world_size < min_world_size: + pytest.skip(f"Test requires at least {min_world_size} GPUs") + elif tp_size == 1 and sequence_parallel: + pytest.skip(reason="Sequence parallelism requires tp_size > 1") + elif tp_size > 1 and ep_size > 1 and not sequence_parallel: + pytest.skip(reason="Sequence parallelism must be used with tp_size > 1 and ep_size > 1") + elif transformer_impl == "inference_optimized": + if ep_size > 1: + pytest.skip( + reason="MoE models are not supported with the inference optimized transformer." + ) + if tp_size > 1 and not sequence_parallel: + pytest.skip( + reason=( + "The inference optimized transformer requires sequence parallelism " + "when tp_size > 1." + ) + ) + + env = self._run_test( + model_provider=model_provider, + tensor_model_parallel_size=tp_size, + pipeline_model_parallel_size=pp_size, + expert_model_parallel_size=ep_size, + sequence_parallel=sequence_parallel, + materialize_only_last_token_logits=materialize_only_last_token_logits, + transformer_impl=transformer_impl, + ) + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize("materialize_only_last_token_logits", [False, True]) + def test_sequence_parallel_fp8_inference(self, materialize_only_last_token_logits: bool): + fp8_available, reason_for_no_fp8 = check_fp8_support() + if not fp8_available: + pytest.skip(reason_for_no_fp8) + + self._run_test( + min_prompt_length=19, + max_prompt_length=19, + tensor_model_parallel_size=4, + sequence_parallel=True, + materialize_only_last_token_logits=True, + fp8=True, + ) + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @torch.inference_mode() + def test_speculative_decoding_pipeline_parallel(self): + """Test speculative decoding with pipeline parallelism (pp_size=2).""" + if not torch.distributed.is_initialized(): + pytest.skip("Distributed not initialized") + world_size = torch.distributed.get_world_size() + pp_size = 2 + if world_size < pp_size: + pytest.skip(f"Test requires at least {pp_size} GPUs") + + env = self._run_test( + model_provider="gpt", + pipeline_model_parallel_size=pp_size, + num_speculative_tokens=2, + num_tokens_to_generate=6, + materialize_only_last_token_logits=False, + ) + + for request in env.requests: + assert ( + request.status == Status.COMPLETED + ), f"Request {request.request_id}: status={request.status}" + num_expected = request.sampling_params.num_tokens_to_generate + assert len(request.generated_tokens) <= num_expected + + CHUNKED_CG_BLOCK_SIZE = 256 CHUNKED_CG_VOCAB_SIZE = 10000 CHUNKED_CG_MAX_SEQ_LEN = 2048 From 96a01eae274e3f2049ab810f8408c37d18137075 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 23 Apr 2026 16:33:49 -0700 Subject: [PATCH 113/124] clear nvte env vars Signed-off-by: Keshav Santhanam --- .../inference/engines/test_dynamic_engine.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 941f3ec24de..ee78e0aaede 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -73,6 +73,13 @@ def skip_if_mamba_sequence_packing_not_available(model_provider: str): pytest.skip(reason_for_no_sequence_packing) +def clear_nvte_env_vars(): + """Clear NVTE env vars set by conftest set_env fixture.""" + os.environ.pop('NVTE_FLASH_ATTN', None) + os.environ.pop('NVTE_FUSED_ATTN', None) + os.environ.pop('NVTE_UNFUSED_ATTN', None) + + def set_rounder(value): """Utility function to set the DynamicInferenceContext rounder.""" DynamicInferenceContext.ROUNDER = value # For backwards compatibility @@ -281,6 +288,7 @@ def _build_inference_context( @classmethod @torch.inference_mode() def _build_test_env(cls, test_config): + clear_nvte_env_vars() set_rounder(4) # Random state. @@ -4604,10 +4612,7 @@ def test_chunked_prefill_cuda_graphs(self, model_provider, chunked_prefill, num_ """Verify generated tokens match across chunked prefill and CUDA graph configs.""" skip_if_mamba_sequence_packing_not_available(model_provider) - # Clear NVTE env vars set by conftest set_env fixture. - os.environ.pop('NVTE_FLASH_ATTN', None) - os.environ.pop('NVTE_FUSED_ATTN', None) - os.environ.pop('NVTE_UNFUSED_ATTN', None) + clear_nvte_env_vars() random.seed(123) torch.manual_seed(123) From 8eb0c8093b762495a636746e67db6f9e11e7cb30 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 09:48:32 -0700 Subject: [PATCH 114/124] Fix test Signed-off-by: Keshav Santhanam --- megatron/core/transformer/cuda_graphs.py | 1 + .../engines/test_mtp_cuda_graph_inference.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 8d1972ff175..ac0e359d0d3 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -531,6 +531,7 @@ def delete_cuda_graphs(): runner.mempool = None mgr.cudagraph_runners.clear() mgr.inference_cudagraphs_lookup_table.clear() + _CudagraphGlobalRecord.mtp_cudagraph_managers.clear() # Reset global tracking state _CudagraphGlobalRecord.cudagraph_created = False diff --git a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py index 53f7c46bd56..a0c67fd4721 100644 --- a/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/engines/test_mtp_cuda_graph_inference.py @@ -39,7 +39,7 @@ from megatron.core.tensor_parallel.mappings import scatter_to_sequence_parallel_region from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import TransformerConfig -from megatron.core.transformer.cuda_graphs import delete_cuda_graphs +from megatron.core.transformer.cuda_graphs import _CudagraphGlobalRecord, delete_cuda_graphs from megatron.core.transformer.enums import AttnBackend from megatron.core.utils import unwrap_model from tests.unit_tests.test_utilities import Utils @@ -134,6 +134,7 @@ def _build_engine( The engine's `__init__` calls `create_cuda_graphs()` which captures both decoder and MTP CUDA graphs, matching production warmup exactly. """ + delete_cuda_graphs() model = self._build_model( sequence_parallel=sequence_parallel, mtp_num_layers=mtp_num_layers, @@ -156,7 +157,6 @@ def _build_engine( wrapped.model_is_pipeline_parallel = False mock_tokenizer = mock.Mock() ctrl = TextGenerationController(inference_wrapped_model=wrapped, tokenizer=mock_tokenizer) - delete_cuda_graphs() engine = DynamicInferenceEngine(ctrl, context) return engine @@ -660,19 +660,23 @@ def test_delete_cuda_graphs_resets_mtp_runners(self): MTP runners are excluded from the global inference record, so they require special handling in `delete_cuda_graphs()`. After deletion, - no MTP runners should have `fwd_graph_recorded=True`. + no MTP runners should have `fwd_graph_recorded=True` and the global + manager list should be cleared. """ engine = self._build_engine() model = engine.controller.inference_wrapped_model.model self._assert_mtp_cuda_graphs_were_replayed(model, True) - delete_cuda_graphs() - unwrapped = unwrap_model(model) manager = getattr(unwrapped, '_mtp_cudagraph_manager', None) assert manager is not None + assert len(manager.inference_cudagraphs_lookup_table) > 0 + + delete_cuda_graphs() + assert len(manager.inference_cudagraphs_lookup_table) == 0 + assert len(_CudagraphGlobalRecord.mtp_cudagraph_managers) == 0 # --------------------------------------------------------------------------- # From ac467b1aa74a28eb466b9ecf7634d052a790d1f9 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 10:16:13 -0700 Subject: [PATCH 115/124] Refactor test_text_generation_controller.py Signed-off-by: Keshav Santhanam --- .../test_text_generation_controller.py | 382 ++++++++++-------- 1 file changed, 222 insertions(+), 160 deletions(-) diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 97794abb9a8..843d599db44 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -45,7 +45,7 @@ from tests.unit_tests.test_utilities import Utils -class TestTextGenerationController: +class TextGenerationControllerTestBase: def setup_model( self, @@ -68,10 +68,6 @@ def setup_model( num_moe_experts: int = None, hybrid_layer_pattern: str = None, ): - Utils.initialize_model_parallel( - tensor_model_parallel_size=tensor_model_parallel_size, - pipeline_model_parallel_size=pipeline_model_parallel_size, - ) if use_training_random_init: # This is necessary to induce the training behavior which permutes the random seed # for every rank; otherwise, every rank will have the same seed. @@ -181,7 +177,17 @@ def setup_model( inference_wrapped_model=inference_wrapped_model, tokenizer=self.mock_tokenizer ) - def teardown_method(self, method): +class TestTextGenerationController(TextGenerationControllerTestBase): + + @classmethod + def setup_class(cls): + Utils.initialize_model_parallel( + tensor_model_parallel_size=2, + pipeline_model_parallel_size=1, + ) + + @classmethod + def teardown_class(cls): Utils.destroy_model_parallel() def test_sample_from_logits(self): @@ -954,160 +960,6 @@ def test_dynamic_top_n_logprobs_calculation( top_n_indices.shape[0] == top_n ), f"Request {req_idx}, token {token_idx}: expected {top_n} indices" - @pytest.mark.parametrize("static", [True, False]) - @pytest.mark.parametrize("tp_size", [1, 2]) - @pytest.mark.parametrize("pp_size", [1, 2]) - def test_sampled_tokens_match_with_parallelism(self, static, tp_size, pp_size): - """ - Verify that sampled tokens match across all parallel ranks. - """ - if tp_size == 1 and pp_size == 1: - pytest.skip(reason="Test requires model parallel size > 1.") - - if not static and not is_fa_min_version("2.7.3"): - pytest.skip(reason="Need latest flash attn for dynamic batching") - - # Ensure that we are using the training setup for random seed initialization - # so that every rank has a different seed - self.setup_model( - dtype=torch.bfloat16, - tensor_model_parallel_size=tp_size, - pipeline_model_parallel_size=pp_size, - static=static, - use_training_random_init=True, - ) - - self.mock_tokenizer.vocab_size = self.vocab_size - self.mock_tokenizer.eod = self.vocab_size - 1 - self.mock_tokenizer.detokenize.side_effect = lambda x, skip_special_tokens=False: ' '.join( - [ - ''.join(random.choices(string.ascii_letters, k=random.randint(4, 10))) - for _ in range(len(x)) - ] - ) - self.mock_tokenizer.offsets.side_effect = lambda _, s: [ - i for i, c in enumerate(s) if c == ' ' - ] + [len(s)] - - # Prepare requests. - active_requests: Dict[str, InferenceRequest] = OrderedDict() - for i in range(self.batch_size): - prompt = "sample" * (i + 1) - prompt_tokens = torch.randint( - low=0, high=self.vocab_size - 1, size=(len(prompt),) - ).tolist() - request_id = str(i) - inference_request = InferenceRequest( - request_id=request_id, - prompt=prompt, - sampling_params=SamplingParams( - top_k=10, num_tokens_to_generate=25, return_log_probs=True - ), - arrival_time=time.time(), - prompt_tokens=prompt_tokens, - status=Status.ACTIVE_BUT_NOT_GENERATING_TOKENS, - ) - active_requests[request_id] = inference_request - - # Generate tokens. - if static: - requests = self.text_generation_controller.generate_all_output_tokens_static_batch( - active_requests - ) - all_generated_tokens = [req.generated_tokens.tolist() for req in requests.values()] - else: - all_generated_tokens = [[] for _ in range(len(active_requests))] - context = self.text_generation_controller.inference_wrapped_model.inference_context - for request_id, request in active_requests.items(): - context.add_request( - DynamicInferenceRequest( - request_id=int(request_id), - prompt_tokens=torch.tensor( - request.prompt_tokens, - dtype=torch.long, - device=torch.cuda.current_device(), - ), - sampling_params=SamplingParams( - top_k=10, return_log_probs=True, num_tokens_to_generate=25 - ), - ) - ) - expected_active_requests = set(int(x) for x in active_requests.keys()) - while context.has_unfinished_requests(): - result = self.text_generation_controller.generate_output_tokens_dynamic_batch() - new_tokens = result["sample"] - active_ids = result["active_request_ids"].tolist() - finished_ids = result["finished_request_ids"].tolist() - assert len(new_tokens) == len(expected_active_requests) - assert set(active_ids) == expected_active_requests - expected_active_requests -= set(finished_ids) - for i, token in enumerate(new_tokens.tolist()): - all_generated_tokens[i].append(token) - - # Wait for all communication to complete before proceeding. - torch.distributed.barrier() - - # Collect all the generated tokens for each request from each rank in the - # model parallel group. - mp_group = parallel_state.get_model_parallel_group() - mp_ranks = torch.distributed.get_process_group_ranks(mp_group) - local_rank = torch.distributed.get_rank() - tokens_per_rank = {} - tokens_per_rank[local_rank] = all_generated_tokens - - for i in mp_ranks: - # Start by communicating the batch size so each rank knows how many requests to expect. - if i == local_rank: - batch_size = torch.tensor( - len(tokens_per_rank[local_rank]), - dtype=torch.long, - device=torch.cuda.current_device(), - ) - else: - tokens_per_rank[i] = [] - batch_size = torch.empty(1, dtype=torch.long, device=torch.cuda.current_device()) - torch.distributed.broadcast(batch_size, group=mp_group, src=i) - - for j in range(batch_size.item()): - # For each request, communicate the sequence length followed by the actual tokens. - if i == local_rank: - sequence_length = torch.tensor( - len(tokens_per_rank[local_rank][j]), - dtype=torch.int32, - device=torch.cuda.current_device(), - ) - else: - sequence_length = torch.empty( - 1, dtype=torch.int32, device=torch.cuda.current_device() - ) - torch.distributed.broadcast(sequence_length, group=mp_group, src=i) - - if i == local_rank: - generated_tokens = torch.tensor( - tokens_per_rank[local_rank][j], - dtype=torch.long, - device=torch.cuda.current_device(), - ) - else: - generated_tokens = torch.empty( - sequence_length.item(), dtype=torch.long, device=torch.cuda.current_device() - ) - torch.distributed.broadcast(generated_tokens, group=mp_group, src=i) - - if i != local_rank: - tokens_per_rank[i].append(generated_tokens.tolist()) - - # Ensure that every rank in the model parallel group produced the same tokens. - for i in mp_ranks: - if i == local_rank: - continue - for j, (expected, actual) in enumerate( - zip(tokens_per_rank[local_rank], tokens_per_rank[i]) - ): - assert ( - expected == actual - ), f"Rank {i} tokens differ from rank {local_rank} tokens for request {j}" - @pytest.mark.internal def test_speculative_verify_tokens(self): """Test consecutive token acceptance logic for speculative decoding.""" @@ -1736,3 +1588,213 @@ def test_mtp_sp_dummy_hidden_uses_full_seq_len(self): f"Depth {depth}: expected logits shape ({tp_size}, 1, {self.vocab_size}), " f"got {logits.shape}" ) + + +class TestTextGenerationControllerParallel(TextGenerationControllerTestBase): + """Tests that require non-default parallel configs (varying tp/pp). + + Each test initializes its own parallel state and tears it down afterward, + so these are separated from TestTextGenerationController to avoid + accumulating NCCL communicator memory from repeated init/destroy cycles. + """ + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @classmethod + def teardown_class(cls): + Utils.destroy_model_parallel() + + def setup_model( + self, + dtype, + symmetric_ar_type=None, + fp8: bool = False, + tensor_model_parallel_size: int = 2, + pipeline_model_parallel_size: int = 1, + batch_size: int = 4, + static: bool = True, + use_training_random_init: bool = False, + materialize_only_last_token_logits: bool = False, + num_speculative_tokens: int = 0, + block_size_tokens: int = 256, + enable_prefix_caching: bool = False, + max_requests: int = None, + mtp_num_layers: int = 0, + sequence_parallel: bool = False, + expert_model_parallel_size: int = 1, + num_moe_experts: int = None, + hybrid_layer_pattern: str = None, + ): + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_model_parallel_size, + pipeline_model_parallel_size=pipeline_model_parallel_size, + ) + super().setup_model( + dtype, + symmetric_ar_type=symmetric_ar_type, + fp8=fp8, + tensor_model_parallel_size=tensor_model_parallel_size, + pipeline_model_parallel_size=pipeline_model_parallel_size, + batch_size=batch_size, + static=static, + use_training_random_init=use_training_random_init, + materialize_only_last_token_logits=materialize_only_last_token_logits, + num_speculative_tokens=num_speculative_tokens, + block_size_tokens=block_size_tokens, + enable_prefix_caching=enable_prefix_caching, + max_requests=max_requests, + mtp_num_layers=mtp_num_layers, + sequence_parallel=sequence_parallel, + expert_model_parallel_size=expert_model_parallel_size, + num_moe_experts=num_moe_experts, + hybrid_layer_pattern=hybrid_layer_pattern, + ) + + @pytest.mark.parametrize("static", [True, False]) + @pytest.mark.parametrize("tp_size", [1, 2]) + @pytest.mark.parametrize("pp_size", [1, 2]) + def test_sampled_tokens_match_with_parallelism(self, static, tp_size, pp_size): + """Verify that sampled tokens match across all parallel ranks.""" + if tp_size == 1 and pp_size == 1: + pytest.skip(reason="Test requires model parallel size > 1.") + + if not static and not is_fa_min_version("2.7.3"): + pytest.skip(reason="Need latest flash attn for dynamic batching") + + self.setup_model( + dtype=torch.bfloat16, + tensor_model_parallel_size=tp_size, + pipeline_model_parallel_size=pp_size, + static=static, + use_training_random_init=True, + ) + + self.mock_tokenizer.vocab_size = self.vocab_size + self.mock_tokenizer.eod = self.vocab_size - 1 + self.mock_tokenizer.detokenize.side_effect = lambda x, skip_special_tokens=False: ' '.join( + [ + ''.join(random.choices(string.ascii_letters, k=random.randint(4, 10))) + for _ in range(len(x)) + ] + ) + self.mock_tokenizer.offsets.side_effect = lambda _, s: [ + i for i, c in enumerate(s) if c == ' ' + ] + [len(s)] + + # Prepare requests. + active_requests: Dict[str, InferenceRequest] = OrderedDict() + for i in range(self.batch_size): + prompt = "sample" * (i + 1) + prompt_tokens = torch.randint( + low=0, high=self.vocab_size - 1, size=(len(prompt),) + ).tolist() + request_id = str(i) + inference_request = InferenceRequest( + request_id=request_id, + prompt=prompt, + sampling_params=SamplingParams( + top_k=10, num_tokens_to_generate=25, return_log_probs=True + ), + arrival_time=time.time(), + prompt_tokens=prompt_tokens, + status=Status.ACTIVE_BUT_NOT_GENERATING_TOKENS, + ) + active_requests[request_id] = inference_request + + # Generate tokens. + if static: + requests = self.text_generation_controller.generate_all_output_tokens_static_batch( + active_requests + ) + all_generated_tokens = [req.generated_tokens.tolist() for req in requests.values()] + else: + all_generated_tokens = [[] for _ in range(len(active_requests))] + context = self.text_generation_controller.inference_wrapped_model.inference_context + for request_id, request in active_requests.items(): + context.add_request( + DynamicInferenceRequest( + request_id=int(request_id), + prompt_tokens=torch.tensor( + request.prompt_tokens, + dtype=torch.long, + device=torch.cuda.current_device(), + ), + sampling_params=SamplingParams( + top_k=10, return_log_probs=True, num_tokens_to_generate=25 + ), + ) + ) + expected_active_requests = set(int(x) for x in active_requests.keys()) + while context.has_unfinished_requests(): + result = self.text_generation_controller.generate_output_tokens_dynamic_batch() + new_tokens = result["sample"] + active_ids = result["active_request_ids"].tolist() + finished_ids = result["finished_request_ids"].tolist() + assert len(new_tokens) == len(expected_active_requests) + assert set(active_ids) == expected_active_requests + expected_active_requests -= set(finished_ids) + for i, token in enumerate(new_tokens.tolist()): + all_generated_tokens[i].append(token) + + # Wait for all communication to complete before proceeding. + torch.distributed.barrier() + + # Collect all the generated tokens for each request from each rank in the + # model parallel group. + mp_group = parallel_state.get_model_parallel_group() + mp_ranks = torch.distributed.get_process_group_ranks(mp_group) + local_rank = torch.distributed.get_rank() + tokens_per_rank = {} + tokens_per_rank[local_rank] = all_generated_tokens + + for i in mp_ranks: + if i == local_rank: + batch_size = torch.tensor( + len(tokens_per_rank[local_rank]), + dtype=torch.long, + device=torch.cuda.current_device(), + ) + else: + tokens_per_rank[i] = [] + batch_size = torch.empty(1, dtype=torch.long, device=torch.cuda.current_device()) + torch.distributed.broadcast(batch_size, group=mp_group, src=i) + + for j in range(batch_size.item()): + if i == local_rank: + sequence_length = torch.tensor( + len(tokens_per_rank[local_rank][j]), + dtype=torch.int32, + device=torch.cuda.current_device(), + ) + else: + sequence_length = torch.empty( + 1, dtype=torch.int32, device=torch.cuda.current_device() + ) + torch.distributed.broadcast(sequence_length, group=mp_group, src=i) + + if i == local_rank: + generated_tokens = torch.tensor( + tokens_per_rank[local_rank][j], + dtype=torch.long, + device=torch.cuda.current_device(), + ) + else: + generated_tokens = torch.empty( + sequence_length.item(), dtype=torch.long, device=torch.cuda.current_device() + ) + torch.distributed.broadcast(generated_tokens, group=mp_group, src=i) + + if i != local_rank: + tokens_per_rank[i].append(generated_tokens.tolist()) + + # Ensure that every rank in the model parallel group produced the same tokens. + for i in mp_ranks: + if i == local_rank: + continue + for j, (expected, actual) in enumerate( + zip(tokens_per_rank[local_rank], tokens_per_rank[i]) + ): + assert ( + expected == actual + ), f"Rank {i} tokens differ from rank {local_rank} tokens for request {j}" From 3e28f26fda784d2c61b8e924090ac1fd88832b30 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 10:30:15 -0700 Subject: [PATCH 116/124] Explicitly delete cuda graphs Signed-off-by: Keshav Santhanam --- .../inference/engines/test_dynamic_engine.py | 28 ++++--------------- .../test_text_generation_controller.py | 12 +++----- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index ee78e0aaede..1c5d86cb034 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -50,7 +50,7 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.enums import CudaGraphScope from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version, is_te_min_version @@ -468,10 +468,7 @@ def _build_test_env(cls, test_config): ), ) - # Reset global cuda graph state. - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - CudaGraphManager.global_mempool = None + delete_cuda_graphs() # Inference engine. engine = DynamicInferenceEngine(text_generation_controller, inference_context) @@ -588,6 +585,7 @@ def setup_class(cls): @classmethod def teardown_class(cls): + delete_cuda_graphs() set_rounder(64) Utils.destroy_model_parallel() @@ -4324,11 +4322,7 @@ class TestDynamicInferenceEngineParallel(DynamicInferenceEngineTestBase): """ def teardown_method(self, method): - Utils.destroy_model_parallel() - - @classmethod - def teardown_class(cls): - set_rounder(64) + delete_cuda_graphs() Utils.destroy_model_parallel() @classmethod @@ -4470,6 +4464,7 @@ def setup_class(cls): @classmethod def teardown_class(cls): + delete_cuda_graphs() set_rounder(64) Utils.destroy_model_parallel() @@ -4534,17 +4529,6 @@ def _create_model(self, model_provider, num_cuda_graphs): model.eval() return model - def _reset_cuda_graph_state(self, model): - """Reset all CUDA graph global and per-module state.""" - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - _CudagraphGlobalRecord.cudagraph_inference_record = [] - CudaGraphManager.global_mempool = None - for module in model.modules(): - if isinstance(module, CudaGraphManager): - module.cudagraph_runners.clear() - module.inference_cudagraphs_lookup_table.clear() - def _build_engine(self, model, enable_chunked_prefill, num_cuda_graphs, context_max_tokens): """Build an engine with the given chunked prefill / CUDA graph config.""" set_rounder(4) @@ -4577,7 +4561,7 @@ def _build_engine(self, model, enable_chunked_prefill, num_cuda_graphs, context_ vocab_size=CHUNKED_CG_VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - self._reset_cuda_graph_state(model) + delete_cuda_graphs() return DynamicInferenceEngine(controller, context) def _run_to_completion(self, engine, prompts, num_tokens_to_generate): diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 843d599db44..9a24f5cfe1b 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -34,8 +34,8 @@ get_gpt_mtp_block_spec, ) from megatron.core.models.gpt.gpt_model import GPTModel -from megatron.core.models.mamba.mamba_layer_specs import mamba_stack_spec -from megatron.core.models.mamba.mamba_model import MambaModel +from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec +from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.module import Float16Module @@ -108,9 +108,9 @@ def setup_model( mamba_inference_state_config = None if hybrid_layer_pattern: - model = MambaModel( + model = HybridModel( config=transformer_config, - mamba_stack_spec=mamba_stack_spec, + hybrid_stack_spec=hybrid_stack_spec, vocab_size=self.vocab_size, max_sequence_length=self.sequence_length, parallel_output=True, @@ -1601,10 +1601,6 @@ class TestTextGenerationControllerParallel(TextGenerationControllerTestBase): def teardown_method(self, method): Utils.destroy_model_parallel() - @classmethod - def teardown_class(cls): - Utils.destroy_model_parallel() - def setup_model( self, dtype, From 15d0e839227e11fa0120113f30340243f3737e30 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 10:43:15 -0700 Subject: [PATCH 117/124] Add cuda graph deletion to static engine Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_static_engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/inference/engines/test_static_engine.py b/tests/unit_tests/inference/engines/test_static_engine.py index 483a21d13bd..f5eb35224c2 100644 --- a/tests/unit_tests/inference/engines/test_static_engine.py +++ b/tests/unit_tests/inference/engines/test_static_engine.py @@ -27,6 +27,7 @@ from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_spec from megatron.core.models.gpt.gpt_model import GPTModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version from tests.unit_tests.test_utilities import Utils @@ -112,6 +113,7 @@ def setup_engine( ) def teardown_method(self, method): + delete_cuda_graphs() Utils.destroy_model_parallel() From b4d0d69375eca8f356b45ef00ea06808a9c0c1d5 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 10:59:51 -0700 Subject: [PATCH 118/124] More cleanup Signed-off-by: Keshav Santhanam --- .../inference/engines/test_static_engine.py | 63 ++++++++++++++++--- .../inference/test_communication_utils.py | 4 ++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_static_engine.py b/tests/unit_tests/inference/engines/test_static_engine.py index f5eb35224c2..319c2b00f12 100644 --- a/tests/unit_tests/inference/engines/test_static_engine.py +++ b/tests/unit_tests/inference/engines/test_static_engine.py @@ -46,11 +46,6 @@ def setup_engine( buffer_size_gb=10, inference_config_params_dtype=torch.float, ): - Utils.initialize_model_parallel( - tensor_model_parallel_size=tensor_model_parallel_size, - pipeline_model_parallel_size=pipeline_model_parallel_size, - ) - model_parallel_cuda_manual_seed(123) self.batch_size = 4 self.hidden_size = 32 @@ -112,12 +107,24 @@ def setup_engine( buffer_size_gb=buffer_size_gb, ) + +class TestStaticInferenceEngine(StaticInferenceEngineTestHarness): + + @classmethod + def setup_class(cls): + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + def teardown_method(self, method): delete_cuda_graphs() - Utils.destroy_model_parallel() + @classmethod + def teardown_class(cls): + delete_cuda_graphs() + Utils.destroy_model_parallel() -class TestStaticInferenceEngine(StaticInferenceEngineTestHarness): @pytest.mark.parametrize( "batch_size,num_trials,empty_prompt", [(4, 1, False), (4, 1, True), (4, 3, False), (2, 1, False), (8, 1, False)], @@ -296,6 +303,48 @@ async def collect_stream(stream_generator, num_tokens_to_generate): f"final_streamed_token.generated_log_probs={final_streamed_token.generated_log_probs}" ) + + +class TestStaticInferenceEngineParallel(StaticInferenceEngineTestHarness): + """Tests that require non-default parallel configs (varying tp/pp/ep). + + Each test initializes its own parallel state and tears it down afterward, + so these are separated from TestStaticInferenceEngine to avoid + accumulating NCCL communicator memory from repeated init/destroy cycles. + """ + + def teardown_method(self, method): + delete_cuda_graphs() + Utils.destroy_model_parallel() + + def setup_engine( + self, + engine_max_batch_size=None, + vocab_size=100, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=1, + sequence_parallel=False, + legacy=False, + buffer_size_gb=10, + inference_config_params_dtype=torch.float, + ): + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_model_parallel_size, + pipeline_model_parallel_size=pipeline_model_parallel_size, + ) + super().setup_engine( + engine_max_batch_size=engine_max_batch_size, + vocab_size=vocab_size, + tensor_model_parallel_size=tensor_model_parallel_size, + pipeline_model_parallel_size=pipeline_model_parallel_size, + expert_model_parallel_size=expert_model_parallel_size, + sequence_parallel=sequence_parallel, + legacy=legacy, + buffer_size_gb=buffer_size_gb, + inference_config_params_dtype=inference_config_params_dtype, + ) + @pytest.mark.parametrize("sequence_parallel", [False, True]) @pytest.mark.parametrize("ep_size", [1, 2]) @pytest.mark.parametrize("pp_size", [1, 2]) diff --git a/tests/unit_tests/inference/test_communication_utils.py b/tests/unit_tests/inference/test_communication_utils.py index 515800c355b..dd4cf14f112 100644 --- a/tests/unit_tests/inference/test_communication_utils.py +++ b/tests/unit_tests/inference/test_communication_utils.py @@ -69,6 +69,8 @@ def test_broadcast_comparison(self, tp_size, pp_size): tensor_received_global, tensor_received_custom ), "broadcast_from_last_pipeline_stage should be the same with or without custom pp_group" + grid.destroy() + @pytest.mark.skipif( not is_torch_min_version("2.4.0"), reason="torch.distributed.init_device_mesh requires torch >= 2.4.0", @@ -128,3 +130,5 @@ def test_send_recv(self, tp_size, pp_size): assert torch.allclose( local_recv_buffer_global, local_recv_buffer_custom ), "Custom and global recv buffers should be the same." + + grid.destroy() From 1a8f7daf4089ccafffa761b6cd4f9070a38201ac Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 12:09:47 -0700 Subject: [PATCH 119/124] Add clear_nvte_env_vars to static test Signed-off-by: Keshav Santhanam --- .../unit_tests/inference/engines/test_dynamic_engine.py | 9 +-------- tests/unit_tests/inference/engines/test_static_engine.py | 3 ++- tests/unit_tests/test_utilities.py | 7 +++++++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 1c5d86cb034..ea8b874eefb 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -54,7 +54,7 @@ from megatron.core.transformer.enums import CudaGraphScope from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version, is_te_min_version -from tests.unit_tests.test_utilities import Utils +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars try: from torch_memory_saver import torch_memory_saver # noqa: F401 @@ -73,13 +73,6 @@ def skip_if_mamba_sequence_packing_not_available(model_provider: str): pytest.skip(reason_for_no_sequence_packing) -def clear_nvte_env_vars(): - """Clear NVTE env vars set by conftest set_env fixture.""" - os.environ.pop('NVTE_FLASH_ATTN', None) - os.environ.pop('NVTE_FUSED_ATTN', None) - os.environ.pop('NVTE_UNFUSED_ATTN', None) - - def set_rounder(value): """Utility function to set the DynamicInferenceContext rounder.""" DynamicInferenceContext.ROUNDER = value # For backwards compatibility diff --git a/tests/unit_tests/inference/engines/test_static_engine.py b/tests/unit_tests/inference/engines/test_static_engine.py index 319c2b00f12..d0905668012 100644 --- a/tests/unit_tests/inference/engines/test_static_engine.py +++ b/tests/unit_tests/inference/engines/test_static_engine.py @@ -30,7 +30,7 @@ from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version -from tests.unit_tests.test_utilities import Utils +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars class StaticInferenceEngineTestHarness: @@ -46,6 +46,7 @@ def setup_engine( buffer_size_gb=10, inference_config_params_dtype=torch.float, ): + clear_nvte_env_vars() model_parallel_cuda_manual_seed(123) self.batch_size = 4 self.hidden_size = 32 diff --git a/tests/unit_tests/test_utilities.py b/tests/unit_tests/test_utilities.py index f8fad3325f5..0ddfef4dc67 100644 --- a/tests/unit_tests/test_utilities.py +++ b/tests/unit_tests/test_utilities.py @@ -26,6 +26,13 @@ def __init__( self.layers[-1].weight.shared_embedding = True +def clear_nvte_env_vars(): + """Clear NVTE env vars set by conftest set_env fixture.""" + os.environ.pop('NVTE_FLASH_ATTN', None) + os.environ.pop('NVTE_FUSED_ATTN', None) + os.environ.pop('NVTE_UNFUSED_ATTN', None) + + class Utils: world_size = int(os.environ.get('WORLD_SIZE', '1')) From 9f64401bdb2a24549907d7b6c32408c1c0bee812 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 12:24:42 -0700 Subject: [PATCH 120/124] Add garbage collection to test_moe_inference.py Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/test_moe_inference.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit_tests/inference/test_moe_inference.py b/tests/unit_tests/inference/test_moe_inference.py index b762b5e638c..209eab3dd83 100644 --- a/tests/unit_tests/inference/test_moe_inference.py +++ b/tests/unit_tests/inference/test_moe_inference.py @@ -10,6 +10,8 @@ - shared experts """ +import gc + import pytest import torch @@ -204,6 +206,10 @@ def teardown_class(cls): SymmetricMemoryManager.destroy() Utils.destroy_model_parallel() + def teardown_method(self, method): + gc.collect() + torch.cuda.empty_cache() + def _make_dispatcher(self, **config_overrides): from megatron.core.transformer.moe.moe_utils import get_default_pg_collection from megatron.core.transformer.moe.token_dispatcher_inference import ( From f193a01b9cc52c13c8eba2b48372bbdbcf82dbad Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 12:36:37 -0700 Subject: [PATCH 121/124] Linting Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/engines/test_static_engine.py | 4 +--- .../test_text_generation_controller.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_static_engine.py b/tests/unit_tests/inference/engines/test_static_engine.py index d0905668012..0067ff6e9bc 100644 --- a/tests/unit_tests/inference/engines/test_static_engine.py +++ b/tests/unit_tests/inference/engines/test_static_engine.py @@ -114,8 +114,7 @@ class TestStaticInferenceEngine(StaticInferenceEngineTestHarness): @classmethod def setup_class(cls): Utils.initialize_model_parallel( - tensor_model_parallel_size=1, - pipeline_model_parallel_size=1, + tensor_model_parallel_size=1, pipeline_model_parallel_size=1 ) def teardown_method(self, method): @@ -305,7 +304,6 @@ async def collect_stream(stream_generator, num_tokens_to_generate): ) - class TestStaticInferenceEngineParallel(StaticInferenceEngineTestHarness): """Tests that require non-default parallel configs (varying tp/pp/ep). diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 9a24f5cfe1b..dd4764ee92d 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -177,13 +177,13 @@ def setup_model( inference_wrapped_model=inference_wrapped_model, tokenizer=self.mock_tokenizer ) + class TestTextGenerationController(TextGenerationControllerTestBase): @classmethod def setup_class(cls): Utils.initialize_model_parallel( - tensor_model_parallel_size=2, - pipeline_model_parallel_size=1, + tensor_model_parallel_size=2, pipeline_model_parallel_size=1 ) @classmethod From c6ad4f382d946222ed86081a48c7b2f61ef3c484 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 12:38:47 -0700 Subject: [PATCH 122/124] More cuda graph deletion Signed-off-by: Keshav Santhanam --- .../engines/test_hybrid_prefix_caching_e2e.py | 15 ++++----- .../test_prefix_caching_cuda_graphs.py | 33 +++++-------------- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py index 303cf76d122..212e37f377a 100644 --- a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py +++ b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py @@ -58,7 +58,7 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version from tests.unit_tests.test_utilities import Utils @@ -111,8 +111,12 @@ def setup_method(self, method): seed=123, inference_rng_tracker=True, use_cudagraphable_rng=False, force_reset_rng=True ) + def teardown_method(self, method): + delete_cuda_graphs() + @classmethod def teardown_class(cls): + delete_cuda_graphs() Utils.destroy_model_parallel() def _create_model(self, num_cuda_graphs=None): @@ -219,14 +223,7 @@ def _build_engine( vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - _CudagraphGlobalRecord.cudagraph_inference_record = [] - CudaGraphManager.global_mempool = None - for module in model.modules(): - if isinstance(module, CudaGraphManager): - module.cudagraph_runners.clear() - module.inference_cudagraphs_lookup_table.clear() + delete_cuda_graphs() return DynamicInferenceEngine(controller, context) def _make_request(self, req_id, prompt, enable_pc, num_tokens=NUM_TOKENS_TO_GENERATE): diff --git a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py index 26a81c5baef..ba649678588 100644 --- a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py +++ b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py @@ -41,7 +41,7 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version from tests.unit_tests.test_utilities import Utils @@ -72,6 +72,7 @@ def setup_method(self, method): ) def teardown_method(self, method): + delete_cuda_graphs() Utils.destroy_model_parallel() def _create_model(self, model_type, num_cuda_graphs=None): @@ -138,17 +139,6 @@ def _create_model(self, model_type, num_cuda_graphs=None): model.eval() return model, mamba_config - def _reset_cuda_graph_state(self, model): - """Reset all CUDA graph global and per-module state.""" - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - _CudagraphGlobalRecord.cudagraph_inference_record = [] - CudaGraphManager.global_mempool = None - for module in model.modules(): - if isinstance(module, CudaGraphManager): - module.cudagraph_runners.clear() - module.inference_cudagraphs_lookup_table.clear() - def _build_engine(self, model, mamba_config, num_cuda_graphs): """Build an engine with prefix caching and optional CUDA graphs.""" set_rounder(4) @@ -180,7 +170,7 @@ def _build_engine(self, model, mamba_config, num_cuda_graphs): vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - self._reset_cuda_graph_state(model) + delete_cuda_graphs() return DynamicInferenceEngine(controller, context) def _create_prompts(self): @@ -320,8 +310,12 @@ class TestHybridChunkedPrefillIntermediateState: def setup_class(cls): Utils.initialize_model_parallel() + def teardown_method(self, method): + delete_cuda_graphs() + @classmethod def teardown_class(cls): + delete_cuda_graphs() set_rounder(64) Utils.destroy_model_parallel() @@ -358,17 +352,6 @@ def _create_hybrid_model(self, num_cuda_graphs=None): model.eval() return model - def _reset_cuda_graph_state(self, model): - """Reset all CUDA graph global and per-module state.""" - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - _CudagraphGlobalRecord.cudagraph_inference_record = [] - CudaGraphManager.global_mempool = None - for module in model.modules(): - if isinstance(module, CudaGraphManager): - module.cudagraph_runners.clear() - module.inference_cudagraphs_lookup_table.clear() - def _build_engine( self, model, @@ -413,7 +396,7 @@ def _build_engine( vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - self._reset_cuda_graph_state(model) + delete_cuda_graphs() return DynamicInferenceEngine(controller, context) def _make_request(self, req_id, prompt, enable_pc): From 2cae20fd447468b0b932a46d61414a224cfbd6ce Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 12:54:47 -0700 Subject: [PATCH 123/124] Fix copyright Signed-off-by: Keshav Santhanam --- tests/unit_tests/inference/test_communication_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/inference/test_communication_utils.py b/tests/unit_tests/inference/test_communication_utils.py index dd4cf14f112..e0c5a9f734d 100644 --- a/tests/unit_tests/inference/test_communication_utils.py +++ b/tests/unit_tests/inference/test_communication_utils.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + import pytest import torch import torch.distributed as dist From f537865cea44f327574cb3df29e2252ec31919fb Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Apr 2026 13:56:01 -0700 Subject: [PATCH 124/124] Revert unnecessary cuda graph changes Signed-off-by: Keshav Santhanam --- .../engines/test_hybrid_prefix_caching_e2e.py | 15 +++++---- .../test_prefix_caching_cuda_graphs.py | 33 ++++++++++++++----- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py index 212e37f377a..303cf76d122 100644 --- a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py +++ b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py @@ -58,7 +58,7 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.cuda_graphs import delete_cuda_graphs +from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version from tests.unit_tests.test_utilities import Utils @@ -111,12 +111,8 @@ def setup_method(self, method): seed=123, inference_rng_tracker=True, use_cudagraphable_rng=False, force_reset_rng=True ) - def teardown_method(self, method): - delete_cuda_graphs() - @classmethod def teardown_class(cls): - delete_cuda_graphs() Utils.destroy_model_parallel() def _create_model(self, num_cuda_graphs=None): @@ -223,7 +219,14 @@ def _build_engine( vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - delete_cuda_graphs() + _CudagraphGlobalRecord.cudagraph_created = False + _CudagraphGlobalRecord.cudagraph_record = [] + _CudagraphGlobalRecord.cudagraph_inference_record = [] + CudaGraphManager.global_mempool = None + for module in model.modules(): + if isinstance(module, CudaGraphManager): + module.cudagraph_runners.clear() + module.inference_cudagraphs_lookup_table.clear() return DynamicInferenceEngine(controller, context) def _make_request(self, req_id, prompt, enable_pc, num_tokens=NUM_TOKENS_TO_GENERATE): diff --git a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py index ba649678588..26a81c5baef 100644 --- a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py +++ b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py @@ -41,7 +41,7 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.cuda_graphs import delete_cuda_graphs +from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version from tests.unit_tests.test_utilities import Utils @@ -72,7 +72,6 @@ def setup_method(self, method): ) def teardown_method(self, method): - delete_cuda_graphs() Utils.destroy_model_parallel() def _create_model(self, model_type, num_cuda_graphs=None): @@ -139,6 +138,17 @@ def _create_model(self, model_type, num_cuda_graphs=None): model.eval() return model, mamba_config + def _reset_cuda_graph_state(self, model): + """Reset all CUDA graph global and per-module state.""" + _CudagraphGlobalRecord.cudagraph_created = False + _CudagraphGlobalRecord.cudagraph_record = [] + _CudagraphGlobalRecord.cudagraph_inference_record = [] + CudaGraphManager.global_mempool = None + for module in model.modules(): + if isinstance(module, CudaGraphManager): + module.cudagraph_runners.clear() + module.inference_cudagraphs_lookup_table.clear() + def _build_engine(self, model, mamba_config, num_cuda_graphs): """Build an engine with prefix caching and optional CUDA graphs.""" set_rounder(4) @@ -170,7 +180,7 @@ def _build_engine(self, model, mamba_config, num_cuda_graphs): vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - delete_cuda_graphs() + self._reset_cuda_graph_state(model) return DynamicInferenceEngine(controller, context) def _create_prompts(self): @@ -310,12 +320,8 @@ class TestHybridChunkedPrefillIntermediateState: def setup_class(cls): Utils.initialize_model_parallel() - def teardown_method(self, method): - delete_cuda_graphs() - @classmethod def teardown_class(cls): - delete_cuda_graphs() set_rounder(64) Utils.destroy_model_parallel() @@ -352,6 +358,17 @@ def _create_hybrid_model(self, num_cuda_graphs=None): model.eval() return model + def _reset_cuda_graph_state(self, model): + """Reset all CUDA graph global and per-module state.""" + _CudagraphGlobalRecord.cudagraph_created = False + _CudagraphGlobalRecord.cudagraph_record = [] + _CudagraphGlobalRecord.cudagraph_inference_record = [] + CudaGraphManager.global_mempool = None + for module in model.modules(): + if isinstance(module, CudaGraphManager): + module.cudagraph_runners.clear() + module.inference_cudagraphs_lookup_table.clear() + def _build_engine( self, model, @@ -396,7 +413,7 @@ def _build_engine( vocab_size=VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" ), ) - delete_cuda_graphs() + self._reset_cuda_graph_state(model) return DynamicInferenceEngine(controller, context) def _make_request(self, req_id, prompt, enable_pc):