# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import enum import itertools import json import math import os import pickle import re import statistics import sys import tempfile from typing import Any, List import cloudpickle import pytest import torch from mpi4py import MPI import tensorrt_llm import tensorrt_llm._torch.autotuner as autotuner from tensorrt_llm._torch.autotuner import (AutoTuner, DistributedTuningStrategy, DynamicDim, DynamicTensorSpec, FakeTensor, NvMMHConfig, OptimizationProfile, StaticDim, TunableRunner, TuningConfig, autotune) from tensorrt_llm._torch.distributed import Distributed from tensorrt_llm._torch.utils import (deep_gemm_jit_warmup_buckets, get_power_of_2_num_tokens_buckets, next_positive_power_of_2) from tensorrt_llm.bindings.internal.runtime import delay_kernel from tensorrt_llm.logger import logger from tensorrt_llm.mapping import Mapping cloudpickle.register_pickle_by_value(sys.modules[__name__]) MPI.pickle.__init__( cloudpickle.dumps, cloudpickle.loads, pickle.HIGHEST_PROTOCOL, ) # needed since we reuse the mpi executor pool, first test running will leak a thread pytestmark = pytest.mark.threadleak(enabled=False) def test_multi_dynamic_dims(): tuner = autotuner.AutoTuner() x = torch.rand([5, 1024]) w = torch.rand([7, 9]) dynamic_tensor_specs = ( DynamicTensorSpec(0, 0, [1, 3, 5]), DynamicTensorSpec(0, 1, [16, 24, 1024]), # map_to_tuning_buckets is only applied at runtime, not during tuning DynamicTensorSpec(1, 1, [3, 7, 9], map_to_tuning_buckets=lambda x: x // 2), ) profiles = tuner._optimization_profiles( tuning_config=TuningConfig(dynamic_tensor_specs=dynamic_tensor_specs), inputs=[x, w]) # choice(0, 0) * choice(0, 1) * choice(1, 1) # 3 * 3 * 3 = 27, input value 9 is already inside the bucket assert len(profiles) == 27 sample_0 = OptimizationProfile(shapes=[[ DynamicDim(min=1, opt=1, max=3), DynamicDim(min=16, opt=16, max=24) ], [StaticDim(val=7), DynamicDim(min=3, opt=3, max=7)]]) sample_26 = OptimizationProfile(shapes=[[ DynamicDim(min=5, opt=5, max=float('inf')), DynamicDim(min=1024, opt=1024, max=float('inf')) ], [StaticDim( val=7), DynamicDim(min=9, opt=9, max=float('inf'))]]) assert sample_0 == profiles[0] assert sample_26 == profiles[-1] # For cache testing """ tactic 0 is better when x.shape[0] <= M // 2 tactic 1 is better when x.shape[0] > M // 2 """ M = 32 # add sleep to simulate bad perf def gemm_0(x, w): if x.shape[0] > M // 2: delay_kernel(100, torch.cuda.current_stream()) return x @ w def gemm_1(x, w): if x.shape[0] <= M // 2: delay_kernel(100, torch.cuda.current_stream()) return x @ w def gemm_fallback(x, w) -> torch.Tensor: # always the slowest delay_kernel(500, torch.cuda.current_stream()) return x @ w def check_gemm_tactic_valid(tactic: int, m: int) -> bool: # TODO: CI is not stable for this test. delay_kernel can not guarantee the profiling result. # We need to find a more determinist way to test this. if m <= M // 2: if tactic != 0: logger.warning( f"Expect tactic 0 but got {tactic} when m ({m}) is small.") elif m <= M: if tactic != 1: logger.warning( f"Expect tactic 1 but got {tactic} when m ({m}) is large.") else: if tactic != -1: logger.warning( f"Expect fallback tactic (-1) but got {tactic} when m ({m}) > {M}." ) class GemmRunner(TunableRunner): def get_valid_tactics(self, inputs: List[FakeTensor], profile: OptimizationProfile, **kwargs) -> List[int]: # The simulated delay is not deterministic, so we need to return specific tactics here return [-1, 0, 1] def forward(self, /, inputs: List[torch.Tensor], *, tactic: int = -1, **kwargs) -> torch.Tensor: assert tactic in [-1, 0, 1] return [gemm_0, gemm_1, gemm_fallback][tactic](*inputs) class JitGemmRunner(GemmRunner): """GemmRunner that compiles per tactic, like the CuTe DSL runners.""" kernel_cache: dict[int, bool] = {} calls: list[tuple[tuple[int, ...], int]] = [] def forward(self, /, inputs: list[torch.Tensor], *, tactic: int = -1, **kwargs) -> torch.Tensor: type(self).kernel_cache.setdefault(tactic, True) type(self).calls.append((tuple(inputs[0].shape), tactic)) return super().forward(inputs, tactic=tactic, **kwargs) @pytest.mark.parametrize("strategy", [ DistributedTuningStrategy.PARALLEL, DistributedTuningStrategy.MERGE, ]) def test_prime_cached_tactics_on_cache_hit(monkeypatch, strategy) -> None: """Prime local cached winners without starting collective profiling.""" monkeypatch.setenv("TLLM_AUTOTUNER_PRIME_CACHED_TACTICS", "1") w = torch.randn(64, 128) x = torch.randn(3, 64) tuning_config = TuningConfig( dynamic_tensor_specs=( DynamicTensorSpec(input_idx=0, dim_idx=0, gen_tuning_buckets=(3, 4, 5), map_to_tuning_buckets=lambda value: value), DynamicTensorSpec(input_idx=1, dim_idx=1, gen_tuning_buckets=(64, 128, 256, 512), map_to_tuning_buckets=lambda value: value), ), distributed_tuning_strategy=DistributedTuningStrategy.PARALLEL) op = "test_prime_cached_tactics" tuner = AutoTuner.get() tuner.clear_cache() with tempfile.TemporaryDirectory() as temp_dir: cache_path = os.path.join(temp_dir, "prime.json") with autotune(cache_path=cache_path): tuner.choose_one(op, [JitGemmRunner()], tuning_config, [x, w]) winners = { value[1] for value in tuner.profiling_cache.get_specific_custom_op( op).values() } assert winners # Simulate a fresh process that only has the persistent tactic cache. tuning_config.distributed_tuning_strategy = strategy tuner.profiling_cache.clear() tuner._primed_cached_tactics.clear() JitGemmRunner.kernel_cache.clear() JitGemmRunner.calls.clear() if strategy == DistributedTuningStrategy.MERGE: def unexpected_prepare(*args, **kwargs) -> None: pytest.fail("A collective cache hit must not prepare inputs") monkeypatch.setattr(tuner, "_prepare_input_tensors", unexpected_prepare) with autotune(cache_path=cache_path): tuner.choose_one(op, [JitGemmRunner()], tuning_config, [x, w]) expected = winners if strategy != DistributedTuningStrategy.MERGE else set( ) assert set(JitGemmRunner.kernel_cache) == expected assert len(JitGemmRunner.calls) == len(expected) with autotune(cache_path=cache_path): tuner.choose_one(op, [JitGemmRunner()], tuning_config, [x, w]) assert len(JitGemmRunner.calls) == len(expected) monkeypatch.setenv("TLLM_AUTOTUNER_PRIME_CACHED_TACTICS", "0") tuner._primed_cached_tactics.clear() JitGemmRunner.calls.clear() with autotune(cache_path=cache_path): tuner.choose_one(op, [JitGemmRunner()], tuning_config, [x, w]) assert JitGemmRunner.calls == [] def test_prime_cached_tactics_after_parallel_cache_merge(monkeypatch) -> None: """Prime winners selected by another rank after a cold profile.""" monkeypatch.setenv("TLLM_AUTOTUNER_PRIME_CACHED_TACTICS", "1") x = torch.randn(20, 64) w = torch.randn(64, 128) tuning_config = TuningConfig( dynamic_tensor_specs=(DynamicTensorSpec( input_idx=0, dim_idx=0, gen_tuning_buckets=(3, 20), map_to_tuning_buckets=lambda value: value, ), ), distributed_tuning_strategy=DistributedTuningStrategy.PARALLEL, ) op = "test_prime_cached_tactics_after_parallel_cache_merge" tuner = AutoTuner.get() runner = JitGemmRunner() tuner.clear_cache() tuner._primed_cached_tactics.clear() JitGemmRunner.kernel_cache.clear() JitGemmRunner.calls.clear() def merge_remote_winners(strategy, custom_op) -> None: assert strategy == DistributedTuningStrategy.PARALLEL for m, tactic in ((3, 0), (20, 1)): cache_key = tuner.profiling_cache.get_cache_key( custom_op, runner, ((m, 64), (64, 128)), tuning_config, apply_map_to_tuning_buckets=False, ) tuner.profiling_cache[cache_key] = (0, tactic, 0.5) monkeypatch.setattr(tuner, "is_tuning_mode", True) # Distributed synchronization is covered separately. monkeypatch.setattr(tuner, "_should_current_rank_tune", lambda _: False) monkeypatch.setattr(tuner, "cache_pp_recv", lambda: None) monkeypatch.setattr(tuner, "_maybe_sync_cache_data", merge_remote_winners) selected_runner, tactic = tuner.choose_one(op, [runner], tuning_config, [x, w]) assert selected_runner is runner assert tactic == 1 assert set(JitGemmRunner.kernel_cache) == {0, 1} assert JitGemmRunner.calls == [((3, 64), 0), ((20, 64), 1)] def test_failed_cached_tactic_prime_is_retried(monkeypatch) -> None: """Do not memoize a cached winner until its prime launch succeeds.""" monkeypatch.setenv("TLLM_AUTOTUNER_PRIME_CACHED_TACTICS", "1") x = torch.randn(3, 64) w = torch.randn(64, 128) tuning_config = TuningConfig( distributed_tuning_strategy=DistributedTuningStrategy.PARALLEL) op = "test_failed_cached_tactic_prime_is_retried" tuner = AutoTuner.get() runner = JitGemmRunner() tuner.clear_cache() tuner._primed_cached_tactics.clear() JitGemmRunner.kernel_cache.clear() JitGemmRunner.calls.clear() profile = tuner._optimization_profiles(tuning_config, [x, w])[0] cache_key = tuner.profiling_cache.get_cache_key( op, runner, profile.get_opt_shapes(), tuning_config, apply_map_to_tuning_buckets=False, ) tuner.profiling_cache[cache_key] = (0, 0, 1.0) original_forward = runner.forward attempts = 0 def fail_once(self, inputs, *, tactic=-1, **kwargs) -> torch.Tensor: nonlocal attempts attempts += 1 if attempts == 1: raise RuntimeError("injected prime failure") return original_forward(inputs, tactic=tactic, **kwargs) monkeypatch.setattr(JitGemmRunner, "forward", fail_once) tuner._prime_cached_tactics(op, [runner], tuning_config, [x, w]) tuner._prime_cached_tactics(op, [runner], tuning_config, [x, w]) assert attempts == 2 assert set(JitGemmRunner.kernel_cache) == {0} assert JitGemmRunner.calls == [((3, 64), 0)] @torch.library.custom_op("autotuner_test::get_best_gemm_tactic", mutates_args=()) def get_best_gemm_tactic(x: torch.Tensor, w: torch.Tensor) -> torch.Tensor: runners = [GemmRunner()] tuner = AutoTuner.get() tuning_config = TuningConfig(dynamic_tensor_specs=(DynamicTensorSpec( input_idx=0, dim_idx=0, gen_tuning_buckets=get_power_of_2_num_tokens_buckets, map_to_tuning_buckets=next_positive_power_of_2), ), ) runner, tactic = tuner.choose_one( "autotuner_test::get_best_gemm_tactic", runners, tuning_config, [x, w], ) return torch.tensor(tactic) @get_best_gemm_tactic.register_fake def _(x: torch.Tensor, w: torch.Tensor) -> torch.Tensor: return torch.empty(1) def test_autotuner_cache_basic(): w = torch.randn(64, 128) # tuning with largest M AutoTuner.get().clear_cache() with autotune(): torch.ops.autotuner_test.get_best_gemm_tactic(torch.randn(M, 64), w) # This tests the logic of print_profiling_cache and print_statistics AutoTuner.get().print_profiling_cache() AutoTuner.get().print_statistics() m = M * 2 while m >= 1: best_tactic = torch.ops.autotuner_test.get_best_gemm_tactic( torch.randn(m, 64), w) check_gemm_tactic_valid(best_tactic, m) m //= 2 def _capture_autotuner_debug_lines(monkeypatch) -> List[str]: """Collect what the tuner writes through ``_debug_logger`` during a tuning run. The lines are the only record of what the tuner considered: the profiling cache keeps just the winner, so a tactic choice can otherwise be explained only by re-running the tuner with tactics removed. Patching the instance attribute rather than the logger keeps other loggers out of the capture -- ``_debug_logger`` is bound once in ``__init__`` to ``logger.info`` or ``logger.debug`` depending on TLLM_AUTOTUNER_LOG_LEVEL_DEBUG_TO_INFO, and this test must not depend on which. """ lines: List[str] = [] monkeypatch.setattr(AutoTuner.get(), "_debug_logger", lines.append) return lines def test_every_candidate_is_logged_and_the_winner_is_the_best_of_them( monkeypatch): """Each profiled (runner, tactic) is logged, and Selected agrees with them. Two separate things, both needed to read a tactic choice off a run's log: 1. Candidates the tuner measured appear, not just the winner. 2. The Selected line is in the same family as the candidates -- it used to be the one [Autotuner] line on logger.debug while the rest went through _debug_logger, so promoting the family to INFO gave a log with every candidate and no winner, which is precisely the log a tuning investigation asks for. Capturing through _debug_logger would not see it at all if that regressed. The agreement check is what makes this more than a formatting test: each Selected time must equal the minimum candidate time of some profile, so a candidate line that reported a different measurement from the one the comparison used would fail here. """ w = torch.randn(64, 128) AutoTuner.get().clear_cache() lines = _capture_autotuner_debug_lines(monkeypatch) with autotune(): torch.ops.autotuner_test.get_best_gemm_tactic(torch.randn(M, 64), w) op = "autotuner_test::get_best_gemm_tactic" # Parsed with a regex, not a split on ", ": the shapes field is a tuple of # tuples and carries commas of its own. candidate_re = re.compile( rf"^\[Autotuner\] Candidate: custom_op={re.escape(op)}, runner=.*?, " r"tactic=(?P-?\d+), shapes=(?P.*), " r"time=(?P