From 2bdd509388d8ec2aa5032c7ce06886e527c3cb07 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Thu, 16 Apr 2026 05:55:32 +0000 Subject: [PATCH 001/142] expr: mi308 config --- alto/models/gpt_oss/config_registry.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index ec1e5daa..f9299c70 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -72,7 +72,7 @@ def gpt_oss_20b() -> Trainer.Config: def gpt_oss_20b_pretrain() -> Trainer.Config: config = gpt_oss_20b_orig() config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" - config.dump_folder = "gpt_oss_20b-pretrain-subset-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-lr4e-4-outputs" config.profiling.enable_profiling = False config.training.steps = 1200000 config.training.local_batch_size = 1 @@ -91,9 +91,9 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.metrics.enable_tensorboard = True config.dataloader.dataset = "megatron" config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" - config.parallelism.expert_parallel_degree = 4 + config.parallelism.expert_parallel_degree = 8 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 4 + config.parallelism.tensor_parallel_degree = 1 config.checkpoint.enable = True config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 @@ -102,7 +102,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.validator.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-validation-91205-samples.en_text_document.idx" config.validator.freq = 768 config.validator.steps = 64 - config.activation_checkpoint.mode = "selective" + config.activation_checkpoint.mode = "none" config.activation_checkpoint.selective_ac_option = "1" config.debug.seed = 1234 return config @@ -110,7 +110,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) From 40fd074de9170fdce76489f06f39113cd823561a Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 20 Apr 2026 03:52:26 +0000 Subject: [PATCH 002/142] fix: fsdp sharded param without gradient --- 3rdparty/torchtitan | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rdparty/torchtitan b/3rdparty/torchtitan index 98128cc2..aee1ada4 160000 --- a/3rdparty/torchtitan +++ b/3rdparty/torchtitan @@ -1 +1 @@ -Subproject commit 98128cc298a899e65e2c2b3296b29f4a351a824f +Subproject commit aee1ada43e6f40502783773ff7757f94a7922b63 From a03db10935df844dc7d153e2b65119146c5a5b7e Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 28 Apr 2026 06:29:03 +0000 Subject: [PATCH 003/142] expr: mxfp4 with mbs --- alto/kernels/fp4/mxfp4/macro_block_scaling.py | 8 ++++---- alto/models/gpt_oss/config_registry.py | 4 ++-- alto/models/gpt_oss/configs/lpt_recipe.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/macro_block_scaling.py b/alto/kernels/fp4/mxfp4/macro_block_scaling.py index 34ce5bc6..391f9032 100644 --- a/alto/kernels/fp4/mxfp4/macro_block_scaling.py +++ b/alto/kernels/fp4/mxfp4/macro_block_scaling.py @@ -406,8 +406,8 @@ def _macro_block_descaling_triton_impl( def macro_block_scaling(x: torch.Tensor, axis: int = -1, use_2d_block: bool = False) -> tuple[torch.Tensor, torch.Tensor]: - if x.is_cuda: - return _macro_block_scaling_triton_impl(x, axis=axis, use_2d_block=use_2d_block) + # if x.is_cuda: + # return _macro_block_scaling_triton_impl(x, axis=axis, use_2d_block=use_2d_block) return _macro_block_scaling_torch_impl(x, axis=axis, use_2d_block=use_2d_block) @@ -417,6 +417,6 @@ def macro_block_descaling( axis: int = -1, use_2d_block: bool = False, ) -> torch.Tensor: - if y.is_cuda: - return _macro_block_descaling_triton_impl(y, scale, axis=axis, use_2d_block=use_2d_block) + # if y.is_cuda: + # return _macro_block_descaling_triton_impl(y, scale, axis=axis, use_2d_block=use_2d_block) return _macro_block_descaling_torch_impl(y, scale, axis=axis, use_2d_block=use_2d_block) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index f9299c70..4ebcddcc 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -94,7 +94,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.parallelism.expert_parallel_degree = 8 config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 1 - config.checkpoint.enable = True + config.checkpoint.enable = False config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 config.validator.enable = True @@ -110,7 +110,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-blkscale-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) diff --git a/alto/models/gpt_oss/configs/lpt_recipe.yaml b/alto/models/gpt_oss/configs/lpt_recipe.yaml index dba94eac..e2f9a9f5 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe.yaml @@ -11,4 +11,4 @@ training_stage: use_sr_grad: true use_dge: false clip_mode: none - two_level_scaling: none + two_level_scaling: blockwise From b6e082f1f8c40c0a53c5df9588b5e1d3d16b5c96 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 28 Apr 2026 06:49:42 +0000 Subject: [PATCH 004/142] chore: refactor mbs to use the same grid for both 1D/2D blocks --- alto/kernels/fp4/mxfp4/macro_block_scaling.py | 89 +++++++++++-------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/macro_block_scaling.py b/alto/kernels/fp4/mxfp4/macro_block_scaling.py index 391f9032..88fd8922 100644 --- a/alto/kernels/fp4/mxfp4/macro_block_scaling.py +++ b/alto/kernels/fp4/mxfp4/macro_block_scaling.py @@ -50,33 +50,46 @@ def _macro_block_scaling_kernel( pid_b = tl.program_id(axis=0) pid_m = tl.program_id(axis=1) pid_n = tl.program_id(axis=2) - BLOCK_M: tl.constexpr = BLOCK_SIZE if USE_2D_BLOCK else 1 + BLOCK_M: tl.constexpr = BLOCK_SIZE BLOCK_N: tl.constexpr = BLOCK_SIZE offs_m = tl.arange(0, BLOCK_M)[:, None] offs_n = tl.arange(0, BLOCK_N)[None, :] - if USE_2D_BLOCK: - idx_m = pid_m * BLOCK_M + offs_m - idx_n = pid_n * BLOCK_N + offs_n - else: - idx_m = pid_m + offs_m - idx_n = pid_n * BLOCK_N + offs_n + row_ids = pid_m * BLOCK_M + tl.arange(0, BLOCK_M) + idx_m = pid_m * BLOCK_M + offs_m + idx_n = pid_n * BLOCK_N + offs_n mask = (idx_m < M) & (idx_n < N) offs_x = pid_b * stride_xb + idx_m * stride_xm + idx_n * stride_xn x = tl.load(x_ptr + offs_x, mask=mask, other=0) - max_abs = tl.max(tl.max(tl.abs(x).to(tl.float32), axis=1), axis=0) - amax = tl.maximum(max_abs, 1e-12) - amax_int32 = (MAX_FP4_CONST / amax).to(tl.int32, bitcast=True) - scale = amax_int32 & 0x007F8000 - scale_uint8 = (scale >> 15).to(tl.uint8) - scale_fp32 = (scale + (127 << 23)).to(tl.float32, bitcast=True) + + if USE_2D_BLOCK: + max_abs = tl.max(tl.max(tl.abs(x).to(tl.float32), axis=1), axis=0) + amax = tl.maximum(max_abs, 1e-12) + amax_int32 = (MAX_FP4_CONST / amax).to(tl.int32, bitcast=True) + scale = amax_int32 & 0x007F8000 + scale_uint8 = (scale >> 15).to(tl.uint8) + scale_fp32 = (scale + (127 << 23)).to(tl.float32, bitcast=True) + else: + max_abs = tl.max(tl.abs(x).to(tl.float32), axis=1) + amax = tl.maximum(max_abs, 1e-12) + amax_int32 = (MAX_FP4_CONST / amax).to(tl.int32, bitcast=True) + scale = amax_int32 & 0x007F8000 + scale_uint8 = (scale >> 15).to(tl.uint8) + scale_fp32 = (scale + (127 << 23)).to(tl.float32, bitcast=True)[:, None] y = x * scale_fp32 offs_y = pid_b * stride_yb + idx_m * stride_ym + idx_n * stride_yn - offs_s = pid_b * stride_sb + pid_m * stride_sm + pid_n * stride_sn + if USE_2D_BLOCK: + offs_s = pid_b * stride_sb + pid_m * stride_sm + pid_n * stride_sn + else: + offs_s = pid_b * stride_sb + row_ids * stride_sm + pid_n * stride_sn + row_mask = row_ids < M tl.store(y_ptr + offs_y, y.to(y_ptr.type.element_ty), mask=mask) - tl.store(scale_ptr + offs_s, scale_uint8) + if USE_2D_BLOCK: + tl.store(scale_ptr + offs_s, scale_uint8) + else: + tl.store(scale_ptr + offs_s, scale_uint8, mask=row_mask) @triton.jit @@ -101,25 +114,28 @@ def _macro_block_descaling_kernel( pid_b = tl.program_id(axis=0) pid_m = tl.program_id(axis=1) pid_n = tl.program_id(axis=2) - BLOCK_M: tl.constexpr = BLOCK_SIZE if USE_2D_BLOCK else 1 + BLOCK_M: tl.constexpr = BLOCK_SIZE BLOCK_N: tl.constexpr = BLOCK_SIZE offs_m = tl.arange(0, BLOCK_M)[:, None] offs_n = tl.arange(0, BLOCK_N)[None, :] - if USE_2D_BLOCK: - idx_m = pid_m * BLOCK_M + offs_m - idx_n = pid_n * BLOCK_N + offs_n - else: - idx_m = pid_m + offs_m - idx_n = pid_n * BLOCK_N + offs_n + row_ids = pid_m * BLOCK_M + tl.arange(0, BLOCK_M) + idx_m = pid_m * BLOCK_M + offs_m + idx_n = pid_n * BLOCK_N + offs_n mask = (idx_m < M) & (idx_n < N) offs_y = pid_b * stride_yb + idx_m * stride_ym + idx_n * stride_yn y = tl.load(y_ptr + offs_y, mask=mask, other=0) - offs_s = pid_b * stride_sb + pid_m * stride_sm + pid_n * stride_sn - scale_uint8 = tl.load(scale_ptr + offs_s).to(tl.uint32) - scale_fp32 = ((scale_uint8 << 15) + (127 << 23)).to(tl.float32, bitcast=True) + if USE_2D_BLOCK: + offs_s = pid_b * stride_sb + pid_m * stride_sm + pid_n * stride_sn + scale_uint8 = tl.load(scale_ptr + offs_s).to(tl.uint32) + scale_fp32 = ((scale_uint8 << 15) + (127 << 23)).to(tl.float32, bitcast=True) + else: + offs_s = pid_b * stride_sb + row_ids * stride_sm + pid_n * stride_sn + row_mask = row_ids < M + scale_uint8 = tl.load(scale_ptr + offs_s, mask=row_mask, other=127).to(tl.uint32) + scale_fp32 = ((scale_uint8 << 15) + (127 << 23)).to(tl.float32, bitcast=True)[:, None] x = y / scale_fp32 offs_x = pid_b * stride_xb + idx_m * stride_xm + idx_n * stride_xn @@ -221,24 +237,24 @@ def _macro_block_scaling_triton_impl( n_tiles = _ceil_div(n, MACRO_BLOCK_SIZE) if use_2d_block: - scale = torch.empty( + scale = torch.zeros( (bsz, m_tiles, n_tiles), dtype=torch.uint8, device=x.device, ) - grid = (bsz, m_tiles, n_tiles) else: - scale = torch.empty( + scale = torch.zeros( (bsz, m, n_tiles), dtype=torch.uint8, device=x.device, ) - grid = (bsz, m, n_tiles) + grid = (bsz, m_tiles, n_tiles) y_3d = torch.empty_like(x_3d) stride_xb, stride_xm, stride_xn = x_3d.stride() stride_yb, stride_ym, stride_yn = y_3d.stride() stride_sb, stride_sm, stride_sn = scale.stride() + _macro_block_scaling_kernel[grid]( x_3d, y_3d, @@ -257,7 +273,6 @@ def _macro_block_scaling_triton_impl( MAX_FP4_CONST=MAX_FP4, BLOCK_SIZE=MACRO_BLOCK_SIZE, USE_2D_BLOCK=use_2d_block, - num_warps=8 if use_2d_block else 4, ) if x.ndim == 2: y = y_3d.squeeze(0) @@ -370,10 +385,7 @@ def _macro_block_descaling_triton_impl( m_tiles = _ceil_div(m, MACRO_BLOCK_SIZE) n_tiles = _ceil_div(n, MACRO_BLOCK_SIZE) - if use_2d_block: - grid = (bsz, m_tiles, n_tiles) - else: - grid = (bsz, m, n_tiles) + grid = (bsz, m_tiles, n_tiles) stride_yb, stride_ym, stride_yn = y_3d.stride() stride_xb, stride_xm, stride_xn = x_3d.stride() @@ -395,7 +407,6 @@ def _macro_block_descaling_triton_impl( stride_sn, BLOCK_SIZE=MACRO_BLOCK_SIZE, USE_2D_BLOCK=use_2d_block, - num_warps=8 if use_2d_block else 4, ) if y.ndim == 2: @@ -406,8 +417,8 @@ def _macro_block_descaling_triton_impl( def macro_block_scaling(x: torch.Tensor, axis: int = -1, use_2d_block: bool = False) -> tuple[torch.Tensor, torch.Tensor]: - # if x.is_cuda: - # return _macro_block_scaling_triton_impl(x, axis=axis, use_2d_block=use_2d_block) + if x.is_cuda: + return _macro_block_scaling_triton_impl(x, axis=axis, use_2d_block=use_2d_block) return _macro_block_scaling_torch_impl(x, axis=axis, use_2d_block=use_2d_block) @@ -417,6 +428,6 @@ def macro_block_descaling( axis: int = -1, use_2d_block: bool = False, ) -> torch.Tensor: - # if y.is_cuda: - # return _macro_block_descaling_triton_impl(y, scale, axis=axis, use_2d_block=use_2d_block) + if y.is_cuda: + return _macro_block_descaling_triton_impl(y, scale, axis=axis, use_2d_block=use_2d_block) return _macro_block_descaling_torch_impl(y, scale, axis=axis, use_2d_block=use_2d_block) From 27fa9e4decf4cda158c267027ac81789afbd9b4f Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 28 Apr 2026 07:21:08 +0000 Subject: [PATCH 005/142] chore: skip unnecessary init --- alto/kernels/fp4/mxfp4/macro_block_scaling.py | 8 ++++++-- alto/kernels/fp4/mxfp4/tests/test_macro_block_scaling.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/macro_block_scaling.py b/alto/kernels/fp4/mxfp4/macro_block_scaling.py index 88fd8922..bedea7fa 100644 --- a/alto/kernels/fp4/mxfp4/macro_block_scaling.py +++ b/alto/kernels/fp4/mxfp4/macro_block_scaling.py @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + import torch import torch.nn.functional as F import triton @@ -237,13 +241,13 @@ def _macro_block_scaling_triton_impl( n_tiles = _ceil_div(n, MACRO_BLOCK_SIZE) if use_2d_block: - scale = torch.zeros( + scale = torch.empty( (bsz, m_tiles, n_tiles), dtype=torch.uint8, device=x.device, ) else: - scale = torch.zeros( + scale = torch.empty( (bsz, m, n_tiles), dtype=torch.uint8, device=x.device, diff --git a/alto/kernels/fp4/mxfp4/tests/test_macro_block_scaling.py b/alto/kernels/fp4/mxfp4/tests/test_macro_block_scaling.py index 8007987b..e7d60e3e 100644 --- a/alto/kernels/fp4/mxfp4/tests/test_macro_block_scaling.py +++ b/alto/kernels/fp4/mxfp4/tests/test_macro_block_scaling.py @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + import pytest import torch From 86f0f28c9c0e553d223aff624cec84ee8305a90c Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 28 Apr 2026 03:31:52 -0500 Subject: [PATCH 006/142] fix: rescale gradients only when clip_mode=static --- alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py | 2 +- alto/kernels/fp4/mxfp4/mxfp_linear.py | 2 +- alto/kernels/fp4/mxfp4/tests/test_mxfp_grouped_gemm.py | 9 +++++++-- alto/kernels/fp4/mxfp4/tests/test_mxfp_linear.py | 9 +++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py index 0ff52066..b215a122 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py @@ -882,7 +882,7 @@ def backward(ctx, grad_output): output_dtype=ctx.original_dtype, ) - if ctx.clip_mode: + if ctx.clip_mode == "static": grad_weights *= (16.0 / 9.0) else: grad_inputs = cg_grouped_gemm_backward_inputs( diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index c4306c9b..4abe7e12 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -531,7 +531,7 @@ def backward(ctx, grad_output): k_pack_b=not ctx.use_2dblock_x, output_dtype=ctx.original_dtype, ) - if ctx.clip_mode: + if ctx.clip_mode == "static": grad_weights *= (16.0 / 9.0) else: grad_inputs = grad_output_dq @ w_dq diff --git a/alto/kernels/fp4/mxfp4/tests/test_mxfp_grouped_gemm.py b/alto/kernels/fp4/mxfp4/tests/test_mxfp_grouped_gemm.py index 0d08d784..aafff5a4 100644 --- a/alto/kernels/fp4/mxfp4/tests/test_mxfp_grouped_gemm.py +++ b/alto/kernels/fp4/mxfp4/tests/test_mxfp_grouped_gemm.py @@ -147,6 +147,11 @@ def test_mxfp_group_gemm(shape, use_2dblock_x, use_2dblock_w, trans_weights, con headers=["Tensor", "SNR", "Cosine Sim"], tablefmt="github")) + if clip_mode == "static": + min_snr = 6.3 + else: + min_snr = 9 + assert output_snr > 10 - assert dx_snr > 9 - assert dw_snr > 9 + assert dx_snr > min_snr + assert dw_snr > min_snr diff --git a/alto/kernels/fp4/mxfp4/tests/test_mxfp_linear.py b/alto/kernels/fp4/mxfp4/tests/test_mxfp_linear.py index 6864f019..35dc5316 100644 --- a/alto/kernels/fp4/mxfp4/tests/test_mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/tests/test_mxfp_linear.py @@ -102,7 +102,7 @@ def test_mxfp4_linear_kernel(shape, use_2dblock_a, use_2dblock_b, trans_a, trans assert torch.allclose(c_ref, c) -@pytest.mark.parametrize("shape", [(1, 32, 32, 32), (1, 512, 384, 128), (4, 1024, 1024, 2048)]) +@pytest.mark.parametrize("shape", [(1, 512, 384, 128), (4, 1024, 1024, 2048)]) @pytest.mark.parametrize("use_2dblock_w", [False, True]) @pytest.mark.parametrize("use_2dblock_x", [False, True]) @pytest.mark.parametrize("use_grad_sr", [False, True]) @@ -181,7 +181,12 @@ def test_mxfp4_linear_autograd_function(shape, use_2dblock_x, use_2dblock_w, use headers=["Tensor", "SNR", "Cosine Sim"], tablefmt="github")) - min_snr = 7 if use_grad_sr else 10 + if use_grad_sr: + min_snr = 6.9 + if clip_mode == "static": + min_snr = 6 + else: + min_snr = 8.6 assert output_snr > min_snr assert dx_snr > min_snr assert dw_snr > min_snr From 145e68dd27fcd9b834df40549dae8addb348fe4f Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 11 May 2026 02:17:21 +0000 Subject: [PATCH 007/142] expr: mixed-precision --- alto/models/gpt_oss/config_registry.py | 4 ++-- alto/models/gpt_oss/configs/lpt_recipe.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 4ebcddcc..3171bd11 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -93,7 +93,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" config.parallelism.expert_parallel_degree = 8 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 2 config.checkpoint.enable = False config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 @@ -110,7 +110,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-blkscale-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-skip12-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) diff --git a/alto/models/gpt_oss/configs/lpt_recipe.yaml b/alto/models/gpt_oss/configs/lpt_recipe.yaml index e2f9a9f5..b1a44ed6 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe.yaml @@ -4,11 +4,11 @@ training_stage: scheme: "mxfp4" targets: ["Linear", "GptOssGroupedExperts"] # targets: ["Linear"] - ignore: ["output", "re:.*\\.router\\.gate"] + ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] use_2dblock_x: false use_2dblock_w: true use_hadamard: true use_sr_grad: true use_dge: false clip_mode: none - two_level_scaling: blockwise + two_level_scaling: none From f5f68ae05589283c031507a071496637c9addda5 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 11 May 2026 08:03:46 +0000 Subject: [PATCH 008/142] fix: llama3_8b_lpt config --- alto/models/llama3/config_registry.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index 17dd4b70..e499b0c4 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -23,6 +23,7 @@ "llama3_1b_opt", "llama3_1b_lpt", "llama3_8b", + "llama3_8b_pretrain", "llama3_8b_opt", "llama3_8b_lpt", "llama3_1b_gptq", @@ -119,7 +120,7 @@ def llama3_1b_lpt() -> Trainer.Config: return config -def llama3_8b() -> Trainer.Config: +def llama3_8b_pretrain() -> Trainer.Config: config = llama3_8b_orig() config.hf_assets_path = "/huggingface/hub/models--unsloth--Llama-3.1-8B/snapshots/3f0d51f8e5640f98f1a96ea9044a0e55c0a83814" config.metrics.log_freq = 1 @@ -132,10 +133,10 @@ def llama3_8b() -> Trainer.Config: config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 8 config.activation_checkpoint.mode = "none" - config.checkpoint.enable = True + config.checkpoint.enable = False config.checkpoint.interval = 10 config.checkpoint.initial_load_path = "/huggingface/hub/models--unsloth--Llama-3.1-8B/snapshots/3f0d51f8e5640f98f1a96ea9044a0e55c0a83814" - config.checkpoint.initial_load_in_hf = True + config.checkpoint.initial_load_in_hf = False config.validator.enable = True config.validator.dataloader.dataset = "wikitext_test" config.validator.freq = 10 @@ -154,7 +155,7 @@ def llama3_8b_opt() -> Trainer.Config: def llama3_8b_lpt() -> Trainer.Config: - config = llama3_8b() + config = llama3_8b_pretrain() config.training.steps = 1000 config.model_converters = ModelConvertersContainer.Config( converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_recipe.yaml",)],) From 72c7613850ce51387b2029cecac2dca96817b4b5 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Thu, 14 May 2026 07:34:46 +0000 Subject: [PATCH 009/142] expr: skip qkvo proj --- alto/models/gpt_oss/config_registry.py | 4 ++-- alto/models/gpt_oss/configs/lpt_recipe.yaml | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 3171bd11..035b8ffc 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -72,7 +72,7 @@ def gpt_oss_20b() -> Trainer.Config: def gpt_oss_20b_pretrain() -> Trainer.Config: config = gpt_oss_20b_orig() config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" - config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-tp2-lr4e-4-outputs" config.profiling.enable_profiling = False config.training.steps = 1200000 config.training.local_batch_size = 1 @@ -110,7 +110,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-skip12-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-skipqkvo-tp2-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) diff --git a/alto/models/gpt_oss/configs/lpt_recipe.yaml b/alto/models/gpt_oss/configs/lpt_recipe.yaml index b1a44ed6..db6a3fad 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe.yaml @@ -4,7 +4,9 @@ training_stage: scheme: "mxfp4" targets: ["Linear", "GptOssGroupedExperts"] # targets: ["Linear"] - ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + # ignore: ["output", "re:.*\\.router\\.gate"] + ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] use_2dblock_x: false use_2dblock_w: true use_hadamard: true From 856cbb2508cdb93e94325754e24dcd7ffe8c53cd Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 18 May 2026 06:13:05 +0000 Subject: [PATCH 010/142] expr: decomp linear --- alto/models/gpt_oss/config_registry.py | 6 +++--- alto/models/gpt_oss/configs/lpt_recipe.yaml | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 035b8ffc..e3b317f7 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -72,7 +72,7 @@ def gpt_oss_20b() -> Trainer.Config: def gpt_oss_20b_pretrain() -> Trainer.Config: config = gpt_oss_20b_orig() config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" - config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-tp2-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-lr4e-4-outputs" config.profiling.enable_profiling = False config.training.steps = 1200000 config.training.local_batch_size = 1 @@ -93,7 +93,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" config.parallelism.expert_parallel_degree = 8 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 2 + config.parallelism.tensor_parallel_degree = 1 config.checkpoint.enable = False config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 @@ -110,7 +110,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-skipqkvo-tp2-lr4e-4-outputs" + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) diff --git a/alto/models/gpt_oss/configs/lpt_recipe.yaml b/alto/models/gpt_oss/configs/lpt_recipe.yaml index db6a3fad..b29baded 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe.yaml @@ -5,8 +5,8 @@ training_stage: targets: ["Linear", "GptOssGroupedExperts"] # targets: ["Linear"] # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] - # ignore: ["output", "re:.*\\.router\\.gate"] - ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] + ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] use_2dblock_x: false use_2dblock_w: true use_hadamard: true @@ -14,3 +14,4 @@ training_stage: use_dge: false clip_mode: none two_level_scaling: none + lora_rank: 32 From 8532ab229c689893d5ac60f2d0a060e40f868919 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 18 May 2026 09:05:57 +0000 Subject: [PATCH 011/142] expr: k8s entrypoint --- scripts/entrypoint.sh | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 scripts/entrypoint.sh diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 00000000..b7a87bd1 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -ex + +mkdir /workspace +ln -s /wekafs/hanwang2/huggingface /huggingface +ln -s /wekafs/hanwang2/workspace /workspace/workspace + +cd /workspace/workspace/ALTO + +pip install --no-build-isolation -e 3rdparty/torchtitan +pip install -e . + +RECIPE_FILE="/tmp/recipe.yaml" +TRAIN_FILE=${TRAIN_FILE:-"alto.train"} +MODULE=${MODULE:-"gpt_oss"} +CONFIG=${CONFIG:-"gpt_oss_20b_lpt"} +GBS=${GBS:-16} +OUTPUT_DIR=${OUTPUT_DIR:-"/workspace/workspace/ALTO/debug-outputs"} + +cat > $RECIPE_FILE < Date: Mon, 25 May 2026 07:23:07 +0000 Subject: [PATCH 012/142] hotfix: init failure on meta device --- alto/nn/decomposed_linear.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/alto/nn/decomposed_linear.py b/alto/nn/decomposed_linear.py index 54e721a2..2469dacd 100644 --- a/alto/nn/decomposed_linear.py +++ b/alto/nn/decomposed_linear.py @@ -31,13 +31,8 @@ def from_linear(cls, linear: nn.Linear, lora_rank: int = 32): new_layer = cls(linear.in_features, linear.out_features, linear.bias is not None, lora_rank) new_layer.weight = linear.weight new_layer.bias = linear.bias - device = linear.weight.device - dtype = linear.weight.dtype - new_layer.u.data = new_layer.u.data.to(device=device, dtype=dtype) - new_layer.v.data = new_layer.v.data.to(device=device, dtype=dtype) - new_layer.sigma.data = new_layer.sigma.data.to(device=device, dtype=dtype) return new_layer - + def init_lora_weights(self, init_std: float = 0.02): nn.init.normal_(self.u, mean=0.0, std=init_std) nn.init.zeros_(self.v) From 47486625e331b6f36d648d277e866b4cceaedf88 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 27 May 2026 17:38:58 +0200 Subject: [PATCH 013/142] mounted submodule, smoke scripts bride built, isolation test passes bash files to debug llama 1B in bf16 and mxfp4 --- .gitmodules | 4 + 3rdparty/adahop | 1 + alto/_adahop_bridge.py | 74 +++++++++++++++++++ .../integration/llama3_debugmodel_baseline.sh | 17 +++++ .../unittest/adahop/test_bridge_isolation.py | 68 +++++++++++++++++ 5 files changed, 164 insertions(+) create mode 160000 3rdparty/adahop create mode 100644 alto/_adahop_bridge.py create mode 100644 tests/integration/llama3_debugmodel_baseline.sh create mode 100644 tests/unittest/adahop/test_bridge_isolation.py diff --git a/.gitmodules b/.gitmodules index 4bb3b06c..eeb7d164 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "3rdparty/torchtitan"] path = 3rdparty/torchtitan url = https://github.com/AMD-AGI/torchtitan-amd.git +[submodule "3rdparty/adahop"] + path = 3rdparty/adahop + url = git@github.com:AMDResearch/low-precision-training.git + branch = AdaHOP_C42 diff --git a/3rdparty/adahop b/3rdparty/adahop new file mode 160000 index 00000000..46fbab57 --- /dev/null +++ b/3rdparty/adahop @@ -0,0 +1 @@ +Subproject commit 46fbab57df595620c27abc7966d5b14092a51bb9 diff --git a/alto/_adahop_bridge.py b/alto/_adahop_bridge.py new file mode 100644 index 00000000..d63f72f3 --- /dev/null +++ b/alto/_adahop_bridge.py @@ -0,0 +1,74 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Bridge to the AdaHOP submodule at ``3rdparty/adahop``. + +Loads selected AdaHOP modules by file path under non-colliding names so that +AdaHOP's vendored ``torchtitan`` package never enters ``sys.modules`` and +cannot shadow ALTO's own torchtitan submodule. +""" + +import importlib.util +import sys +from pathlib import Path + +_ALTO_ROOT = Path(__file__).resolve().parent.parent +_ADAHOP_ROOT = _ALTO_ROOT / "3rdparty" / "adahop" +_HT_DIR = _ADAHOP_ROOT / "torchtitan" / "experiments" / "kernels" / "hadamard_transform" +_TC_PATH = _ADAHOP_ROOT / "torchtitan" / "experiments" / "kernels" / "mxfp4" / "transform_config.py" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _load_package(name: str, pkg_dir: Path): + init_path = pkg_dir / "__init__.py" + spec = importlib.util.spec_from_file_location( + name, + init_path, + submodule_search_locations=[str(pkg_dir)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load package {name} from {pkg_dir}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +if not _ADAHOP_ROOT.exists(): + raise ImportError(f"AdaHOP submodule not found at {_ADAHOP_ROOT}. " + "Run `git submodule update --init --recursive` from the ALTO root.") + +_ht = _load_package("_alto_adahop_ht", _HT_DIR) +_tc = _load_module("_alto_adahop_transform_config", _TC_PATH) + +HadamardFactory = _ht.HadamardFactory +HadamardTransform = _ht.HadamardTransform +detect_outlier_pattern = _ht.detect_outlier_pattern + +transform_config = _tc +configure_global_transforms = _tc.configure_global_transforms +configure_layer_transforms = _tc.configure_layer_transforms +get_layer_transform_config = _tc.get_layer_transform_config +should_apply_transform = _tc.should_apply_transform +clear_all_configs = _tc.clear_all_configs + +__all__ = [ + "HadamardFactory", + "HadamardTransform", + "detect_outlier_pattern", + "transform_config", + "configure_global_transforms", + "configure_layer_transforms", + "get_layer_transform_config", + "should_apply_transform", + "clear_all_configs", +] diff --git a/tests/integration/llama3_debugmodel_baseline.sh b/tests/integration/llama3_debugmodel_baseline.sh new file mode 100644 index 00000000..dcb8e38b --- /dev/null +++ b/tests/integration/llama3_debugmodel_baseline.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +# +# Step-1 baseline companion to llama3_debugmodel_lpt.sh: runs the same llama3 +# debug config without the LowPrecisionTrainingModifier, so loss/throughput +# can be A/B'd against the mxfp4 path. + +SCRIPT_DIR=$(dirname "$0") +cd $SCRIPT_DIR/../.. + +NGPU=2 \ +MODULE=llama3 \ +CONFIG=llama3_debugmodel \ +./examples/run.sh \ + --training.steps 10 diff --git a/tests/unittest/adahop/test_bridge_isolation.py b/tests/unittest/adahop/test_bridge_isolation.py new file mode 100644 index 00000000..3b2dd59a --- /dev/null +++ b/tests/unittest/adahop/test_bridge_isolation.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Verify ``alto._adahop_bridge`` loads AdaHOP modules without shadowing +ALTO's own ``torchtitan`` submodule.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +BRIDGE_PATH = Path(__file__).resolve().parents[3] / "alto" / "_adahop_bridge.py" + + +def _load_bridge_isolated(): + """Load the bridge by file path so this test can run on CPU-only boxes + where ``import alto`` fails on triton driver initialization.""" + spec = importlib.util.spec_from_file_location("alto_adahop_bridge_under_test", BRIDGE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def bridge(): + pre = sys.modules.get("torchtitan") + module = _load_bridge_isolated() + yield module, pre + + +def test_bridge_does_not_pollute_torchtitan_namespace(bridge): + _, pre = bridge + post = sys.modules.get("torchtitan") + assert post is pre, ("Bridge inserted or replaced sys.modules['torchtitan']; " + "AdaHOP's torchtitan would shadow ALTO's submodule.") + + +def test_bridge_exports_callables(bridge): + module, _ = bridge + for name in [ + "HadamardFactory", + "HadamardTransform", + "detect_outlier_pattern", + "configure_global_transforms", + "configure_layer_transforms", + "get_layer_transform_config", + "should_apply_transform", + "clear_all_configs", + ]: + assert hasattr(module, name), f"bridge missing export: {name}" + + +def test_transform_config_registry_roundtrip(bridge): + module, _ = bridge + module.clear_all_configs() + module.configure_layer_transforms({ + "layers.0.attention.wq": { + "forward_y": "hadamard", + "backward_gw": "none", + "backward_gx": "none", + }, + }) + assert module.get_layer_transform_config("layers.0.attention.wq", "forward_y") == "hadamard" + assert module.should_apply_transform("layers.0.attention.wq", "forward_y") is True + assert module.should_apply_transform("layers.0.attention.wq", "backward_gw") is False + assert module.get_layer_transform_config("unknown.layer", "forward_y") == "none" + module.clear_all_configs() From 3513023394031ac9c2d9d14384c00fba5cd00e10 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 27 May 2026 17:59:22 +0200 Subject: [PATCH 014/142] adahop internal packaging --- .../lpt/adahop_internals/__init__.py | 8 + .../adahop_internals/mxfp4_linear_function.py | 289 ++++++++++++++++++ .../adahop_internals/pattern_aggregation.py | 92 ++++++ .../lpt/adahop_internals/transform_mode.py | 55 ++++ .../adahop/test_pattern_aggregation.py | 128 ++++++++ 5 files changed, 572 insertions(+) create mode 100644 alto/modifiers/lpt/adahop_internals/__init__.py create mode 100644 alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py create mode 100644 alto/modifiers/lpt/adahop_internals/pattern_aggregation.py create mode 100644 alto/modifiers/lpt/adahop_internals/transform_mode.py create mode 100644 tests/unittest/adahop/test_pattern_aggregation.py diff --git a/alto/modifiers/lpt/adahop_internals/__init__.py b/alto/modifiers/lpt/adahop_internals/__init__.py new file mode 100644 index 00000000..f3e33abb --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Internal building blocks for the AdaHOP port. + +ALTO-side implementations of AdaHOP's per-slot Hadamard + MXFP4 math. +Reference: 3rdparty/adahop/torchtitan/experiments/kernels/mxfp4/mxfp_linear.py +""" diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py new file mode 100644 index 00000000..3f0c456b --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -0,0 +1,289 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""ALTO-side MXFP4 + per-slot Hadamard autograd Function. + +Port of AdaHOP's ``MXFP4LinearFunction`` adapted for ALTO's tensor-subclass +wrapper design: modes are passed as ``apply()`` arguments instead of looked +up from a global FQN-keyed registry. + +Reference (AdaHOP, BSD-3): + 3rdparty/adahop/torchtitan/experiments/kernels/mxfp4/mxfp_linear.py:206-635 + +Key differences from AdaHOP's version: + * ``layer_name`` argument removed; ``forward_y_mode``, ``backward_gx_mode``, + ``backward_gw_mode`` are explicit ``apply()`` arguments and ride on + ``ctx`` for backward. + * Calibration-mode globals (``is_calibration_mode``, ``store_detected_pattern``) + removed; calibration is done by the wrapper's ``__torch_function__`` + via a callback installed by the modifier. + * Tensor-capture mode removed (debug-only feature, not needed for MVP). + * ``iht_quantization`` (AdaHOP's fused Hadamard+MXFP4 Triton kernel) is + not ported; ``hadamard`` mode falls back to the equivalent two-step + ``hadamard_transform`` → ``convert_to_mxfp4``. Slower than AdaHOP but + numerically equivalent for round-to-nearest. SR composability documented + inline. + * Outlier-extract modes (``inner_outlier_extract_*``, ``outer_outlier_extract_*``) + are guarded by ``assert_mode_supported`` and raise ``NotImplementedError`` + at apply time. Add them when calibration starts producing those modes. + * Calls ALTO's already-registered ``torch.ops.torchtitan.{convert_to_mxfp4, + blockwise_mxfp4_gemm}`` — no new op registrations, no collision with + ALTO's own ``alto/kernels/fp4/mxfp4/mxfp_linear.py``. +""" + +from typing import Optional + +import torch + +from .transform_mode import TransformMode, assert_mode_supported + +HadamardTransformType = "HadamardTransform" # type-hint placeholder; avoid hard import + + +@torch.compiler.allow_in_graph +class MXFP4AdaHOPLinearFunction(torch.autograd.Function): + """MXFP4 linear with per-slot Hadamard, modes baked into ``apply`` args. + + The 3 slots and their AdaHOP names: + * ``forward_y`` — applied to the forward ``y = x @ wᵀ`` computation + * ``backward_gx`` — applied to ``grad_inputs = grad_output @ w`` + * ``backward_gw`` — applied to ``grad_weights = grad_outputᵀ @ x`` + """ + + @staticmethod + def forward( + ctx, + x: torch.Tensor, + weight: torch.Tensor, + use_sr_grad: bool, + hadamard_transform: Optional["HadamardTransformType"], + forward_y_mode: TransformMode, + backward_gx_mode: TransformMode, + backward_gw_mode: TransformMode, + ) -> torch.Tensor: + assert_mode_supported(forward_y_mode, "forward_y") + assert_mode_supported(backward_gx_mode, "backward_gx") + assert_mode_supported(backward_gw_mode, "backward_gw") + + original_shape = x.shape + original_dtype = x.dtype + x = x.reshape(-1, original_shape[-1]) + + # ---- forward: y = x @ wᵀ under forward_y_mode ----------------------- + if forward_y_mode == "full_precision": + y = x @ weight.T + # Still need to prepare x/w for backward branches below. + # full_precision in forward does NOT short-circuit backward prep. + x_mxfp4_fwd, x_scale_fwd, w_mxfp4_fwd, w_scale_fwd = None, None, None, None + else: + if forward_y_mode == "hadamard": + assert hadamard_transform is not None, ("forward_y_mode=hadamard requires a hadamard_transform") + # Unfused fallback for AdaHOP's iht_quantization(x, left_mul=False): + # right-Hadamard then axis=-1 quantize. + x_for_y = hadamard_transform(x, left_mul=False) + w_for_y = hadamard_transform(weight, left_mul=False) + elif forward_y_mode == "outer_hadamard": + assert hadamard_transform is not None, ("forward_y_mode=outer_hadamard requires a hadamard_transform") + # left-multiply pre-transform; the outer-HT is applied after the GEMM. + x_for_y = hadamard_transform(x, left_mul=True) + w_for_y = hadamard_transform(weight, left_mul=True) + else: # "none" + x_for_y = x + w_for_y = weight + + x_mxfp4_fwd, x_scale_fwd = torch.ops.torchtitan.convert_to_mxfp4( + x_for_y, + axis=-1, + is_2d_block=False, + ) + w_mxfp4_fwd, w_scale_fwd = torch.ops.torchtitan.convert_to_mxfp4( + w_for_y, + axis=-1, + is_2d_block=False, + ) + y = torch.ops.torchtitan.blockwise_mxfp4_gemm( + x_mxfp4_fwd, + x_scale_fwd, + w_mxfp4_fwd, + w_scale_fwd, + trans_b=True, + output_dtype=original_dtype, + ) + if forward_y_mode == "outer_hadamard": + y = hadamard_transform(hadamard_transform(y, left_mul=True)) + + # ---- prepare w for backward_gx (grad_output @ w) -------------------- + w_mxfp4_bx, w_scale_bx = _prepare_operand_for_backward( + tensor=weight, + mode=backward_gx_mode, + hadamard_transform=hadamard_transform, + ) + + # ---- prepare x for backward_gw (grad_outputᵀ @ x) ------------------- + x_mxfp4_bw, x_scale_bw = _prepare_operand_for_backward( + tensor=x, + mode=backward_gw_mode, + hadamard_transform=hadamard_transform, + ) + + ctx.save_for_backward(x_mxfp4_bw, x_scale_bw, w_mxfp4_bx, w_scale_bx) + ctx.original_dtype = original_dtype + ctx.use_sr_grad = use_sr_grad + ctx.hadamard_transform = hadamard_transform + ctx.forward_y_mode = forward_y_mode + ctx.backward_gx_mode = backward_gx_mode + ctx.backward_gw_mode = backward_gw_mode + + return y.view(*original_shape[:-1], -1) + + @staticmethod + def backward(ctx, grad_output): + original_shape = grad_output.shape + grad_output = grad_output.reshape(-1, original_shape[-1]) + + x_mxfp4_bw, x_scale_bw, w_mxfp4_bx, w_scale_bx = ctx.saved_tensors + + grad_inputs = _backward_gx( + grad_output=grad_output, + w_mxfp4=w_mxfp4_bx, + w_scale=w_scale_bx, + mode=ctx.backward_gx_mode, + hadamard_transform=ctx.hadamard_transform, + use_sr_grad=ctx.use_sr_grad, + original_dtype=ctx.original_dtype, + ) + + grad_weights = _backward_gw( + grad_output=grad_output, + x_mxfp4=x_mxfp4_bw, + x_scale=x_scale_bw, + mode=ctx.backward_gw_mode, + hadamard_transform=ctx.hadamard_transform, + use_sr_grad=ctx.use_sr_grad, + original_dtype=ctx.original_dtype, + ) + + # forward signature has 7 inputs (x, weight, use_sr_grad, hadamard_transform, + # forward_y_mode, backward_gx_mode, backward_gw_mode); return 7 grads with + # only the first two non-None. + return ( + grad_inputs.view(*original_shape[:-1], -1), + grad_weights, + None, + None, + None, + None, + None, + ) + + +def _prepare_operand_for_backward( + *, + tensor: torch.Tensor, + mode: TransformMode, + hadamard_transform, +): + """Pre-rotate and quantize ``tensor`` for the backward GEMM. + + Returns ``(packed_or_dense, scale_or_None)``. For ``full_precision``, + returns the original tensor and ``None`` (the consumer detects ``None`` + scale to mean "no quant"). + """ + if mode == "full_precision": + return tensor, None + if mode == "hadamard": + assert hadamard_transform is not None, "hadamard mode requires a hadamard_transform" + prepared = hadamard_transform(tensor, left_mul=True) + elif mode == "outer_hadamard": + assert hadamard_transform is not None, "outer_hadamard requires a hadamard_transform" + prepared = hadamard_transform(tensor) + else: # "none" + prepared = tensor + + t_mxfp4, t_scale = torch.ops.torchtitan.convert_to_mxfp4(prepared, axis=0, is_2d_block=False) + return t_mxfp4, t_scale + + +def _backward_gx( + *, + grad_output: torch.Tensor, + w_mxfp4: torch.Tensor, + w_scale: Optional[torch.Tensor], + mode: TransformMode, + hadamard_transform, + use_sr_grad: bool, + original_dtype: torch.dtype, +) -> torch.Tensor: + """``grad_inputs = grad_output @ w`` under ``backward_gx_mode``.""" + if mode == "full_precision": + # w_mxfp4 is actually the unquantized weight here. + return grad_output @ w_mxfp4 + + if mode == "hadamard": + assert hadamard_transform is not None + g_for_gx = hadamard_transform(grad_output, left_mul=False) + elif mode == "outer_hadamard": + assert hadamard_transform is not None + g_for_gx = hadamard_transform(grad_output, left_mul=True) + else: # "none" + g_for_gx = grad_output + + g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( + g_for_gx, + axis=-1, + use_sr=use_sr_grad, + is_2d_block=False, + ) + grad_inputs = torch.ops.torchtitan.blockwise_mxfp4_gemm( + g_mxfp4, + g_scale, + w_mxfp4, + w_scale, + output_dtype=original_dtype, + ) + if mode == "outer_hadamard": + grad_inputs = hadamard_transform(hadamard_transform(grad_inputs, left_mul=True)) + return grad_inputs + + +def _backward_gw( + *, + grad_output: torch.Tensor, + x_mxfp4: torch.Tensor, + x_scale: Optional[torch.Tensor], + mode: TransformMode, + hadamard_transform, + use_sr_grad: bool, + original_dtype: torch.dtype, +) -> torch.Tensor: + """``grad_weights = grad_outputᵀ @ x`` under ``backward_gw_mode``.""" + if mode == "full_precision": + # x_mxfp4 is actually the unquantized x here. + return grad_output.T @ x_mxfp4 + + if mode == "hadamard": + assert hadamard_transform is not None + g_for_gw = hadamard_transform(grad_output, left_mul=True) + elif mode == "outer_hadamard": + assert hadamard_transform is not None + g_for_gw = hadamard_transform(grad_output) + else: # "none" + g_for_gw = grad_output + + g_mxfp4_m, g_scale_m = torch.ops.torchtitan.convert_to_mxfp4( + g_for_gw, + axis=0, + use_sr=use_sr_grad, + is_2d_block=False, + ) + grad_weights = torch.ops.torchtitan.blockwise_mxfp4_gemm( + g_mxfp4_m, + g_scale_m, + x_mxfp4, + x_scale, + trans_a=True, + output_dtype=original_dtype, + ) + if mode == "outer_hadamard": + grad_weights = hadamard_transform(hadamard_transform(grad_weights, left_mul=True)) + return grad_weights diff --git a/alto/modifiers/lpt/adahop_internals/pattern_aggregation.py b/alto/modifiers/lpt/adahop_internals/pattern_aggregation.py new file mode 100644 index 00000000..9c77d2ff --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/pattern_aggregation.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Per-layer outlier-pattern aggregation and pattern-pair → TransformMode mapping. + +Reference (AdaHOP, BSD-3): + 3rdparty/adahop/torchtitan/components/quantization/mx_calibration.py:158-265 (_aggregate_patterns) + 3rdparty/adahop/torchtitan/components/quantization/mx_calibration.py:412-481 (_apply_calibrated_transforms) + +The aggregation logic is verbatim (Counter.most_common per tensor type). The +"T1..T8" pattern-pair construction is identical. The pair→mode lookup is also +identical; the only difference is that we return a dict of per-layer +(forward_y, backward_gx, backward_gw) modes instead of calling AdaHOP's global +``configure_layer_transforms``. The caller (the modifier) feeds the result +into the Phase-B wrapper constructors. +""" + +from collections import Counter +from typing import Dict, List + +from .transform_mode import OutlierPattern, PerSlotModes, TransformMode + + +def _opposite_pattern(pattern: OutlierPattern) -> OutlierPattern: + if pattern == "row": + return "col" + if pattern == "col": + return "row" + return "none" + + +def aggregate_patterns( + per_step_patterns: List[Dict[str, Dict[str, OutlierPattern]]],) -> Dict[str, Dict[str, OutlierPattern]]: + """Reduce per-step pattern observations to one majority pattern per (layer, tensor). + + ``per_step_patterns[i][layer_name][tensor_name]`` is the pattern observed + at step ``i`` for ``tensor_name`` in ``{"x", "w", "grad_output"}``. Missing + entries are skipped; missing tensors default to ``"none"``. + + Returns ``{layer_name: {"x": ..., "w": ..., "grad_output": ...}}``. + """ + all_layers = set() + for step in per_step_patterns: + all_layers.update(step.keys()) + + final: Dict[str, Dict[str, OutlierPattern]] = {} + for layer in sorted(all_layers): + per_tensor: Dict[str, List[OutlierPattern]] = {"x": [], "w": [], "grad_output": []} + for step in per_step_patterns: + if layer not in step: + continue + for t in per_tensor: + if t in step[layer]: + per_tensor[t].append(step[layer][t]) + + final[layer] = { + t: (Counter(per_tensor[t]).most_common(1)[0][0] if per_tensor[t] else "none") for t in per_tensor + } + return final + + +def patterns_to_modes( + aggregated: Dict[str, Dict[str, OutlierPattern]], + layer_transform_config: Dict[str, TransformMode], +) -> Dict[str, PerSlotModes]: + """Map per-layer aggregated patterns to per-slot ``TransformMode`` triples. + + ``layer_transform_config`` is the recipe-supplied pattern-pair → mode lookup, + e.g. ``{"row-row": "hadamard", "col-col": "full_precision", "none-none": "hadamard"}``. + Unknown pairs fall back to ``"none"``. + + T1..T8 follow AdaHOP exactly: + T1 = x_pat, T2 = opposite(w_pat) → forward_y key "T1-T2" + T4 = opposite(grad_output_pat), T5 = x_pat → backward_gw key "T4-T5" + T7 = grad_output_pat, T8 = w_pat → backward_gx key "T7-T8" + """ + out: Dict[str, PerSlotModes] = {} + for layer, pats in aggregated.items(): + x_pat = pats.get("x", "none") + w_pat = pats.get("w", "none") + g_pat = pats.get("grad_output", "none") + + t1, t2 = x_pat, _opposite_pattern(w_pat) + t4, t5 = _opposite_pattern(g_pat), x_pat + t7, t8 = g_pat, w_pat + + out[layer] = { + "forward_y": layer_transform_config.get(f"{t1}-{t2}", "none"), + "backward_gw": layer_transform_config.get(f"{t4}-{t5}", "none"), + "backward_gx": layer_transform_config.get(f"{t7}-{t8}", "none"), + } + return out diff --git a/alto/modifiers/lpt/adahop_internals/transform_mode.py b/alto/modifiers/lpt/adahop_internals/transform_mode.py new file mode 100644 index 00000000..8514acc4 --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/transform_mode.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Per-slot Hadamard transform modes for the AdaHOP-in-ALTO port. + +Mirrors AdaHOP's ``TransformMode`` Literal (see reference below) so JSON +configs produced by AdaHOP's calibration deserialize without translation. + +Reference: 3rdparty/adahop/torchtitan/experiments/kernels/mxfp4/transform_config.py:21 +""" + +from typing import Dict, Literal, Tuple + +TransformMode = Literal[ + "none", + "hadamard", + "outer_hadamard", + "inner_outlier_extract_left", + "inner_outlier_extract_left_col", + "inner_outlier_extract_right", + "outer_outlier_extract_left", + "outer_outlier_extract_right", + "full_precision", +] + +OutlierPattern = Literal["row", "col", "none"] + +VALID_MODES: frozenset = frozenset([ + "none", + "hadamard", + "outer_hadamard", + "inner_outlier_extract_left", + "inner_outlier_extract_left_col", + "inner_outlier_extract_right", + "outer_outlier_extract_left", + "outer_outlier_extract_right", + "full_precision", +]) + +# Modes implemented in the ALTO-side MXFP4LinearFunction port. +# Other VALID_MODES are accepted by the schema but will raise at runtime if +# encountered in a calibration JSON until their kernel paths are ported. +SUPPORTED_MODES: frozenset = frozenset(["none", "hadamard", "outer_hadamard", "full_precision"]) + + +def assert_mode_supported(mode: str, slot: str) -> None: + if mode not in VALID_MODES: + raise ValueError(f"Unknown TransformMode {mode!r} for slot {slot!r}") + if mode not in SUPPORTED_MODES: + raise NotImplementedError(f"TransformMode {mode!r} (slot {slot!r}) is not yet implemented in ALTO. " + f"Supported: {sorted(SUPPORTED_MODES)}.") + + +PatternPair = Tuple[OutlierPattern, OutlierPattern] +PerSlotModes = Dict[Literal["forward_y", "backward_gx", "backward_gw"], TransformMode] diff --git a/tests/unittest/adahop/test_pattern_aggregation.py b/tests/unittest/adahop/test_pattern_aggregation.py new file mode 100644 index 00000000..82405d99 --- /dev/null +++ b/tests/unittest/adahop/test_pattern_aggregation.py @@ -0,0 +1,128 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""CPU-side tests for adahop_internals.pattern_aggregation.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_ADAHOP_INTERNALS = Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "lpt" / "adahop_internals" + + +def _load(name: str): + """Load a module from adahop_internals by file path so this test can run + on CPU-only boxes where ``import alto`` triggers triton driver init.""" + path = _ADAHOP_INTERNALS / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"alto_adahop_test_{name}", path) + module = importlib.util.module_from_spec(spec) + # Some adahop_internals files use relative imports (`from .transform_mode import ...`). + # Register the package + sibling so relative resolution works. + pkg_name = "alto_adahop_internals_under_test" + if pkg_name not in sys.modules: + pkg_spec = importlib.util.spec_from_file_location( + pkg_name, + _ADAHOP_INTERNALS / "__init__.py", + submodule_search_locations=[str(_ADAHOP_INTERNALS)], + ) + pkg = importlib.util.module_from_spec(pkg_spec) + sys.modules[pkg_name] = pkg + pkg_spec.loader.exec_module(pkg) + # Re-create spec under the package + qualified = f"{pkg_name}.{name}" + if qualified in sys.modules: + return sys.modules[qualified] + spec = importlib.util.spec_from_file_location(qualified, path) + module = importlib.util.module_from_spec(spec) + sys.modules[qualified] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def aggregation(): + return _load("pattern_aggregation") + + +@pytest.fixture(scope="module") +def transform_mode(): + return _load("transform_mode") + + +def test_aggregate_picks_majority_per_tensor(aggregation): + per_step = [ + { + "layers.0.wq": { + "x": "row", + "w": "col", + "grad_output": "none" + } + }, + { + "layers.0.wq": { + "x": "row", + "w": "col", + "grad_output": "row" + } + }, + { + "layers.0.wq": { + "x": "col", + "w": "col", + "grad_output": "row" + } + }, + ] + out = aggregation.aggregate_patterns(per_step) + assert out == {"layers.0.wq": {"x": "row", "w": "col", "grad_output": "row"}} + + +def test_aggregate_missing_layers_and_tensors_default_to_none(aggregation): + per_step = [ + { + "layers.0.wq": { + "x": "row" + } + }, + { + "layers.1.wk": { + "w": "col" + } + }, + ] + out = aggregation.aggregate_patterns(per_step) + assert out["layers.0.wq"] == {"x": "row", "w": "none", "grad_output": "none"} + assert out["layers.1.wk"] == {"x": "none", "w": "col", "grad_output": "none"} + + +def test_patterns_to_modes_t1_t2_mapping(aggregation): + # x=row, w=col → T1=row, T2=opposite(col)=row → "row-row" + aggregated = {"l0": {"x": "row", "w": "col", "grad_output": "none"}} + cfg = {"row-row": "hadamard", "none-none": "none"} + out = aggregation.patterns_to_modes(aggregated, cfg) + assert out["l0"]["forward_y"] == "hadamard" + + +def test_patterns_to_modes_unknown_pair_falls_back_to_none(aggregation): + aggregated = {"l0": {"x": "row", "w": "row", "grad_output": "row"}} + cfg = {"row-row": "hadamard"} + # T4 = opposite(row) = col, T5 = row → "col-row" not in cfg → "none" + out = aggregation.patterns_to_modes(aggregated, cfg) + assert out["l0"]["backward_gw"] == "none" + + +def test_assert_mode_supported_accepts_basic_four(transform_mode): + for mode in ["none", "hadamard", "outer_hadamard", "full_precision"]: + transform_mode.assert_mode_supported(mode, "forward_y") + + +def test_assert_mode_supported_rejects_unported_mode(transform_mode): + with pytest.raises(NotImplementedError): + transform_mode.assert_mode_supported("inner_outlier_extract_left", "forward_y") + + +def test_assert_mode_supported_rejects_garbage(transform_mode): + with pytest.raises(ValueError): + transform_mode.assert_mode_supported("not_a_mode", "backward_gx") From f9499c749e4218253d65eabe1c34030b464461e7 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 27 May 2026 18:11:08 +0200 Subject: [PATCH 015/142] calibration wrapper, swap_params extension --- alto/kernels/dispatch/adahop_tensor.py | 195 +++++++++++++++++++ alto/kernels/dispatch/conversion.py | 12 +- tests/unittest/adahop/test_adahop_wrapper.py | 97 +++++++++ 3 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 alto/kernels/dispatch/adahop_tensor.py create mode 100644 tests/unittest/adahop/test_adahop_wrapper.py diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py new file mode 100644 index 00000000..b55e6bfc --- /dev/null +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -0,0 +1,195 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""AdaHOP-aware MXFP4 wrapper tensor subclasses. + +Two child classes of ``MXFP4TrainingWeightWrapperTensor``: + +* :class:`MXFP4CalibrationWrapper` — Phase A. Dispatch identical to its parent + (plain MXFP4), plus an optional ``_calibration_callback`` invoked from + ``__torch_function__`` before the standard path. Used during the 30-step + outlier-pattern observation window. + +* :class:`MXFP4AdaHOPWrapper` — Phase B. Carries frozen ``(forward_y_mode, + backward_gx_mode, backward_gw_mode)`` and a ``HadamardTransform``; routes + the ``linear`` dispatch through :class:`MXFP4AdaHOPLinearFunction` with + those modes as ``apply()`` arguments. No global state, no FQN lookup. + +Design notes (see ADAHOP_TO_ALTO_INTEGRATION_PLAN.md): + +* Modes ride on the wrapper instance — the canonical instance that lives on + ``nn.Parameter.data`` is the one PyTorch passes to ``__torch_function__`` + for the linear op, so the modes are reachable at the right moment. +* ``__torch_dispatch__`` is **not** overridden. Detach/view/copy re-wraps + drop the modes; that is fine because those transient re-wraps never + re-enter ``__torch_function__`` for ``linear`` — by then the canonical + wrapper has already supplied modes to ``MXFP4AdaHOPLinearFunction``. +* ``__tensor_flatten__`` / ``__tensor_unflatten__`` round-trip the *string* + modes so DCP checkpoints survive. The ``HadamardTransform`` is not + serializable; on load it's restored to ``None`` and the modifier + re-attaches a fresh one (or keeps ``None`` if the recipe is hadamard-free + and only ``none``/``full_precision`` modes are used). +* ``fsdp_post_all_gather`` is overridden minimally to preserve modes on the + fresh wrapper instance produced at training step 0. +""" + +from typing import Any, Callable, Optional, Tuple + +import torch +from torch.distributed.fsdp import MixedPrecisionPolicy + +from alto.modifiers.lpt.adahop_internals.mxfp4_linear_function import MXFP4AdaHOPLinearFunction +from alto.modifiers.lpt.adahop_internals.transform_mode import TransformMode, assert_mode_supported +from .tensor import MXFP4TrainingWeightWrapperTensor, TrainingWeightWrapperBaseTensor, gemm_ops + +CalibrationCallback = Callable[[torch.Tensor, torch.Tensor], None] + + +class MXFP4CalibrationWrapper(MXFP4TrainingWeightWrapperTensor): + """Phase-A wrapper: standard MXFP4 dispatch plus an optional observation hook.""" + + @staticmethod + def __new__(cls, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): + return super().__new__(cls, tensor, config) + + def __init__(self, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): + super().__init__(tensor, config) + self._calibration_callback = calibration_callback + + def attach_calibration_callback(self, cb: Optional[CalibrationCallback]) -> None: + self._calibration_callback = cb + + @classmethod + def __torch_function__(cls, func, types, args, kwargs={}): + if func.__name__ in gemm_ops: + x, weight = _extract_x_w(func, args) + if isinstance(weight, cls) and weight._calibration_callback is not None: + # Detach to avoid building a graph on the observation path. + weight._calibration_callback(x.detach(), weight._data.detach()) + return super().__torch_function__(func, types, args, kwargs) + + +class MXFP4AdaHOPWrapper(MXFP4TrainingWeightWrapperTensor): + """Phase-B wrapper: frozen per-slot modes baked in at construction.""" + + @staticmethod + def __new__( + cls, + tensor, + config, + *, + hadamard_transform: Optional[Any] = None, + forward_y_mode: TransformMode = "none", + backward_gx_mode: TransformMode = "none", + backward_gw_mode: TransformMode = "none", + ): + return super().__new__(cls, tensor, config) + + def __init__( + self, + tensor, + config, + *, + hadamard_transform: Optional[Any] = None, + forward_y_mode: TransformMode = "none", + backward_gx_mode: TransformMode = "none", + backward_gw_mode: TransformMode = "none", + ): + super().__init__(tensor, config) + assert_mode_supported(forward_y_mode, "forward_y") + assert_mode_supported(backward_gx_mode, "backward_gx") + assert_mode_supported(backward_gw_mode, "backward_gw") + self._hadamard_transform = hadamard_transform + self._forward_y_mode = forward_y_mode + self._backward_gx_mode = backward_gx_mode + self._backward_gw_mode = backward_gw_mode + + def _adahop_kwargs(self) -> dict: + return { + "hadamard_transform": self._hadamard_transform, + "forward_y_mode": self._forward_y_mode, + "backward_gx_mode": self._backward_gx_mode, + "backward_gw_mode": self._backward_gw_mode, + } + + @classmethod + def __torch_function__(cls, func, types, args, kwargs={}): + if func.__name__ in gemm_ops: + x, weight, bias, trans_b = _extract_x_w_with_bias(func, args) + assert isinstance(weight, cls), f"weight should be a {cls.__name__} for {func.__name__}" + operand_w = weight._data if trans_b else weight._data.T + y = MXFP4AdaHOPLinearFunction.apply( + x, + operand_w, + weight.config.use_sr_grad, + weight._hadamard_transform, + weight._forward_y_mode, + weight._backward_gx_mode, + weight._backward_gw_mode, + ) + if bias is not None: + y = y + bias + return y + # All other ops (grouped_mm, etc.) defer to the standard parent path. + return super().__torch_function__(func, types, args, kwargs) + + def __tensor_flatten__(self): + meta = { + "config": self.config, + "forward_y_mode": self._forward_y_mode, + "backward_gx_mode": self._backward_gx_mode, + "backward_gw_mode": self._backward_gw_mode, + } + return ["_data"], meta + + @classmethod + def __tensor_unflatten__(cls, inner_tensors, flatten_spec, outer_size, outer_stride): + return cls( + inner_tensors["_data"], + flatten_spec["config"], + hadamard_transform=None, + forward_y_mode=flatten_spec["forward_y_mode"], + backward_gx_mode=flatten_spec["backward_gx_mode"], + backward_gw_mode=flatten_spec["backward_gw_mode"], + ) + + def fsdp_post_all_gather( + self, + all_gather_outputs: Tuple[torch.Tensor, ...], + metadata: Any, + param_dtype: torch.dtype, + *, + out: Optional[torch.Tensor] = None, + ): + # Step 1+: `out` is pre-allocated. Preserve our modes onto it if it's a sibling instance. + if out is not None: + if isinstance(out, MXFP4AdaHOPWrapper): + out._hadamard_transform = self._hadamard_transform + out._forward_y_mode = self._forward_y_mode + out._backward_gx_mode = self._backward_gx_mode + out._backward_gw_mode = self._backward_gw_mode + return super().fsdp_post_all_gather(all_gather_outputs, metadata, param_dtype, out=out) + # Step 0: parent creates a fresh wrapper without modes; re-create with modes. + (data,) = all_gather_outputs + output = type(self)(data, self.config, **self._adahop_kwargs()) + return output, (data,) + + +def _extract_x_w(func, args): + """Return ``(x, weight)`` for a gemm-family op. Mirrors parent dispatch.""" + if func.__name__ == "addmm.default": + _, A, B = args[0], args[1], args[2] + else: + A, B = args[0], args[1] + return A, B + + +def _extract_x_w_with_bias(func, args): + """Return ``(x, weight, bias, trans_b)`` for a gemm-family op.""" + trans_b = func.__name__ == "linear" + if func.__name__ == "addmm.default": + bias, A, B = args[0], args[1], args[2] + else: + A, B = args[0], args[1] + bias = args[2] if len(args) > 2 else None + return A, B, bias, trans_b diff --git a/alto/kernels/dispatch/conversion.py b/alto/kernels/dispatch/conversion.py index 6127ce8e..5f25177e 100644 --- a/alto/kernels/dispatch/conversion.py +++ b/alto/kernels/dispatch/conversion.py @@ -8,7 +8,7 @@ # # SPDX-License-Identifier: BSD-3-Clause AND MIT -from typing import Callable, Optional, Type +from typing import Any, Callable, Dict, Optional, Type import torch from torch import nn @@ -44,6 +44,8 @@ def swap_params( config: Optional[TrainingOpConfig] = None, target_parameter_name: Optional[str] = None, module_name: Optional[str] = None, + tensor_cls: Optional[Type[torch.Tensor]] = None, + tensor_cls_kwargs: Optional[Dict[str, Any]] = None, ) -> nn.Module: """ Recurses through the nn.Module, recursively swapping the data tensor of @@ -66,13 +68,15 @@ def swap_params( if config is None: raise ValueError("training op config is required") - tensor_cls = _get_tensor_cls_for_config(config) + if tensor_cls is None: + tensor_cls = _get_tensor_cls_for_config(config) + extra_kwargs: Dict[str, Any] = dict(tensor_cls_kwargs or {}) if isinstance(module, nn.Parameter) and (module_filter_fn is None or module_filter_fn(module, "")): if len(list(module.children())) > 0: raise AssertionError(f"Does not support a root nn.Parameter with children: {module}") if not isinstance(module.data, TrainingWeightWrapperBaseTensor): - new_data = tensor_cls(module.data, config) + new_data = tensor_cls(module.data, config, **extra_kwargs) return nn.Parameter(new_data, requires_grad=module.requires_grad) return module @@ -99,7 +103,7 @@ def post_order_traversal( continue if not isinstance(param.data, TrainingWeightWrapperBaseTensor): new_param = nn.Parameter( - tensor_cls(param.data, config), + tensor_cls(param.data, config, **extra_kwargs), requires_grad=param.requires_grad, ) setattr(module, param_name, new_param) diff --git a/tests/unittest/adahop/test_adahop_wrapper.py b/tests/unittest/adahop/test_adahop_wrapper.py new file mode 100644 index 00000000..0cbd9378 --- /dev/null +++ b/tests/unittest/adahop/test_adahop_wrapper.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""CPU-side smoke tests for the AdaHOP wrapper subclasses. + +These tests construct the wrappers and exercise the flatten / unflatten +round-trip without firing any MXFP4 kernel. Running on a CPU-only box +without triton drivers — full dispatch tests live in cluster integration. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest +import torch + +_ALTO_ROOT = Path(__file__).resolve().parents[3] + + +def _import_alto_kernel_free(name: str, path_parts: list[str]): + """Import a single ALTO module by file path so ``alto/__init__.py`` (which + transitively loads triton kernels) does not run.""" + path = _ALTO_ROOT.joinpath(*path_parts) + qualified = f"alto_under_test_{name}" + if qualified in sys.modules: + return sys.modules[qualified] + spec = importlib.util.spec_from_file_location(qualified, path) + module = importlib.util.module_from_spec(spec) + sys.modules[qualified] = module + spec.loader.exec_module(module) + return module + + +pytest.importorskip("triton") # adahop_tensor imports through alto.kernels.fp4 -> triton + +try: + from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper, MXFP4CalibrationWrapper + from alto.kernels.dispatch.config import TrainingOpConfig +except RuntimeError as exc: + # ALTO's kernels init triton at import; on a no-GPU box that raises + # "0 active drivers". Skip the whole module — these tests need a GPU box. + pytest.skip(f"alto import requires triton driver: {exc}", allow_module_level=True) + + +@pytest.fixture +def mxfp4_config(): + return TrainingOpConfig(precision="mxfp4") + + +def test_calibration_wrapper_accepts_optional_callback(mxfp4_config): + t = torch.randn(8, 8) + w = MXFP4CalibrationWrapper(t, mxfp4_config) + assert w._calibration_callback is None + seen = [] + w.attach_calibration_callback(lambda x, ww: seen.append((x.shape, ww.shape))) + assert w._calibration_callback is not None + + +def test_adahop_wrapper_rejects_unsupported_mode(mxfp4_config): + t = torch.randn(8, 8) + with pytest.raises(NotImplementedError): + MXFP4AdaHOPWrapper(t, mxfp4_config, forward_y_mode="inner_outlier_extract_left") + + +def test_adahop_wrapper_rejects_garbage_mode(mxfp4_config): + t = torch.randn(8, 8) + with pytest.raises(ValueError): + MXFP4AdaHOPWrapper(t, mxfp4_config, forward_y_mode="not_a_mode") + + +def test_adahop_wrapper_flatten_unflatten_round_trips_modes(mxfp4_config): + t = torch.randn(8, 8) + w = MXFP4AdaHOPWrapper( + t, + mxfp4_config, + hadamard_transform=None, + forward_y_mode="hadamard", + backward_gx_mode="outer_hadamard", + backward_gw_mode="full_precision", + ) + inner_names, meta = w.__tensor_flatten__() + assert inner_names == ["_data"] + assert meta["forward_y_mode"] == "hadamard" + assert meta["backward_gx_mode"] == "outer_hadamard" + assert meta["backward_gw_mode"] == "full_precision" + + rebuilt = MXFP4AdaHOPWrapper.__tensor_unflatten__( + {"_data": t}, + meta, + outer_size=t.size(), + outer_stride=t.stride(), + ) + assert rebuilt._forward_y_mode == "hadamard" + assert rebuilt._backward_gx_mode == "outer_hadamard" + assert rebuilt._backward_gw_mode == "full_precision" + assert rebuilt._hadamard_transform is None # not serialized; modifier rebinds on load From 9875451b89654d98d48401244f4a1246ac1b1288 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 27 May 2026 18:55:44 +0200 Subject: [PATCH 016/142] logging --- alto/kernels/dispatch/adahop_tensor.py | 20 +- alto/models/llama3/config_registry.py | 23 ++ .../configs/lpt_adahop_debug_recipe.yaml | 21 ++ .../llama3/configs/lpt_adahop_recipe.yaml | 21 ++ alto/modifiers/lpt/__init__.py | 3 +- alto/modifiers/lpt/adahop.py | 290 ++++++++++++++++++ .../lpt/adahop_internals/calibration_hooks.py | 68 ++++ alto/modifiers/lpt/base.py | 48 ++- tests/integration/llama3_debugmodel_adahop.sh | 17 + .../llama3_debugmodel_adahop_short.sh | 16 + .../adahop/test_adahop_modifier_helpers.py | 103 +++++++ .../unittest/adahop/test_calibration_hooks.py | 78 +++++ 12 files changed, 692 insertions(+), 16 deletions(-) create mode 100644 alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml create mode 100644 alto/models/llama3/configs/lpt_adahop_recipe.yaml create mode 100644 alto/modifiers/lpt/adahop.py create mode 100644 alto/modifiers/lpt/adahop_internals/calibration_hooks.py create mode 100644 tests/integration/llama3_debugmodel_adahop.sh create mode 100644 tests/integration/llama3_debugmodel_adahop_short.sh create mode 100644 tests/unittest/adahop/test_adahop_modifier_helpers.py create mode 100644 tests/unittest/adahop/test_calibration_hooks.py diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index b55e6bfc..70207c17 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -37,6 +37,7 @@ import torch from torch.distributed.fsdp import MixedPrecisionPolicy +from torchtitan.tools.logging import logger from alto.modifiers.lpt.adahop_internals.mxfp4_linear_function import MXFP4AdaHOPLinearFunction from alto.modifiers.lpt.adahop_internals.transform_mode import TransformMode, assert_mode_supported @@ -59,6 +60,11 @@ def __init__(self, tensor, config, *, calibration_callback: Optional[Calibration def attach_calibration_callback(self, cb: Optional[CalibrationCallback]) -> None: self._calibration_callback = cb + def __repr__(self): + cb_state = "attached" if self._calibration_callback is not None else "none" + return (f"MXFP4CalibrationWrapper(shape={tuple(self.shape)}, dtype={self.dtype}, " + f"callback={cb_state})") + @classmethod def __torch_function__(cls, func, types, args, kwargs={}): if func.__name__ in gemm_ops: @@ -112,6 +118,12 @@ def _adahop_kwargs(self) -> dict: "backward_gw_mode": self._backward_gw_mode, } + def __repr__(self): + ht_state = "attached" if self._hadamard_transform is not None else "none" + return (f"MXFP4AdaHOPWrapper(shape={tuple(self.shape)}, dtype={self.dtype}, " + f"forward_y={self._forward_y_mode}, backward_gx={self._backward_gx_mode}, " + f"backward_gw={self._backward_gw_mode}, hadamard={ht_state})") + @classmethod def __torch_function__(cls, func, types, args, kwargs={}): if func.__name__ in gemm_ops: @@ -144,7 +156,7 @@ def __tensor_flatten__(self): @classmethod def __tensor_unflatten__(cls, inner_tensors, flatten_spec, outer_size, outer_stride): - return cls( + instance = cls( inner_tensors["_data"], flatten_spec["config"], hadamard_transform=None, @@ -152,6 +164,12 @@ def __tensor_unflatten__(cls, inner_tensors, flatten_spec, outer_size, outer_str backward_gx_mode=flatten_spec["backward_gx_mode"], backward_gw_mode=flatten_spec["backward_gw_mode"], ) + logger.info(f"[AdaHOP] Restored MXFP4AdaHOPWrapper from checkpoint: " + f"forward_y={instance._forward_y_mode}, " + f"backward_gx={instance._backward_gx_mode}, " + f"backward_gw={instance._backward_gw_mode}, " + f"hadamard_transform=None (will be re-bound by modifier on first use)") + return instance def fsdp_post_all_gather( self, diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index e499b0c4..3dde67be 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -19,6 +19,8 @@ "llama3_debugmodel", "llama3_debugmodel_opt", "llama3_debugmodel_lpt", + "llama3_debugmodel_adahop", + "llama3_debugmodel_adahop_short", "llama3_1b", "llama3_1b_opt", "llama3_1b_lpt", @@ -79,6 +81,27 @@ def llama3_debugmodel_lpt() -> Trainer.Config: return config +def llama3_debugmodel_adahop() -> Trainer.Config: + config = llama3_debugmodel() + # Need enough steps for the 30-step calibration plus a handful of post-calibration + # iterations to confirm Phase-B wrappers are actually exercised. + config.training.steps = 35 + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_adahop_recipe.yaml",), + ],) + return config + + +def llama3_debugmodel_adahop_short() -> Trainer.Config: + """3-step calibration variant for fast debug iteration on cluster.""" + config = llama3_debugmodel() + config.training.steps = 8 # 3 calibration + 5 post-Phase-B + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml",), + ],) + return config + + def llama3_1b() -> Trainer.Config: config = llama3_1b_orig() config.hf_assets_path = "/group/archive_dataset_6_nobkup/archive_modelzoo/sequence_learning/weights/nlp-pretrained-model/meta-llama/Llama-3.2-1B" diff --git a/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml b/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml new file mode 100644 index 00000000..79a95679 --- /dev/null +++ b/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml @@ -0,0 +1,21 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4_adahop" + targets: ["Linear"] + ignore: ["output"] + AdaHOPModifier: + enabled: true + use_hadamard: true + use_randomized_hadamard: false + calibration_steps: 3 + layer_transform_config: + "row-row": "hadamard" + "col-col": "full_precision" + "none-none": "hadamard" + "row-col": "hadamard" + "col-row": "hadamard" + "row-none": "hadamard" + "none-row": "hadamard" + "col-none": "hadamard" + "none-col": "hadamard" diff --git a/alto/models/llama3/configs/lpt_adahop_recipe.yaml b/alto/models/llama3/configs/lpt_adahop_recipe.yaml new file mode 100644 index 00000000..1585dc44 --- /dev/null +++ b/alto/models/llama3/configs/lpt_adahop_recipe.yaml @@ -0,0 +1,21 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4_adahop" + targets: ["Linear"] + ignore: ["output"] + AdaHOPModifier: + enabled: true + use_hadamard: true + use_randomized_hadamard: false + calibration_steps: 30 + layer_transform_config: + "row-row": "hadamard" + "col-col": "full_precision" + "none-none": "hadamard" + "row-col": "hadamard" + "col-row": "hadamard" + "row-none": "hadamard" + "none-row": "hadamard" + "col-none": "hadamard" + "none-col": "hadamard" diff --git a/alto/modifiers/lpt/__init__.py b/alto/modifiers/lpt/__init__.py index 22cdef1e..a2b3d969 100644 --- a/alto/modifiers/lpt/__init__.py +++ b/alto/modifiers/lpt/__init__.py @@ -3,5 +3,6 @@ # SPDX-License-Identifier: MIT from .base import LowPrecisionTrainingModifier +from .adahop import AdaHOPModifier -__all__ = ["LowPrecisionTrainingModifier"] +__all__ = ["LowPrecisionTrainingModifier", "AdaHOPModifier"] diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py new file mode 100644 index 00000000..ef09f00c --- /dev/null +++ b/alto/modifiers/lpt/adahop.py @@ -0,0 +1,290 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""AdaHOP calibration + per-slot Hadamard mode selection modifier. + +Orchestrates the two-phase swap described in ADAHOP_TO_ALTO_INTEGRATION_PLAN.md: + +1. **Phase A** is set up by ``LowPrecisionTrainingModifier`` when ``scheme="mxfp4_adahop"`` + is used in the recipe — all targeted weights wrapped in + :class:`MXFP4CalibrationWrapper`. Identical dispatch to plain MXFP4. +2. **Phase A → Phase B transition** is owned by this modifier: + + * ``on_initialize`` walks the model, finds every ``MXFP4CalibrationWrapper`` + weight, attaches an outlier-pattern observation callback (forward path), + and registers a backward hook on the parent linear module for + ``grad_output`` observation. The layer FQN is closed over in both + closures so the wrapper itself stays FQN-agnostic. + * ``on_pre_step`` opens a fresh per-step pattern dict and increments the + step counter. + * ``on_post_step`` does nothing until step == ``calibration_steps``, then: + aggregates per-layer majority patterns, maps pattern pairs to per-slot + ``TransformMode`` triples via the recipe's ``layer_transform_config``, + and re-swaps every calibration wrapper as + :class:`MXFP4AdaHOPWrapper` carrying the frozen modes. Observation + hooks are removed. + +A ``transform_config_path`` shortcut accepts a pre-baked JSON +(``{layer_fqn: {forward_y, backward_gx, backward_gw}}``) and skips +calibration entirely — the re-swap happens at ``on_initialize`` instead. +""" + +from typing import Any, Callable, Dict, List, Optional + +import torch +from pydantic import Field, PrivateAttr +from torch import nn +from torch.nn import Module +from torchtitan.tools.logging import logger + +from alto.modifiers import Modifier +from alto.modifiers.lpt.adahop_internals.calibration_hooks import ( + load_modes_from_json, + make_backward_hook, + make_forward_callback, + write_modes_json, +) + +__all__ = ["AdaHOPModifier"] + + +class AdaHOPModifier(Modifier): + """Outlier-pattern-aware Hadamard calibration over MXFP4-wrapped linears.""" + + enabled: bool = True + + use_hadamard: bool = True + use_randomized_hadamard: bool = False + + calibration_steps: int = 30 + """Number of pre-step observations before transitioning to Phase B.""" + + layer_transform_config: Dict[str, str] = Field(default_factory=dict) + """Pattern-pair → ``TransformMode`` lookup. Keys are ``"row-row"``, + ``"col-col"``, etc.; values are members of ``TransformMode``.""" + + transform_config_path: Optional[str] = None + """If set, load a pre-baked per-layer mode JSON and skip calibration. + Format: ``{layer_fqn: {forward_y, backward_gx, backward_gw}}``.""" + + dump_json_path: Optional[str] = None + """If set, write the aggregated per-layer modes here after Phase B.""" + + _step_idx: int = PrivateAttr(default=0) + _per_step_patterns: List[Dict[str, Dict[str, str]]] = PrivateAttr(default_factory=list) + _fqn_to_wrapper: Dict[str, Any] = PrivateAttr(default_factory=dict) + _fqn_to_module: Dict[str, nn.Module] = PrivateAttr(default_factory=dict) + _backward_handles: List[Any] = PrivateAttr(default_factory=list) + _hadamard_transform: Any = PrivateAttr(default=None) + _phase_b_done: bool = PrivateAttr(default=False) + + @property + def requires_training_mode(self) -> bool: + return True + + def on_convert(self, model: Module, **kwargs) -> bool: + # The Phase-A wrapper swap is performed by LowPrecisionTrainingModifier + # when scheme="mxfp4_adahop". Configure the HadamardFactory here so + # the transform object is available at re-swap time. + if not self.enabled: + logger.info("[AdaHOP] Modifier disabled (enabled=False); pass-through only.") + return True + logger.info(f"[AdaHOP] Modifier active: use_hadamard={self.use_hadamard}, " + f"use_randomized_hadamard={self.use_randomized_hadamard}, " + f"calibration_steps={self.calibration_steps}, " + f"transform_config_path={self.transform_config_path}, " + f"dump_json_path={self.dump_json_path or ''}, " + f"layer_transform_config entries={len(self.layer_transform_config)}") + if not self.use_hadamard: + logger.info("[AdaHOP] use_hadamard=False; skipping HadamardFactory configuration.") + return True + from alto._adahop_bridge import HadamardFactory + HadamardFactory.configure(randomized=self.use_randomized_hadamard) + logger.info(f"[AdaHOP] HadamardFactory configured (randomized={self.use_randomized_hadamard}).") + return True + + def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: + if not self.enabled: + return True + from alto.kernels.dispatch.adahop_tensor import MXFP4CalibrationWrapper + + self._collect_calibration_wrappers(model_parts, MXFP4CalibrationWrapper) + n_wrappers = len(self._fqn_to_wrapper) + logger.info(f"[AdaHOP] Phase A: discovered {n_wrappers} MXFP4CalibrationWrapper-wrapped linears.") + if n_wrappers > 0: + sample = list(self._fqn_to_wrapper.keys())[:5] + logger.info(f"[AdaHOP] First {len(sample)} FQNs: {', '.join(sample)}" + f"{' ...' if n_wrappers > 5 else ''}") + + if self.transform_config_path is not None: + modes_by_fqn = load_modes_from_json(self.transform_config_path) + logger.info(f"[AdaHOP] Phase B (JSON-load): {len(modes_by_fqn)} modes loaded from " + f"{self.transform_config_path}; re-swap starting (calibration skipped).") + self._log_mode_table(modes_by_fqn, aggregated=None) + self._do_phase_b_reswap(modes_by_fqn) + return True + + if not self._fqn_to_wrapper: + logger.warning("[AdaHOP] No MXFP4CalibrationWrapper found; " + "is LowPrecisionTrainingModifier(scheme='mxfp4_adahop') in the recipe?") + return True + + from alto._adahop_bridge import detect_outlier_pattern + + for fqn, wrapper in self._fqn_to_wrapper.items(): + wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) + + for fqn, module in self._fqn_to_module.items(): + handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) + self._backward_handles.append(handle) + + logger.info(f"[AdaHOP] Calibration armed: {n_wrappers} layers, {self.calibration_steps} steps. " + f"Forward callbacks attached. Backward hooks registered. " + f"Patterns will be observed every step.") + return True + + def on_pre_step(self, model_parts: list[Module], **kwargs) -> bool: + if not self.enabled or self._phase_b_done: + return True + if self.transform_config_path is not None: + return True + if self._step_idx >= self.calibration_steps: + return True + # Open a fresh dict for this step's pattern observations. + self._per_step_patterns.append({}) + logger.info(f"[AdaHOP] Calibration step {self._step_idx + 1}/{self.calibration_steps} opening bucket.") + return True + + def on_post_step(self, model_parts: list[Module], **kwargs) -> bool: + if not self.enabled or self._phase_b_done: + return True + if self.transform_config_path is not None: + return True + self._step_idx += 1 + + # Per-step observation summary + total_layers = len(self._fqn_to_wrapper) + bucket = self._per_step_patterns[-1] if self._per_step_patterns else {} + layers_with_data = len(bucket) + nx = sum(1 for v in bucket.values() if "x" in v) + nw = sum(1 for v in bucket.values() if "w" in v) + ng = sum(1 for v in bucket.values() if "grad_output" in v) + logger.info(f"[AdaHOP] Calibration step {self._step_idx}/{self.calibration_steps} observed: " + f"{layers_with_data}/{total_layers} layers reported patterns. " + f"(x, w, grad_output) presence: {nx}/{nw}/{ng}.") + + if self._step_idx < self.calibration_steps: + return True + + from alto.modifiers.lpt.adahop_internals.pattern_aggregation import (aggregate_patterns, patterns_to_modes) + logger.info(f"[AdaHOP] Calibration window complete. Aggregating {self._step_idx} steps " + f"of observations across {total_layers} layers.") + aggregated = aggregate_patterns(self._per_step_patterns) + logger.info(f"[AdaHOP] Aggregation done. Mapping pattern pairs → TransformModes " + f"via recipe table ({len(self.layer_transform_config)} entries).") + modes_by_fqn = patterns_to_modes(aggregated, self.layer_transform_config) + self._log_mode_table(modes_by_fqn, aggregated=aggregated) + + effective_dump_path = self.dump_json_path or "./outputs/adahop_calibration.json" + write_modes_json(effective_dump_path, aggregated, modes_by_fqn) + logger.info(f"[AdaHOP] JSON dump: wrote {len(modes_by_fqn)} layer modes to {effective_dump_path}") + + logger.info("[AdaHOP] Phase B re-swap starting...") + self._do_phase_b_reswap(modes_by_fqn) + self._detach_observation_hooks() + logger.info(f"[AdaHOP] Phase B complete. Forward path now uses MXFP4AdaHOPLinearFunction " + f"with frozen modes for {len(modes_by_fqn)} layers. Observation hooks detached.") + return True + + def on_finalize(self, model_parts: list[Module], **kwargs) -> bool: + logger.info(f"[AdaHOP] Modifier finalize: phase_b_done={self._phase_b_done}, " + f"total_calibration_steps_seen={self._step_idx}.") + self._detach_observation_hooks() + return True + + def _log_mode_table( + self, + modes_by_fqn: Dict[str, Dict[str, str]], + aggregated: Optional[Dict[str, Dict[str, str]]] = None, + ) -> None: + """Emit a compact per-layer ``x w g -> forward_y backward_gx backward_gw`` table to stdout.""" + logger.info("[AdaHOP] Per-layer mode assignments:") + for fqn in sorted(modes_by_fqn.keys()): + slots = modes_by_fqn[fqn] + if aggregated and fqn in aggregated: + pats = aggregated[fqn] + prefix = (f"x={pats.get('x', '?')} " + f"w={pats.get('w', '?')} " + f"g={pats.get('grad_output', '?')} -> ") + else: + prefix = "" + logger.info(f" {fqn}: {prefix}" + f"forward_y={slots.get('forward_y', 'none')} " + f"backward_gx={slots.get('backward_gx', 'none')} " + f"backward_gw={slots.get('backward_gw', 'none')}") + + # ------------------------------------------------------------------ helpers + + def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: + self._fqn_to_wrapper.clear() + self._fqn_to_module.clear() + for part in model_parts: + for module_fqn, module in part.named_modules(): + if not isinstance(module, nn.Linear): + continue + weight = getattr(module, "weight", None) + if weight is None or not isinstance(weight.data, cal_wrapper_cls): + continue + self._fqn_to_wrapper[module_fqn] = weight.data + self._fqn_to_module[module_fqn] = module + + def _do_phase_b_reswap(self, modes_by_fqn: Dict[str, Dict[str, str]]) -> None: + from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper + + ht = None + if self.use_hadamard: + from alto._adahop_bridge import HadamardFactory + # Lazy construction; the modifier doesn't know the device until + # weights actually live somewhere, so build per-device on demand. + ht_cache: Dict[torch.device, Any] = {} + + def _get_ht(device): + if device not in ht_cache: + ht_cache[device] = HadamardFactory.create_transform(device=device) + return ht_cache[device] + + ht_resolver: Callable[[torch.device], Any] = _get_ht + else: + ht_resolver = lambda _dev: None # noqa: E731 + + for fqn, wrapper in self._fqn_to_wrapper.items(): + modes = modes_by_fqn.get(fqn, {"forward_y": "none", "backward_gx": "none", "backward_gw": "none"}) + module = self._fqn_to_module[fqn] + old_param = module.weight + data = wrapper._data + ht = ht_resolver(data.device) + new_wrapper = MXFP4AdaHOPWrapper( + data, + wrapper.config, + hadamard_transform=ht, + forward_y_mode=modes.get("forward_y", "none"), + backward_gx_mode=modes.get("backward_gx", "none"), + backward_gw_mode=modes.get("backward_gw", "none"), + ) + module.weight = nn.Parameter(new_wrapper, requires_grad=old_param.requires_grad) + ht_state = "attached" if ht is not None else "none" + logger.info(f" [AdaHOP] re-swapped {fqn}: MXFP4CalibrationWrapper → " + f"MXFP4AdaHOPWrapper(forward_y={new_wrapper._forward_y_mode}, " + f"backward_gx={new_wrapper._backward_gx_mode}, " + f"backward_gw={new_wrapper._backward_gw_mode}, " + f"hadamard={ht_state})") + + self._phase_b_done = True + + def _detach_observation_hooks(self) -> None: + for handle in self._backward_handles: + handle.remove() + self._backward_handles.clear() + for wrapper in self._fqn_to_wrapper.values(): + if hasattr(wrapper, "attach_calibration_callback"): + wrapper.attach_calibration_callback(None) diff --git a/alto/modifiers/lpt/adahop_internals/calibration_hooks.py b/alto/modifiers/lpt/adahop_internals/calibration_hooks.py new file mode 100644 index 00000000..b7379f6f --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/calibration_hooks.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Pure-Python hook factories + JSON I/O for ``AdaHOPModifier``. + +Split out of ``adahop.py`` so it can be unit-tested without dragging in +ALTO's triton-backed kernel imports. +""" + +import json +from pathlib import Path +from typing import Any, Callable, Dict + + +def make_forward_callback(modifier: Any, fqn: str, detect: Callable) -> Callable: + """Build a closure that writes ``(x, w)`` patterns for ``fqn`` into the + modifier's current per-step bucket.""" + + def _cb(x, w) -> None: + if not modifier._per_step_patterns: + return + per_step = modifier._per_step_patterns[-1] + per_layer = per_step.setdefault(fqn, {}) + per_layer["x"] = detect(x) + per_layer["w"] = detect(w) + + return _cb + + +def make_backward_hook(modifier: Any, fqn: str, detect: Callable) -> Callable: + """Build a backward hook that writes ``grad_output`` pattern for ``fqn``.""" + + def _hook(_module, _grad_input, grad_output) -> None: + if not modifier._per_step_patterns: + return + go = grad_output[0] if isinstance(grad_output, (list, tuple)) else grad_output + if go is None: + return + per_step = modifier._per_step_patterns[-1] + per_layer = per_step.setdefault(fqn, {}) + per_layer["grad_output"] = detect(go.detach()) + + return _hook + + +def load_modes_from_json(path: str) -> Dict[str, Dict[str, str]]: + """Load per-layer modes from JSON, tolerating both schemas: + + * Bare ``{fqn: {slot: mode}}`` — what users hand-author or extract. + * Wrapped ``{"aggregated_patterns": {...}, "per_layer_modes": {...}}`` + — what :func:`write_modes_json` produces. + """ + with open(path) as f: + blob = json.load(f) + if isinstance(blob, dict) and "per_layer_modes" in blob and isinstance(blob["per_layer_modes"], dict): + return blob["per_layer_modes"] + return blob + + +def write_modes_json( + path: str, + aggregated: Dict[str, Dict[str, str]], + modes: Dict[str, Dict[str, str]], +) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + blob = {"aggregated_patterns": aggregated, "per_layer_modes": modes} + with open(path, "w") as f: + json.dump(blob, f, indent=2, sort_keys=True) diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index b46ee879..cbcacaca 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -36,7 +36,7 @@ class LowPrecisionTrainingModifier(Modifier): use_dge: bool = False two_level_scaling: Literal["none", "tensorwise", "blockwise"] = "none" clip_mode: Literal["none", "static", "dynamic"] = "none" - + lora_rank: int = 0 """ Lora rank for the decomposed linear layer. @@ -58,7 +58,7 @@ def validate_targets(cls, value: str | list[str]) -> list[str]: @field_validator("scheme", mode="before") def validate_scheme(cls, value: str | dict[str, str | list[str]]) -> str | dict[str, list[str]]: - if isinstance(value, str) and value not in ["mxfp4", "mxfp8_e4m3", "mxfp8_e5m2", "nvfp4"]: + if isinstance(value, str) and value not in ["mxfp4", "mxfp4_adahop", "mxfp8_e4m3", "mxfp8_e5m2", "nvfp4"]: raise ValueError(f"Unsupported training op scheme: {value}") if isinstance(value, dict): @@ -79,14 +79,10 @@ def validate_lora_rank_alignment(self): for scheme_name in schemes: if scheme_name == "nvfp4": if self.lora_rank % 16 != 0: - raise ValueError( - f"lora_rank must be divisible by 16 for nvfp4, got {self.lora_rank}" - ) + raise ValueError(f"lora_rank must be divisible by 16 for nvfp4, got {self.lora_rank}") elif scheme_name in ("mxfp4", "mxfp8_e4m3", "mxfp8_e5m2"): if self.lora_rank % 32 != 0: - raise ValueError( - f"lora_rank must be divisible by 32 for {scheme_name}, got {self.lora_rank}" - ) + raise ValueError(f"lora_rank must be divisible by 32 for {scheme_name}, got {self.lora_rank}") return self @property @@ -102,8 +98,13 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: self._resolved_config = {} for scheme_name, targets in self.scheme.items(): + # "mxfp4_adahop" reuses the MXFP4 kernel path; the only + # difference is which wrapper class is used at swap time + # (handled in on_convert). At the TrainingOpConfig level it + # is plain mxfp4. + precision = "mxfp4" if scheme_name == "mxfp4_adahop" else scheme_name scheme_obj = TrainingOpConfig( - precision=scheme_name, + precision=precision, use_2dblock_x=self.use_2dblock_x, use_2dblock_w=self.use_2dblock_w, use_hadamard=self.use_hadamard, @@ -112,11 +113,21 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: two_level_scaling=self.two_level_scaling, clip_mode=self.clip_mode, ) + # Tag the underlying scheme so on_convert can pick the right + # wrapper class. Stored on the dict key via a sibling attribute + # of the modifier rather than mutating TrainingOpConfig. self._resolved_config[scheme_obj] = targets + self._scheme_tag = getattr(self, "_scheme_tag", {}) + self._scheme_tag[scheme_obj] = scheme_name return self._resolved_config def on_convert(self, model: Module, **kwargs) -> bool: for scheme_obj, targets in self.resolved_config.items(): + tensor_cls = self._wrapper_cls_for_scheme(scheme_obj) + scheme_name = getattr(self, "_scheme_tag", {}).get(scheme_obj, scheme_obj.precision) + wrapper_label = tensor_cls.__name__ if tensor_cls is not None else "default-for-precision" + logger.info(f"LowPrecisionTrainingModifier: scheme={scheme_name}, wrapper_cls={wrapper_label}, " + f"targets={targets}, ignore={self.ignore}") for name, module in match_named_modules(model, targets, self.ignore): if isinstance(module, BaseAttention): assert module.attn_backend == "sdpa", "Only SDPA attention is supported for now." @@ -124,20 +135,29 @@ def on_convert(self, model: Module, **kwargs) -> bool: elif isinstance(module, torch.nn.Linear): if self.lora_rank > 0: module = DecomposedLinear.from_linear(module, lora_rank=self.lora_rank) - swap_params(module, config=scheme_obj, target_parameter_name="weight") - swap_params(module, config=scheme_obj, target_parameter_name="u") - swap_params(module, config=scheme_obj, target_parameter_name="v") + swap_params(module, config=scheme_obj, target_parameter_name="weight", tensor_cls=tensor_cls) + swap_params(module, config=scheme_obj, target_parameter_name="u", tensor_cls=tensor_cls) + swap_params(module, config=scheme_obj, target_parameter_name="v", tensor_cls=tensor_cls) model.set_submodule(name, module, strict=True) else: - swap_params(module, config=scheme_obj, module_name=name) + swap_params(module, config=scheme_obj, module_name=name, tensor_cls=tensor_cls) elif module.__class__.__name__.endswith("GroupedExperts"): - swap_params(module, config=scheme_obj, module_name=name) + swap_params(module, config=scheme_obj, module_name=name, tensor_cls=tensor_cls) else: raise ValueError(f"Unsupported module type: {type(module)}") logger.info(f"LowPrecisionTrainingModifier converted model: {model}") return True + def _wrapper_cls_for_scheme(self, scheme_obj): + """Return the wrapper tensor class for ``scheme_obj``. ``None`` falls + back to ``swap_params``' default (looked up from the config precision).""" + scheme_name = getattr(self, "_scheme_tag", {}).get(scheme_obj) + if scheme_name == "mxfp4_adahop": + from alto.kernels.dispatch.adahop_tensor import MXFP4CalibrationWrapper + return MXFP4CalibrationWrapper + return None + def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: for model_part in model_parts: for child in model_part.modules(): diff --git a/tests/integration/llama3_debugmodel_adahop.sh b/tests/integration/llama3_debugmodel_adahop.sh new file mode 100644 index 00000000..7f7a92c3 --- /dev/null +++ b/tests/integration/llama3_debugmodel_adahop.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +# +# Phase-3 smoke: llama3 debug model under MXFP4 + AdaHOP two-phase swap. +# Runs 30 calibration steps (Phase A) followed by 5 post-calibration training +# steps (Phase B). Confirms the calibration callback + backward hook + re-swap +# pipeline runs end-to-end without crashing. + +SCRIPT_DIR=$(dirname "$0") +cd $SCRIPT_DIR/../.. + +NGPU=2 \ +MODULE=llama3 \ +CONFIG=llama3_debugmodel_adahop \ +./examples/run.sh diff --git a/tests/integration/llama3_debugmodel_adahop_short.sh b/tests/integration/llama3_debugmodel_adahop_short.sh new file mode 100644 index 00000000..09164dd1 --- /dev/null +++ b/tests/integration/llama3_debugmodel_adahop_short.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +# +# Fast-iteration smoke for AdaHOP two-phase swap: 3 calibration steps +# followed by 5 post-Phase-B training steps. Use to debug the calibration +# → re-swap → frozen-mode pipeline without waiting 30 steps of calibration. + +SCRIPT_DIR=$(dirname "$0") +cd $SCRIPT_DIR/../.. + +NGPU=1 \ +MODULE=llama3 \ +CONFIG=llama3_debugmodel_adahop_short \ +./examples/run.sh diff --git a/tests/unittest/adahop/test_adahop_modifier_helpers.py b/tests/unittest/adahop/test_adahop_modifier_helpers.py new file mode 100644 index 00000000..34fd864a --- /dev/null +++ b/tests/unittest/adahop/test_adahop_modifier_helpers.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""CPU-side tests for AdaHOPModifier's pure-Python helpers. + +The full modifier needs ALTO's triton-backed kernels at import time, but the +helper functions live in a sibling module (``calibration_hooks``) with +stdlib-only deps so they can be tested standalone.""" + +import importlib.util +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_HELPERS_PATH = (Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "lpt" / "adahop_internals" / + "calibration_hooks.py") + + +def _load_helpers(): + name = "alto_adahop_calibration_hooks_under_test" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, _HELPERS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def helpers(): + return _load_helpers() + + +def test_forward_callback_writes_x_and_w_patterns(helpers): + modifier = SimpleNamespace(_per_step_patterns=[{}]) + + def detect(t): + return t # echo back; tests pass strings as "tensors" + + cb = helpers.make_forward_callback(modifier, "layers.0.wq", detect) + cb("row", "col") + assert modifier._per_step_patterns[-1]["layers.0.wq"] == {"x": "row", "w": "col"} + + +def test_forward_callback_noop_when_no_step_dict(helpers): + modifier = SimpleNamespace(_per_step_patterns=[]) + cb = helpers.make_forward_callback(modifier, "layers.0.wq", lambda t: t) + cb("row", "col") + assert modifier._per_step_patterns == [] + + +def test_backward_hook_writes_grad_output_pattern(helpers): + + class FakeGrad: + + def detach(self): + return "row" + + modifier = SimpleNamespace(_per_step_patterns=[{}]) + hook = helpers.make_backward_hook(modifier, "layers.0.wq", lambda t: t) + hook(None, None, (FakeGrad(),)) + assert modifier._per_step_patterns[-1]["layers.0.wq"]["grad_output"] == "row" + + +def test_backward_hook_ignores_none_grad(helpers): + modifier = SimpleNamespace(_per_step_patterns=[{}]) + hook = helpers.make_backward_hook(modifier, "l0", lambda t: t) + hook(None, None, (None,)) + assert modifier._per_step_patterns[-1] == {} + + +def test_backward_hook_handles_bare_tensor(helpers): + + class FakeGrad: + + def detach(self): + return "col" + + modifier = SimpleNamespace(_per_step_patterns=[{}]) + hook = helpers.make_backward_hook(modifier, "l0", lambda t: t) + hook(None, None, FakeGrad()) # not a tuple + assert modifier._per_step_patterns[-1]["l0"]["grad_output"] == "col" + + +def test_write_and_load_modes_json(tmp_path, helpers): + path = tmp_path / "modes.json" + aggregated = {"l0": {"x": "row", "w": "col", "grad_output": "none"}} + modes = {"l0": {"forward_y": "hadamard", "backward_gx": "none", "backward_gw": "full_precision"}} + helpers.write_modes_json(str(path), aggregated, modes) + + raw = json.loads(path.read_text()) + assert raw["aggregated_patterns"] == aggregated + assert raw["per_layer_modes"] == modes + + # load_modes_from_json expects the bare {fqn: {slot: mode}} format used + # by the transform_config_path recipe option. + modes_only = tmp_path / "modes_only.json" + modes_only.write_text(json.dumps(modes)) + assert helpers.load_modes_from_json(str(modes_only)) == modes diff --git a/tests/unittest/adahop/test_calibration_hooks.py b/tests/unittest/adahop/test_calibration_hooks.py new file mode 100644 index 00000000..bb83f776 --- /dev/null +++ b/tests/unittest/adahop/test_calibration_hooks.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Tests for the JSON round-trip schemas in calibration_hooks.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_HELPERS_PATH = (Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "lpt" / "adahop_internals" / + "calibration_hooks.py") + + +def _load_helpers(): + name = "alto_adahop_calibration_hooks_under_test" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, _HELPERS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def helpers(): + return _load_helpers() + + +def test_load_accepts_bare_form(tmp_path, helpers): + path = tmp_path / "bare.json" + bare = {"l0": {"forward_y": "hadamard", "backward_gx": "none", "backward_gw": "full_precision"}} + path.write_text(json.dumps(bare)) + assert helpers.load_modes_from_json(str(path)) == bare + + +def test_load_accepts_wrapped_form(tmp_path, helpers): + """``write_modes_json`` output (with ``aggregated_patterns`` + + ``per_layer_modes``) must be loadable as a modes-only dict.""" + path = tmp_path / "wrapped.json" + aggregated = {"l0": {"x": "row", "w": "col", "grad_output": "none"}} + modes = {"l0": {"forward_y": "hadamard", "backward_gx": "none", "backward_gw": "full_precision"}} + helpers.write_modes_json(str(path), aggregated, modes) + assert helpers.load_modes_from_json(str(path)) == modes + + +def test_write_then_load_round_trips(tmp_path, helpers): + """End-to-end: dump → load yields the same per-layer modes.""" + path = tmp_path / "rt.json" + aggregated = { + "layers.0.attention.wq": { + "x": "row", + "w": "row", + "grad_output": "col" + }, + "layers.1.feed_forward.w1": { + "x": "none", + "w": "none", + "grad_output": "none" + }, + } + modes = { + "layers.0.attention.wq": { + "forward_y": "hadamard", + "backward_gx": "hadamard", + "backward_gw": "hadamard" + }, + "layers.1.feed_forward.w1": { + "forward_y": "none", + "backward_gx": "none", + "backward_gw": "none" + }, + } + helpers.write_modes_json(str(path), aggregated, modes) + assert helpers.load_modes_from_json(str(path)) == modes From 931d22c2cb00c63cc2c602ba9f533afbfae52b85 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 27 May 2026 19:10:02 +0200 Subject: [PATCH 017/142] fixture TrainingOpConfig --- tests/unittest/adahop/test_adahop_wrapper.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unittest/adahop/test_adahop_wrapper.py b/tests/unittest/adahop/test_adahop_wrapper.py index 0cbd9378..ea427506 100644 --- a/tests/unittest/adahop/test_adahop_wrapper.py +++ b/tests/unittest/adahop/test_adahop_wrapper.py @@ -45,7 +45,14 @@ def _import_alto_kernel_free(name: str, path_parts: list[str]): @pytest.fixture def mxfp4_config(): - return TrainingOpConfig(precision="mxfp4") + return TrainingOpConfig( + precision="mxfp4", + use_2dblock_x=False, + use_2dblock_w=True, + use_hadamard=False, + use_sr_grad=False, + use_dge=False, + ) def test_calibration_wrapper_accepts_optional_callback(mxfp4_config): From 2c6667bc6b5d8bbf35a78122ad6c5913d982b3b5 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Thu, 28 May 2026 09:10:55 +0000 Subject: [PATCH 018/142] feat: enable de-osc by env var --- alto/train.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/alto/train.py b/alto/train.py index 4dbab96d..df8ff66b 100644 --- a/alto/train.py +++ b/alto/train.py @@ -4,6 +4,7 @@ from typing import Iterable, Any from contextlib import contextmanager +import os import time import torch from torchtitan.components.loss import IGNORE_INDEX @@ -110,14 +111,6 @@ def __init__(self, config: TitanTrainer.Config): logger.info("data replay buffer disabled") self.enable_data_cache = False - deosc_config = DeOscillationConfig( - enable=True, - period=4, - ratio_threshold=16.0, - log_freq=1, - ) - enable_de_oscillation(self.optimizers, deosc_config) - def cache_input(self, microbatches: list[tuple[dict[str, torch.Tensor], torch.Tensor]]): if self.enable_data_cache: self._input_cache = microbatches @@ -199,6 +192,17 @@ def train_step( data_iterator: Iterable[tuple[dict[str, torch.Tensor], torch.Tensor]], ): if self.training_mode: + # FIXME: This is a hack to enable de-oscillation at a specific step. + deosc_step = int(os.environ.get("DEOSC_STEP", "0")) + if deosc_step > 0 and self.step == deosc_step: + deosc_config = DeOscillationConfig( + enable=True, + period=200, + ratio_threshold=8.0, + log_freq=1, + ) + enable_de_oscillation(self.optimizers, deosc_config) + return super().train_step(data_iterator) # Keep these variables local to shorten the code as these are From d6dc69343276cb504dae78cd8164e67373c70e04 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Thu, 28 May 2026 10:02:21 +0000 Subject: [PATCH 019/142] hotfix: de-osc with FSDP for GPT-OSS 20B model --- alto/components/optimizer.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/alto/components/optimizer.py b/alto/components/optimizer.py index 8da5f2bb..c5758187 100644 --- a/alto/components/optimizer.py +++ b/alto/components/optimizer.py @@ -144,16 +144,27 @@ def _make_qdq_fn_for(cfg: TrainingOpConfig) -> QdqFn: if cfg.precision == "mxfp4": def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: + # hotfix for GPT-OSS 20B model + original_rows = -1 + if w.dim() == 2 and w.shape[0] % 32 != 0: + # for 2-D w, if the first dim is not divisible by 32, pad it to be divisible by 32 + original_rows = w.shape[0] + w = torch.nn.functional.pad(w, (0, 0, 0, 32 - original_rows % 32)) + data_lp, scales = convert_to_mxfp4( w, axis=axis, is_2d_block=is_2d_block, ) - return convert_from_mxfp4( + dequantized = convert_from_mxfp4( data_lp, scales, output_dtype=w.dtype, axis=axis, is_2d_block=is_2d_block, ) + # hotfix for GPT-OSS 20B model + if original_rows != -1: + dequantized = dequantized[:original_rows, :] + return dequantized elif cfg.precision == "nvfp4": From ec9a5b9dedd085756c3f302c3410a378917ac065 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Fri, 29 May 2026 15:38:59 +0200 Subject: [PATCH 020/142] update short bash --- tests/integration/llama3_debugmodel_adahop_short.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/llama3_debugmodel_adahop_short.sh b/tests/integration/llama3_debugmodel_adahop_short.sh index 09164dd1..b397c719 100644 --- a/tests/integration/llama3_debugmodel_adahop_short.sh +++ b/tests/integration/llama3_debugmodel_adahop_short.sh @@ -10,7 +10,7 @@ SCRIPT_DIR=$(dirname "$0") cd $SCRIPT_DIR/../.. -NGPU=1 \ +NGPU=2 \ MODULE=llama3 \ CONFIG=llama3_debugmodel_adahop_short \ ./examples/run.sh From 7eb313a217ac0ecd447c35c887fa49f2bb75dd93 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Fri, 29 May 2026 16:53:15 +0200 Subject: [PATCH 021/142] fix(mxfp4): use ctx.original_dtype in backward to match saved x_dq/w_dq --- alto/kernels/fp4/mxfp4/mxfp_linear.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 4abe7e12..72bfbf3d 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -419,7 +419,11 @@ def forward( def backward(ctx, grad_output): original_shape = grad_output.shape grad_output = grad_output.reshape(-1, original_shape[-1]) # Ensure grad_output is 2D - original_dtype = grad_output.dtype + # PyTorch disables autocast inside autograd.Function.backward, so without FSDP's + # MixedPrecisionPolicy grad_output can arrive in a dtype that differs from the saved + # x_dq / w_dq (which were cast to ctx.original_dtype in forward). Use the dtype + # committed by forward to keep the non-CDNA4 matmul below well-typed. + original_dtype = ctx.original_dtype if is_cdna4(): inputs_mxfp4, input_scales, weight_mxfp4, weight_scales, x_mbs, w_mbs = ctx.saved_tensors From 3426fde8348112739f8521395006f504ec923e97 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Fri, 29 May 2026 14:55:49 +0000 Subject: [PATCH 022/142] update integration test bashes --- tests/integration/llama3_debugmodel_adahop.sh | 0 tests/integration/llama3_debugmodel_adahop_short.sh | 0 tests/integration/llama3_debugmodel_baseline.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tests/integration/llama3_debugmodel_adahop.sh mode change 100644 => 100755 tests/integration/llama3_debugmodel_adahop_short.sh mode change 100644 => 100755 tests/integration/llama3_debugmodel_baseline.sh diff --git a/tests/integration/llama3_debugmodel_adahop.sh b/tests/integration/llama3_debugmodel_adahop.sh old mode 100644 new mode 100755 diff --git a/tests/integration/llama3_debugmodel_adahop_short.sh b/tests/integration/llama3_debugmodel_adahop_short.sh old mode 100644 new mode 100755 diff --git a/tests/integration/llama3_debugmodel_baseline.sh b/tests/integration/llama3_debugmodel_baseline.sh old mode 100644 new mode 100755 From 19e102eed9ca77b9e0c612a3ff6f7d656f15478e Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 13:33:39 +0200 Subject: [PATCH 023/142] unwrap DTensor in discovery and preserve sharding in re-swap --- alto/modifiers/lpt/adahop.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index ef09f00c..8fcdf1f8 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -226,6 +226,7 @@ def _log_mode_table( # ------------------------------------------------------------------ helpers def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: + from torch.distributed.tensor import DTensor self._fqn_to_wrapper.clear() self._fqn_to_module.clear() for part in model_parts: @@ -233,9 +234,14 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: if not isinstance(module, nn.Linear): continue weight = getattr(module, "weight", None) - if weight is None or not isinstance(weight.data, cal_wrapper_cls): + if weight is None: continue - self._fqn_to_wrapper[module_fqn] = weight.data + inner = weight.data + if isinstance(inner, DTensor): + inner = inner._local_tensor + if not isinstance(inner, cal_wrapper_cls): + continue + self._fqn_to_wrapper[module_fqn] = inner self._fqn_to_module[module_fqn] = module def _do_phase_b_reswap(self, modes_by_fqn: Dict[str, Dict[str, str]]) -> None: @@ -257,6 +263,8 @@ def _get_ht(device): else: ht_resolver = lambda _dev: None # noqa: E731 + from torch.distributed.tensor import DTensor + for fqn, wrapper in self._fqn_to_wrapper.items(): modes = modes_by_fqn.get(fqn, {"forward_y": "none", "backward_gx": "none", "backward_gw": "none"}) module = self._fqn_to_module[fqn] @@ -271,7 +279,21 @@ def _get_ht(device): backward_gx_mode=modes.get("backward_gx", "none"), backward_gw_mode=modes.get("backward_gw", "none"), ) - module.weight = nn.Parameter(new_wrapper, requires_grad=old_param.requires_grad) + if isinstance(old_param.data, DTensor): + # Preserve FSDP sharding: rebuild the DTensor around the new local wrapper + # using the same device mesh and placements as the existing param. + old_dt = old_param.data + new_dt = DTensor.from_local( + new_wrapper, + device_mesh=old_dt.device_mesh, + placements=old_dt.placements, + run_check=False, + shape=old_dt.shape, + stride=old_dt.stride(), + ) + module.weight = nn.Parameter(new_dt, requires_grad=old_param.requires_grad) + else: + module.weight = nn.Parameter(new_wrapper, requires_grad=old_param.requires_grad) ht_state = "attached" if ht is not None else "none" logger.info(f" [AdaHOP] re-swapped {fqn}: MXFP4CalibrationWrapper → " f"MXFP4AdaHOPWrapper(forward_y={new_wrapper._forward_y_mode}, " From 187b65a06c1ebf1256c23df1da09e424d5fbdc5d Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 13:47:32 +0200 Subject: [PATCH 024/142] update dispatch to detect callback on the source wrapper --- alto/kernels/dispatch/adahop_tensor.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 70207c17..6dd50319 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -74,6 +74,29 @@ def __torch_function__(cls, func, types, args, kwargs={}): weight._calibration_callback(x.detach(), weight._data.detach()) return super().__torch_function__(func, types, args, kwargs) + @classmethod + def __torch_dispatch__(cls, func, types, args, kwargs={}): + # The parent's __torch_dispatch__ rewraps subclass-preserving ops + # (detach, view, _to_copy, etc.) via cls(data, config), which loses + # our _calibration_callback because it isn't carried in the rewrap + # path. Re-attach the callback from the source wrapper onto the + # output so the canonical instance seen by __torch_function__ + # ("linear") still has a callback to invoke. + src_cb = None + for a in args: + if isinstance(a, MXFP4CalibrationWrapper) and a._calibration_callback is not None: + src_cb = a._calibration_callback + break + out = super().__torch_dispatch__(func, types, args, kwargs) + if src_cb is not None: + import torch.utils._pytree as pytree + pytree.tree_map_only( + MXFP4CalibrationWrapper, + lambda t: setattr(t, "_calibration_callback", src_cb) or t, + out, + ) + return out + class MXFP4AdaHOPWrapper(MXFP4TrainingWeightWrapperTensor): """Phase-B wrapper: frozen per-slot modes baked in at construction.""" From 20fdbbb472c5e5937b93a578496082e9a3329c88 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 13:52:50 +0200 Subject: [PATCH 025/142] fsdp_post_all_gather in MXFP4CalibrationWrapper --- alto/kernels/dispatch/adahop_tensor.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 6dd50319..ce68cf8b 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -97,6 +97,28 @@ def __torch_dispatch__(cls, func, types, args, kwargs={}): ) return out + def fsdp_post_all_gather( + self, + all_gather_outputs: Tuple[torch.Tensor, ...], + metadata: Any, + param_dtype: torch.dtype, + *, + out: Optional[torch.Tensor] = None, + ): + # FSDP's all-gather produces a fresh wrapper that doesn't carry our + # _calibration_callback. Mirror MXFP4AdaHOPWrapper's pattern: stamp + # the callback onto the post-all-gather instance so the canonical + # wrapper seen by __torch_function__("linear") has a callback to + # invoke during calibration. Without this the forward observation + # path is silently a no-op under FSDP. + if out is not None: + if isinstance(out, MXFP4CalibrationWrapper): + out._calibration_callback = self._calibration_callback + return super().fsdp_post_all_gather(all_gather_outputs, metadata, param_dtype, out=out) + (data,) = all_gather_outputs + output = type(self)(data, self.config, calibration_callback=self._calibration_callback) + return output, (data,) + class MXFP4AdaHOPWrapper(MXFP4TrainingWeightWrapperTensor): """Phase-B wrapper: frozen per-slot modes baked in at construction.""" From 1bba2e1415b15987cb79a4815da9d49b5253317c Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 13:59:43 +0200 Subject: [PATCH 026/142] use pre-hooks for inputs and weights --- alto/modifiers/lpt/adahop.py | 10 ++++++ .../lpt/adahop_internals/calibration_hooks.py | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 8fcdf1f8..6adf9242 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -42,6 +42,7 @@ load_modes_from_json, make_backward_hook, make_forward_callback, + make_forward_pre_hook, write_modes_json, ) @@ -135,6 +136,15 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) for fqn, module in self._fqn_to_module.items(): + # Forward pre-hook on the nn.Linear catches (x, weight._data) before + # any tensor-subclass dispatch. Belt-and-suspenders alongside the + # wrapper-side callback: under FSDP the wrapper instance gets + # replaced by FSDP's all-gather and any callback we attach to the + # canonical instance is invisible by the time the linear actually + # runs. The module reference is stable. + fwd_handle = module.register_forward_pre_hook( + make_forward_pre_hook(self, fqn, detect_outlier_pattern)) + self._backward_handles.append(fwd_handle) handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) self._backward_handles.append(handle) diff --git a/alto/modifiers/lpt/adahop_internals/calibration_hooks.py b/alto/modifiers/lpt/adahop_internals/calibration_hooks.py index b7379f6f..1bff5a75 100644 --- a/alto/modifiers/lpt/adahop_internals/calibration_hooks.py +++ b/alto/modifiers/lpt/adahop_internals/calibration_hooks.py @@ -27,6 +27,40 @@ def _cb(x, w) -> None: return _cb +def make_forward_pre_hook(modifier: Any, fqn: str, detect: Callable) -> Callable: + """Forward pre-hook on the nn.Linear module. Captures (x, weight._data) directly + from the module — avoids the tensor-subclass dispatch path entirely, which is + fragile under FSDP because all the rewrap paths (__torch_dispatch__, + fsdp_post_all_gather, and any intervening detach/view) drop wrapper instance + state. By the time __torch_function__("linear") fires the canonical wrapper + is gone; the module reference is stable across all of that.""" + + def _pre(module, args): + if not modifier._per_step_patterns: + return + if not args: + return + x = args[0] + weight = module.weight + # Under FSDP weight is a DTensor wrapping our subclass; under no-FSDP + # it's the subclass directly. Either way ._data lives on the subclass. + w = weight.data + # Unwrap DTensor if present. + try: + from torch.distributed.tensor import DTensor + if isinstance(w, DTensor): + w = w._local_tensor + except Exception: + pass + w_data = getattr(w, "_data", w) + per_step = modifier._per_step_patterns[-1] + per_layer = per_step.setdefault(fqn, {}) + per_layer["x"] = detect(x.detach()) + per_layer["w"] = detect(w_data.detach()) + + return _pre + + def make_backward_hook(modifier: Any, fqn: str, detect: Callable) -> Callable: """Build a backward hook that writes ``grad_output`` pattern for ``fqn``.""" From 8b9928800a37ba4381d3fd4ae9e2759ceb31d27d Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:10:48 +0200 Subject: [PATCH 027/142] add log for torch_function *mxfp4* path --- alto/kernels/dispatch/tensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index ffa3da32..33e13174 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -250,6 +250,13 @@ def __torch_function__(cls, func, types, args, kwargs={}): # linear op override elif func.__name__ in gemm_ops: + import os + if os.environ.get("ADAHOP_DEBUG_TF"): + _B = args[1] if func.__name__ != "addmm.default" else args[2] + _A = args[0] if func.__name__ != "addmm.default" else args[1] + print(f"[DBG TF] func={func.__name__} " + f"A_type={type(_A).__name__} B_type={type(_B).__name__} " + f"cb={getattr(_B, '_calibration_callback', 'NO_ATTR')!r}", flush=True) trans_b = func.__name__ == "linear" if func.__name__ == "addmm.default": bias, A, B = args[0], args[1], args[2] From 2298c7f6e89bfd79c93eeba7630bf0fcd6284736 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:15:17 +0200 Subject: [PATCH 028/142] update logs --- alto/kernels/dispatch/tensor.py | 2 +- alto/modifiers/lpt/adahop.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index 33e13174..a6d6949e 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -255,7 +255,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): _B = args[1] if func.__name__ != "addmm.default" else args[2] _A = args[0] if func.__name__ != "addmm.default" else args[1] print(f"[DBG TF] func={func.__name__} " - f"A_type={type(_A).__name__} B_type={type(_B).__name__} " + f"B_id={id(_B)} B_type={type(_B).__name__} " f"cb={getattr(_B, '_calibration_callback', 'NO_ATTR')!r}", flush=True) trans_b = func.__name__ == "linear" if func.__name__ == "addmm.default": diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 6adf9242..8f04edbc 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -132,8 +132,13 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: from alto._adahop_bridge import detect_outlier_pattern + import os for fqn, wrapper in self._fqn_to_wrapper.items(): wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) + if os.environ.get("ADAHOP_DEBUG_TF"): + print(f"[DBG ATTACH] fqn={fqn} wrapper_id={id(wrapper)} " + f"type={type(wrapper).__name__} cb_set={wrapper._calibration_callback is not None}", + flush=True) for fqn, module in self._fqn_to_module.items(): # Forward pre-hook on the nn.Linear catches (x, weight._data) before From f75bcebafbeb8eae2e0f5064c4b4b20e2ba91bfd Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:18:57 +0200 Subject: [PATCH 029/142] update log --- alto/kernels/dispatch/adahop_tensor.py | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index ce68cf8b..45e83b3f 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -56,6 +56,11 @@ def __new__(cls, tensor, config, *, calibration_callback: Optional[CalibrationCa def __init__(self, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): super().__init__(tensor, config) self._calibration_callback = calibration_callback + import os + if os.environ.get("ADAHOP_DEBUG_TF") and os.environ.get("ADAHOP_DEBUG_CTOR"): + import traceback + print(f"[DBG CTOR] id={id(self)} cb_set={calibration_callback is not None}", flush=True) + traceback.print_stack(limit=8) def attach_calibration_callback(self, cb: Optional[CalibrationCallback]) -> None: self._calibration_callback = cb @@ -76,17 +81,15 @@ def __torch_function__(cls, func, types, args, kwargs={}): @classmethod def __torch_dispatch__(cls, func, types, args, kwargs={}): - # The parent's __torch_dispatch__ rewraps subclass-preserving ops - # (detach, view, _to_copy, etc.) via cls(data, config), which loses - # our _calibration_callback because it isn't carried in the rewrap - # path. Re-attach the callback from the source wrapper onto the - # output so the canonical instance seen by __torch_function__ - # ("linear") still has a callback to invoke. + import os + debug = os.environ.get("ADAHOP_DEBUG_TF") src_cb = None + src_ids = [] for a in args: - if isinstance(a, MXFP4CalibrationWrapper) and a._calibration_callback is not None: - src_cb = a._calibration_callback - break + if isinstance(a, MXFP4CalibrationWrapper): + src_ids.append((id(a), a._calibration_callback is not None)) + if a._calibration_callback is not None and src_cb is None: + src_cb = a._calibration_callback out = super().__torch_dispatch__(func, types, args, kwargs) if src_cb is not None: import torch.utils._pytree as pytree @@ -95,6 +98,18 @@ def __torch_dispatch__(cls, func, types, args, kwargs={}): lambda t: setattr(t, "_calibration_callback", src_cb) or t, out, ) + if debug: + out_ids = [] + if isinstance(out, MXFP4CalibrationWrapper): + out_ids.append(id(out)) + elif isinstance(out, (list, tuple)): + for o in out: + if isinstance(o, MXFP4CalibrationWrapper): + out_ids.append(id(o)) + if src_ids or out_ids: + print(f"[DBG DISP] func={func.__name__} " + f"src={src_ids} out={out_ids} reattached={src_cb is not None}", + flush=True) return out def fsdp_post_all_gather( From 083737c9d36d4385190341499b3509afa22cc82b Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:23:00 +0200 Subject: [PATCH 030/142] remove pre-hook, try wrapper again by solving the id mismatch issue --- alto/modifiers/lpt/adahop.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 8f04edbc..008a0b2f 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -141,15 +141,6 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: flush=True) for fqn, module in self._fqn_to_module.items(): - # Forward pre-hook on the nn.Linear catches (x, weight._data) before - # any tensor-subclass dispatch. Belt-and-suspenders alongside the - # wrapper-side callback: under FSDP the wrapper instance gets - # replaced by FSDP's all-gather and any callback we attach to the - # canonical instance is invisible by the time the linear actually - # runs. The module reference is stable. - fwd_handle = module.register_forward_pre_hook( - make_forward_pre_hook(self, fqn, detect_outlier_pattern)) - self._backward_handles.append(fwd_handle) handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) self._backward_handles.append(handle) @@ -251,11 +242,24 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: weight = getattr(module, "weight", None) if weight is None: continue - inner = weight.data - if isinstance(inner, DTensor): - inner = inner._local_tensor - if not isinstance(inner, cal_wrapper_cls): - continue + # CRITICAL: attach to the canonical instance F.linear will see. + # `weight.data` triggers a detach in __torch_dispatch__ and + # returns a FRESH wrapper (different Python object); the + # callback set on that transient is invisible to F.linear, + # which passes `weight` itself. Under no-FSDP `weight` IS the + # subclass (Parameter subclasses the underlying tensor type). + # Under FSDP weight.data is a DTensor; its _local_tensor is + # the canonical wrapper. + if isinstance(weight, cal_wrapper_cls): + inner = weight + else: + raw = weight.data + if isinstance(raw, DTensor): + inner = raw._local_tensor + else: + inner = raw + if not isinstance(inner, cal_wrapper_cls): + continue self._fqn_to_wrapper[module_fqn] = inner self._fqn_to_module[module_fqn] = module From 2a3addd9965ece799f05b4955ae9a32fb108b95d Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:28:58 +0200 Subject: [PATCH 031/142] fix: attach calibration callback to canonical wrapper, not weight.data --- alto/kernels/dispatch/adahop_tensor.py | 34 ++++++++------------------ alto/kernels/dispatch/tensor.py | 7 ------ alto/modifiers/lpt/adahop.py | 6 ----- 3 files changed, 10 insertions(+), 37 deletions(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 45e83b3f..bd5f91bd 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -56,11 +56,6 @@ def __new__(cls, tensor, config, *, calibration_callback: Optional[CalibrationCa def __init__(self, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): super().__init__(tensor, config) self._calibration_callback = calibration_callback - import os - if os.environ.get("ADAHOP_DEBUG_TF") and os.environ.get("ADAHOP_DEBUG_CTOR"): - import traceback - print(f"[DBG CTOR] id={id(self)} cb_set={calibration_callback is not None}", flush=True) - traceback.print_stack(limit=8) def attach_calibration_callback(self, cb: Optional[CalibrationCallback]) -> None: self._calibration_callback = cb @@ -81,15 +76,18 @@ def __torch_function__(cls, func, types, args, kwargs={}): @classmethod def __torch_dispatch__(cls, func, types, args, kwargs={}): - import os - debug = os.environ.get("ADAHOP_DEBUG_TF") + # Belt-and-suspenders: even though discovery now attaches to the + # canonical wrapper instance, autograd-internal detach/view/clone + # rewraps will produce fresh MXFP4CalibrationWrapper instances via + # the parent's `cls(data, config)` path with cb=None. Propagate the + # callback from any input MXFP4CalibrationWrapper that has one onto + # every output MXFP4CalibrationWrapper. Harmless when no callback + # is in flight (calibration disabled or Phase-B already complete). src_cb = None - src_ids = [] for a in args: - if isinstance(a, MXFP4CalibrationWrapper): - src_ids.append((id(a), a._calibration_callback is not None)) - if a._calibration_callback is not None and src_cb is None: - src_cb = a._calibration_callback + if isinstance(a, MXFP4CalibrationWrapper) and a._calibration_callback is not None: + src_cb = a._calibration_callback + break out = super().__torch_dispatch__(func, types, args, kwargs) if src_cb is not None: import torch.utils._pytree as pytree @@ -98,18 +96,6 @@ def __torch_dispatch__(cls, func, types, args, kwargs={}): lambda t: setattr(t, "_calibration_callback", src_cb) or t, out, ) - if debug: - out_ids = [] - if isinstance(out, MXFP4CalibrationWrapper): - out_ids.append(id(out)) - elif isinstance(out, (list, tuple)): - for o in out: - if isinstance(o, MXFP4CalibrationWrapper): - out_ids.append(id(o)) - if src_ids or out_ids: - print(f"[DBG DISP] func={func.__name__} " - f"src={src_ids} out={out_ids} reattached={src_cb is not None}", - flush=True) return out def fsdp_post_all_gather( diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index a6d6949e..ffa3da32 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -250,13 +250,6 @@ def __torch_function__(cls, func, types, args, kwargs={}): # linear op override elif func.__name__ in gemm_ops: - import os - if os.environ.get("ADAHOP_DEBUG_TF"): - _B = args[1] if func.__name__ != "addmm.default" else args[2] - _A = args[0] if func.__name__ != "addmm.default" else args[1] - print(f"[DBG TF] func={func.__name__} " - f"B_id={id(_B)} B_type={type(_B).__name__} " - f"cb={getattr(_B, '_calibration_callback', 'NO_ATTR')!r}", flush=True) trans_b = func.__name__ == "linear" if func.__name__ == "addmm.default": bias, A, B = args[0], args[1], args[2] diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 008a0b2f..c8ebf54b 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -42,7 +42,6 @@ load_modes_from_json, make_backward_hook, make_forward_callback, - make_forward_pre_hook, write_modes_json, ) @@ -132,13 +131,8 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: from alto._adahop_bridge import detect_outlier_pattern - import os for fqn, wrapper in self._fqn_to_wrapper.items(): wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) - if os.environ.get("ADAHOP_DEBUG_TF"): - print(f"[DBG ATTACH] fqn={fqn} wrapper_id={id(wrapper)} " - f"type={type(wrapper).__name__} cb_set={wrapper._calibration_callback is not None}", - flush=True) for fqn, module in self._fqn_to_module.items(): handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) From e1379661d4e022e9f163c87a78de6b3da4af22e0 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:34:29 +0200 Subject: [PATCH 032/142] log for ngpu=2 debugging --- alto/kernels/dispatch/adahop_tensor.py | 12 ++++++------ alto/kernels/dispatch/tensor.py | 5 +++++ alto/modifiers/lpt/adahop.py | 4 ++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index bd5f91bd..3105e1db 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -106,12 +106,12 @@ def fsdp_post_all_gather( *, out: Optional[torch.Tensor] = None, ): - # FSDP's all-gather produces a fresh wrapper that doesn't carry our - # _calibration_callback. Mirror MXFP4AdaHOPWrapper's pattern: stamp - # the callback onto the post-all-gather instance so the canonical - # wrapper seen by __torch_function__("linear") has a callback to - # invoke during calibration. Without this the forward observation - # path is silently a no-op under FSDP. + import os + if os.environ.get("ADAHOP_DEBUG_TF"): + print(f"[DBG FSDP] self_id={id(self)} type={type(self).__name__} " + f"self_cb={self._calibration_callback is not None} " + f"out_type={type(out).__name__ if out is not None else None} " + f"out_id={id(out) if out is not None else None}", flush=True) if out is not None: if isinstance(out, MXFP4CalibrationWrapper): out._calibration_callback = self._calibration_callback diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index ffa3da32..46c91c01 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -250,6 +250,11 @@ def __torch_function__(cls, func, types, args, kwargs={}): # linear op override elif func.__name__ in gemm_ops: + import os + if os.environ.get("ADAHOP_DEBUG_TF"): + _B = args[1] if func.__name__ != "addmm.default" else args[2] + print(f"[DBG TF] func={func.__name__} B_id={id(_B)} B_type={type(_B).__name__} " + f"cb={getattr(_B, '_calibration_callback', 'NO_ATTR')!r}", flush=True) trans_b = func.__name__ == "linear" if func.__name__ == "addmm.default": bias, A, B = args[0], args[1], args[2] diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index c8ebf54b..ed7f21f4 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -131,8 +131,12 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: from alto._adahop_bridge import detect_outlier_pattern + import os for fqn, wrapper in self._fqn_to_wrapper.items(): wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) + if os.environ.get("ADAHOP_DEBUG_TF"): + print(f"[DBG ATTACH] fqn={fqn} wrapper_id={id(wrapper)} type={type(wrapper).__name__} " + f"cb_set={wrapper._calibration_callback is not None}", flush=True) for fqn, module in self._fqn_to_module.items(): handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) From 0df3c9607d7b6eb9961f83a2b434673e4e602cb2 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:43:59 +0200 Subject: [PATCH 033/142] debug fsdp state --- alto/modifiers/lpt/adahop.py | 57 +++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index ed7f21f4..5724391b 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -230,9 +230,47 @@ def _log_mode_table( # ------------------------------------------------------------------ helpers def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: + # CRITICAL: attach to the canonical instance F.linear will see. + # + # No-FSDP path: `module.weight` IS the subclass (Parameter of a + # tensor subclass IS the subclass). Use that directly. + # `module.weight.data` triggers a detach in __torch_dispatch__ and + # returns a FRESH wrapper (different Python object); callbacks on + # that transient are invisible to F.linear. + # + # FSDP path: fully_shard swaps module.weight for a sharded DTensor + # whose _local_tensor is a FSDP-owned wrapper, distinct from any + # wrapper reachable through module.weight at discovery time. The + # canonical instance lives at + # fsdp_state._fsdp_param_group.fsdp_params[i]._sharded_local_tensor + # (a property returning `cast(DTensor, sharded_param)._local_tensor`). + # That's what FSDP later passes as `self` into fsdp_post_all_gather + # and where state must be stamped for FSDP's all-gather rewrap to + # propagate it onto the unsharded param used in forward. from torch.distributed.tensor import DTensor + try: + from torch.distributed.fsdp._fully_shard._fsdp_state import _get_module_fsdp_state + except Exception: + _get_module_fsdp_state = lambda _m: None # noqa: E731 + self._fqn_to_wrapper.clear() self._fqn_to_module.clear() + + # Pre-build a map from nn.Module → FSDPParam by walking every FSDP + # state and its param group. Each FSDPParam knows its origin module + # via _module_info.module and parameter name via _module_info.param_name. + module_param_to_fsdp_param = {} + for part in model_parts: + for _m in part.modules(): + state = _get_module_fsdp_state(_m) + if state is None or state._fsdp_param_group is None: + continue + for fp in state._fsdp_param_group.fsdp_params: + mi = getattr(fp, "_module_info", None) + if mi is None: + continue + module_param_to_fsdp_param[(id(mi.module), mi.param_name)] = fp + for part in model_parts: for module_fqn, module in part.named_modules(): if not isinstance(module, nn.Linear): @@ -240,15 +278,11 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: weight = getattr(module, "weight", None) if weight is None: continue - # CRITICAL: attach to the canonical instance F.linear will see. - # `weight.data` triggers a detach in __torch_dispatch__ and - # returns a FRESH wrapper (different Python object); the - # callback set on that transient is invisible to F.linear, - # which passes `weight` itself. Under no-FSDP `weight` IS the - # subclass (Parameter subclasses the underlying tensor type). - # Under FSDP weight.data is a DTensor; its _local_tensor is - # the canonical wrapper. - if isinstance(weight, cal_wrapper_cls): + + fp = module_param_to_fsdp_param.get((id(module), "weight")) + if fp is not None: + inner = fp._sharded_local_tensor + elif isinstance(weight, cal_wrapper_cls): inner = weight else: raw = weight.data @@ -256,8 +290,9 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: inner = raw._local_tensor else: inner = raw - if not isinstance(inner, cal_wrapper_cls): - continue + + if not isinstance(inner, cal_wrapper_cls): + continue self._fqn_to_wrapper[module_fqn] = inner self._fqn_to_module[module_fqn] = module From 66e4948acd2a08489b637e7a2522f1c1df5ca695 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 14:51:00 +0200 Subject: [PATCH 034/142] fix: attach calibration callback via fsdpparam._shared_local_tensor --- alto/kernels/dispatch/adahop_tensor.py | 6 ------ alto/kernels/dispatch/tensor.py | 5 ----- alto/modifiers/lpt/adahop.py | 4 ---- 3 files changed, 15 deletions(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 3105e1db..87298cbf 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -106,12 +106,6 @@ def fsdp_post_all_gather( *, out: Optional[torch.Tensor] = None, ): - import os - if os.environ.get("ADAHOP_DEBUG_TF"): - print(f"[DBG FSDP] self_id={id(self)} type={type(self).__name__} " - f"self_cb={self._calibration_callback is not None} " - f"out_type={type(out).__name__ if out is not None else None} " - f"out_id={id(out) if out is not None else None}", flush=True) if out is not None: if isinstance(out, MXFP4CalibrationWrapper): out._calibration_callback = self._calibration_callback diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index 46c91c01..ffa3da32 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -250,11 +250,6 @@ def __torch_function__(cls, func, types, args, kwargs={}): # linear op override elif func.__name__ in gemm_ops: - import os - if os.environ.get("ADAHOP_DEBUG_TF"): - _B = args[1] if func.__name__ != "addmm.default" else args[2] - print(f"[DBG TF] func={func.__name__} B_id={id(_B)} B_type={type(_B).__name__} " - f"cb={getattr(_B, '_calibration_callback', 'NO_ATTR')!r}", flush=True) trans_b = func.__name__ == "linear" if func.__name__ == "addmm.default": bias, A, B = args[0], args[1], args[2] diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 5724391b..5853cd44 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -131,12 +131,8 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: from alto._adahop_bridge import detect_outlier_pattern - import os for fqn, wrapper in self._fqn_to_wrapper.items(): wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) - if os.environ.get("ADAHOP_DEBUG_TF"): - print(f"[DBG ATTACH] fqn={fqn} wrapper_id={id(wrapper)} type={type(wrapper).__name__} " - f"cb_set={wrapper._calibration_callback is not None}", flush=True) for fqn, module in self._fqn_to_module.items(): handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) From 97336427dc15f0ed3f3030d302e93934400402d3 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 15:15:45 +0200 Subject: [PATCH 035/142] fix cdna3 --- .../adahop_internals/mxfp4_linear_function.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index 3f0c456b..62bba46e 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -35,11 +35,51 @@ import torch +from alto.kernels.fp4.mxfp4.mxfp_quantization import is_cdna4 from .transform_mode import TransformMode, assert_mode_supported HadamardTransformType = "HadamardTransform" # type-hint placeholder; avoid hard import +def _blockwise_mxfp4_gemm_or_dequant( + a_mxfp4: torch.Tensor, + a_scale: torch.Tensor, + b_mxfp4: torch.Tensor, + b_scale: torch.Tensor, + *, + output_dtype: torch.dtype, + trans_a: bool = False, + trans_b: bool = False, +) -> torch.Tensor: + """Routes to the Triton MXFP4 GEMM on cdna4 (gfx950) and falls back to + dequantize-then-bf16-matmul on cdna3 (gfx942). Mirrors ALTO's own gating + in ``alto/kernels/fp4/mxfp4/mxfp_linear.py`` (lines 321-353). The cdna3 + fall-back keeps numerical equivalence at the MXFP4 quantization grain + while bypassing ``tt.dot_scaled``, which doesn't lower on gfx942. + """ + if is_cdna4(): + return torch.ops.torchtitan.blockwise_mxfp4_gemm( + a_mxfp4, + a_scale, + b_mxfp4, + b_scale, + trans_a=trans_a, + trans_b=trans_b, + output_dtype=output_dtype, + ) + a_dq = torch.ops.torchtitan.convert_from_mxfp4( + a_mxfp4, a_scale, output_dtype, axis=-1, is_2d_block=False, + ) + b_dq = torch.ops.torchtitan.convert_from_mxfp4( + b_mxfp4, b_scale, output_dtype, axis=-1, is_2d_block=False, + ) + if trans_a: + a_dq = a_dq.T + if trans_b: + b_dq = b_dq.T + return a_dq @ b_dq + + @torch.compiler.allow_in_graph class MXFP4AdaHOPLinearFunction(torch.autograd.Function): """MXFP4 linear with per-slot Hadamard, modes baked into ``apply`` args. @@ -101,7 +141,7 @@ def forward( axis=-1, is_2d_block=False, ) - y = torch.ops.torchtitan.blockwise_mxfp4_gemm( + y = _blockwise_mxfp4_gemm_or_dequant( x_mxfp4_fwd, x_scale_fwd, w_mxfp4_fwd, @@ -234,7 +274,7 @@ def _backward_gx( use_sr=use_sr_grad, is_2d_block=False, ) - grad_inputs = torch.ops.torchtitan.blockwise_mxfp4_gemm( + grad_inputs = _blockwise_mxfp4_gemm_or_dequant( g_mxfp4, g_scale, w_mxfp4, @@ -276,7 +316,7 @@ def _backward_gw( use_sr=use_sr_grad, is_2d_block=False, ) - grad_weights = torch.ops.torchtitan.blockwise_mxfp4_gemm( + grad_weights = _blockwise_mxfp4_gemm_or_dequant( g_mxfp4_m, g_scale_m, x_mxfp4, From f22e06ca2f413ea523d52be53ad016ebd60fee0f Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 15:39:31 +0200 Subject: [PATCH 036/142] lpt_adahop recipe --- alto/models/llama3/config_registry.py | 9 +++++++++ tests/integration/llama3_1b_adahop.sh | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tests/integration/llama3_1b_adahop.sh diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index 3dde67be..473d7c83 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -24,6 +24,7 @@ "llama3_1b", "llama3_1b_opt", "llama3_1b_lpt", + "llama3_1b_adahop", "llama3_8b", "llama3_8b_pretrain", "llama3_8b_opt", @@ -143,6 +144,14 @@ def llama3_1b_lpt() -> Trainer.Config: return config +def llama3_1b_adahop() -> Trainer.Config: + config = llama3_1b() + config.training.steps = 1000 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_adahop_recipe.yaml",)],) + return config + + def llama3_8b_pretrain() -> Trainer.Config: config = llama3_8b_orig() config.hf_assets_path = "/huggingface/hub/models--unsloth--Llama-3.1-8B/snapshots/3f0d51f8e5640f98f1a96ea9044a0e55c0a83814" diff --git a/tests/integration/llama3_1b_adahop.sh b/tests/integration/llama3_1b_adahop.sh new file mode 100644 index 00000000..e972d8b4 --- /dev/null +++ b/tests/integration/llama3_1b_adahop.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +# +# Phase-3 real-model run: Llama-3.2-1B under MXFP4 + AdaHOP two-phase swap. +# 1000 training steps (calibration window controlled by the recipe). +# Mirrors the llama3_1b_lpt smoke but with the AdaHOP recipe — same model, +# same data, same target subset (Linear modules, output excluded). + +SCRIPT_DIR=$(dirname "$0") +cd $SCRIPT_DIR/../.. + +NGPU=${NGPU:-8} \ +MODULE=llama3 \ +CONFIG=llama3_1b_adahop \ +./examples/run.sh "$@" From c010697f180f896a7eaccf2f60b60c8868bc655a Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 17:15:53 +0200 Subject: [PATCH 037/142] port adahop strategies (inner_outlier_extract_[right/left]) --- alto/_adahop_bridge.py | 66 ++- .../configs/lpt_adahop_debug_recipe.yaml | 12 +- .../llama3/configs/lpt_adahop_recipe.yaml | 14 +- .../adahop_internals/mxfp4_linear_function.py | 493 ++++++++++++------ .../lpt/adahop_internals/transform_mode.py | 9 +- 5 files changed, 435 insertions(+), 159 deletions(-) diff --git a/alto/_adahop_bridge.py b/alto/_adahop_bridge.py index d63f72f3..dbae5b8c 100644 --- a/alto/_adahop_bridge.py +++ b/alto/_adahop_bridge.py @@ -11,11 +11,13 @@ import importlib.util import sys from pathlib import Path +from typing import Any _ALTO_ROOT = Path(__file__).resolve().parent.parent _ADAHOP_ROOT = _ALTO_ROOT / "3rdparty" / "adahop" _HT_DIR = _ADAHOP_ROOT / "torchtitan" / "experiments" / "kernels" / "hadamard_transform" -_TC_PATH = _ADAHOP_ROOT / "torchtitan" / "experiments" / "kernels" / "mxfp4" / "transform_config.py" +_MXFP4_DIR = _ADAHOP_ROOT / "torchtitan" / "experiments" / "kernels" / "mxfp4" +_TC_PATH = _MXFP4_DIR / "transform_config.py" def _load_module(name: str, path: Path): @@ -50,6 +52,55 @@ def _load_package(name: str, pkg_dir: Path): _ht = _load_package("_alto_adahop_ht", _HT_DIR) _tc = _load_module("_alto_adahop_transform_config", _TC_PATH) + +def _load_mxfp4_package() -> Any: + """Load AdaHOP's ``mxfp4/`` package without triggering the colliding + ``torch.ops.torchtitan.*`` registrations from ``mxfp_linear.py`` and + ``mxfp_quantization.py``. + + The package's ``__init__.py`` does ``from .mxfp_linear import MXFP4Linear`` + and ``from .mxfp_quantization import convert_to_mxfp4, convert_from_mxfp4, + BLOCK_SIZE_DEFAULT`` at import time. Both files register ``@triton_op``s + in the ``torchtitan::`` namespace that ALTO already owns (collision would + raise ``RuntimeError: Tried to register operator ... twice``). We install + stubs at the expected ``sys.modules`` names BEFORE exec'ing the package + so those import lines succeed without touching the real source. + + What we actually need from the package: ``iht_quantization``, + ``foid.OUTLIER_K``, ``foid.prepare_outlier_clean_row/column``, + ``outlier_extract.inner_outlier_extract_left_cdna4`` and + ``..._right_cdna4``. None of these go through the stubbed modules at + call time — ``outlier_extract`` calls ``torch.ops.torchtitan.`` + ``blockwise_mxfp4_gemm`` (ALTO's registration, cdna-gated) directly, + and ``iht_quantization`` self-gates cdna3/cdna4 via ``is_cdna4()`` from + ``fp4_common``. + """ + pkg_name = "_alto_adahop_mxfp4" + import types + + # Stub mxfp_linear (only exports MXFP4Linear, referenced by __init__). + mxfp_linear_stub = types.ModuleType(f"{pkg_name}.mxfp_linear") + mxfp_linear_stub.MXFP4Linear = object # placeholder, never instantiated + sys.modules[f"{pkg_name}.mxfp_linear"] = mxfp_linear_stub + + # Stub mxfp_quantization with the three symbols __init__ imports. + mxfp_quant_stub = types.ModuleType(f"{pkg_name}.mxfp_quantization") + mxfp_quant_stub.BLOCK_SIZE_DEFAULT = 32 # matches AdaHOP's real value + mxfp_quant_stub.convert_to_mxfp4 = None # never called via this stub + mxfp_quant_stub.convert_from_mxfp4 = None + sys.modules[f"{pkg_name}.mxfp_quantization"] = mxfp_quant_stub + + # Stub mxfp_grouped_gemm (only referenced by the lazy mxfp4_grouped_gemm + # wrapper, never imported eagerly). + mxfp_grouped_stub = types.ModuleType(f"{pkg_name}.mxfp_grouped_gemm") + mxfp_grouped_stub.mxfp4_grouped_gemm = None + sys.modules[f"{pkg_name}.mxfp_grouped_gemm"] = mxfp_grouped_stub + + return _load_package(pkg_name, _MXFP4_DIR) + + +_mxfp4 = _load_mxfp4_package() + HadamardFactory = _ht.HadamardFactory HadamardTransform = _ht.HadamardTransform detect_outlier_pattern = _ht.detect_outlier_pattern @@ -61,6 +112,13 @@ def _load_package(name: str, pkg_dir: Path): should_apply_transform = _tc.should_apply_transform clear_all_configs = _tc.clear_all_configs +iht_quantization = _mxfp4.iht_quantization +OUTLIER_K = _mxfp4.foid.OUTLIER_K +prepare_outlier_clean_row = _mxfp4.foid.prepare_outlier_clean_row +prepare_outlier_clean_column = _mxfp4.foid.prepare_outlier_clean_column +inner_outlier_extract_left_cdna4 = _mxfp4.outlier_extract.inner_outlier_extract_left_cdna4 +inner_outlier_extract_right_cdna4 = _mxfp4.outlier_extract.inner_outlier_extract_right_cdna4 + __all__ = [ "HadamardFactory", "HadamardTransform", @@ -71,4 +129,10 @@ def _load_package(name: str, pkg_dir: Path): "get_layer_transform_config", "should_apply_transform", "clear_all_configs", + "iht_quantization", + "OUTLIER_K", + "prepare_outlier_clean_row", + "prepare_outlier_clean_column", + "inner_outlier_extract_left_cdna4", + "inner_outlier_extract_right_cdna4", ] diff --git a/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml b/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml index 79a95679..96df8392 100644 --- a/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml +++ b/alto/models/llama3/configs/lpt_adahop_debug_recipe.yaml @@ -11,11 +11,11 @@ training_stage: calibration_steps: 3 layer_transform_config: "row-row": "hadamard" - "col-col": "full_precision" - "none-none": "hadamard" - "row-col": "hadamard" + "row-none": "inner_outlier_extract_left" + "row-col": "inner_outlier_extract_right" "col-row": "hadamard" - "row-none": "hadamard" - "none-row": "hadamard" "col-none": "hadamard" - "none-col": "hadamard" + "col-col": "full_precision" + "none-row": "hadamard" + "none-none": "hadamard" + "none-col": "inner_outlier_extract_right" diff --git a/alto/models/llama3/configs/lpt_adahop_recipe.yaml b/alto/models/llama3/configs/lpt_adahop_recipe.yaml index 1585dc44..76eaf172 100644 --- a/alto/models/llama3/configs/lpt_adahop_recipe.yaml +++ b/alto/models/llama3/configs/lpt_adahop_recipe.yaml @@ -9,13 +9,15 @@ training_stage: use_hadamard: true use_randomized_hadamard: false calibration_steps: 30 + # Mirrors AdaHOP's reference config + # (3rdparty/adahop/torchtitan/models/llama3/train_configs/llama3_3b_mxfp4_adahop_lv2.toml). layer_transform_config: "row-row": "hadamard" - "col-col": "full_precision" - "none-none": "hadamard" - "row-col": "hadamard" + "row-none": "inner_outlier_extract_left" + "row-col": "inner_outlier_extract_right" "col-row": "hadamard" - "row-none": "hadamard" - "none-row": "hadamard" "col-none": "hadamard" - "none-col": "hadamard" + "col-col": "full_precision" + "none-row": "hadamard" + "none-none": "hadamard" + "none-col": "inner_outlier_extract_right" diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index 62bba46e..060b90b8 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -10,28 +10,29 @@ Reference (AdaHOP, BSD-3): 3rdparty/adahop/torchtitan/experiments/kernels/mxfp4/mxfp_linear.py:206-635 -Key differences from AdaHOP's version: - * ``layer_name`` argument removed; ``forward_y_mode``, ``backward_gx_mode``, - ``backward_gw_mode`` are explicit ``apply()`` arguments and ride on - ``ctx`` for backward. - * Calibration-mode globals (``is_calibration_mode``, ``store_detected_pattern``) - removed; calibration is done by the wrapper's ``__torch_function__`` - via a callback installed by the modifier. - * Tensor-capture mode removed (debug-only feature, not needed for MVP). - * ``iht_quantization`` (AdaHOP's fused Hadamard+MXFP4 Triton kernel) is - not ported; ``hadamard`` mode falls back to the equivalent two-step - ``hadamard_transform`` → ``convert_to_mxfp4``. Slower than AdaHOP but - numerically equivalent for round-to-nearest. SR composability documented - inline. - * Outlier-extract modes (``inner_outlier_extract_*``, ``outer_outlier_extract_*``) - are guarded by ``assert_mode_supported`` and raise ``NotImplementedError`` - at apply time. Add them when calibration starts producing those modes. - * Calls ALTO's already-registered ``torch.ops.torchtitan.{convert_to_mxfp4, - blockwise_mxfp4_gemm}`` — no new op registrations, no collision with - ALTO's own ``alto/kernels/fp4/mxfp4/mxfp_linear.py``. +Supported modes per slot: + * ``none`` + * ``hadamard`` / ``outer_hadamard`` + * ``inner_outlier_extract_left`` / ``inner_outlier_extract_right`` + * ``full_precision`` + +Notes vs AdaHOP's version: + * ``layer_name`` removed; modes are explicit ``apply()`` args. + * Calibration/tensor-capture globals removed; calibration runs via the + wrapper's ``__torch_function__`` callback installed by the modifier. + * ``hadamard`` mode uses AdaHOP's fused ``iht_quantization`` kernel + (loaded via the bridge); when SR is requested it composes through. + * Outlier-extract paths use ``inner_outlier_extract_{left,right}_cdna4`` + from AdaHOP's submodule — those kernels self-gate cdna3 (gfx942) via + PyTorch fallbacks (see outlier_extract.py:165, :323). + * Forward ``inner_outlier_extract_left`` saves the BF16 weight to + ``ctx`` for backward (the kernel takes B as BF16 internally). + * Backward ``inner_outlier_extract_left`` for ``backward_gw`` falls + back to plain ``hadamard`` semantics (matches AdaHOP's note at + mxfp_linear.py:560-575). """ -from typing import Optional +from typing import Optional, Tuple import torch @@ -51,12 +52,8 @@ def _blockwise_mxfp4_gemm_or_dequant( trans_a: bool = False, trans_b: bool = False, ) -> torch.Tensor: - """Routes to the Triton MXFP4 GEMM on cdna4 (gfx950) and falls back to - dequantize-then-bf16-matmul on cdna3 (gfx942). Mirrors ALTO's own gating - in ``alto/kernels/fp4/mxfp4/mxfp_linear.py`` (lines 321-353). The cdna3 - fall-back keeps numerical equivalence at the MXFP4 quantization grain - while bypassing ``tt.dot_scaled``, which doesn't lower on gfx942. - """ + """Triton MXFP4 GEMM on cdna4, dequant-then-bf16-matmul fallback on cdna3. + Mirrors ``alto/kernels/fp4/mxfp4/mxfp_linear.py:321-353``.""" if is_cdna4(): return torch.ops.torchtitan.blockwise_mxfp4_gemm( a_mxfp4, @@ -82,12 +79,12 @@ def _blockwise_mxfp4_gemm_or_dequant( @torch.compiler.allow_in_graph class MXFP4AdaHOPLinearFunction(torch.autograd.Function): - """MXFP4 linear with per-slot Hadamard, modes baked into ``apply`` args. + """MXFP4 linear with per-slot Hadamard / outlier-extract, modes baked in. - The 3 slots and their AdaHOP names: - * ``forward_y`` — applied to the forward ``y = x @ wᵀ`` computation - * ``backward_gx`` — applied to ``grad_inputs = grad_output @ w`` - * ``backward_gw`` — applied to ``grad_weights = grad_outputᵀ @ x`` + Slots: + * ``forward_y`` — ``y = x @ wᵀ`` + * ``backward_gx`` — ``grad_inputs = grad_output @ w`` + * ``backward_gw`` — ``grad_weights = grad_outputᵀ @ x`` """ @staticmethod @@ -109,64 +106,33 @@ def forward( original_dtype = x.dtype x = x.reshape(-1, original_shape[-1]) - # ---- forward: y = x @ wᵀ under forward_y_mode ----------------------- - if forward_y_mode == "full_precision": - y = x @ weight.T - # Still need to prepare x/w for backward branches below. - # full_precision in forward does NOT short-circuit backward prep. - x_mxfp4_fwd, x_scale_fwd, w_mxfp4_fwd, w_scale_fwd = None, None, None, None - else: - if forward_y_mode == "hadamard": - assert hadamard_transform is not None, ("forward_y_mode=hadamard requires a hadamard_transform") - # Unfused fallback for AdaHOP's iht_quantization(x, left_mul=False): - # right-Hadamard then axis=-1 quantize. - x_for_y = hadamard_transform(x, left_mul=False) - w_for_y = hadamard_transform(weight, left_mul=False) - elif forward_y_mode == "outer_hadamard": - assert hadamard_transform is not None, ("forward_y_mode=outer_hadamard requires a hadamard_transform") - # left-multiply pre-transform; the outer-HT is applied after the GEMM. - x_for_y = hadamard_transform(x, left_mul=True) - w_for_y = hadamard_transform(weight, left_mul=True) - else: # "none" - x_for_y = x - w_for_y = weight - - x_mxfp4_fwd, x_scale_fwd = torch.ops.torchtitan.convert_to_mxfp4( - x_for_y, - axis=-1, - is_2d_block=False, - ) - w_mxfp4_fwd, w_scale_fwd = torch.ops.torchtitan.convert_to_mxfp4( - w_for_y, - axis=-1, - is_2d_block=False, - ) - y = _blockwise_mxfp4_gemm_or_dequant( - x_mxfp4_fwd, - x_scale_fwd, - w_mxfp4_fwd, - w_scale_fwd, - trans_b=True, - output_dtype=original_dtype, - ) - if forward_y_mode == "outer_hadamard": - y = hadamard_transform(hadamard_transform(y, left_mul=True)) - - # ---- prepare w for backward_gx (grad_output @ w) -------------------- - w_mxfp4_bx, w_scale_bx = _prepare_operand_for_backward( - tensor=weight, - mode=backward_gx_mode, + # ---- forward: y = x @ wᵀ ------------------------------------------ + y = _forward_y( + x=x, + weight=weight, + mode=forward_y_mode, hadamard_transform=hadamard_transform, + original_dtype=original_dtype, ) - # ---- prepare x for backward_gw (grad_outputᵀ @ x) ------------------- - x_mxfp4_bw, x_scale_bw = _prepare_operand_for_backward( - tensor=x, + # ---- prep operands for the two backward GEMMs --------------------- + w_bx, w_scale_bx, w_outlier_compact_bx, w_outlier_indices_bx = _prep_w_for_gx( + weight=weight, + mode=backward_gx_mode, + hadamard_transform=hadamard_transform, + ) + x_bw, x_scale_bw, x_outlier_compact_bw, x_outlier_indices_bw = _prep_x_for_gw( + x=x, mode=backward_gw_mode, hadamard_transform=hadamard_transform, + use_sr_grad=use_sr_grad, ) - ctx.save_for_backward(x_mxfp4_bw, x_scale_bw, w_mxfp4_bx, w_scale_bx) + ctx.save_for_backward( + x_bw, x_scale_bw, w_bx, w_scale_bx, + x_outlier_compact_bw, x_outlier_indices_bw, + w_outlier_compact_bx, w_outlier_indices_bx, + ) ctx.original_dtype = original_dtype ctx.use_sr_grad = use_sr_grad ctx.hadamard_transform = hadamard_transform @@ -181,12 +147,16 @@ def backward(ctx, grad_output): original_shape = grad_output.shape grad_output = grad_output.reshape(-1, original_shape[-1]) - x_mxfp4_bw, x_scale_bw, w_mxfp4_bx, w_scale_bx = ctx.saved_tensors + (x_bw, x_scale_bw, w_bx, w_scale_bx, + x_outlier_compact_bw, x_outlier_indices_bw, + w_outlier_compact_bx, w_outlier_indices_bx) = ctx.saved_tensors grad_inputs = _backward_gx( grad_output=grad_output, - w_mxfp4=w_mxfp4_bx, + w=w_bx, w_scale=w_scale_bx, + w_outlier_compact=w_outlier_compact_bx, + w_outlier_indices=w_outlier_indices_bx, mode=ctx.backward_gx_mode, hadamard_transform=ctx.hadamard_transform, use_sr_grad=ctx.use_sr_grad, @@ -195,60 +165,230 @@ def backward(ctx, grad_output): grad_weights = _backward_gw( grad_output=grad_output, - x_mxfp4=x_mxfp4_bw, + x=x_bw, x_scale=x_scale_bw, + x_outlier_compact=x_outlier_compact_bw, + x_outlier_indices=x_outlier_indices_bw, mode=ctx.backward_gw_mode, hadamard_transform=ctx.hadamard_transform, use_sr_grad=ctx.use_sr_grad, original_dtype=ctx.original_dtype, ) - # forward signature has 7 inputs (x, weight, use_sr_grad, hadamard_transform, - # forward_y_mode, backward_gx_mode, backward_gw_mode); return 7 grads with - # only the first two non-None. + # forward has 7 inputs; only the first two get grads. return ( grad_inputs.view(*original_shape[:-1], -1), grad_weights, - None, - None, - None, - None, - None, + None, None, None, None, None, ) -def _prepare_operand_for_backward( +# --------------------------------------------------------------------------- +# Forward y +# --------------------------------------------------------------------------- + +def _forward_y( *, - tensor: torch.Tensor, + x: torch.Tensor, + weight: torch.Tensor, mode: TransformMode, hadamard_transform, -): - """Pre-rotate and quantize ``tensor`` for the backward GEMM. - - Returns ``(packed_or_dense, scale_or_None)``. For ``full_precision``, - returns the original tensor and ``None`` (the consumer detects ``None`` - scale to mean "no quant"). - """ + original_dtype: torch.dtype, +) -> torch.Tensor: + """Compute ``y = x @ wᵀ`` under ``forward_y_mode``.""" if mode == "full_precision": - return tensor, None + return x @ weight.T + + if mode == "inner_outlier_extract_left": + # Extract row outliers from x, quantize the clean part, run the + # AdaHOP outlier-aware GEMM. Weight stays BF16; the kernel applies + # its own HT+quant to it internally. + from alto._adahop_bridge import ( + iht_quantization, + inner_outlier_extract_left_cdna4, + prepare_outlier_clean_row, + OUTLIER_K, + ) + assert hadamard_transform is not None + x_clean, x_outlier_compact, x_outlier_indices = prepare_outlier_clean_row(x, k=OUTLIER_K) + x_clean_mxfp4, x_clean_scale = iht_quantization(x_clean, left_mul=False) + return inner_outlier_extract_left_cdna4( + x_clean_mxfp4, + x_clean_scale, + x_outlier_compact, + x_outlier_indices, + weight, + hadamard_transform, + trans_a=False, + trans_b=True, + k=OUTLIER_K, + original_dtype=original_dtype, + ) + + if mode == "inner_outlier_extract_right": + # Extract row outliers from weight (== col outliers in wᵀ), + # quantize the clean weight, run the AdaHOP outlier-aware GEMM. + # x stays BF16; the kernel handles it. + from alto._adahop_bridge import ( + iht_quantization, + inner_outlier_extract_right_cdna4, + prepare_outlier_clean_row, + OUTLIER_K, + ) + assert hadamard_transform is not None + w_clean, w_outlier_compact, w_outlier_indices = prepare_outlier_clean_row(weight, k=OUTLIER_K) + w_clean_mxfp4, w_clean_scale = iht_quantization(w_clean, left_mul=False) + return inner_outlier_extract_right_cdna4( + x, + w_clean_mxfp4, + w_clean_scale, + w_outlier_compact, + w_outlier_indices, + hadamard_transform, + trans_a=False, + trans_b=True, + k=OUTLIER_K, + original_dtype=original_dtype, + ) + + # hadamard / outer_hadamard / none — two-step convert+GEMM. if mode == "hadamard": - assert hadamard_transform is not None, "hadamard mode requires a hadamard_transform" - prepared = hadamard_transform(tensor, left_mul=True) + assert hadamard_transform is not None + x_for_y = hadamard_transform(x, left_mul=False) + w_for_y = hadamard_transform(weight, left_mul=False) elif mode == "outer_hadamard": - assert hadamard_transform is not None, "outer_hadamard requires a hadamard_transform" - prepared = hadamard_transform(tensor) + assert hadamard_transform is not None + x_for_y = hadamard_transform(x, left_mul=True) + w_for_y = hadamard_transform(weight, left_mul=True) else: # "none" - prepared = tensor + x_for_y = x + w_for_y = weight + + x_mxfp4, x_scale = torch.ops.torchtitan.convert_to_mxfp4( + x_for_y, axis=-1, is_2d_block=False, + ) + w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4( + w_for_y, axis=-1, is_2d_block=False, + ) + y = _blockwise_mxfp4_gemm_or_dequant( + x_mxfp4, x_scale, w_mxfp4, w_scale, + trans_b=True, output_dtype=original_dtype, + ) + if mode == "outer_hadamard": + y = hadamard_transform(hadamard_transform(y, left_mul=True)) + return y + + +# --------------------------------------------------------------------------- +# Backward operand preparation (runs in forward, saves into ctx) +# --------------------------------------------------------------------------- + +def _prep_w_for_gx( + *, + weight: torch.Tensor, + mode: TransformMode, + hadamard_transform, +) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]: + """Prepare weight for ``backward_gx`` (``grad_output @ w``). + + Returns ``(w_data, w_scale, w_outlier_compact, w_outlier_indices)``. + Last three are ``None`` unless mode needs them. + """ + if mode == "full_precision": + return weight, None, None, None + + if mode == "inner_outlier_extract_left": + # Outliers will be extracted from grad_output at backward time; + # weight is saved as BF16 (kernel handles HT+quant internally). + return weight, None, None, None + + if mode == "inner_outlier_extract_right": + # Extract column outliers from weight now; quantize the clean part. + from alto._adahop_bridge import ( + iht_quantization, + prepare_outlier_clean_column, + OUTLIER_K, + ) + assert hadamard_transform is not None + w_clean, w_outlier_compact, w_outlier_indices = prepare_outlier_clean_column(weight, k=OUTLIER_K) + w_mxfp4, w_scale = iht_quantization(w_clean, left_mul=True) + return w_mxfp4, w_scale, w_outlier_compact, w_outlier_indices + + # hadamard / outer_hadamard / none — single-tensor convert path. + if mode == "hadamard": + from alto._adahop_bridge import iht_quantization + assert hadamard_transform is not None + w_mxfp4, w_scale = iht_quantization(weight, left_mul=True) + return w_mxfp4, w_scale, None, None + if mode == "outer_hadamard": + assert hadamard_transform is not None + prepared = hadamard_transform(weight) + w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4(prepared, axis=0, is_2d_block=False) + return w_mxfp4, w_scale, None, None + # "none" + w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4(weight, axis=0, is_2d_block=False) + return w_mxfp4, w_scale, None, None + + +def _prep_x_for_gw( + *, + x: torch.Tensor, + mode: TransformMode, + hadamard_transform, + use_sr_grad: bool, +) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]: + """Prepare x for ``backward_gw`` (``grad_outputᵀ @ x``). + + Returns ``(x_data, x_scale, x_outlier_compact, x_outlier_indices)``. + Last three are ``None`` unless mode needs them. + + Note: ``inner_outlier_extract_left`` for ``backward_gw`` is identical + to plain ``hadamard`` (per AdaHOP mxfp_linear.py:560-575) — left OE + can't compose with backward_gw because x needs to be saved as MXFP4 + for memory, conflicting with the BF16 requirement of the left kernel. + """ + if mode == "full_precision": + return x, None, None, None + + if mode in ("hadamard", "inner_outlier_extract_left"): + from alto._adahop_bridge import iht_quantization + assert hadamard_transform is not None + x_mxfp4, x_scale = iht_quantization(x, left_mul=True) + return x_mxfp4, x_scale, None, None + + if mode == "inner_outlier_extract_right": + from alto._adahop_bridge import ( + iht_quantization, + prepare_outlier_clean_column, + OUTLIER_K, + ) + assert hadamard_transform is not None + x_clean, x_outlier_compact, x_outlier_indices = prepare_outlier_clean_column(x, k=OUTLIER_K) + x_mxfp4, x_scale = iht_quantization(x_clean, left_mul=True) + return x_mxfp4, x_scale, x_outlier_compact, x_outlier_indices + + if mode == "outer_hadamard": + assert hadamard_transform is not None + prepared = hadamard_transform(x) + x_mxfp4, x_scale = torch.ops.torchtitan.convert_to_mxfp4(prepared, axis=0, is_2d_block=False) + return x_mxfp4, x_scale, None, None + + # "none" + x_mxfp4, x_scale = torch.ops.torchtitan.convert_to_mxfp4(x, axis=0, is_2d_block=False) + return x_mxfp4, x_scale, None, None - t_mxfp4, t_scale = torch.ops.torchtitan.convert_to_mxfp4(prepared, axis=0, is_2d_block=False) - return t_mxfp4, t_scale +# --------------------------------------------------------------------------- +# Backward compute +# --------------------------------------------------------------------------- def _backward_gx( *, grad_output: torch.Tensor, - w_mxfp4: torch.Tensor, + w: torch.Tensor, w_scale: Optional[torch.Tensor], + w_outlier_compact: Optional[torch.Tensor], + w_outlier_indices: Optional[torch.Tensor], mode: TransformMode, hadamard_transform, use_sr_grad: bool, @@ -256,29 +396,73 @@ def _backward_gx( ) -> torch.Tensor: """``grad_inputs = grad_output @ w`` under ``backward_gx_mode``.""" if mode == "full_precision": - # w_mxfp4 is actually the unquantized weight here. - return grad_output @ w_mxfp4 + # w is the unquantized weight here. + return grad_output @ w + + if mode == "inner_outlier_extract_left": + # Extract row outliers from grad_output; w is BF16 in `w`. + from alto._adahop_bridge import ( + iht_quantization, + inner_outlier_extract_left_cdna4, + prepare_outlier_clean_row, + OUTLIER_K, + ) + assert hadamard_transform is not None + g_clean, g_outlier_compact, g_outlier_indices = prepare_outlier_clean_row(grad_output, k=OUTLIER_K) + g_clean_mxfp4, g_clean_scale = iht_quantization( + g_clean, left_mul=False, use_sr=use_sr_grad, + ) + return inner_outlier_extract_left_cdna4( + g_clean_mxfp4, + g_clean_scale, + g_outlier_compact, + g_outlier_indices, + w, # BF16 weight + hadamard_transform, + trans_a=False, + trans_b=False, + k=OUTLIER_K, + original_dtype=original_dtype, + ) + + if mode == "inner_outlier_extract_right": + # Pre-extracted column outliers from weight (saved at forward). + from alto._adahop_bridge import ( + inner_outlier_extract_right_cdna4, + OUTLIER_K, + ) + assert hadamard_transform is not None + return inner_outlier_extract_right_cdna4( + grad_output, + w, + w_scale, + w_outlier_compact, + w_outlier_indices, + hadamard_transform, + trans_a=False, + trans_b=False, + k=OUTLIER_K, + original_dtype=original_dtype, + ) + # hadamard / outer_hadamard / none if mode == "hadamard": + from alto._adahop_bridge import iht_quantization assert hadamard_transform is not None - g_for_gx = hadamard_transform(grad_output, left_mul=False) + g_mxfp4, g_scale = iht_quantization(grad_output, left_mul=False, use_sr=use_sr_grad) elif mode == "outer_hadamard": assert hadamard_transform is not None - g_for_gx = hadamard_transform(grad_output, left_mul=True) + g_transformed = hadamard_transform(grad_output, left_mul=True) + g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( + g_transformed, axis=-1, use_sr=use_sr_grad, is_2d_block=False, + ) else: # "none" - g_for_gx = grad_output + g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( + grad_output, axis=-1, use_sr=use_sr_grad, is_2d_block=False, + ) - g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( - g_for_gx, - axis=-1, - use_sr=use_sr_grad, - is_2d_block=False, - ) grad_inputs = _blockwise_mxfp4_gemm_or_dequant( - g_mxfp4, - g_scale, - w_mxfp4, - w_scale, + g_mxfp4, g_scale, w, w_scale, output_dtype=original_dtype, ) if mode == "outer_hadamard": @@ -289,8 +473,10 @@ def _backward_gx( def _backward_gw( *, grad_output: torch.Tensor, - x_mxfp4: torch.Tensor, + x: torch.Tensor, x_scale: Optional[torch.Tensor], + x_outlier_compact: Optional[torch.Tensor], + x_outlier_indices: Optional[torch.Tensor], mode: TransformMode, hadamard_transform, use_sr_grad: bool, @@ -298,31 +484,48 @@ def _backward_gw( ) -> torch.Tensor: """``grad_weights = grad_outputᵀ @ x`` under ``backward_gw_mode``.""" if mode == "full_precision": - # x_mxfp4 is actually the unquantized x here. - return grad_output.T @ x_mxfp4 + # x is the unquantized input here. + return grad_output.T @ x - if mode == "hadamard": + if mode == "inner_outlier_extract_right": + from alto._adahop_bridge import ( + inner_outlier_extract_right_cdna4, + OUTLIER_K, + ) + assert hadamard_transform is not None + return inner_outlier_extract_right_cdna4( + grad_output, + x, + x_scale, + x_outlier_compact, + x_outlier_indices, + hadamard_transform, + trans_a=True, + trans_b=False, + k=OUTLIER_K, + original_dtype=original_dtype, + ) + + # hadamard / inner_outlier_extract_left (same path per AdaHOP) / + # outer_hadamard / none + if mode in ("hadamard", "inner_outlier_extract_left"): + from alto._adahop_bridge import iht_quantization assert hadamard_transform is not None - g_for_gw = hadamard_transform(grad_output, left_mul=True) + g_mxfp4, g_scale = iht_quantization(grad_output, left_mul=True, use_sr=use_sr_grad) elif mode == "outer_hadamard": assert hadamard_transform is not None - g_for_gw = hadamard_transform(grad_output) + g_transformed = hadamard_transform(grad_output) + g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( + g_transformed, axis=0, use_sr=use_sr_grad, is_2d_block=False, + ) else: # "none" - g_for_gw = grad_output + g_mxfp4, g_scale = torch.ops.torchtitan.convert_to_mxfp4( + grad_output, axis=0, use_sr=use_sr_grad, is_2d_block=False, + ) - g_mxfp4_m, g_scale_m = torch.ops.torchtitan.convert_to_mxfp4( - g_for_gw, - axis=0, - use_sr=use_sr_grad, - is_2d_block=False, - ) grad_weights = _blockwise_mxfp4_gemm_or_dequant( - g_mxfp4_m, - g_scale_m, - x_mxfp4, - x_scale, - trans_a=True, - output_dtype=original_dtype, + g_mxfp4, g_scale, x, x_scale, + trans_a=True, output_dtype=original_dtype, ) if mode == "outer_hadamard": grad_weights = hadamard_transform(hadamard_transform(grad_weights, left_mul=True)) diff --git a/alto/modifiers/lpt/adahop_internals/transform_mode.py b/alto/modifiers/lpt/adahop_internals/transform_mode.py index 8514acc4..8c3da211 100644 --- a/alto/modifiers/lpt/adahop_internals/transform_mode.py +++ b/alto/modifiers/lpt/adahop_internals/transform_mode.py @@ -40,7 +40,14 @@ # Modes implemented in the ALTO-side MXFP4LinearFunction port. # Other VALID_MODES are accepted by the schema but will raise at runtime if # encountered in a calibration JSON until their kernel paths are ported. -SUPPORTED_MODES: frozenset = frozenset(["none", "hadamard", "outer_hadamard", "full_precision"]) +SUPPORTED_MODES: frozenset = frozenset([ + "none", + "hadamard", + "outer_hadamard", + "full_precision", + "inner_outlier_extract_left", + "inner_outlier_extract_right", +]) def assert_mode_supported(mode: str, slot: str) -> None: From 18aa82af77a12d44cb7b507f082838bf8d96b20e Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 17:24:47 +0200 Subject: [PATCH 038/142] fix: iht_quantization.py imports from torchtitan.experiments.kernels.hadamard_transform.hadamard --- alto/_adahop_bridge.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/alto/_adahop_bridge.py b/alto/_adahop_bridge.py index dbae5b8c..11803f05 100644 --- a/alto/_adahop_bridge.py +++ b/alto/_adahop_bridge.py @@ -53,6 +53,34 @@ def _load_package(name: str, pkg_dir: Path): _tc = _load_module("_alto_adahop_transform_config", _TC_PATH) +def _alias_ht_at_absolute_path() -> None: + """AdaHOP's ``iht_quantization`` does an *absolute* import: + ``from torchtitan.experiments.kernels.hadamard_transform.hadamard import _build_H_b32`` + ALTO's vendored ``torchtitan`` package does not contain AdaHOP's + ``hadamard_transform`` subpackage, so the import fails. Alias the + bridge-loaded hadamard package at the absolute path the file expects. + Also stub the intermediate ``torchtitan.experiments`` and + ``torchtitan.experiments.kernels`` packages if they aren't already + populated, so the dotted lookup resolves. + """ + import types + # Build out the chain torchtitan -> .experiments -> .kernels -> .hadamard_transform + # without clobbering whatever ALTO already has installed. + if "torchtitan" not in sys.modules: + sys.modules["torchtitan"] = types.ModuleType("torchtitan") + if "torchtitan.experiments" not in sys.modules: + sys.modules["torchtitan.experiments"] = types.ModuleType("torchtitan.experiments") + if "torchtitan.experiments.kernels" not in sys.modules: + sys.modules["torchtitan.experiments.kernels"] = types.ModuleType("torchtitan.experiments.kernels") + sys.modules["torchtitan.experiments.kernels.hadamard_transform"] = _ht + # The submodule that gets cherry-picked too: + if hasattr(_ht, "hadamard"): + sys.modules["torchtitan.experiments.kernels.hadamard_transform.hadamard"] = _ht.hadamard + + +_alias_ht_at_absolute_path() + + def _load_mxfp4_package() -> Any: """Load AdaHOP's ``mxfp4/`` package without triggering the colliding ``torch.ops.torchtitan.*`` registrations from ``mxfp_linear.py`` and From bfb17ab6450f59036fbd881b9e853bda346a804f Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 17:27:54 +0200 Subject: [PATCH 039/142] fix: explicit importation for foid --- alto/_adahop_bridge.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/alto/_adahop_bridge.py b/alto/_adahop_bridge.py index 11803f05..3b7809a9 100644 --- a/alto/_adahop_bridge.py +++ b/alto/_adahop_bridge.py @@ -124,7 +124,13 @@ def _load_mxfp4_package() -> Any: mxfp_grouped_stub.mxfp4_grouped_gemm = None sys.modules[f"{pkg_name}.mxfp_grouped_gemm"] = mxfp_grouped_stub - return _load_package(pkg_name, _MXFP4_DIR) + pkg = _load_package(pkg_name, _MXFP4_DIR) + # __init__.py doesn't import foid / outlier_extract; force-load them + # via the package's submodule search so they hang off `pkg` as attributes. + import importlib + pkg.foid = importlib.import_module(f"{pkg_name}.foid") + pkg.outlier_extract = importlib.import_module(f"{pkg_name}.outlier_extract") + return pkg _mxfp4 = _load_mxfp4_package() From ce5418bb588e93abab57bd197f4b2865d7007c12 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 1 Jun 2026 17:43:04 +0200 Subject: [PATCH 040/142] increase number of iteration steps --- alto/models/llama3/config_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index 473d7c83..b088e0ad 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -76,7 +76,7 @@ def llama3_debugmodel_opt() -> Trainer.Config: def llama3_debugmodel_lpt() -> Trainer.Config: config = llama3_debugmodel() - config.training.steps = 10 + config.training.steps = 100 config.model_converters = ModelConvertersContainer.Config( converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_recipe.yaml",)],) return config @@ -86,7 +86,7 @@ def llama3_debugmodel_adahop() -> Trainer.Config: config = llama3_debugmodel() # Need enough steps for the 30-step calibration plus a handful of post-calibration # iterations to confirm Phase-B wrappers are actually exercised. - config.training.steps = 35 + config.training.steps = 130 config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_adahop_recipe.yaml",), ],) From 1a945360f18466b871f8c4f9487b6012cfd44654 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 2 Jun 2026 02:27:06 +0000 Subject: [PATCH 041/142] feat: add DEOSC_RATIO env --- alto/train.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/alto/train.py b/alto/train.py index df8ff66b..223a7559 100644 --- a/alto/train.py +++ b/alto/train.py @@ -194,11 +194,12 @@ def train_step( if self.training_mode: # FIXME: This is a hack to enable de-oscillation at a specific step. deosc_step = int(os.environ.get("DEOSC_STEP", "0")) + ratio_threshold = float(os.environ.get("DEOSC_RATIO", "8.0")) if deosc_step > 0 and self.step == deosc_step: deosc_config = DeOscillationConfig( enable=True, period=200, - ratio_threshold=8.0, + ratio_threshold=ratio_threshold, log_freq=1, ) enable_de_oscillation(self.optimizers, deosc_config) From 1b5f2973de0067957655375e663f4d0471099a21 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 2 Jun 2026 03:06:25 +0000 Subject: [PATCH 042/142] expr: llama3 mxfp4 --- alto/models/llama3/config_registry.py | 28 +++++++++++++++------- alto/models/llama3/configs/lpt_recipe.yaml | 8 +++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index e499b0c4..89b4dfb1 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -123,24 +123,34 @@ def llama3_1b_lpt() -> Trainer.Config: def llama3_8b_pretrain() -> Trainer.Config: config = llama3_8b_orig() config.hf_assets_path = "/huggingface/hub/models--unsloth--Llama-3.1-8B/snapshots/3f0d51f8e5640f98f1a96ea9044a0e55c0a83814" + config.dump_folder = "llama3_8b-mi308-pretrain-subset-gbs384-lr1e-4-outputs" config.metrics.log_freq = 1 + config.metrics.enable_tensorboard = True config.profiling.enable_profiling = False - config.training.steps = 0 - config.training.local_batch_size = 1 + config.training.steps = 5000 + config.training.local_batch_size = 3 + config.training.global_batch_size = 384 config.training.seq_len = 8192 - config.dataloader.dataset = "c4_test" + config.optimizer.lr = 1e-4 + config.lr_scheduler.min_lr_factor = 0.0 + config.lr_scheduler.warmup_steps = 500 + config.lr_scheduler.decay_ratio = 0.9 + config.lr_scheduler.decay_type = "cosine" + config.dataloader.dataset = "megatron" + config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" config.parallelism.expert_parallel_degree = 1 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 8 + config.parallelism.tensor_parallel_degree = 2 config.activation_checkpoint.mode = "none" config.checkpoint.enable = False config.checkpoint.interval = 10 config.checkpoint.initial_load_path = "/huggingface/hub/models--unsloth--Llama-3.1-8B/snapshots/3f0d51f8e5640f98f1a96ea9044a0e55c0a83814" config.checkpoint.initial_load_in_hf = False - config.validator.enable = True - config.validator.dataloader.dataset = "wikitext_test" - config.validator.freq = 10 - config.validator.steps = 10 + config.validator.enable = False + config.validator.dataloader.dataset = "megatron" + config.validator.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-validation-91205-samples.en_text_document.idx" + config.validator.freq = 768 + config.validator.steps = 64 config.debug.seed = 1234 return config @@ -156,7 +166,7 @@ def llama3_8b_opt() -> Trainer.Config: def llama3_8b_lpt() -> Trainer.Config: config = llama3_8b_pretrain() - config.training.steps = 1000 + config.dump_folder = "llama3_8b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-gbs384-lr1e-4-outputs" config.model_converters = ModelConvertersContainer.Config( converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_recipe.yaml",)],) return config diff --git a/alto/models/llama3/configs/lpt_recipe.yaml b/alto/models/llama3/configs/lpt_recipe.yaml index b1cfbdc9..2a12a1d1 100644 --- a/alto/models/llama3/configs/lpt_recipe.yaml +++ b/alto/models/llama3/configs/lpt_recipe.yaml @@ -4,3 +4,11 @@ training_stage: scheme: "mxfp4" targets: ["Linear"] ignore: ["output"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + lora_rank: 0 From 71ff836711afbc3875796e983f1a94f2d847afee Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 2 Jun 2026 04:45:56 +0000 Subject: [PATCH 043/142] fix: tensor dispatch with TP enabled --- alto/kernels/dispatch/tensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index de96b9fa..3f7c91ef 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -39,6 +39,8 @@ torch.ops.aten.clone.default, torch.ops.aten.transpose.int, torch.ops.aten.t.default, + # required for TP - scatter_ is used to distribute weights + torch.ops.c10d.scatter_.default, } gemm_ops = ("linear", "mm.default", "matmul.default", "addmm.default", "matmul") @@ -111,6 +113,8 @@ def unwrap(t): # detach is special case if func == torch.ops.aten.detach.default: return cls(args_unwrapped[0], config) + elif func.__name__ in gemm_ops or func.__name__ == "_grouped_mm": + return func(*args, **kwargs) # perform op out = func(*args_unwrapped, **kwargs_unwrapped) From 2b06b23a6adc6b23c3f40b2bcdb3a693506266ec Mon Sep 17 00:00:00 2001 From: Han Wang Date: Wed, 3 Jun 2026 03:42:18 +0000 Subject: [PATCH 044/142] expr: decrease lbs of llama3-8b --- alto/models/llama3/config_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index 89b4dfb1..d2df1713 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -128,7 +128,7 @@ def llama3_8b_pretrain() -> Trainer.Config: config.metrics.enable_tensorboard = True config.profiling.enable_profiling = False config.training.steps = 5000 - config.training.local_batch_size = 3 + config.training.local_batch_size = 2 config.training.global_batch_size = 384 config.training.seq_len = 8192 config.optimizer.lr = 1e-4 @@ -140,7 +140,7 @@ def llama3_8b_pretrain() -> Trainer.Config: config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" config.parallelism.expert_parallel_degree = 1 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 2 + config.parallelism.tensor_parallel_degree = 1 config.activation_checkpoint.mode = "none" config.checkpoint.enable = False config.checkpoint.interval = 10 From 696904175c633a6f2c7ecbc656ce54a126940b6e Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Sun, 7 Jun 2026 19:22:59 +0200 Subject: [PATCH 045/142] solve llama parallelization issue --- alto/models/llama3/config_registry.py | 9 +++++++++ alto/models/llama3/configs/lpt_hadamard_recipe.yaml | 13 +++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 alto/models/llama3/configs/lpt_hadamard_recipe.yaml diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index b088e0ad..e22548bc 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -24,6 +24,7 @@ "llama3_1b", "llama3_1b_opt", "llama3_1b_lpt", + "llama3_1b_lpt_hadamard", "llama3_1b_adahop", "llama3_8b", "llama3_8b_pretrain", @@ -144,6 +145,14 @@ def llama3_1b_lpt() -> Trainer.Config: return config +def llama3_1b_lpt_hadamard() -> Trainer.Config: + config = llama3_1b() + config.training.steps = 1000 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_hadamard_recipe.yaml",)],) + return config + + def llama3_1b_adahop() -> Trainer.Config: config = llama3_1b() config.training.steps = 1000 diff --git a/alto/models/llama3/configs/lpt_hadamard_recipe.yaml b/alto/models/llama3/configs/lpt_hadamard_recipe.yaml new file mode 100644 index 00000000..d5ff5859 --- /dev/null +++ b/alto/models/llama3/configs/lpt_hadamard_recipe.yaml @@ -0,0 +1,13 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear"] + ignore: ["output"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none From e179f5e0216acf80f603ff20afc9ef82cfc6b423 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Sun, 7 Jun 2026 23:46:52 +0200 Subject: [PATCH 046/142] llama 3 registry --- alto/models/llama3/config_registry.py | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index e22548bc..f92f1532 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -28,8 +28,11 @@ "llama3_1b_adahop", "llama3_8b", "llama3_8b_pretrain", + "llama3_8b_random_init", "llama3_8b_opt", "llama3_8b_lpt", + "llama3_8b_lpt_hadamard", + "llama3_8b_adahop", "llama3_1b_gptq", "llama3_1b_awq", "llama3_8b", @@ -195,6 +198,31 @@ def llama3_8b_opt() -> Trainer.Config: return config +def llama3_8b_random_init() -> Trainer.Config: + """Random-init Llama 3.1 8B for a controlled init checkpoint, mirroring + AdaHOP's pretrain-from-scratch recipe. The launcher runs this for 1 step, + relies on torchtitan's checkpoint.interval=1 to save a DCP at step 1, and + all quantization variants then `--checkpoint.initial_load_path` that file + so every variant starts from the SAME random weights (seeded with 1234). + """ + config = llama3_8b_orig() + # Tokenizer (overridden on CLI anyway), no weight initial load. + config.hf_assets_path = LLAMA3_8B_PATH + config.metrics.log_freq = 1 + config.profiling.enable_profiling = False + config.training.local_batch_size = 1 + config.training.global_batch_size = 8 + config.training.seq_len = 2048 + config.dataloader = HuggingFaceTextDataLoader.Config(dataset="c4") + config.activation_checkpoint.mode = "selective" + config.activation_checkpoint.selective_ac_option = "op" + config.checkpoint.enable = True + config.checkpoint.interval = 1 # save IMMEDIATELY so we can branch from this + config.validator.enable = False + config.debug.seed = 1234 + return config + + def llama3_8b_lpt() -> Trainer.Config: config = llama3_8b_pretrain() config.training.steps = 1000 @@ -203,6 +231,22 @@ def llama3_8b_lpt() -> Trainer.Config: return config +def llama3_8b_lpt_hadamard() -> Trainer.Config: + config = llama3_8b_pretrain() + config.training.steps = 1000 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_hadamard_recipe.yaml",)],) + return config + + +def llama3_8b_adahop() -> Trainer.Config: + config = llama3_8b_pretrain() + config.training.steps = 1000 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_adahop_recipe.yaml",)],) + return config + + def llama3_1b_gptq() -> Trainer.Config: config = llama3_1b() config.training.steps = 1 From f93288bf172730257bece7aef5038e115be01fed Mon Sep 17 00:00:00 2001 From: Han Wang Date: Mon, 8 Jun 2026 06:25:50 +0000 Subject: [PATCH 047/142] fix: nvfp4 de-osc padding --- alto/components/optimizer.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/alto/components/optimizer.py b/alto/components/optimizer.py index c5758187..3b602cb7 100644 --- a/alto/components/optimizer.py +++ b/alto/components/optimizer.py @@ -169,16 +169,27 @@ def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: elif cfg.precision == "nvfp4": def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: + # hotfix for GPT-OSS 20B model + original_rows = -1 + if w.dim() == 2 and w.shape[0] % 16 != 0: + # for 2-D w, if the first dim is not divisible by 16, pad it to be divisible by 16 + original_rows = w.shape[0] + w = torch.nn.functional.pad(w, (0, 0, 0, 16 - original_rows % 16)) + data_lp, scales = convert_to_nvfp4( w, axis=axis, is_2d_block=is_2d_block, ) - return convert_from_nvfp4( + dequantized = convert_from_nvfp4( data_lp, scales, output_dtype=w.dtype, axis=axis, is_2d_block=is_2d_block, ) + # hotfix for GPT-OSS 20B model + if original_rows != -1: + dequantized = dequantized[:original_rows, :] + return dequantized else: raise ValueError( f"de-oscillation only supports FP4 wrappers, " From 262631dfcd6eeaf718b6b59933749d46f7249cca Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 8 Jun 2026 13:46:42 +0200 Subject: [PATCH 048/142] config registry for gpt oss pretrain --- alto/models/gpt_oss/config_registry.py | 36 +++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index ec1e5daa..854edb43 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -14,6 +14,8 @@ __all__ = [ "gpt_oss_debugmodel", "gpt_oss_debugmodel_lpt", + "gpt_oss_debugmodel_obs_lpt", + "gpt_oss_debugmodel_obs_bf16", "gpt_oss_20b", "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", @@ -40,6 +42,32 @@ def gpt_oss_debugmodel_lpt() -> Trainer.Config: return config +def gpt_oss_debugmodel_obs_lpt() -> Trainer.Config: + """gpt_oss debugmodel + MXFP4 + DebugObserver. Produces a per-step + tensor dump under the path configured in + ``configs/debug_observer_lpt_recipe.yaml``.""" + config = gpt_oss_debugmodel() + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config( + recipe="./alto/models/gpt_oss/configs/debug_observer_lpt_recipe.yaml", + ), + ],) + return config + + +def gpt_oss_debugmodel_obs_bf16() -> Trainer.Config: + """gpt_oss debugmodel in plain BF16 + DebugObserver (no LPT). Used to + capture the reference dump that visualizer can diff against the + quantized run.""" + config = gpt_oss_debugmodel() + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config( + recipe="./alto/models/gpt_oss/configs/debug_observer_bf16_recipe.yaml", + ), + ],) + return config + + def gpt_oss_20b() -> Trainer.Config: config = gpt_oss_20b_orig() config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" @@ -89,8 +117,8 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.lr_scheduler.decay_type = "cosine" config.metrics.log_freq = 1 config.metrics.enable_tensorboard = True - config.dataloader.dataset = "megatron" - config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = "" config.parallelism.expert_parallel_degree = 4 config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 4 @@ -98,8 +126,8 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 config.validator.enable = True - config.validator.dataloader.dataset = "megatron" - config.validator.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-validation-91205-samples.en_text_document.idx" + config.validator.dataloader.dataset = "wikitext_test" + config.validator.dataloader.dataset_path = "" config.validator.freq = 768 config.validator.steps = 64 config.activation_checkpoint.mode = "selective" From c603d390a27b84ea98100255619be1453ee6ae87 Mon Sep 17 00:00:00 2001 From: Han Wang Date: Tue, 9 Jun 2026 05:02:53 +0000 Subject: [PATCH 049/142] feat: enable nvfp4 tensor-wise scaling in de-osc --- alto/components/optimizer.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/alto/components/optimizer.py b/alto/components/optimizer.py index 3b602cb7..3cbe240c 100644 --- a/alto/components/optimizer.py +++ b/alto/components/optimizer.py @@ -167,6 +167,7 @@ def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: return dequantized elif cfg.precision == "nvfp4": + use_outer_scale = cfg.two_level_scaling == "tensorwise" def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: # hotfix for GPT-OSS 20B model @@ -176,8 +177,16 @@ def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: original_rows = w.shape[0] w = torch.nn.functional.pad(w, (0, 0, 0, 16 - original_rows % 16)) + outer_scale = ( + torch.empty(1, dtype=torch.float32, device=w.device) + if use_outer_scale + else None + ) + data_lp, scales = convert_to_nvfp4( w, axis=axis, is_2d_block=is_2d_block, + outer_scale=outer_scale, + update_outer_scale=use_outer_scale, ) dequantized = convert_from_nvfp4( data_lp, @@ -185,6 +194,7 @@ def qdq(w: torch.Tensor, axis: int) -> torch.Tensor: output_dtype=w.dtype, axis=axis, is_2d_block=is_2d_block, + outer_scale=outer_scale, ) # hotfix for GPT-OSS 20B model if original_rows != -1: From 80aca3f98df2ca5138683873b9e1f9df9d3032ce Mon Sep 17 00:00:00 2001 From: Natasha Frumkin Date: Thu, 11 Jun 2026 16:09:27 +0000 Subject: [PATCH 050/142] add midmax implementation --- alto/kernels/dispatch/config.py | 2 + alto/kernels/dispatch/tensor.py | 2 + .../fp4/mxfp4/mxfp_grouped_gemm/functional.py | 3 +- alto/kernels/fp4/mxfp4/mxfp_linear.py | 12 +- alto/kernels/fp4/mxfp4/mxfp_quantization.py | 30 +- .../gpt_oss/configs/lpt_recipe_midmax.yaml | 15 + alto/modifiers/lpt/base.py | 5 +- .../mxfp4/test_midmax_quantization.py | 300 ++++++++++++++++++ 8 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml create mode 100644 tests/unittest/mxfp4/test_midmax_quantization.py diff --git a/alto/kernels/dispatch/config.py b/alto/kernels/dispatch/config.py index 314f46da..4433b614 100644 --- a/alto/kernels/dispatch/config.py +++ b/alto/kernels/dispatch/config.py @@ -34,5 +34,7 @@ class TrainingOpConfig: * NVFP4: not implemented """ + use_midmax: bool = False + torch.serialization.add_safe_globals([TrainingOpConfig]) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index a13d8a55..fd49183c 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -252,6 +252,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): use_hadamard=config.use_hadamard, clip_mode=config.clip_mode, use_macro_block_scaling=config.two_level_scaling == "blockwise", + use_midmax=config.use_midmax, ) # linear op override @@ -283,6 +284,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): clip_mode=config.clip_mode, use_hadamard=config.use_hadamard, use_macro_block_scaling=config.two_level_scaling == "blockwise", + use_midmax=config.use_midmax, ) if bias is not None: Y = Y + bias diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py index 9dee3cf6..b266a298 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py @@ -18,7 +18,8 @@ def _quantize_then_mxfp_scaled_grouped_mm( use_dge: bool, use_hadamard: bool, clip_mode: str, - use_macro_block_scaling: bool, + use_midmax: bool = False, + use_macro_block_scaling: bool = False, ) -> torch.Tensor: m_indices = create_indices_from_offsets_nosync(offs) return mxfp4_grouped_gemm( diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 4abe7e12..059de958 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -272,6 +272,7 @@ def forward( use_sr_grad, use_dge, clip_mode, + use_midmax, use_macro_block_scaling, hadamard_transform: Optional[HadamardTransform] = None, ): @@ -310,12 +311,14 @@ def forward( x_scaled, axis=-1, is_2d_block=use_2dblock_x, + use_midmax=use_midmax, ) w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4( w_scaled, axis=-1, is_2d_block=use_2dblock_w, + use_midmax=use_midmax, ) if is_cdna4(): @@ -363,6 +366,7 @@ def forward( w_scaled, axis=0, is_2d_block=False, + use_midmax=use_midmax, ) if not is_cdna4(): w_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -388,6 +392,7 @@ def forward( axis=0, is_2d_block=False, clip_mode=clip_mode, + use_midmax=use_midmax, ) if not is_cdna4(): x_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -411,6 +416,7 @@ def forward( ctx.hadamard_transform = hadamard_transform ctx.use_dge = use_dge ctx.clip_mode = clip_mode + ctx.use_midmax = use_midmax ctx.use_macro_block_scaling = use_macro_block_scaling return y.view(*original_shape[:-1], -1) # Reshape back to original @@ -467,6 +473,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=False, + use_midmax=ctx.use_midmax, ) if ctx.hadamard_transform is not None: @@ -482,6 +489,7 @@ def backward(ctx, grad_output): use_sr=ctx.use_sr_grad, is_2d_block=False, clip_mode=ctx.clip_mode, + use_midmax=ctx.use_midmax, ) if not is_cdna4(): @@ -558,7 +566,7 @@ def backward(ctx, grad_output): ) grad_weights *= dge_bwd(w_fp4_values, torch.float4_e2m1fn_x2) - return grad_inputs.view(*original_shape[:-1], -1), grad_weights, None, None, None, None, None, None, None + return grad_inputs.view(*original_shape[:-1], -1), grad_weights, None, None, None, None, None, None, None, None def _to_mxfp4_then_scaled_mm( @@ -571,6 +579,7 @@ def _to_mxfp4_then_scaled_mm( clip_mode: str, use_hadamard: bool, use_macro_block_scaling: bool = False, + use_midmax: bool = False, ) -> torch.Tensor: if use_hadamard: with torch.no_grad(): @@ -585,6 +594,7 @@ def _to_mxfp4_then_scaled_mm( use_sr_grad, use_dge, clip_mode, + use_midmax, use_macro_block_scaling, hadamard_transform, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_quantization.py b/alto/kernels/fp4/mxfp4/mxfp_quantization.py index dde4e6ae..35d3808c 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_quantization.py +++ b/alto/kernels/fp4/mxfp4/mxfp_quantization.py @@ -33,6 +33,7 @@ def _calculate_scales( QUANT_BLOCK_SIZE: tl.constexpr, IS_2D_BLOCK: tl.constexpr = False, USE_DYNAMIC_CLIP: tl.constexpr = False, + USE_MIDMAX: tl.constexpr = False, ): if x.type.element_ty == tl.float32: hp_int_dtype = tl.int32 @@ -71,12 +72,25 @@ def _calculate_scales( max_abs = tl.max(tl.abs(x), axis=-1) max_abs = max_abs.to(x.type.element_ty) - # round even (adaptive) - max_abs = max_abs.to(hp_int_dtype, bitcast=True) - val_to_add = 1 << (hp_mbits - mbits - 1) - mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits - max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits - scales = max_abs - target_max_pow2 + if USE_MIDMAX: + # Normalize absmax into [2^target_max_pow2, 2^(target_max_pow2+1)) by replacing + # its FP32 exponent field, then bump the scale by 1 if it exceeds the E2M1 + # midmax threshold (7.0), which sits between the two largest representable values. + max_abs_bits = max_abs.to(tl.float32).to(tl.int32, bitcast=True) + f32_exp = (max_abs_bits >> 23) & 0xFF + # NaN/Inf have exponent=0xFF (255); cap to 0xFE so scale stays bounded. + f32_exp = tl.where(f32_exp >= 0xFF, 0xFE, f32_exp) + scales = f32_exp - target_max_pow2 + amax_scaled_bits = (max_abs_bits & 0x7FFFFF) | ((127 + target_max_pow2) << 23) + amax_scaled = amax_scaled_bits.to(tl.float32, bitcast=True) + scales = scales + (amax_scaled > 7.0).to(tl.int32) + else: + # round even (adaptive) + max_abs = max_abs.to(hp_int_dtype, bitcast=True) + val_to_add = 1 << (hp_mbits - mbits - 1) + mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits + max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits + scales = max_abs - target_max_pow2 # Today, 2**-127 returns 0 in compile+inductor+triton because it is in the # float32 denormal range. For now, manually adjust the fp scale. This is @@ -269,6 +283,7 @@ def _convert_to_mxfp4_kernel( USE_ASM: tl.constexpr, USE_STATIC_CLIP: tl.constexpr, USE_DYNAMIC_CLIP: tl.constexpr, + USE_MIDMAX: tl.constexpr, ): """ Quantizes the input tensor `x_ptr` and stores the result in `y_ptr` and the scaling factor in `s_ptr`. @@ -308,6 +323,7 @@ def _convert_to_mxfp4_kernel( QUANT_BLOCK_SIZE=QUANT_BLOCK_SIZE, IS_2D_BLOCK=IS_2D_BLOCK, USE_DYNAMIC_CLIP=USE_DYNAMIC_CLIP, + USE_MIDMAX=USE_MIDMAX, ) if USE_STATIC_CLIP: @@ -410,6 +426,7 @@ def convert_to_mxfp4( philox_seed: Optional[int] = None, philox_offset: Optional[int] = None, clip_mode: str = "none", + use_midmax: bool = False, ) -> Tuple[torch.Tensor, torch.Tensor]: torch._check(data_hp.shape[axis] % block_size == 0) assert not is_2d_block or data_hp.size(-2) % block_size == 0 @@ -469,6 +486,7 @@ def convert_to_mxfp4( USE_ASM=use_asm, USE_STATIC_CLIP=use_static_clip, USE_DYNAMIC_CLIP=use_dynamic_clip, + USE_MIDMAX=use_midmax, ) return data_lp.reshape(new_shape).transpose(axis, -1), scales.reshape(scales_shape).transpose(axis, -1) diff --git a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml new file mode 100644 index 00000000..378f853e --- /dev/null +++ b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml @@ -0,0 +1,15 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + # targets: ["Linear"] + ignore: ["output", "re:.*\\.router\\.gate"] + use_2dblock_x: false + use_2dblock_w: false + use_hadamard: false + use_sr_grad: false + use_dge: false + clip_mode: none + two_level_scaling: none + use_midmax: true diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index b46ee879..47418e7c 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -36,7 +36,9 @@ class LowPrecisionTrainingModifier(Modifier): use_dge: bool = False two_level_scaling: Literal["none", "tensorwise", "blockwise"] = "none" clip_mode: Literal["none", "static", "dynamic"] = "none" - + use_midmax: bool = False + + lora_rank: int = 0 """ Lora rank for the decomposed linear layer. @@ -111,6 +113,7 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: use_dge=self.use_dge, two_level_scaling=self.two_level_scaling, clip_mode=self.clip_mode, + use_midmax=self.use_midmax, ) self._resolved_config[scheme_obj] = targets return self._resolved_config diff --git a/tests/unittest/mxfp4/test_midmax_quantization.py b/tests/unittest/mxfp4/test_midmax_quantization.py new file mode 100644 index 00000000..975014d3 --- /dev/null +++ b/tests/unittest/mxfp4/test_midmax_quantization.py @@ -0,0 +1,300 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for the midmax scale-selection logic in the mxfp4 quantization kernel. + +Baseline reference: ROCm/tensorcast tcast/number.py _decode(), which defines + emax = 2^ebits - 1 - bias (for E2M1: emax = 2) + maxfloat = 2^emax * (2 - 2^-mbits) (for E2M1: 6.0) + midmax = (2^(emax+1) - maxfloat)/2 + maxfloat (for E2M1: 7.0) + +The kernel uses midmax to decide whether to bump the uint8 scale exponent by 1: + bump if amax_normalized > 7.0 (i.e. amax_normalized > midmax) +""" + +import pytest +import torch + +from alto.kernels.fp4.mxfp4.mxfp_quantization import ( + convert_to_mxfp4, + convert_from_mxfp4, + is_cdna4, +) +from .utils import ( + prepare_data, + convert_to_mxfp4_pytorch, + convert_from_mxfp4_pytorch, +) + +# E2M1 constants (baseline from tensorcast number.py) +_EBITS, _MBITS, _BIAS = 2, 1, 1 +_EMAX = 2**_EBITS - 1 - _BIAS # 2 +_MAXFLOAT = 2**_EMAX * (2.0 - 2**-_MBITS) # 6.0 +_MIDMAX = (2**(_EMAX + 1) - _MAXFLOAT) / 2.0 + _MAXFLOAT # 7.0 +_TARGET_MAX_POW2 = 2 # matches kernel constant + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _make_block(value: float, block_size: int = 32, dtype=torch.float32) -> torch.Tensor: + """Return a 1-D tensor of length block_size filled with `value`, on CUDA.""" + return torch.full((block_size,), value, dtype=dtype, device="cuda") + + +def _midmax_scale_ref(amax: float, block_size: int = 32) -> int: + """ + Pure-Python reference for the midmax uint8 scale given a block's amax. + Mirrors the triton kernel logic in _calculate_scales with USE_MIDMAX=True. + """ + import struct + + amax_f32 = float(amax) + bits = struct.unpack("I", struct.pack("f", abs(amax_f32)))[0] + f32_exp = (bits >> 23) & 0xFF + if f32_exp >= 0xFF: + f32_exp = 0xFE # cap NaN/Inf + + scale = f32_exp - _TARGET_MAX_POW2 + + # normalize amax into [2^target_max_pow2, 2^(target_max_pow2+1)) + mantissa = bits & 0x7FFFFF + norm_bits = mantissa | ((127 + _TARGET_MAX_POW2) << 23) + amax_scaled = struct.unpack("f", struct.pack("I", norm_bits))[0] + + if amax_scaled > _MIDMAX: + scale += 1 + + scale = max(scale, 1) # clamp to minimum normal + return scale + + +# --------------------------------------------------------------------------- +# 1. Baseline constant sanity +# --------------------------------------------------------------------------- + +def test_e2m1_midmax_value(): + """E2M1 midmax must equal 7.0 per the tensorcast baseline formula.""" + assert _MAXFLOAT == 6.0 + assert _MIDMAX == 7.0 + + +# --------------------------------------------------------------------------- +# 2. Scale correctness: midmax vs round-even on crafted inputs +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("amax,expect_bump", [ + (6.0, False), # exactly maxfloat — amax_normalized == 6.0, no bump + (6.9, False), # below midmax — no bump + (7.0, False), # at midmax (not strictly greater) — no bump + (7.1, True), # just above midmax — bump + (8.0, True), # well above midmax — bump + (12.0, True), # large value — bump +]) +def test_midmax_scale_bump(amax, expect_bump): + """ + Verify the pure-Python reference bumps the scale exactly when + amax_normalized > 7.0 (strict greater-than, matching the kernel). + """ + ref_scale = _midmax_scale_ref(amax) + ref_no_bump = _midmax_scale_ref(6.0) + ref_bump = _midmax_scale_ref(7.1) + if expect_bump: + assert ref_scale == ref_bump, f"amax={amax} expected bump, got scale={ref_scale}" + else: + assert ref_scale == ref_no_bump, f"amax={amax} expected no bump, got scale={ref_scale}" + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_midmax_scale_matches_ref_random(dtype): + """ + Triton kernel (use_midmax=True) scales must match the pure-Python reference + on random data. + """ + torch.manual_seed(42) + x = torch.randn(128, 64, dtype=dtype, device="cuda") + _, scales_triton = convert_to_mxfp4(x, use_midmax=True) + + # build reference scales block-by-block + x_f32 = x.float() + block_size = 32 + M, N = 128, 64 + scales_ref = torch.zeros(M, N // block_size, dtype=torch.uint8, device="cpu") + for m in range(M): + for nb in range(N // block_size): + block = x_f32[m, nb * block_size:(nb + 1) * block_size] + amax = block.abs().max().item() + scales_ref[m, nb] = _midmax_scale_ref(amax) + + assert torch.all(scales_triton.cpu() == scales_ref).item(), \ + "Triton midmax scales differ from pure-Python reference" + + +# --------------------------------------------------------------------------- +# 3. Kernel output: use_midmax=True vs use_midmax=False differ appropriately +# --------------------------------------------------------------------------- + +def test_midmax_differs_from_round_even_on_outliers(): + """ + For blocks whose amax == 7.0 (exactly E2M1 midmax), round-even rounds up + but midmax does not (strict > comparison), so scales must differ. + """ + block_size = 32 + x = torch.full((4, 64), 7.0, dtype=torch.float32, device="cuda") + + _, scales_midmax = convert_to_mxfp4(x, use_midmax=True) + _, scales_round_even = convert_to_mxfp4(x, use_midmax=False) + + assert not torch.equal(scales_midmax, scales_round_even), \ + "Expected scales to differ at amax==7.0 (midmax boundary)" + # midmax should be strictly lower (no bump) than round-even (bumps at 7.0) + assert torch.all(scales_midmax < scales_round_even).item(), \ + "midmax scales should be lower than round-even at amax==7.0" + + +def test_midmax_false_matches_pytorch_ref(): + """ + With use_midmax=False the triton kernel must match the existing pytorch + reference implementation (which implements round-even only). + """ + x = prepare_data((128, 64), torch.float32) + _, scales_triton = convert_to_mxfp4(x, use_midmax=False) + _, scales_ref = convert_to_mxfp4_pytorch(x) + assert torch.all(scales_triton == scales_ref).item(), \ + "Triton round-even scales differ from pytorch reference" + + +# --------------------------------------------------------------------------- +# 4. Quantize → dequantize roundtrip with use_midmax=True +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("tensor_shape", [(128, 64), (4, 128, 64)]) +@pytest.mark.parametrize("axis", [-1, -2]) +@pytest.mark.parametrize("is_2d_block", [False, True]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_midmax_roundtrip(tensor_shape, axis, is_2d_block, dtype): + """ + Roundtrip (quant → dequant) with use_midmax=True should reconstruct + the input with reasonable accuracy (MAE within one E2M1 quantum of the + block scale). + """ + x = prepare_data(tensor_shape, dtype) + data_lp, scales = convert_to_mxfp4(x, axis=axis, is_2d_block=is_2d_block, use_midmax=True) + x_dq = convert_from_mxfp4(data_lp, scales, output_dtype=dtype, axis=axis, is_2d_block=is_2d_block) + + mae = (x.float() - x_dq.float()).abs().mean().item() + # E2M1 has 4 representable values per octave; 25% relative error is generous + amax = x.float().abs().max().item() + assert mae < 0.25 * amax + 1e-3, f"Roundtrip MAE {mae:.4f} too large (amax={amax:.4f})" + + +# --------------------------------------------------------------------------- +# 5. Edge cases +# --------------------------------------------------------------------------- + +def test_midmax_all_zeros(): + """A block of all zeros must not produce NaN/Inf scales or outputs.""" + x = torch.zeros(32, 32, dtype=torch.float32, device="cuda") + data_lp, scales = convert_to_mxfp4(x, use_midmax=True) + assert not torch.any(torch.isnan(scales.float())), "NaN in scales for zero input" + assert torch.all(scales >= 1).item(), "scale below minimum-normal clamp" + + x_dq = convert_from_mxfp4(data_lp, scales, output_dtype=torch.float32) + assert not torch.any(torch.isnan(x_dq)), "NaN in dequantized output for zero input" + assert torch.all(x_dq == 0).item(), "Zero input should dequantize to zeros" + + +def test_midmax_large_values(): + """Very large values (near FP32 max) must not produce inf/nan scales.""" + x = torch.full((32, 32), 1e30, dtype=torch.float32, device="cuda") + data_lp, scales = convert_to_mxfp4(x, use_midmax=True) + assert torch.all(scales < 255).item(), "scale hit 0xFF (inf/nan exponent)" + assert not torch.any(torch.isnan(scales.float())) + + +def test_midmax_negative_values(): + """Negative-valued blocks should produce the same scales as their positive counterpart.""" + torch.manual_seed(7) + x_pos = torch.abs(torch.randn(64, 64, dtype=torch.float32, device="cuda")) + x_neg = -x_pos + + _, scales_pos = convert_to_mxfp4(x_pos, use_midmax=True) + _, scales_neg = convert_to_mxfp4(x_neg, use_midmax=True) + assert torch.all(scales_pos == scales_neg).item(), \ + "Negating all values should not change midmax scales" + + +def test_midmax_single_outlier_block(): + """ + A single block containing one value just above midmax (7.1) surrounded by + small values should bump only that block's scale. + """ + block_size = 32 + x = torch.ones(1, 2 * block_size, dtype=torch.float32, device="cuda") * 0.5 + # First block: push amax above midmax + x[0, :block_size] = 7.1 + + _, scales = convert_to_mxfp4(x, use_midmax=True) + scale_bumped_block = scales[0, 0].item() + scale_small_block = scales[0, 1].item() + assert scale_bumped_block > scale_small_block, \ + "Block with amax > midmax should have a higher scale than a block with small values" + + +# --------------------------------------------------------------------------- +# 6. Scale minimum clamp (all-zero block) +# --------------------------------------------------------------------------- + +def test_midmax_scale_minimum_clamp(): + """ + The kernel clamps scales to minimum 1 (= 2^1 in FP32 representation). + An all-zero block exercises this path. + """ + x = torch.zeros(32, 64, dtype=torch.float32, device="cuda") + _, scales = convert_to_mxfp4(x, use_midmax=True) + assert torch.all(scales >= 1).item(), "All scales must be >= 1 (minimum-normal clamp)" + + +# --------------------------------------------------------------------------- +# 7. Consistency: use_midmax=True gives same result across dtypes (f32 vs bf16) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("use_asm", [False]) +def test_midmax_dtype_consistency(use_asm): + """ + Quantizing the same tensor in float32 and bfloat16 with use_midmax=True + should yield scales that are close (within ±1) due to bf16 precision loss. + """ + if use_asm and not is_cdna4(): + pytest.skip("ASM mode only on CDNA4") + + torch.manual_seed(0) + x_f32 = torch.randn(64, 64, dtype=torch.float32, device="cuda") + x_bf16 = x_f32.to(torch.bfloat16) + + _, scales_f32 = convert_to_mxfp4(x_f32, use_midmax=True, use_asm=use_asm) + _, scales_bf16 = convert_to_mxfp4(x_bf16, use_midmax=True, use_asm=use_asm) + + diff = (scales_f32.int() - scales_bf16.int()).abs() + assert diff.max().item() <= 1, \ + f"Scale difference between f32 and bf16 exceeded ±1: max={diff.max().item()}" + + +# --------------------------------------------------------------------------- +# 8. ASM path (CDNA4 only) +# --------------------------------------------------------------------------- + +def test_midmax_asm_matches_non_asm(): + """On CDNA4, use_asm=True with use_midmax=True must match use_asm=False.""" + if not is_cdna4(): + pytest.skip("ASM path requires CDNA4 hardware") + + x = prepare_data((128, 64), torch.float32) + data_lp_asm, scales_asm = convert_to_mxfp4(x, use_midmax=True, use_asm=True) + data_lp_ref, scales_ref = convert_to_mxfp4(x, use_midmax=True, use_asm=False) + + assert torch.all(scales_asm == scales_ref).item(), "ASM/non-ASM scales differ under midmax" + assert torch.all(data_lp_asm == data_lp_ref).item(), "ASM/non-ASM fp4 values differ under midmax" From dfbbd6de0f3991420b72bb93cbf908849c9014b3 Mon Sep 17 00:00:00 2001 From: Natasha Frumkin Date: Fri, 12 Jun 2026 14:34:24 +0000 Subject: [PATCH 051/142] Add new c4 dset paths --- alto/models/gpt_oss/config_registry.py | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index e3b317f7..b22fbb3d 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -17,6 +17,10 @@ "gpt_oss_20b", "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", + "gpt_oss_20b_lpt_c4", + "gpt_oss_20b_lpt_c4_midmax", + "gpt_oss_20b_lpt_c4_lowrank", + "gpt_oss_20b_pretrain_c4", ] @@ -107,6 +111,18 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.debug.seed = 1234 return config +def gpt_oss_20b_pretrain_c4() -> Trainer.Config: + """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" + config = gpt_oss_20b_pretrain() + config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.initial_load_in_hf = True + config.checkpoint.initial_load_in_hf_quantized = True + config.checkpoint.interval = 100 + return config def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() @@ -115,3 +131,36 @@ def gpt_oss_20b_lpt() -> Trainer.Config: ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) return config + + +def gpt_oss_20b_lpt_c4() -> Trainer.Config: + """gpt_oss_20b_lpt using HuggingFace C4 dataset (no Megatron binary files required).""" + config = gpt_oss_20b_lpt() + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.initial_load_in_hf = True + config.checkpoint.initial_load_in_hf_quantized = True + config.checkpoint.interval = 100 + return config + + +def gpt_oss_20b_lpt_c4_midmax() -> Trainer.Config: + """gpt_oss_20b_lpt_c4 with midmax scale selection for MXFP4 quantization.""" + config = gpt_oss_20b_lpt_c4() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-midmax-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml",), + ],) + return config + + +def gpt_oss_20b_lpt_c4_lowrank() -> Trainer.Config: + """gpt_oss_20b_lpt_c4 with low-rank (lora_rank=32) correction for MXFP4 quantization.""" + config = gpt_oss_20b_lpt_c4() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-lowrank-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_lowrank.yaml",), + ],) + return config From 883a7c8b092d79e7e5fae0b43aba7c45a1f8aafb Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Tue, 16 Jun 2026 17:51:43 +0200 Subject: [PATCH 052/142] debug observers --- .../configs/debug_observer_bf16_recipe.yaml | 12 + .../configs/debug_observer_lpt_recipe.yaml | 24 ++ alto/modifiers/debug/__init__.py | 3 + alto/modifiers/debug/debug_observer.py | 238 +++++++++++++ alto/modifiers/debug/observer_hooks.py | 194 +++++++++++ scripts/debug_observer_viz.py | 249 ++++++++++++++ tests/unittest/debug/__init__.py | 0 .../debug/test_debug_observer_modifier.py | 314 ++++++++++++++++++ tests/unittest/debug/test_observer_hooks.py | 219 ++++++++++++ 9 files changed, 1253 insertions(+) create mode 100644 alto/models/gpt_oss/configs/debug_observer_bf16_recipe.yaml create mode 100644 alto/models/gpt_oss/configs/debug_observer_lpt_recipe.yaml create mode 100644 alto/modifiers/debug/__init__.py create mode 100644 alto/modifiers/debug/debug_observer.py create mode 100644 alto/modifiers/debug/observer_hooks.py create mode 100644 scripts/debug_observer_viz.py create mode 100644 tests/unittest/debug/__init__.py create mode 100644 tests/unittest/debug/test_debug_observer_modifier.py create mode 100644 tests/unittest/debug/test_observer_hooks.py diff --git a/alto/models/gpt_oss/configs/debug_observer_bf16_recipe.yaml b/alto/models/gpt_oss/configs/debug_observer_bf16_recipe.yaml new file mode 100644 index 00000000..0906bfa7 --- /dev/null +++ b/alto/models/gpt_oss/configs/debug_observer_bf16_recipe.yaml @@ -0,0 +1,12 @@ +training_stage: + debug_modifiers: + DebugObserverModifier: + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + capture_every: 1 + max_captures: 10 + output_path: "./outputs/debug_obs_bf16.pt" + capture_input: true + capture_weight: true + capture_grad_output: true + capture_grad_weight: true diff --git a/alto/models/gpt_oss/configs/debug_observer_lpt_recipe.yaml b/alto/models/gpt_oss/configs/debug_observer_lpt_recipe.yaml new file mode 100644 index 00000000..236a363e --- /dev/null +++ b/alto/models/gpt_oss/configs/debug_observer_lpt_recipe.yaml @@ -0,0 +1,24 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + debug_modifiers: + DebugObserverModifier: + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + capture_every: 1 + max_captures: 10 + output_path: "./outputs/debug_obs_lpt.pt" + capture_input: true + capture_weight: true + capture_grad_output: true + capture_grad_weight: true diff --git a/alto/modifiers/debug/__init__.py b/alto/modifiers/debug/__init__.py new file mode 100644 index 00000000..85eac522 --- /dev/null +++ b/alto/modifiers/debug/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/alto/modifiers/debug/debug_observer.py b/alto/modifiers/debug/debug_observer.py new file mode 100644 index 00000000..fedc6d71 --- /dev/null +++ b/alto/modifiers/debug/debug_observer.py @@ -0,0 +1,238 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""DebugObserverModifier — per-step tensor capture for low-precision MoE debugging. + +Hooks into a normal training run, captures the raw tensors that flow through +targeted layers at every captured iteration, and dumps everything to a single +torch.save'd file at finalization. + +See DEBUG_OBSERVER.md for the full output schema and quickstart. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import torch +from pydantic import Field, PrivateAttr +from torch.nn import Module +from compressed_tensors.utils import match_named_modules +from torchtitan.tools.logging import logger + +from alto.modifiers import Modifier +from alto.modifiers.debug.observer_hooks import ( + make_linear_fwd_pre_hook, + make_linear_bwd_hook, + make_linear_grad_weight_hook, + make_grouped_experts_fwd_pre_hook, + make_grouped_experts_bwd_hook, + make_grouped_experts_grad_weight_hook, +) + +__all__ = ["DebugObserverModifier"] + + +class DebugObserverModifier(Modifier): + """Captures input, weight, grad_output, and grad_weight tensors for targeted + layers at configurable step intervals. Dumps to a .pt file on finalization. + + Composes with any other modifier (LPT, AdaHOP, BF16 baseline). Drop it into + any recipe YAML under a ``debug_modifiers`` section. + """ + + targets: list[str] = Field(default_factory=lambda: ["Linear", "GptOssGroupedExperts"]) + ignore: list[str] = Field(default_factory=lambda: ["output", "re:.*\\.router\\.gate"]) + + capture_every: int = 1 + max_captures: int = 10 + output_path: str = "./outputs/debug_obs.pt" + + capture_input: bool = True + capture_weight: bool = True + capture_grad_output: bool = True + capture_grad_weight: bool = True + + # --- private mutable state --- + _step_idx: int = PrivateAttr(default=0) + # captures[fqn] = {"active": bool, step_idx: {"input": T, ...}, ...} + _captures: dict = PrivateAttr(default_factory=dict) + # number of steps for which we have already stored data + _n_captured: int = PrivateAttr(default=0) + # set to True once max_captures is reached and hooks are removed early + _detached: bool = PrivateAttr(default=False) + # one-element list shared with all hook closures so they always see the + # current step without needing a reference to self (avoids Pydantic issues) + _step_ref: list = PrivateAttr(default_factory=lambda: [0]) + # handles for parameter-level grad hooks (not managed by HooksMixin) + _param_hook_handles: list = PrivateAttr(default_factory=list) + + def on_convert(self, model: Module, **kwargs) -> bool: + return True + + def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: + for model_part in model_parts: + for fqn, module in match_named_modules(model_part, self.targets, self.ignore): + self._captures[fqn] = {"active": False} + cls_name = module.__class__.__name__ + + if cls_name == "Linear" or isinstance(module, torch.nn.Linear): + self.register_hook( + module, + make_linear_fwd_pre_hook( + self._captures, fqn, self._step_ref, + self.capture_input, self.capture_weight, + ), + "forward_pre", + ) + self.register_hook( + module, + make_linear_bwd_hook( + self._captures, fqn, self._step_ref, self.capture_grad_output, + ), + "full_backward", + ) + if self.capture_grad_weight and hasattr(module, "weight") and module.weight is not None: + handle = module.weight.register_hook( + make_linear_grad_weight_hook( + self._captures, fqn, self._step_ref, self.capture_grad_weight, + ) + ) + self._param_hook_handles.append(handle) + + elif cls_name.endswith("GroupedExperts"): + self.register_hook( + module, + make_grouped_experts_fwd_pre_hook( + self._captures, fqn, self._step_ref, + self.capture_input, self.capture_weight, + ), + "forward_pre", + ) + self.register_hook( + module, + make_grouped_experts_bwd_hook( + self._captures, fqn, self._step_ref, self.capture_grad_output, + ), + "full_backward", + ) + if self.capture_grad_weight: + for attr, key in [ + ("mlp1_weight", "grad_mlp1_weight"), + ("mlp2_weight", "grad_mlp2_weight"), + ]: + param = getattr(module, attr, None) + if param is not None: + handle = param.register_hook( + make_grouped_experts_grad_weight_hook( + self._captures, fqn, self._step_ref, + key, self.capture_grad_weight, + ) + ) + self._param_hook_handles.append(handle) + + logger.info( + f"DebugObserverModifier: monitoring {len(self._captures)} layers, " + f"capture_every={self.capture_every}, max_captures={self.max_captures}" + ) + return True + + def on_pre_step(self, model_parts: list[Module], **kwargs) -> bool: + self._step_idx += 1 + self._step_ref[0] = self._step_idx + + if self._detached: + return True + + should_capture = ( + (self._step_idx % self.capture_every == 0) and + (self._n_captured < self.max_captures) + ) + for fqn in self._captures: + self._captures[fqn]["active"] = should_capture + + return True + + def on_post_step(self, model_parts: list[Module], **kwargs) -> bool: + if self._detached: + return True + + # Check whether this step produced a capture + step_idx = self._step_idx + captured_this_step = any( + step_idx in self._captures[fqn] + for fqn in self._captures + ) + if captured_this_step: + self._n_captured += 1 + logger.debug(f"DebugObserverModifier: captured step {step_idx} ({self._n_captured}/{self.max_captures})") + + # Deactivate all gates + for fqn in self._captures: + self._captures[fqn]["active"] = False + + # Detach once max reached + if self._n_captured >= self.max_captures: + logger.info(f"DebugObserverModifier: reached max_captures={self.max_captures}, detaching hooks") + self._detach() + + return True + + def on_finalize(self, model_parts: list[Module], **kwargs) -> bool: + if not self._detached: + self._detach() + self._dump() + return True + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _detach(self) -> None: + self.remove_hooks() + for h in self._param_hook_handles: + h.remove() + self._param_hook_handles.clear() + self._detached = True + + def _dump(self) -> None: + rank = int(os.environ.get("RANK", 0)) + path = Path(self.output_path) + if rank != 0: + # Insert rank suffix: debug_obs.pt -> debug_obs_rank1.pt + path = path.with_stem(f"{path.stem}_rank{rank}") + + path.parent.mkdir(parents=True, exist_ok=True) + + captured_steps = sorted({ + step + for fqn, data in self._captures.items() + for step in data + if step != "active" + }) + + layer_shapes: dict[str, Any] = {} + for fqn, data in self._captures.items(): + for step, tensors in data.items(): + if step == "active": + continue + layer_shapes[fqn] = {k: list(t.shape) for k, t in tensors.items()} + break + + blob: dict[str, Any] = { + fqn: {step: tensors for step, tensors in data.items() if step != "active"} + for fqn, data in self._captures.items() + } + blob["_meta"] = { + "rank": rank, + "iterations_captured": captured_steps, + "layer_shapes": layer_shapes, + "capture_every": self.capture_every, + "max_captures": self.max_captures, + "mlp2_input_captured": False, # v1 limitation + } + + torch.save(blob, path) + logger.info(f"DebugObserverModifier: saved {len(captured_steps)} captures to {path}") diff --git a/alto/modifiers/debug/observer_hooks.py b/alto/modifiers/debug/observer_hooks.py new file mode 100644 index 00000000..9e106153 --- /dev/null +++ b/alto/modifiers/debug/observer_hooks.py @@ -0,0 +1,194 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Pure-Python hook factories for DebugObserverModifier. + +No triton imports — safe to run on a CPU-only box. + +Each factory returns a closure that: +- Checks the per-fqn ``active`` gate before doing anything. +- Detaches and moves tensors to CPU before storing them. +- Writes into ``captures[fqn][step_idx][key]``. + +The captures dict has the shape:: + + { + fqn: { + "active": bool, + step_idx: { + "input": Tensor, + "weight": Tensor, + "grad_output": Tensor, + "grad_weight": Tensor, # nn.Linear + "grad_mlp1_weight": Tensor, # GptOssGroupedExperts + "grad_mlp2_weight": Tensor, # GptOssGroupedExperts + }, + } + } +""" + +from __future__ import annotations + +from typing import Any + + +def _unwrap_weight(weight: Any) -> Any: + """Strip DTensor and TrainingWeightWrapperTensor wrappers to reach the + underlying storage tensor, mirroring the pattern in + alto/modifiers/lpt/adahop_internals/calibration_hooks.py.""" + w = weight.data + try: + from torch.distributed.tensor import DTensor + if isinstance(w, DTensor): + w = w._local_tensor + except Exception: + pass + # TrainingWeightWrapperTensor stores the raw data in ._data + w = getattr(w, "_data", w) + return w + + +def _store(captures: dict, fqn: str, step_idx: int, key: str, tensor: Any) -> None: + """Detach, move to CPU, and store tensor in captures.""" + captures[fqn].setdefault(step_idx, {})[key] = tensor.detach().cpu() + + +# --------------------------------------------------------------------------- +# nn.Linear hooks +# --------------------------------------------------------------------------- + +def make_linear_fwd_pre_hook( + captures: dict, + fqn: str, + step_ref: list, + capture_input: bool, + capture_weight: bool, +): + """Forward pre-hook for nn.Linear. + + Captures the raw (pre-quant) activation and unwrapped weight storage. + step_ref is a one-element list so the closure always sees the current step. + """ + + def _hook(module, args): + if not captures[fqn]["active"]: + return + step_idx = step_ref[0] + if capture_input and args: + _store(captures, fqn, step_idx, "input", args[0]) + if capture_weight: + try: + w = _unwrap_weight(module.weight) + _store(captures, fqn, step_idx, "weight", w) + except Exception: + pass + + return _hook + + +def make_linear_bwd_hook(captures: dict, fqn: str, step_ref: list, capture_grad_output: bool): + """Full-backward hook for nn.Linear — captures grad_output[0].""" + + def _hook(module, grad_input, grad_output): + if not captures[fqn]["active"]: + return + if not capture_grad_output: + return + go = grad_output[0] if isinstance(grad_output, (list, tuple)) else grad_output + if go is None: + return + _store(captures, fqn, step_ref[0], "grad_output", go) + + return _hook + + +def make_linear_grad_weight_hook( + captures: dict, fqn: str, step_ref: list, capture_grad_weight: bool +): + """Parameter grad hook for nn.Linear.weight — captures accumulated grad.""" + + def _hook(grad): + if not captures[fqn]["active"]: + return + if not capture_grad_weight: + return + _store(captures, fqn, step_ref[0], "grad_weight", grad) + + return _hook + + +# --------------------------------------------------------------------------- +# GptOssGroupedExperts hooks +# --------------------------------------------------------------------------- + +def make_grouped_experts_fwd_pre_hook( + captures: dict, + fqn: str, + step_ref: list, + capture_input: bool, + capture_weight: bool, +): + """Forward pre-hook for GptOssGroupedExperts. + + Captures the activation entering mlp1 (args[0]) and the mlp1_weight storage. + The mlp2 activation is internal to _run_experts_grouped_mm and cannot be + captured at the module-hook level (v1 limitation, noted in _meta). + """ + + def _hook(module, args): + if not captures[fqn]["active"]: + return + step_idx = step_ref[0] + if capture_input and args: + _store(captures, fqn, step_idx, "input", args[0]) + if capture_weight: + for attr in ("mlp1_weight", "mlp2_weight"): + param = getattr(module, attr, None) + if param is not None: + try: + w = _unwrap_weight(param) + _store(captures, fqn, step_idx, attr, w) + except Exception: + pass + + return _hook + + +def make_grouped_experts_bwd_hook( + captures: dict, fqn: str, step_ref: list, capture_grad_output: bool +): + """Full-backward hook for GptOssGroupedExperts.""" + + def _hook(module, grad_input, grad_output): + if not captures[fqn]["active"]: + return + if not capture_grad_output: + return + go = grad_output[0] if isinstance(grad_output, (list, tuple)) else grad_output + if go is None: + return + _store(captures, fqn, step_ref[0], "grad_output", go) + + return _hook + + +def make_grouped_experts_grad_weight_hook( + captures: dict, + fqn: str, + step_ref: list, + key: str, + capture_grad_weight: bool, +): + """Parameter grad hook for mlp1_weight or mlp2_weight of GptOssGroupedExperts. + + key should be "grad_mlp1_weight" or "grad_mlp2_weight". + """ + + def _hook(grad): + if not captures[fqn]["active"]: + return + if not capture_grad_weight: + return + _store(captures, fqn, step_ref[0], key, grad) + + return _hook diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py new file mode 100644 index 00000000..8f2a648c --- /dev/null +++ b/scripts/debug_observer_viz.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Standalone visualizer for DebugObserverModifier dumps. + +No alto import — only torch + matplotlib required. + +Usage +----- +# Summary only (no plots written): +python scripts/debug_observer_viz.py --dump ./outputs/debug_obs_lpt.pt --summary-only + +# Full plots: +python scripts/debug_observer_viz.py \\ + --dump ./outputs/debug_obs_lpt.pt \\ + --out-dir ./viz + +# With BF16 baseline overlay: +python scripts/debug_observer_viz.py \\ + --dump ./outputs/debug_obs_lpt.pt \\ + --baseline ./outputs/debug_obs_bf16.pt \\ + --out-dir ./viz + +# Filter to specific layers: +python scripts/debug_observer_viz.py \\ + --dump ./outputs/debug_obs_lpt.pt \\ + --layer-regex 'experts' +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Optional + +import torch + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + +def load_dump(path: str) -> tuple[dict, dict]: + """Returns (layers_dict, meta_dict). + + layers_dict: {fqn: {step_idx: {"input": T, ...}}} + meta_dict: the "_meta" entry + """ + blob: dict = torch.load(path, map_location="cpu", weights_only=False) + meta = blob.pop("_meta", {}) + return blob, meta + + +# --------------------------------------------------------------------------- +# Stats helpers +# --------------------------------------------------------------------------- + +def _absmax(t: torch.Tensor) -> float: + return t.float().abs().max().item() + + +def _std(t: torch.Tensor) -> float: + return t.float().std().item() + + +def _collect_series(layer_data: dict, key: str) -> tuple[list[int], list[float], list[float]]: + """Returns (steps, absmax_values, std_values) for a given tensor key.""" + steps, abs_maxes, stds = [], [], [] + for step in sorted(s for s in layer_data if isinstance(s, int)): + tensors = layer_data[step] + if key not in tensors: + continue + t = tensors[key] + steps.append(step) + abs_maxes.append(_absmax(t)) + stds.append(_std(t)) + return steps, abs_maxes, stds + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +def print_summary(layers: dict, meta: dict) -> None: + print(f"\n=== DebugObserver summary ===") + print(f" rank: {meta.get('rank', 'unknown')}") + print(f" iterations captured: {meta.get('iterations_captured', [])}") + print(f" layers: {len(layers)}") + print(f" capture_every: {meta.get('capture_every', '?')} max_captures: {meta.get('max_captures', '?')}") + print(f" mlp2 input captured: {meta.get('mlp2_input_captured', False)}") + print() + print(f"{'Layer':<60} {'keys':>30} {'input absmax':>12} {'gw absmax':>12}") + print("-" * 120) + for fqn, layer_data in sorted(layers.items()): + steps = [s for s in layer_data if isinstance(s, int)] + if not steps: + continue + sample = layer_data[min(steps)] + keys = ",".join(sorted(sample.keys())) + inp_absmax = _absmax(sample["input"]) if "input" in sample else float("nan") + gw_key = "grad_weight" if "grad_weight" in sample else ( + "grad_mlp1_weight" if "grad_mlp1_weight" in sample else None + ) + gw_absmax = _absmax(sample[gw_key]) if gw_key else float("nan") + print(f"{fqn:<60} {keys:>30} {inp_absmax:>12.4f} {gw_absmax:>12.4f}") + print() + + +# --------------------------------------------------------------------------- +# Plotting +# --------------------------------------------------------------------------- + +def _plot_time_series( + ax_absmax, + ax_std, + steps: list[int], + absmax_vals: list[float], + std_vals: list[float], + label: str, + color: str, +) -> None: + ax_absmax.plot(steps, absmax_vals, marker="o", label=label, color=color) + ax_std.plot(steps, std_vals, marker="o", label=label, color=color, linestyle="--") + + +def _plot_histogram(ax, t: torch.Tensor, label: str, color: str, alpha: float = 0.5) -> None: + vals = t.float().abs().flatten().numpy() + ax.hist(vals, bins=50, alpha=alpha, label=label, color=color, density=True) + + +def plot_layer( + fqn: str, + layer_data: dict, + out_dir: Path, + baseline_data: Optional[dict] = None, +) -> None: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except ImportError: + print("matplotlib not installed; skipping plots", file=sys.stderr) + return + + tensor_keys = sorted({ + k for step, tensors in layer_data.items() + if isinstance(step, int) + for k in tensors + }) + + safe_fqn = re.sub(r"[^a-zA-Z0-9_.\-]", "_", fqn) + layer_out = out_dir / safe_fqn + layer_out.mkdir(parents=True, exist_ok=True) + + for key in tensor_keys: + steps, absmax_vals, std_vals = _collect_series(layer_data, key) + if not steps: + continue + + # --- time-series plot --- + fig, (ax_absmax, ax_std) = plt.subplots(2, 1, figsize=(10, 6), sharex=True) + fig.suptitle(f"{fqn}\n{key}", fontsize=9) + _plot_time_series(ax_absmax, ax_std, steps, absmax_vals, std_vals, label="run", color="blue") + if baseline_data is not None: + bsteps, babs, bstd = _collect_series(baseline_data, key) + if bsteps: + _plot_time_series(ax_absmax, ax_std, bsteps, babs, bstd, label="baseline", color="red") + ax_absmax.set_ylabel("absmax") + ax_absmax.legend(fontsize=7) + ax_absmax.grid(True, alpha=0.3) + ax_std.set_ylabel("std") + ax_std.set_xlabel("step") + ax_std.legend(fontsize=7) + ax_std.grid(True, alpha=0.3) + ts_path = layer_out / f"{key}_timeseries.png" + fig.tight_layout() + fig.savefig(ts_path, dpi=120) + plt.close(fig) + + # --- per-iteration histogram --- + steps_sorted = sorted(steps) + n_cols = min(5, len(steps_sorted)) + n_rows = (len(steps_sorted) + n_cols - 1) // n_cols + fig, axes = plt.subplots(n_rows, n_cols, figsize=(4 * n_cols, 3 * n_rows), squeeze=False) + fig.suptitle(f"{fqn} | {key} | abs-value histograms", fontsize=9) + for idx, step in enumerate(steps_sorted): + ax = axes[idx // n_cols][idx % n_cols] + t = layer_data[step].get(key) + if t is None: + ax.set_visible(False) + continue + _plot_histogram(ax, t, label=f"step {step}", color="steelblue") + if baseline_data is not None and step in baseline_data: + bt = baseline_data[step].get(key) + if bt is not None: + _plot_histogram(ax, bt, label="baseline", color="red") + ax.set_title(f"step {step}", fontsize=7) + ax.legend(fontsize=6) + ax.set_xlabel("|value|", fontsize=6) + # hide unused axes + for idx in range(len(steps_sorted), n_rows * n_cols): + axes[idx // n_cols][idx % n_cols].set_visible(False) + hist_path = layer_out / f"{key}_histograms.png" + fig.tight_layout() + fig.savefig(hist_path, dpi=120) + plt.close(fig) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Visualize DebugObserverModifier dumps") + parser.add_argument("--dump", required=True, help="Path to the .pt dump file") + parser.add_argument("--baseline", default=None, help="Optional BF16 baseline .pt dump") + parser.add_argument("--out-dir", default="./viz", help="Directory to write PNG files") + parser.add_argument("--layer-regex", default=None, help="Regex to filter layer FQNs") + parser.add_argument("--summary-only", action="store_true", help="Print summary, skip plotting") + args = parser.parse_args() + + layers, meta = load_dump(args.dump) + baseline_layers, _ = load_dump(args.baseline) if args.baseline else ({}, {}) + + if args.layer_regex: + pat = re.compile(args.layer_regex) + layers = {fqn: v for fqn, v in layers.items() if pat.search(fqn)} + baseline_layers = {fqn: v for fqn, v in baseline_layers.items() if pat.search(fqn)} + + print_summary(layers, meta) + + if args.summary_only: + return + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + for fqn, layer_data in sorted(layers.items()): + print(f"Plotting {fqn} ...", end=" ", flush=True) + plot_layer(fqn, layer_data, out_dir, baseline_data=baseline_layers.get(fqn)) + print("done") + + print(f"\nPlots written to {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/tests/unittest/debug/__init__.py b/tests/unittest/debug/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unittest/debug/test_debug_observer_modifier.py b/tests/unittest/debug/test_debug_observer_modifier.py new file mode 100644 index 00000000..a77b4adc --- /dev/null +++ b/tests/unittest/debug/test_debug_observer_modifier.py @@ -0,0 +1,314 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""End-to-end tests for DebugObserverModifier. + +These tests mock the alto package imports that trigger the triton driver so +they can run on a CPU-only box, following the pattern established in +tests/unittest/adahop/test_adahop_modifier_helpers.py. + +The modifier's external dependencies (alto.modifiers.Modifier, HooksMixin, +match_named_modules, logger) are stubbed at the sys.modules level before the +module is exec'd. +""" + +import importlib.util +import os +import sys +import tempfile +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import torch +import torch.nn as nn + + +# --------------------------------------------------------------------------- +# Stub the heavy alto / torchtitan imports before loading the modifier module +# --------------------------------------------------------------------------- + +def _install_stubs(): + """Install minimal stubs so debug_observer.py can be loaded without GPU.""" + if "_alto_stubs_installed" in sys.modules: + return + + # --- torchtitan.tools.logging --- + tt_tools = ModuleType("torchtitan.tools") + tt_logging = ModuleType("torchtitan.tools.logging") + tt_logging.logger = MagicMock() + sys.modules.setdefault("torchtitan", ModuleType("torchtitan")) + sys.modules.setdefault("torchtitan.tools", tt_tools) + sys.modules["torchtitan.tools.logging"] = tt_logging + + # --- compressed_tensors.utils.match_named_modules --- + def _match_named_modules(model, targets, ignore): + """Simple FQN walker that matches by class name.""" + ignore_patterns = [] + for ig in ignore: + ignore_patterns.append(ig.lstrip("re:")) + + for fqn, module in model.named_modules(): + if not fqn: + continue + cls_name = module.__class__.__name__ + if cls_name not in targets and "Linear" not in targets: + continue + matched = any(cls_name == t or cls_name.endswith(t) for t in targets) + if not matched: + continue + if any(p in fqn for p in ignore_patterns): + continue + yield fqn, module + + ct_utils = ModuleType("compressed_tensors.utils") + ct_utils.match_named_modules = _match_named_modules + sys.modules.setdefault("compressed_tensors", ModuleType("compressed_tensors")) + sys.modules["compressed_tensors.utils"] = ct_utils + + # --- alto.modifiers (Modifier base class) --- + # We need a real Pydantic base class so PrivateAttr and Field work. + from pydantic import BaseModel, PrivateAttr, Field + from torch.utils.hooks import RemovableHandle + + class _FakeHooksMixin(BaseModel): + model_config = {"extra": "forbid"} + index: int | None = None + group: str | None = None + start: float | None = None + end: float | None = None + update: float | None = None + initialized_: bool = False + finalized_: bool = False + started_: bool = False + ended_: bool = False + _hooks: set = PrivateAttr(default_factory=set) + + def register_hook(self, target, hook, hook_type, **kwargs): + if hook_type in ("forward_pre", "forward", "full_backward"): + handle = getattr(target, f"register_{hook_type}_hook")(hook, **kwargs) + else: + handle = getattr(target, f"register_{hook_type}_hook")(hook, **kwargs) + self._hooks.add(handle) + return handle + + def remove_hooks(self, handles=None): + if handles is None: + handles = set(self._hooks) + for h in handles: + h.remove() + self._hooks -= handles + + class _FakeModifier(_FakeHooksMixin): + + @property + def initialized(self): + return self.initialized_ + + @property + def requires_training_mode(self): + return False + + def initialize(self, model_parts, **kwargs): + self.initialized_ = self.on_initialize(model_parts, **kwargs) + + def finalize(self, model_parts, **kwargs): + self.finalized_ = self.on_finalize(model_parts, **kwargs) + + def pre_step(self, model_parts, **kwargs): + self.started_ = self.on_pre_step(model_parts, **kwargs) + + def post_step(self, model_parts, **kwargs): + self.ended_ = self.on_post_step(model_parts, **kwargs) + + def convert(self, model, **kwargs): + return self.on_convert(model, **kwargs) + + def on_initialize(self, model_parts, **kwargs): + raise NotImplementedError + def on_finalize(self, model_parts, **kwargs): + raise NotImplementedError + def on_pre_step(self, model_parts, **kwargs): + raise NotImplementedError + def on_post_step(self, model_parts, **kwargs): + raise NotImplementedError + def on_convert(self, model, **kwargs): + raise NotImplementedError + + alto_mod = ModuleType("alto") + alto_modifiers = ModuleType("alto.modifiers") + alto_modifiers.Modifier = _FakeModifier + sys.modules["alto"] = alto_mod + sys.modules["alto.modifiers"] = alto_modifiers + + sys.modules["_alto_stubs_installed"] = True + + +def _load_file_as_module(name: str, path: Path) -> ModuleType: + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +# --------------------------------------------------------------------------- +# Load the debug_observer module in isolation +# --------------------------------------------------------------------------- + +def _load_debug_observer(): + _install_stubs() + + debug_root = Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "debug" + + # Register observer_hooks so that `from alto.modifiers.debug.observer_hooks import ...` + # inside debug_observer.py resolves correctly. + alto_debug = _load_file_as_module( + "alto.modifiers.debug", debug_root / "__init__.py" + ) + _load_file_as_module( + "alto.modifiers.debug.observer_hooks", debug_root / "observer_hooks.py" + ) + + return _load_file_as_module( + "_debug_observer_under_test", debug_root / "debug_observer.py" + ) + + +@pytest.fixture(scope="module") +def obs_module(): + return _load_debug_observer() + + +@pytest.fixture(scope="module") +def DebugObserverModifier(obs_module): + return obs_module.DebugObserverModifier + + +# --------------------------------------------------------------------------- +# Tiny test model (plain nn.Linear, no triton) +# --------------------------------------------------------------------------- + +class _TinyMLP(nn.Module): + + def __init__(self): + super().__init__() + self.fc1 = nn.Linear(8, 16, bias=False) + self.fc2 = nn.Linear(16, 8, bias=False) + + def forward(self, x): + return self.fc2(torch.relu(self.fc1(x))) + + +def _run_n_steps(model, modifier, n: int): + opt = torch.optim.SGD(model.parameters(), lr=0.01) + model_parts = [model] + for _ in range(n): + modifier.pre_step(model_parts) + x = torch.randn(4, 8, requires_grad=True) + loss = model(x).sum() + loss.backward() + opt.step() + opt.zero_grad() + modifier.post_step(model_parts) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestDebugObserverModifierLifecycle: + + def _make_modifier(self, DebugObserverModifier, **kwargs): + defaults = dict( + targets=["Linear"], + ignore=[], + capture_every=1, + max_captures=5, + output_path="/tmp/_test_obs.pt", + ) + defaults.update(kwargs) + return DebugObserverModifier(**defaults) + + def test_initialize_registers_layers(self, DebugObserverModifier): + model = _TinyMLP() + mod = self._make_modifier(DebugObserverModifier) + mod.initialize([model]) + assert len(mod._captures) == 2 + for fqn, data in mod._captures.items(): + assert "active" in data + + def test_captures_accumulate_over_steps(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier(DebugObserverModifier, output_path=path, max_captures=3) + mod.initialize([model]) + _run_n_steps(model, mod, n=3) + assert mod._n_captured == 3 + + def test_max_captures_hard_cap(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier(DebugObserverModifier, output_path=path, max_captures=2) + mod.initialize([model]) + _run_n_steps(model, mod, n=5) + assert mod._n_captured <= 2 + assert mod._detached is True + + def test_finalize_writes_file(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier(DebugObserverModifier, output_path=path, max_captures=2) + mod.initialize([model]) + _run_n_steps(model, mod, n=2) + mod.finalize([model]) + assert os.path.exists(path) + + def test_dump_has_expected_schema(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier(DebugObserverModifier, output_path=path, max_captures=2) + mod.initialize([model]) + _run_n_steps(model, mod, n=2) + mod.finalize([model]) + blob = torch.load(path, map_location="cpu", weights_only=False) + assert "_meta" in blob + meta = blob["_meta"] + assert "rank" in meta + assert len(meta["iterations_captured"]) == 2 + for fqn, layer_data in blob.items(): + if fqn == "_meta": + continue + for step, tensors in layer_data.items(): + assert "input" in tensors + assert "grad_weight" in tensors + + def test_hooks_removed_after_finalize(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier(DebugObserverModifier, output_path=path, max_captures=1) + mod.initialize([model]) + _run_n_steps(model, mod, n=1) + mod.finalize([model]) + assert len(mod._hooks) == 0 + assert len(mod._param_hook_handles) == 0 + + def test_capture_every_skips_steps(self, DebugObserverModifier): + model = _TinyMLP() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "obs.pt") + mod = self._make_modifier( + DebugObserverModifier, output_path=path, capture_every=2, max_captures=10 + ) + mod.initialize([model]) + _run_n_steps(model, mod, n=6) + # capture_every=2, 6 steps → captures at steps 2, 4, 6 + assert mod._n_captured == 3 diff --git a/tests/unittest/debug/test_observer_hooks.py b/tests/unittest/debug/test_observer_hooks.py new file mode 100644 index 00000000..9dff6bb5 --- /dev/null +++ b/tests/unittest/debug/test_observer_hooks.py @@ -0,0 +1,219 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Unit tests for observer_hooks.py — loaded directly to avoid triton imports. + +observer_hooks.py has no alto or triton imports so it can be exec'd in isolation +on a CPU-only box, following the same pattern as test_adahop_modifier_helpers.py. +""" + +import importlib.util +import sys +from pathlib import Path + +import torch +import torch.nn as nn +import pytest + +_HOOKS_PATH = (Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "debug" / "observer_hooks.py") + + +def _load_hooks(): + name = "_debug_observer_hooks_under_test" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, _HOOKS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def hooks(): + return _load_hooks() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_captures(fqn: str, active: bool = True) -> dict: + return {fqn: {"active": active}} + + +def _step_ref(step: int = 0) -> list: + return [step] + + +# --------------------------------------------------------------------------- +# nn.Linear forward pre-hook +# --------------------------------------------------------------------------- + +class TestLinearFwdPreHook: + + def test_captures_input(self, hooks): + fqn = "layers.0.linear" + captures = _make_captures(fqn) + step_ref = _step_ref(1) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_fwd_pre_hook( + captures, fqn, step_ref, capture_input=True, capture_weight=False + ) + x = torch.randn(2, 4, requires_grad=True) + hook(module, (x,)) + assert 1 in captures[fqn] + assert "input" in captures[fqn][1] + assert torch.allclose(captures[fqn][1]["input"], x.detach().cpu()) + + def test_captures_weight(self, hooks): + fqn = "layers.0.linear" + captures = _make_captures(fqn) + step_ref = _step_ref(2) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_fwd_pre_hook( + captures, fqn, step_ref, capture_input=False, capture_weight=True + ) + hook(module, (torch.randn(2, 4),)) + assert "weight" in captures[fqn][2] + assert captures[fqn][2]["weight"].shape == module.weight.shape + + def test_gate_prevents_capture(self, hooks): + fqn = "layers.0.linear" + captures = _make_captures(fqn, active=False) + step_ref = _step_ref(0) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_fwd_pre_hook( + captures, fqn, step_ref, capture_input=True, capture_weight=True + ) + hook(module, (torch.randn(2, 4),)) + assert 0 not in captures[fqn] + + def test_captured_tensor_not_requires_grad(self, hooks): + fqn = "l" + captures = _make_captures(fqn) + step_ref = _step_ref(0) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_fwd_pre_hook( + captures, fqn, step_ref, capture_input=True, capture_weight=False + ) + x = torch.randn(2, 4, requires_grad=True) + hook(module, (x,)) + assert not captures[fqn][0]["input"].requires_grad + + +# --------------------------------------------------------------------------- +# nn.Linear backward hook +# --------------------------------------------------------------------------- + +class TestLinearBwdHook: + + def test_captures_grad_output(self, hooks): + fqn = "layers.0" + captures = _make_captures(fqn) + step_ref = _step_ref(3) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_bwd_hook(captures, fqn, step_ref, capture_grad_output=True) + go = torch.randn(2, 4) + hook(module, (None,), (go,)) + assert "grad_output" in captures[fqn][3] + assert torch.allclose(captures[fqn][3]["grad_output"], go.detach().cpu()) + + def test_inactive_gate_bwd(self, hooks): + fqn = "l" + captures = _make_captures(fqn, active=False) + step_ref = _step_ref(0) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_bwd_hook(captures, fqn, step_ref, capture_grad_output=True) + hook(module, (None,), (torch.randn(2, 4),)) + assert 0 not in captures[fqn] + + +# --------------------------------------------------------------------------- +# nn.Linear param grad hook +# --------------------------------------------------------------------------- + +class TestLinearGradWeightHook: + + def test_captures_grad_weight(self, hooks): + fqn = "linear" + captures = _make_captures(fqn) + step_ref = _step_ref(5) + hook_fn = hooks.make_linear_grad_weight_hook( + captures, fqn, step_ref, capture_grad_weight=True + ) + gw = torch.randn(4, 4) + hook_fn(gw) + assert "grad_weight" in captures[fqn][5] + assert torch.allclose(captures[fqn][5]["grad_weight"], gw.detach().cpu()) + + def test_inactive_gate_grad_weight(self, hooks): + fqn = "l" + captures = _make_captures(fqn, active=False) + step_ref = _step_ref(0) + hook_fn = hooks.make_linear_grad_weight_hook( + captures, fqn, step_ref, capture_grad_weight=True + ) + hook_fn(torch.randn(4, 4)) + assert 0 not in captures[fqn] + + +# --------------------------------------------------------------------------- +# GptOssGroupedExperts hooks +# --------------------------------------------------------------------------- + +class _FakeGroupedExperts(nn.Module): + """Minimal stand-in for GptOssGroupedExperts (no triton ops).""" + + def __init__(self): + super().__init__() + self.mlp1_weight = nn.Parameter(torch.randn(2, 8, 4)) + self.mlp2_weight = nn.Parameter(torch.randn(2, 4, 8)) + + def forward(self, x): + return x + + +class TestGroupedExpertsHooks: + + def test_fwd_pre_captures_input(self, hooks): + fqn = "moe.experts" + captures = _make_captures(fqn) + step_ref = _step_ref(1) + module = _FakeGroupedExperts() + hook = hooks.make_grouped_experts_fwd_pre_hook( + captures, fqn, step_ref, capture_input=True, capture_weight=False + ) + x = torch.randn(4, 4) + hook(module, (x,)) + assert "input" in captures[fqn][1] + assert torch.allclose(captures[fqn][1]["input"], x.detach().cpu()) + + def test_fwd_pre_captures_both_mlp_weights(self, hooks): + fqn = "moe.experts" + captures = _make_captures(fqn) + step_ref = _step_ref(1) + module = _FakeGroupedExperts() + hook = hooks.make_grouped_experts_fwd_pre_hook( + captures, fqn, step_ref, capture_input=False, capture_weight=True + ) + hook(module, (torch.randn(4, 4),)) + assert "mlp1_weight" in captures[fqn][1] + assert "mlp2_weight" in captures[fqn][1] + + def test_grad_weight_hooks_stored_under_separate_keys(self, hooks): + fqn = "moe.experts" + captures = _make_captures(fqn) + step_ref = _step_ref(7) + hook1 = hooks.make_grouped_experts_grad_weight_hook( + captures, fqn, step_ref, key="grad_mlp1_weight", capture_grad_weight=True + ) + hook2 = hooks.make_grouped_experts_grad_weight_hook( + captures, fqn, step_ref, key="grad_mlp2_weight", capture_grad_weight=True + ) + g1 = torch.randn(2, 8, 4) + g2 = torch.randn(2, 4, 8) + hook1(g1) + hook2(g2) + assert captures[fqn][7]["grad_mlp1_weight"].shape == g1.shape + assert captures[fqn][7]["grad_mlp2_weight"].shape == g2.shape From 1d5093289703f32f1329e6e0b0a13f43741168db Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Tue, 16 Jun 2026 17:12:10 +0000 Subject: [PATCH 053/142] update config registry --- .../hadamard_transform/hadamards.safetensors | Bin 132 -> 1436901 bytes alto/models/gpt_oss/config_registry.py | 26 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/alto/kernels/hadamard_transform/hadamards.safetensors b/alto/kernels/hadamard_transform/hadamards.safetensors index bd00063f140e39e04a7acb988692417d22cf668b..9624e008623e86678a2da7f27000106e03055257 100644 GIT binary patch literal 1436901 zcmeGAZH^?#(xnGZZ(6s&1G^n5xRhIY&g*EIjl7_y7EVc>Q1h_>ceb|MQ>!{onr6zy15a{m1|K zkN@HS`p36#zy0HX_{V?wxBv9dn*H;C{kQ-6k1zlD_WSo?`~CYr|J(nG?dz|@RJi zzm2%x*08UO#IN5Ml3$nHUq|jABkqsypnap{MN7?w@%!NTK8hPyqtE z5%i-3EE!3#y$e|#neN8d<+RppDE+oT*X*DFK91aS8ot$O==|jv7G%{4&G#XrW^Mbn zw4-MK{9pc4h5Z;}ek_U0L-MsAk|zBzz<&G~l*?AtPK_#-ZTVW;;!b;@mh<+t4qV4B zEJcFJ!@6|qTeWKxyllj}592IW0n66a&gF^zW1NjY#?kn?$gkNy|NVde-zx9hdaQD; z=-HB5vr+o8Gs|ma>FnCr<=B6#V?TudK7{zJ{Vzy0ICS1G&$|I@$!pa1KBuEhV5llb5M%fGSse|`P(^?&;E4_}o3%m4g8 z|F{4A@Bgigzm$TY?RRzXoZZCuUKrnh#xVQK?>~l) zJ2Fbe6%nQu!1orxTkV#JZ?z%cu55_eU;g-Y6v7Fy?Hiy$I^h#Gmd9FR_~_wJ*P3*_Wh$sXu6c{1Pqv z@w$cbTQ&B#D~(P1mwJo+I#BMk_P1*7Z&zBI^ec`x5YsqX%MrMpT0^6mFue(jyU15y~*Aj}2g zdpCtY8n=XeuMPTsWrLFb<=Y?MzYUZ-8}z+4==+roO8S>yfBE(6uX!c$#=ohhHt746 z4NCf#Ukl`Wwwb>!w^0754f^BC1||K=uit+e+swDAn<&-XKdy8)>0f^Rv%Y6W3x8B^ zq5P=s{oAfWg*IxC(OP#d^<@N^Gzb6Xi$_6L>%lF@Z`H`)u zKh8IsTE0TLvcXCJ^8L@>e#zE+yYn}_d|&GK=fB5EufK(V)_an_9`&91wwABo3+2iN zC;dzP8T;N^?(Gm;bZw84{^iH7OGS77FxNj?3*}08lg?k}Z+WfwMfS##EnlHr>2A`$ z{P}CWD0AfQ{B5p(eSE3kpSzp%FMs~|%P-&4-8+Aq>z^Eja;3XT=X=8Me|(RaJEyMN za-}Ps49NFi^Xm7Xr?>i2f8opjb48tBU+#YS_0NHGPYrUe=}A6c5`XPpj{yY4V-F^z|Plg>z-EI={YT{r=l8 z{dC_ctE#$k_&QmTdTa4m-Z_1B_^zEjKzQZV`1_AM+jkCMoxN*Euag1!<7>Wg-`?!s zzFgbrWCkGz=FT_EI(Ao29U#BezoUNr{l^~-az{rAxvqrd0{Q;S_Z+!9C$0|M)$^8Q zK)&^PyYscOPTSSPmSjN23-iudtD|=Hq$L@UAN_aa&NssPLU`@_U?&&IAK!kYeX{-+z3$_HD3}3*^Vw+G@`Hov(xSZSdNc!A=I``>#2! zcfJk&@%_uS?}D9NAoX1^-&bxw!9TuSeSXahLf+VSzE0M+$!lLGrT@V;hI81#WFJHTF|dol47`{HMu}w$kGD9a)Q+#-Jvki z98-iM+TW4a*~SVIyz3+;Clj(#)mg>y(Cm_$;7xTs%Lf$he- zNrs{V5-hh^Kb(Z^yGl~VcUF>4q!AaDct+ku;-P}yiD3PlJd?!TPPd%eAL?atqg^*D z8GD_y&Bjx*Xd<$x6+bsggd&DEiO1AL<*3I>8!JAe9S%T1O2ADK;Q2 zsmz&)iSC4c3<+!y{zQU3&ICkgLe)n#21VXjA@fBgomxXe*Gk?^B07SOlb|1=-oZ;3 zm}KREA4h6(;`*WzbF`2LoTh7rAMU=5EPa6T}EGTnUE5Us2b&O9+mVDDhn+3Y2!}Mj=vish(O5jBdTFdYuMTV#qHm zN$!H07?#aZIrSlGKZ)t)2lfC74Y*oK>b^pP@LX@LgoH^X-L~$ZJ?z^plHQ55?6O_A zD!E0HO}tV`C0uWp9lGAG8zdlIZ`Uo7is#dpi&_QbI!VP`CjsMnCGRF7;e8}fU#(=V zrnRAxA1GvzXuH4=KPdy(@p_5I2oummwW6ye#yyKf-fNX~weP4TZM)X4XOh@*_Um{h z)u!u}1pYlFg>$`KWq%%tO?v0X->4+*zS1u0zPcybB7EtL1I^HKwOzMJ%p$+3(ozl0 z{$>(ED*vZS*rB(NZ3y9k)cnuBzM1gH6oxe6?B+J7Q3h+z2z zSA}UfqaRyW!i)vDW)s9Grk?1+qE(=PkrZ=`9av#t51h-fy+GF&Cm8@x-Bphv+4Nzks4DDi_yMvJ;42TFk3 z!uzh46s`x+R%u|G)KY({1WCND?}dNh*uQm6--4e}Tqk z_5u#YRg!^AF#7o+ zL^6ClQ8Rov(dgMH(b9pM_^9P6%emrsLM6{N-p(l2nAy4)z$NRH&J6dO(eN;d6fzA71yoAQC&VOM0eemH#KpZZJW+8xf=HGJ5EaiuDuU+`M4W?%5K+tnh>GPQM0tK5P|>4^>P_&7 z))mWRi0T+Tg2<*lfG8AyBck4&kFKcjAJe)|qI!LdRxeLQOuYG>^(&?Z9#3|L+ap>h z$KQ#_=6!tY(z1u_(L*Yto`(=E2mE9AC{NnQSG0X(KeD3QpY`4FkwmpWk05e?9zc|8 zKAx!e=i`Zbe?FRM*`E(4qHmAcqsI{W9(_bbe>b9fRr4q4qgw~VBZxfH4%*VH`wuhbha3UxkL1Zm|C!*@wBU)E1k0Gi*aE~D3 zTt9+nJUaaZedxd*`4ly&1?Iil9_jTi#govF9yTz%W6p<8*{s-2aGE^f5$t*#M=dVL% z87YJUT7L|aD-(Zo?W4)q2`_j2Ln~X3PrCM@l|k`$tjw|=R9SKPls@8scqAUSXOF9l zL;k=vK8(z|{)UxxU5}~kk!1Ok^^nt8&3jm7Mf6GLwrM zZA6bh|IGam-d?lF)oJWsh%B|)iI1+#7Sa543c8PL<0qN@<>2lfs~X_%Jfg{G-S`h7Tfx>rs_?Fdju#Jddi(v+*FZoQ=nj zbq60pCf9@ZEa&iVNS1d7&+~`xSy4Q!jSxMIjOIOvtY{ubR#cB7v#v*xS=VF8x~@+$ zz6s@D*c>VkmWM0m>W`1mINmjSkwx&s+6d9#lFUXvrZRi@xHdklG9Kbbk#YVXMV5!} zF_q=HeoSTIdQ2PZ%x_*;AN-tslJRs@$MegirZn9<=6rCpk5ChIj)aHonL@)KHRv1) z+a7f&SELi7hS0ZGW{QcHYb(mu?GPSJSdNncgfQS5kkv*gCg-NCS@$nnvL_&_US$Vr-RZDAfjSV}h+iZ|EBlP(wD>j^7TCOwx72-FZk%vhcT+%4-?Kt8iD| zme6jAS%7TSrlq~2ZxHGoguNoOrmy7T9)c4LW)K0gZY{2G>fN2UCW7rMgr!1?Chb)S zM`VkAbKXMnb0XBY2(d@nd-HB53V4H1AvXx`=Fz@IX|^pev~*kHc72Z3(0DI$^52PWX;Idg6zc z$kc=OxXRwWTv{scuDlDvB7sw0Avc7uLd-A%a2x-QJYhN!IIzSM9EYoh9fcATmnrYb z+awm+N(i5;5Eg@xWz)VT9FYpWn}=*+Mj5vVvEL%Z{?@z&hOXU7+|=|_h409tHp8v2 z=Bv_dTXefsOC;Po^DYvL?Iz&`#S6ms zeFeE~1x^KNzT=~aJ%p67rFbhLWGf+A7pt2-$X3Fnl>spAw|oL#P>@Sf3%OXWzPq51 z-5^YLH$d-*xnIR?LYbD9W;Lp~=TrVtMOGExUD;ND=`&*4!(LSgExqR(0ZS0!O?b~& zn7F&bu_Dvtn?5rj<2U&X7prSNGFH%KH6U*K;FC$vWi>?J^x?P(ddEl3P0%|&lXeg) zat9%;Un|_MaNPE}QA>r}Rl2-Zf9bmmdWX<$y9K(Xpb=~HLEKzay16!h&nL%4~g`9N4vfZ42=ZkAi8EdyA= ztoW=e{a1YA-M|dlb!V!(pm-DEJxrMrAiP~%=&hg$9qN_}vl6I|S2$L~8H#`!BOM51b&#LAa@@t8DsLD!hluMywTXSCqf4aJ#~B+qXr5 zdu5<0o8{$-@@|9P@!iENj`gC_^(wZ_wMIGHcp_@`Q^k5+1eVHef#utlgwtfqg|KM_ zhFd;dn;u?Z0MvVp<1fTDOCdE0jnldKI)!LJl?ErdmO5lJF8?iW5BSX9q+gBdNu zse^#PB(GPJEign1W#pyr9wzeqs3uCvaD~uFfze&phVasN53`cCgfIyOp}fL|UNRFp z{WR6fYgMEyz0A$*(BrzaYHD0v_uIZgOKrt?;a*TVM16aDNY{09Qh0w+Por2-nEbnxNoYBW=J1=A~0=!cpOG(~E+kl2^b){^YxdN$`Mq zsRqW%<(|(H3F&R$Jxnqem=rZAE?4lbk!o1QT>36nmncG*96UoKKKdm=5Gv%0g?Y?Y zA16Q>i8e=P&FoJ*iN$~ol)lB{G89)x10zln$uFRgi94JxNWr~HiUgGaMkK4~15S>C z8z)#xtT~2Xk_v?#1o3mzB93d8`0E;^B(%AcaRRi&-XJxGTjvQJ_JsvCjsEeZka=Ar zO}*1ArMyRzTHYlrex98@;le25%~< z3T1)>nlqE8^t3^>+x99xeY&=%dn+w0w&6NyIyRlO5A=1?-lo~4#d)XFd!+c%o_8Ol z-K53z5u`ao)mkR>!&#OiuUGQ1Y<$ko+MYC;5E~(}t8RucQwaMp4@- zOfx_Q2?1?#9iZX7LmB{(lYT)eZWO`MAyU0UDvTnyks(0F2DnaYh@45Qb;UbZL1r;T z(TWo;315;9fHk-*xy%C4t0pyjE2-Mxs!JT@I~z-V197l4$ymONREp;NiL}@NdnhR) zZj!2{+ob4swD(S>w);$^t>t>9+5VJM4XIe>y-zV<(_+IC;FRcgRzRY(U{4NKW*LUD z{hb_dlR}9-X}vOCW6f4)a2KbE2AqUc8B{aM*;bm;)?!?b0CH|tT8_FB@KdGq4bZEk z$t)i~0t_TAdr3u`PdWr%t&|G=)Huo^ka>!sG?(wH^d4#9egr9sf$q^F%3^u(+^RI? znflI3ZPP7MJ9w+ov^bm-NhzKV_{pN?UbY9pJ4i)M69EN93Z}gRn2#=IUIu>vDMS!U z`5;oOyG4qe1YBva-B5DEGEMW$0+l6~V_OI2^-4RKTr(ZR1K5X>)?S;ChK_(YwwH}c3mtkdD|x|z@)~K< zrjr)c?e?~72PvpGE5&t_bhsvys%JhaTXnNixAg-my+>MdZnqa->-o98deWgnVE5cX zGc3|ux6lEmG75T|G(h-`4S!OlVX84mVbQNSgfLwwYT5>O6$VJ4QrcWnSSaHCq^MU| zvV+4MV@n;lh7xWpemqx6t@Bo;*gt?&VYk|AEw@OmH$^31eQ12O(tD(|My?M>bMWI@lk!pFp(F=EtPgEK5KT6X+R{M+3*q}Y>xoxC72mM`21-;e8U+a(}WOB)WjsV!It1c1H+7NF zL`fs+&7=s6xGSVlshpXl65b+(XjLSw9kHTb?x?iVZ&w=FMbSG*OBy?2@0dx$h}Gq$ z7$l^b&P=^dT5);R~X)-1^8TH~Zf4~x;k*t=b$>k*}ezV(`HAw@-N#Ev>D(bCKY*r|3n%Mlp}qHHjboJU@C!8-NjXm28%NR_eG^H zF{M%xgaGR&(l!wxG_lOh0Lj(6N#PVCxWQR*qvI;6oVQ3DG8zd|UQM)o4C$!jDk+`X zSE=Ho-^O>{gXLbOA46&rflf2iS$n*A_Eg$9*DDR#V@bQhhmxkJH}+H|+}_>Zf~~xq zoJ<2jv+><-FL{wOl@v7}K#Injm3p#nktT0XrB%mmQUPyON)K+fw?P|#KsTaN+=ve$ z9iUf96)?Y2%URlxmMoPPC-vGU=5-OOtrxCL;Fef|3|(+#*>>=Ddy~3{RMv=P3TMWa z%yLnLZi8^<8vd~yX{?Ry3Tw4gn&b`cn@OAXI_ZFo1o$?jQC=ZsZ4W3}pp0vECeTQu z42-{~T;SxE1uK7qY%BssT*zQC!uVUHi862{5osW1@i0{BlrQg2Eoz!_v8LmCYQk05_Hq-g9Cdti<59<^pkU<0HO6k|vN;Jq?1q4@l* z$OY0F6~{AhkCBQ$pH%&~Dos&)NYjqUElD0c}e=a(A5>xW- z+(XHq#Mv?qj7O}79LI4G*%vm4Ht%iqq1;2s2SgvJxFqi;_DR^=>LbJl_E_4T6_4Y! z=Wrd$J(N7&uk3R*ZY)_bzxz1tCzi@)zrS0u?%d-;xrdT%=Dv#A3V$m7-R(M*dnkFg zXsg^;aXPd!_fYZ?(VxV0fL*KKOf$dw_90-`!6a{IvT3_>_Y+g+UePvbck(B(Cy_mW z{b2rUwb~=O?_}mV+MBzdxEXg5SJH0cqq%#D;XRbQGg;ofxrdS|_%B9Wa`&qCwY0P1 zBHc}VFn2dGjoK^PChtr>lzS*So4fCP>?h9Ox_zxK*4@Mhb9WOXc&})W)b8ZJAaRbx zu8RA8wJ-Nj@)6NbB1ZDg!)4;$+(XF+MAs9zx8lm&pL;0z05PdMRhzjxcRz7GxVuI3 z(CV1V6qAc!4R#YFO#)bHDV+JUsRB zCs8cS03tx3=}c?`V&vlw^CjMZ#$m2AvCEt^auOEEk+%Sqvuyr|-&3+OHQ?ikhlV}G zlwjVDinDQ}^lpWCb)$BMG&rF$r8_!z5{s!`0p3O2Y7z0+iVqMY)ZfN<&*7p__F-qM zpF*6C*;ldkKBZzBy_0wx<7}+km1*OZpBV!O!NHwzUw0D=h3XxxHjSHV2MEjPi$?E+ zj0y}lOVvAwM~XeDm=dh1@|{yAmARxCy>oP5W~BdSkAq~hbpGPoy6|I-iotdw)dT_CUbAAsdaC~?CxG-o4k|Q zT6Yq|ySHL^_f{CTCz?Gk)?i zsU_{qGNrM0MjrE+6GxWOZhi7j9!@haGv z1!P89-6keuxvAI&uK+ed8!;niU;x1nF0SBcKzf@xXe12~qX`4TTQFP<6Nvm6Bak#K z`OymozkqOM(3v~Mi)wzN%i7jI% zF-S{`Ea2_LNZDJlz1v9)=M#vlRlA98)6Q1grJWU%w!hT}Dn3B$k=)tpr0uIXS^J1B zcXz7~RD6Iqto!!3XgOJrulN8l_40DtQ?ay9saVnbT20p8ihUR=+aH@o+sbP9nAL-V zvpWit>oIvev67&#NzWs%?qyK9_smdBV1j!_75r!ggaj&M<~=j~1SUQcfs9I|_W>Y` zP-Qk5h=hQ<>pGaOdISz3=t&ik9i7$~Dnpqjkjc*MVC+su*>3VKU>`XR+D~rh4y1pQ z?>KPFe(yOl*{r>LUT18a^d00Qd*6ZceD6MROZG1EJeyA;={N$={l z)aKI?Ost84rB`}70<&S|B*l>Sjd*geWDDKtK)f?us!#HF_w8S@?R1Ir_T~fU z*}TZ+QF!Zdg_Ua``IYQhRmYc5!h;5ZKz$r`H;U01V- z0@Z}x&}-Sotpp@A@f*?sye9o^8uOCH)3DDErA3@rLvdA&2)Z+OLwE_?B!aC{wo4b|C*H$*ULp zE2ooh&91l?ZKvUHPA9T{*Ij#8_D|$MeUj5pTS_ z_Fu=kUcBzN?;1t)8>eAuDf^PHj)2kVcJ4eepzSAD*Mal)Jo2s@0W^>nFvUC?dZ+E} zGFPNBn4CSbqu?tsEhahE^w`SRZtWnq&ZXt=u6(mq?kiJuC_b z<-Ozg z_mZ>UsE-D*-u0Xxs{9anF&`ke$fs5AdvtHxYr~FIPT%(Kxeeb<9^JdimHiy@?%7Uq z>L&F$l^-JatnEHPQ74u&5PTfnM4cklJ$7LtE59I#JkC2nL~Pa;?J zZgQWm-IaUFc2}N8*xIRyhj~Swq!lAk{v2 z9Xcx?r$5z4Sj+o3f1kvs>^+C=nR}lD=Bay-(5Ok#`)={TMHtY}bqH zu%GfbpUqVB*2jFhAIQz`<%;SqEtjha{uh83Vm4=D$e8Z8LPV^4JLZgBZ21_sbDc;1 zy{Fr~9rLmG?D?>5=TrO_)luuHG~;FUAK)*-wYuyw_hH-4u?_ne5Bqx)Up&?Ye|deF zpHl17Ll#~AviL}UTIvb6x!vS$b+2;jKg8dl3(;neTbh;+^Vc?yy=BK!Y4(21Kh(e3 z+i?!YzH=sqZ9AXp?~2)mwfbzz*0!A!w~fteXMQD*y#;nuyq$ljKU;nCKKB0Bcr&pb zxAKO4?B%VIj@z! znR_u*!^J*vWwowj{n!O{*SG8=Z;ZF%ZrMj3 zRd27cm$$}k?6vu=ykY+-XKZSZFP3c6#phyi>2h_&bdhqgAULgGMmJ(vz4W5~e#Cw6 zmHZb8cc_hgh5qM)UI_jv-->`HFxQx08?V{O8usmc*tX+#ZfxyAwjtwo&R)G-U$bpv zw)1PTKiRB$Rkc>K+R~Y6_L^;*vz;4DS#_}& z+bh=9KGtI#u`k!R>?3ZBOC05Q^*z-PYt&*N>oJblm+M>h5jVyq4hpO1zW$Ecu#NQ? zU)sldjJM)`vUkFUTFVeK>|;H~m-ew9t`E^$^vO+#76RJq#6dW<9f<@%O=#Eo%@tI2lH zMun+TSrw3o;oHW)j2kg8?PER0C600rT;nY49odf~pF_!Xo`xLwJ(hi#o(c|0VC%=T zxM$pxv*l#(*~7i#kz6>BRiEY`!8lfZ_WlS4d%-oH->lxg-kUdg+=D&?V=u>9c*~lY zx5VAz$qwce^B}! z{yjXh_vU)?vm0Yi-m|BB&diqQJ$rp^kKb}euH_BhvFBqPdpO2nALFp!8b{n1U&aml zGG^ETYhiBNwdW^$9#$g}hVo%FUPWE<;mn+m>TE=SF$9jyzKE`1m<6`g03;2?E zX}MfcMJ&TU)?*y@F%J6}7kh2UvKKwy?h*5Q{&Xf8V}39GC0;7YZjAYMkA{tiG2i-! zed`O__4pCD7gALC-LiOJ;vW~koI@A=;wKX8{k ztvt3|bRQt19w&$`V?_0l`d$sCS z_3h5AG1og6|2?1BlvCGuzO!1_x9l(DZmnZmngCS#UytcLxq>sRBfIJ-fWT+80{T;mHnwr$+4^({NO z_)>9pFy;tI2guH-z;ia0(h|AT_{#WB`gQ6AQyivO?PX(zac1=VL3jw(a~H)=OKeUR7UK^K%8S#lQ5wT))JpS}yi+d&Roi z$9jz0Smwh%#^Haterc!jQT^DTQT5h7;>Ng*-NwK4zg&;JVW+~eXPL$F(mvK>+{UK* zOUqcj8sCA67{N8RteRvt)?*y@F%J6}U)sldj3bWy+3pGRmwUEdzZU<}|8o5jKb2-z z#+-4?nQYfD?XUS?jko$VIh}~&F;6$LNn@VXsO&QSR^B!@Y~6`+Z1*J9#@hLu#744GA!>*Tf>Kkkkr#t)4>KayX zSu(5$tO3uh{kjt;b_Wp$oFK-?Bf*$0bQp}SaI-3bb@BqPBSvF$!|KOG7okw3n7Ks@ zR_nJCXE>z8Wh_vO^{*#_rrA5H@spCR30I1=*hCm;4<>aYO?$EkxAnQ5u-nhyj;D7b zeR%T0{1m~1KhDA!eIKx>&%@pm$awm8oI!*e^4w1N=itfP;n92YnW$aJ^YP5>ID-hz z7AetpCWiHKRe+$$TRWOGcjIK z|MDW12lIKI=oeLCtqJiilX5J4B7+zFq}XN$<6tfzK@jd=EUcp5%MWQ5*5}POuT5B1R9M+X=Sf+1sIjXN#y4`Rq>Ef+ve84Nva` z8lEj8e=MGTCZ6Ak>cjIzl!RxC;3Pa-M8Bw?{B%6O6DoM}c5oh_-3c^2Tg3XFeB#sb zDZ*pmzcQbFChGgkv$uoi^4TJMS3g_CI2BJk6V-?3Z^x$yPhxGw(>syF@Z2-e&*rCy zoQmh33D4wn4`!dqr-~S7@~I;FiSWt^vqjj4=ZYwHHsh%xLjGJ4{eF0^h~9>0ieN8}UQ;lt3G{(|S^nAAz&Lb{muXgY zC%_Cksr5L98lEqNx-8oIxKaJPIuZbqj$j-Y;@%uACKVh7@w61wb2|e6(XLv=VUP(z z=;gs`+>gT;$AwVG(PBKOhe7^ELO7!b+gh740pmDG&iS!o3gfVlDHu~gW)$-&gcHHV z$;#veXGAH-_BZ7m#Fzpyp_tyBBh`!zK7=s^WJWQcLg)a0+xn?p9^ga!`?)nQ+j^`c z>&yS=D5Dr6GIVIigCIK~u^ z2|}pkU^SkPqZm^_W)$-&Bpt{ryym`;kDQSy7*jxI6mt?GRm#z70630u9EA7!Q6av? z4uZ_WI9d%lreGWdQO6XFV<6FS5Mv4m8*_APjti+{eppDq)Q?p&LrB$fv>FhOV;l!5 z4TptL$5D`37)OP;CkKnEb9%fZQ!u80%qZp@Lh5yVR7f3-2^hygs*b}#DC9WE42+Wq zscsythVyY4V+zOwA-qfvR#OPaF{Xh0jfBwAgKahaFb01b4qo*FI({8*NIkRsc-pHX zB-vson6vUMQ?a4OanniCGIz1YCCET>@fz)fu)%Ih(6Pl2tnsAEB~i^?IfxJ;QIWX_ zn>f~>HPQgq7QTj5&_N+hrsBOqtZQi6+mYi!M4)MQ4pFww(%!l?Bh~I(mpFmUCcb5B z%zU;G`465E`T<1UojRwp5AVnXA!`Xow&t*qw3CL~T5q(}GfJc1a9As3tzoSTYulZO z20=&!4g+o|%MmCh8o0IH31*`bX_~fLz_lsX9=(g6E% ztZ-P{?!^2B3De>Q4HQB!GSR@T?cpl3Q5}XcX(YZ^2&trz388GF#32bcd>ba#n%Wa5 zXo*UOkQie(NRCh$w5SgfH1XotWVRh+D1ha{p)M(Wp)=UtB zD21VQr;rpeO4`>EMI01j1=$SCVn--TMG#bds!Xkh<8w#sLsT5^2vnioEhJ(U9J#2B z{2KI9ONTLajw^O!u*E>G&czKJwoYxkK|fK2S}=T_)v;^lil%D_G2x&P>q07mcM3rV zH6Q5+I_%m3A?i3NL>du@kUY1?I^vcd>XI&3lZV@LaL6#Tf?jL6djo$gnk_D$awu6IU}5mqe9#jZ%3Y* z2|}uvvs`sfIjSit4bn9ByuCTkBhHPWhe>s3E>=sY8@`=MFbPgLmfV`x)pWy*&EH{H zstaetHB7KaA={Op!Z-sD3F96ije?wbAt7wlh{xYy*Xv@5`w%gT9i%8^{vqru7GaGYeDlyeR-p4-pPxEY?8UMS?9BQAk2Ax&Lmo?__KEE@Z z-Lr^6$fp?3NLtbV6zNm*xlQzA+nHKq`TVEJqk9rDqeZ89nm)yNW~&X141HAk=w|u! z13Xkc{mj^;b39FBqv*-Aw`uIpDLQi|G3)d6^lhrPoToFNVt8olaq-ofZsZMop3daG zg2>hy#i~X*j(n`7g|9}^{H>dHdUug^5P%4BdU3hJiA5`OCq1x;kU&*b;;`$&pb@*j z!>$Au&WOs-t761!f(~YQh8R>(6}P8%=1gMf$4NSq&G{5FHpIt`&2f*0^|=SQ_vjpA zPShFSi6=S0oSUE*rzsEJ`usdq%-ATNcjiwNv!0sg zi(z+O^y0HKo{zenq%+y0lZZ)MP9cW(3wv}5F^D*cm~`YEVoJ+tI)j$8h{<2Yvz(dp zbjGuLl5Ohu?m5oPnZ)>A@f>G{^KzCmLr2acCcATv&Zy-ione1YvrT6b$$DW=ZI=FFU^Gkt{5@Sdrz z4B8f70`w-b|E|`M4tMq;9M}}|DdnP4Npie>BRUeFNPsf~2_dDt6-YaIpmT}JWi=qREaTZSh#k4;M1eKgAg;h(Y08PW0 z1~jX%lL@L_pHUf`J_TnQ(5Ikm$k>&Po9{7mI^A!L!Ls)~G+r9Vapnl3cXgry1NG-pDG zgqVRd4QNtfY0lxw3Sf$$dK*pwnus$+kZ<`39pVr2r=Zb;>O{U?(313pZ!Kr)Q0@5) zK|B;waHauG669e%UK#s8qeG=+3eYs1X+V<-Tb{FHPZ|3)qto?JOahvOLs1hd17!}* z9H3}9UKlU2DV0sbnFchguyYB@TlMgk6~L6Or#Ul#CgMyHRC?y%Oaq!Eh_~2;$~=Hm zaOMc&{LCmUXL&-0{;q;*&u6rn_sqU}$a&oV zf~~{j#Xy(1gXSbRL-Zvel#8!*m_(kxtb*bsx8N?wjl1}wL#tvc9N#`cfVHwig1}H% zWb76s#son^Oc2x%fulwmy<^L|q?A|py;~MN$G0qcj&E7(IV`C492Vs9LsU1shMikB zsY8V+E2L37I%Jjr#{?mz5e{t`QX1fpAjQlOl)fJC&?G_W&hZYF z9J+I&4owo2$7#kHnj(njTrmf?jJ=v6h{tP&AWqH{L7<#S5H-yZlxORBhw?f+-l5Vn zWy^RG&gc+grU)uAQv|W=GXxdL6hWZO5Cq7H1f{Cug1W539qKk87L=niW6LIWXp*3A z%;7Usa;iCV4)v4{O%gOj!Z2x{i`TKQTP-&Rbp;A(fo^ggI2}*B|3!(e7HlNMHaqqnnj#1(GXxdJln!waX9(iKIFTUsYK9;&PSqj0JV6lMnb9G|Oc11) z34%(`ln#}iDIG%33_<9bAxJ$F1gU3+pa31ebDU>h+h@!d9dNts=)d6h*@8Z!HQ&CYaE;!)-YMS!<8}-wDCZKD^D*J+qou&~ z?Lep2*Guq@K>e*n(U|nx{d2zSpaU|eQ#@z`>X@i4Qeiggp7JX7e4?t|b41bY73oN) zz}Y|@*uED?2SgRpBvCJs_KV^?olX>jrik)9T_El4l#lGEsOms>B3<=$RGU>R92s$4 zG=1082jWjiySA_7T!Gpn>N?UMQMTyk`gVxAfwV(ZHty|q(?^BU9}x9<%|ZxqGeeTu3s z@FKhW@*Hj>X_-l;bh?|@^ZQWo-b4D!ZeN5?I8***dBf>^y;FeH^?K7^efr*v^r|T3 zoJ|zFHl+XGNOMHJ59yvL&iLs>jW<(- zj}-@pv+ug5eU3a)J-3qbcVx$s;IVrZDPhT%Y()y~+o6=zQeHp}8K0u;z{htAns+w+ zx+qS@dy%rsWNuGqj^qqcyO6Gm@|eE6zH6P@hjdL8UBf=1Q~QwqDxyl~oKEGW?&$eX zonqHcC#nw898ny}ok%Y_RXy4bb@}uiLAn&>zU{5=LewFo3sLFz$wc{Lnz4PHrvnFJ z(BVP={S@J zeG1a2sCwtrl{b%jvcAuC!`Kqaw(?$Of=0Rb|-+3G*`9 z2fT5+#!TF0M2rFG@I;tu2Ik-ZffqBga0@tWiP$5Gocax}95U<>g&;1V6uMhf^vnBU@(7%+Q&rlW?W3|e+vnplMO6MsO*vDuMA7Xzo$@@*+P>3? za?_@CD(`Xdj-IK!$7hK01eBl&qVmkm5Y_#eAga_5QUO@%W2Rjx+ZmsM{e5o%@RdKnfieoUTQ`1D1qDi9Y_MA>}0_KRa z+fzh&EYBsX>zW|SGd^qkW_4b+%7Z!OO!=@(5fzXbqJl9)l>O#yFyTyzF+-G( z%M?*QaZ}C|#TD9wP60FNpwAMO+GdFA;wE&eYnvd7{Xbo&rir3K9?ip@;&h*{Q`1D{ zOwM@v`thA`ruvm~CQ&&|GtLy}DTne*oth?!^EB&BO%lc4%@O5^oU(oH{S;AoJ53Sg z@jjO*o|HM;$8%Tb;9O^Fnked;)2TeYGdh*m+>GrjO_MrRnkID$O>;z{X^yBm0jIqB zG%;`-GCEz}Hk@88xz&*NI-xm2*zcuPYtuR{+fk9^W}&Ci#2EZb6cK@XerFDhW47^$fm}>&+3Wn&xZU!x64G5AFUM@a*)>$)R zNSb$o@EDGx!&WS34VF}OnV94th{3I)2nf2MhT@EM2^?vqhSz(q*d8yboS*@+HI9=Ru=4P;^k3I=u7b<38 zEwiNQep|{HnIL)Sa>>g(dOW1k(P1AIfO!MEUe-}E039Ql-C4J3n=F5c?o zW}yeP*vxX8aMt{Ee#VBXMH0al2NZ(wBuiNHaq$V(GAqMyC+&r>#=~mhz;$e}FyaqX z`AFl2B{wW#ND!+rODyYkmNY;*&E#4L*vYs$aG*H~3|hOL$_S?jj4L0oxbT^iSz__5 zv!r#P(VZnAb`ZG4cO-(fzSYc3P7y+ZMe_h6z@UbZIk9-wS<-YvR=>h}6ATzH@$ri5 z=oB=~1PmWhq+`j=JYb#RU1y19z0Q&bi0It1NJ!mvU?t6w2-f;mGqZB&(`yP*gc__j zK@B0<{rnb}B027WBLVIdnJ23BL1Sk~)|#S&GM6y?C2bv?k)y&K^aLCk`dn|=xh5;-L zH!}0i(lgL1#xw?Cyb-7ao3xC$9Gxy^0)~$$aoVK)4P!b;L-$9QCD#MqxP8_xzE;$-Xhh%cdMIsD2$<5r$kST_809m^>@-x+fo z5%<|K|KfH(eEhGyF3Nw_tvsbLkJbr0#(6*ER&xG6g>jUZHt@*h1YLV7^_{O{5xmsE zgo-$F{hsm5Ni2FX*OjnQu%mfq$K61+fF;Pe#{!CpEQK_5yXrin!kAFw6HWL7g~5g# zbY#XN{jo3HQoYex9!ZV3{GrqZbaE^ z!rTELpiC1irc<+7dXbh?K)8ZGfJ6&~fpsfaKFo=)JF_I}5;7+t33@TqC5abfhO=)~BWRaV&2GN9BYv&m=@CYQ$JLv!$9-FiSwKEw2%;=PrY37Ttuo-A7A!Zru z3LFqjhBwd&G&->cBnZ5X0{pD`*_gq>fKRVJri5bgGRKgB;L1nO2qmZSOiVUjg7pTk z!^*77(W%)iy-4Gi%-Fog@+s`S9dkQqxJ|dUJxhP>dpDPs*JbzVG||1#BS>t9k(($p zSz^f8K=|?M%bkUp<_3baF&l0W63C?Sf*%p(H6TH;88)ySg=yWj?kcc2e3pO1L<_`!~BA}TMz(n_q1*ZB4n0qXQX%-{X1dHj^Y#{># zlyITItz$re9CSeqmA%oJCnk8n2$*|P1;tR;LaR&khDsuS;$f1FdE4S3T(5ZvGu+6Q zldOhc1C16o*g%6;m$Z(!SP!W14GrzpZxV3?8?JZd8?)hF1BD-M=<1hyjUWv)Y^)p; z)xOo7XcLnh1mn0pQCN#dzcRSB+@}g_871mm!lLVZTiLo!C#-fa7u$HIt^5?0-54() z-(Pp%W9RG`r|N`ftR$UM7_B;^Ft_l8!g97gh4J?GyFnl9>Im=kGzJ%If3^-^{q2o2 z7E{NY2jrj!1fG2KI8Tgex`>233y}EL0$Qd)6+Pb7!3@kGgaV7^0Y!j8hV?A5l0crA zfkxo_gf)d~1RYS|AV^8MH=hh2E=+dGtqED-e6BEiV$)G`o{lMHvs+QdeQY38FZ#&b zhB{5m2p&=d_KRgnZxCyEJR z1>P4I)}X7qGde8@pf+0U-iMaOGz{RxXr4=eo-3|voFuM0Vp)Zp^13bQ16F$frp}S# z@7>OA;S4vax#dfboAecNxZfZywY@6tqSnLWBz;|6kt&St-RWG(+0=SeTu5IP2kBYG z*{j#Z^#faV)WumNg)x$|G&h zwGqSwU*9`Qhbab_&CRQ0fioF<2ykN?w*dpVM*PhA8E_rmaHKYhGr~;JK~P;60XPC4 zyBUe9Y1;_ttc<~on<(8E;@X0zNtY-r!Yp?tM*S7VrLMYfN?mMg=cw3DjT%WvoW;8o zV~I>pl9g;p2zLn~pt+bWE~mm9VaC{ppT!YFc6|~q&IgMNp?7tcyrylBslf@PkmOsofD3p2b3+uJc_9wd zUtAnDS=Ie*O%#C%r*dc3pY2L75|FlB1bddUt=QktxxrCM{;ZUN0Yz7o%n>K)>*9=1 zz+G{pggKoH>8s+>+gHUcCEORsR*lv=O-*cp`8 zH&F|B^9>4Ug;@f~Z18>J5F|a2HRGqX;$XTiFS(_TiK$n@gCMRqyule!wFb6BI%}8) z8d;qWyi0&O(P?m(s4%U3gJQZkDHhtB;?TH6MON->8I)F6_dvTDofZU=5(QN^zzMg- z#5_=##L_^L_QWQ{KI#cN8KxPkLnfjZFhY}v(HY~29{IR1GjP?+DCeQU+aVbZ7CsbY z_A&sXU=$#uck-LiS&T7lqhXAK!JK7;w5yTxuvGIX*fyO$)P7>NCamri%jfMaO91q!pDQIm;q6;C6 z5(QoO&w}La{PQ9|NZ|WRaZxB~hkk-8qiAg_+GvIY`5#F?- zGM7UVXhPKts2p*vz-&^m6V@3H!YL7FLKD#OTFWxB0?;hMivZw63rgVRAR7+?Zi+J% z|B7Z(rw(z+Zybz`nk<6J;-inTQbb*Bl3;I_(ZnoqWPm`nL*H4)MfC76G1R1igHc3~ zOoE`Z^o<<++8ZT_&7=q5fDPDiXTnpd>N zMg~{12nbG$u96jiP;^2ZO&S~dfDM;Cs8JCsv@J1&W+8A~L<>^)FC0MfphoXDpn$cWJBO1Vf%hl7j zh!Y^Ud*T!ckL&`6HkKiHFwPN2QBZP|$BDfeCATE`BLz1p<}fhF#^8FmMih~WgQi0g znjCHI3S8aG$lY54g41OB@cWxPXdxn0A2irPl(<44vrO+`tZ)jtirbxU~@B2U={vMHfNL335bd0<7nQGeF2$t_bjDgO+$l>NWTj5K<^)?i?ZLJDz9y~$ z8^u{DViKcCZNGEK2!&{`I4TW-xDBrCa3}!uC^203V(NT{&L^5R!&$^3t<~HUXD110 z{TWlxXcaEvG6uK7@#wT)jnmx#BuP3$VS5snRx#%X6tG-2sOtn9qR$zmBSrqPEU#ID zImMGpY?#QzWRku?iv%Fb<>ww3=$-V$!cS5I}W0kmfrPV&twd z&XT}g2IvA}(g*$Fag`$(H)z# zU?(7Im<#qn;znJtM0FSNqI0;*V>ptzMu!C@AQsl}2nKvEM@ z_qdr5mRRqo;aA%raaks=c903U(dNV-GD zItP%(!=8C^!Vu5^#Iiy(T@9g13Q_lP4?jNsi8C#C|L6IQ4kYoc%5SnGR; zxMGS9CKhu<+`#CX3Mxfdlhf2ng_3o#(XiOMd}8N`TMC5JS|1>;c=(Z6>Fm%*3iK4LrweC7xI|H->4KYnO=7CYOL_rr2v<-hUH3ZK*l43I@7={8CUm#+v zyAE!d1uP)wG_CI$2B&a;M{yRN%IUyb^=0Scz`Jybx$Sv>=QHi)FQ~g!&1Q z^FkbGrZO6m;13noTp>IxZmDM!m`bVw>J3*GHIGFPe#tL8_cn3l+OLc^J2zEaHmXaC zYBf+*U05iw?#A>wAy(R9hIB7RXk5v}51dFYfYqW0lX%e(Tg^?#O&v8&qPeUZRL8QY zNw60R*-SR2m#{MRm!w{VgFA-(;?@G3$pHU8aZwV*-mi-jU6#ICN3k+`4O!8H125%! zXU@fdHS>fFm%+p&u7gHh$uy4$2pGh7m2?gI;~~LWA_rr}cGS>e0kU2(Q#DZo-v4fnNk+U9lc8RBR|Dj0S3CbH!zHs#M0m&4S& zw3(kFBScYE^OK4zU2hRbQ&UilB=n7Hb)-HQdPhvBk${Qdbi#n1;x&M^4x-3#6>F!) zI08GXSy*+tc;ORgi$|0Mz{``Yrj#gpxHo8x%*Y^l0|7>K>TUl3CYB^)OkD#;T1c`2 zm~it5Wx3E|FfPWj>o!6YeryARvD2dhkIs$~dV)B3-XKoazqYv1-#MLgr(W+|NMGO9 z6N}^EadG}Q0gPpFqA@0o9^wRcj=|&zLDtmHg>{lR!Kg-!sfD}Z9hun#%dwIrNk_cI zR@h8&0XwI-uJko=w1n1rF=Ii}uFjE^2D>S)w#W-rqTm_Z;vRCV3!68>v|vP&(M*_f z)7WcMhZ(0y-m^%W_?=G*C@x8GooK-mM+!Lc_JX9&aJKLT4VxF?iL1dRM&Rn=E^b^! zK&ZBnV{k%skJQUt^SB8I0C8T-MiOo!%I1|dVXJu!c7|_+0hvHjY8tp2D#AEsTnxh& zC#%IlfESiT8^7Gdhznq_Fl~ZO!>7y12;3q!kZ~szr~KE&S%qz=7Ua5UhJDGc3+}ih zol9>YERM3cUfb5G;tGuB!EF;2kh!)#ID|Z0oZQpI(Y807xl#LQj8(Hpdur^65ip~3 z?)vNE2KCaIL*lBYH*M=warUp7?{)4O;^@ZG+S}r6+v}a_9ULStEx#%d$$JI6#^~r5G7`^ny zZO$6d8671=l9rTSHf;?}3J@#(V}Xe|SI1=)2j1aeY}%OD)MOeOs0M}tz#esKmm?&+`YwB%r1tT#E%*wK9^~QBxaS_SgS{#|J z*gy<;Fd98tLN-mD!kaIZH1nmntPpxvoVmnMh~gcYS&RzZh>a7Tpj7BKxC0_b zf+asG2_|Pn5QDjVJdFr&0zUy0yXC|3MxBIWk^;6HDZ{FC&Ns@e=ULR$;67?_?dXq0cRI{ za)CS<6So+!xp;E{^KO|}@cQDKbf`ZC^7kpnD1WK!Ncvpn{!~cWz1L>4pM-Jp=I_Y# zEg7;=JnHb6?z0Fn_`yXLsPTUfeOvRfku%3&l1@qlyF5qmuPk{%& zU!Hr5IeiniIB$}9XE4&{37n2M9njv*E4bdJGq;#`{an1cfT)`(u)wAQegxi3f#{o# zHxW>M)A8m3M&Crd>40qT%q^ZP(D%zkf%UiTQ=m{IU#ZE04u(^Pf@#^87DX{i;GTvN3)XfxFU~{j0 ziI{kwTd=*$p{~2#jMj08Xb28JOCXV4WyQhF+#1Gs=_V9s?&SGNp7%jZRj5T?J#ofkc!r^BLTxp3! zK{DWA&A?=M+o)Gt0GB`_xy*HLhR|4r+`8M?06`N3i{S_WBv~tAps9DG+@#1!jLv+`-8LJtGr4Sn_H+rfxAuZ{ikD7RaM^ zVu3ly^8{M$+%2Bl!N~%7K4uE!(ct|z>mlLMm?_X_ccMT~-b8`uo7qA1&FrB1CJI#F zM1j#aPhj-T6UbraDnvnR=IL>Cda+*wncX3|re1Id&KD{W1m3MOr9iAu%U&;Qq(Zk=#=8sJeY?P zlTxy-sRE^hnu*8<1zOC^4kB%$K&4fowq0OrZwmGzym30>7OfhoCr zN{7H@I7QnSyA3=|2PX^c{!Q!PkayY+P8PWIZ&C;ItVZOFGsqDh zpBWDc55vHl@sRk84ZsNkOWa(69^{Dv+0>aGq-R6k3D01k;iclETTJt2c5s~GSprMn zRDnJp6So+BGXPjlI#^}Sx~`d9Ja~sZ2*l_9et}D>(vc4c zkls%oJEklr~GCRZbL0lV%FcX_zOl^i36rzL{H$ zzL^5mH&LMa&MPp_+Pn@{rRVP8WPzOJnOp2(p4dSjwuu5g$y2v@at9|1%)4sd7IXY~ z+9qx>e=p|=4A?w@DR!Q~KG4$y_5(LfAR7zVEP*_`V4YN837aYqu$dhMYo4u%&~KC03!W~shx2foOro}O&%D)xwpGCp1zYD$4`+qftN=PRF3^S|Kj`wI$3p{eY@F$vx`g*&)rGXoxPLp^QXxE zcFTVCPaiEFg!*;h#iTn+Y+mhj6=R37`~D=)cynPXTjmmS8(9Ok)qo)icjhM4(DOD2 z4O`%x@-tcbK1F&SkDkfTPUhu%@afKRJoT9zJAG=A zd<(1V%uhFe1kNt9I`}D)4)}08&=C$+-SZQ#jq1ppJNGt`iS{1fzzYWNjGA$`-A-`D z(5^TQO?3ImS+WMGMiKP^Bp9*zA##|@r`NjyF|hU;4pKT1(netFJTB*=i^2?$4q{{( zYp3a}JbB{6W8^RchQ&|38n}67#WW`DLWsZ=gVA-@={lzJlbN98#emT?ePW5K*$f#I z0x)qzgkl(kZ=g`&A3;vmtab&#Bp?aTEsn@&Yk3yha%XM=9utQdNm%^EtAU$WR#Iu= zCIo3qR=eVqy#YiqvPlA3cW?#(qw|=#lQ_)f;|fCfU2+SakxXO4E=260udX;_B|7(mWV=ve%OjJvVe%xNoV z4OEWIOeDw&fyp9Oz`FNLldBQL2vEnE5MLPeg3M1~K1F7ix@YvIyF-84iiHm1yVJ=8 z1+SycNy@+r2FNwz-kTA-1dA(9Llf<;9S|uA99-ZGewW<5vSJ!jbfDCP&N8|Jtl(cv zWI913Y(P!0c(+IPru&zkNfS>lQiUfMnY})_$n5p0MUK5bv&hnT`c85lPcIUICl@*XUbjvK*Xt6qSIK9J zn{{qgRT*538Eb=_;EDljCykk=^5a;tTw?H7Y<&J?6^A)Og37PMisX`;S5{18!d_V{ z{o%H_;&j~rq8MqGO91>8djQo!T(NN*M5@0X8#;`!s+rPg9txx8w6CDkcalDSinIgV>bGv(-~HhH1v+VU=ict#f>Vne=j7BPdF}Trx^>oY zuJ>dfv2%+YEj#nm&3QckcAsBl&f~c|* zDSO4XzH7U4Dw289?M^{TJoA}6eJ42^XBX*7KDkH_;mJEW-s$IlPoBS%J~RG-Id3QP z%$!?f@ARofj%VgmWOab=5qZaD=J$$xO^Cf;fHv*r_sP#r+M~4Lvy;3;X$XJU)0mo< zt$&>3@wX!hG^4E^$&MMP6Ig?s5EuhiLUkIz0;9Ymj1gO`1P=&C=emR;a+m>=42x*- z5Mx+$1kkil8D;$H3Iakz6{2qqj!#&ilPG>T^c74c;hTC|@%hiCB5*SR|BHe71e?kx-|wFqK6b5wt30oHA=<0zp_LSQ&CiR5RNM z*$SlYgs=vqRMis$rg3;HYY{?vsu4@r8L)&PCUSMQinw!aBw|H|1%4-7&9u$YK_E*S zGPkP8N}!f5WiE)6@=cf2-d529mPR6J5lCGTc#v7uNMy-bFe09G`k;lSH*F`iOjAh9 z2vZ@oXUgIy8DUBVk$O??G$CpviiA2x+|h{TtNKcz*fIcPh<=L%OK+=#hazSp@(QGm zmL-;OtF8_;xrb+(DoUu(^CGrICovtrMFN!3D&m!aE1Ff1L4YYy2Vy&sC1)uco>GK< zI~p61PdV6GTSk~JsXbE`KgkGFDyaO6Cw?Gm2oB!4mv~a<_yyx&cH@*;g|s}qApecY zopJ793RPD&Rvc*~qg$o20_?`pi>043VsJ^Lmd+%6Y#I|u6I}k`wHXa*!E5;vPs$v> z9qsJIDYJH(LNXU#D}a1dA=c)nlan>YxM7l`bO=$wy#WHp}v#8mArAy0p;ximRB*ar7K-7 z8EjxBut0U|%2rwlqK$2ks-~hjfi=v*YtqBEZu?e>qTWKK&ZEOSb06N1Ee0dFuAUn)} zkYi>VG|$x%8#PwJSs+2%i%4>4gcd|Kb4msnS`IHdVK|AUEqlg}Q(&6snrh>;W<{ur zO;dH!05}R+1sdaujNLBT1{+5?2qX^^E{a{lW6ZfaZ#vXi1!u7dw^x)xDG?x4_gUPn zq%^J2g^82lq>CAl6lWpn7dlsdtY!flIrRZtmyOW6*4Q>knGkhD#m` z=;!?%eUlUI`}x)1QT`o#hZ7x-?_rexa(IIi<(a>QQU6u{`X|a^zQc)*&$lqj@B3RA z<)OcU(fq!@fzep}@<#Xf{i_@G-_0@k<=@f2M*UwgJgNL^=T2tM@2^j>!Nxw?BBthl zc`Sqc%RhQ7zk$)5?bkOt4&UNL_htTjqWq5X)E<6!$?_O)|JL(op`5S%TmRQ6Pu7Q? zwU4@wkL;;O|M1%I8uc|m?MwZy(Dl=s`?5wo!*_T`{~G00@^#{+_mOcXeecsCrAIf| z1RE<*1fc4qGHV8~MnZPP=oxiEQn+ZErde$O?0^YlY#XHX6x-HvK}!e%P<6tgOFuJ} zV9;LFxw@#d%haKsi%2whdI1{Sm^ORF5()}0I*R~Qop5H&0M->NDfH`TFzR=Evul*v55Q>DgNDBn^G!$<#?ct1pIYn2rEKt%<2*2p9@6+MPfn zq^<&uZIBKiQxfV}9t|`PhU{?DE3gfk=lZXV*439c>QUv?zVwM=@jV{>dl=>Ye1{X= zSM$sN*1wBU{uaK$qaT-ljpknupVu6Rk8K~Gzq?b*KYbik{yY*SGw(41YZyRfiXo#N z4SUAUl;_ik0=U%J;X&(S(^Qo-KzBgVRu~G69T~F%mrX4oF=Qaip=Zn*31uQi&!`JX z;i74pX0@So227YC+aP6zVj*6k!$lT@sA|cP?5#xw>Su^Fl#wujn9@0mF&0 zZIG%J(QQURlE`8xGqxP27_-*T?0eDW>H<=@Xqu*3Z77`q6K2RZNY$$14vZuuk;Ray zmYg1fnMuR%Y+lr@Wyxsgg%a(o(HUSgAja4>Na?Mji7=3qMHWK_vK)HGtd)Sx4 zwgDM^J^)P&HdbOWZI?4y2Czmzkx1!<+>{ks7n`O~+W;6H9aNL1H1oK)J3M-QzJbyB{Q5@ugZ>te{#}gDBY%ez9g}Zil*4=n zqk4P?qy3Myul|nCQNG8c|7(=r&t9MKU-%>S4v*fypKo9^zeHc(C_dle(a+2I*JvKp z=ls_5r{fJyG=JdUz-ayie|e++k$;Cr|JSJhc;x>RI=}MNn)sVRn|H2D+N8N`r%?Q{S7MtM7P zeHfWxUI#(Bv z02g1HwWv%uco?AqHMRj$H4xjHU}J+KC?6(h6uZ_BBE9HxbpaVMDpK93(esO-1gI1t zyOWtKmrzjJvr2#g>49k$6;`_W&gM1M)NBB+*yKdrlB5MbDp1#rJDF3|OehFktN|cQ zrdIM4(PBF?skf{;|J>)V`fqEO5)zm1$11TYU zXs2jjTFB%N5Zz2vV8U}z=jyVW&I>7}hX&A&m~h>=lVz;3 zhUmw^21QU-D>)$KTmRx6B+b`6Qmm$nG`w_Clf##6)P;KAt(Ll9dhNjT1}MZ6J2!(3ATy} z^!e6C3rX8@@G=7yuMn8|xG9zisSPOul+B1BiDA*75FnrEF2~AN19uJO6snNctg1yD zUlmQ2c0LYaJOEIc);OB3G!~IE$oc$xvyN2smu7?dY_s0e-a_u)HTQQ4rY|w;Y+Ym+ z$DgzK;bbibdLG2@o9$!gx#u~0o1|Ft)Y+VP-)!#Tmf5)l$2cmt-g$_ur;Zj$`7}cR z9kV^~J7yWCh5s$H9&~Q$S10QMJiH7%io6{>0zP?StK+F|)x%>>Ks`wkWN)6V@DO&0 zJ9OvUqmP4eA(s` zZK|ganluIX;Hn)KAj=UtD@3o^`k@!K=}`6y>~yTFAwR8niVfAGjnwL$N>G;32P4X9 z4E3_8IQ1elvR0`sd*c7xEVgc%#pq4573Fr|NDH0Nn`A12d-DWWnli@bM>;G4;!-3> zZV^#fU}*LR0c-_MhdKl3(N&3BzHAifQ86sJmC5XY1kn^4lm`y^6b^zWxg~pLryS^k zJ0;81kWFhOwiPvdC$y&t>XU(Yz~+&P7c2>ict?QXVrKSLp|s_Qo{mDPK?28zZA0Z& zK|DRs*@Gvs+7zwUmZP{;iB$-w6DLszD}J4_a}+v*szftisWBnQ&|XK0tXOcv^Ju8nQM`JmHYe`ke!*C5f1!|eL;%a52 zz0L{BW(-Z}G-MmbAwtf#oU9^ZkA`%R^BDlFNSUJ{q8-NwTopOOR-m1t&Y&u(@SRy| z)84^m))-AT3u~-^oKI?35hPU!Qzi^Z-T?--0)dMLOmo)gN6V@qoT%C-1Y5GHc{RWS zG{6Qbk!#GZP>s0v8T>98}Eu6a_l5z`$0ZouYzgym~b% zv9u{^Z~Dk)ETka3l2TAuYC@6b&KGf!Lm*eO znjq(MYHBKO)sf847=f7(AUu>JkOXH^ElW4f8B5<*eY*D~bW3TeE9(0D(l5w9L#Im4&25;gsq(h^hLC2H8PQ$&|_t zvc#Qs5Lk>V7jDvKllcS2bDXMBao)WpA0FVxd zVojZd=RNc&IpV=X)Gt;Mh%m`<)XrQ)GL_N} zEH*$^VTTm4&`x)`Vo}0HnZ}c;oB6aTq(v_mEUQJM0qr1ZTUx53B!W~05)`yV)&^4j z>{rfwGvNHB;A(3MZKcP$Vvd$>@j?!uxNos2>T5P-L^0st+fXZW#G1z&%*c+KA7vZ1 zg>bHj>;P+wJ|ak&QgQlZw$EBmB@JxxZKxG4`Uon8z{ZsQux%Kaau(M>2Lza8TcbB+ zWh0KLj=EYc3LghAE}V*?e++6%c*N12ZoX_N(Xvt#RudO&Qe@H5ldL8Z5KRyyM4k^3 zN%oY_SUU(IU@sdvm3-My0Ab}@F{=UFD6(i}p^88#aBShoB_Qv zkK}`seIv6Ro_Fx;ljY#~37)esr?6+k^G>`K3KQqc_Q;~K`G_GLV3O*O`so>ilDDhF zief-MRMmtupP;NZ&9oh(Z09pU^@|m;%tuR@Nt<4-t!R*<-^?g$*cowQDFtkA^<(wmBgJ$CSf>J3p-nf-jzmzb&?cY2 zYb#K#P&GU((elb393a2Q6q(3OKS%=$*j0fgDDgyAG?RFzfAA<4BDkve@qj2@ExvZ2DtRK8VhdBMS?3&c^ZV1Xa_%yD4i7& zf=W@{_|Zxr4CzEiJtB~`m$_gR*Pc5!AHtmHu=$jC^NDd0c>+)5#YQI z87>vSvz7!EDBFdl6mSCDAxR8AQt)g=Xo^B8Uh!l{o-sCeA~>(hK*(2P$(iAZ6}P11 z8CeuQFbBCq%_p;h0`c}XF2%IH)2h>mrkb0X6)_qLy6$jPtc#cg@G_Qk0LKs=c(RVh zVTzk9d+4Y_3EDA_2-}@Mm)(lG=D`0l9AFp3qEPcembL5ZXx+7*n$)szA(VDZITDf%cbEW=qPD|p3B zu*JlQG9IWo(+U9CEccm55tIW7qEIX7mtx0Whh@2k6aDXr(TG54PXAXrH%=8RcLn4-3L z4l)=K45ls-$Tfl;Y)v>q%Wtb7S1 zScke(8*l1hx()^vTTviOD^1WCtk|Ho*hdO3;KD$S$grHDS5K%c?ZRsiap5 z^!cP_>od(Nea~#}N9@{+um9|1@0;!6|F4SF10;CBUO1`>XBTyeG?mU&Fa1sYUz+Wa z+%b!15AIKy^?Y(>9(E`*jTa_t(6T_hfK6^x0C#AB_$5Ni=eY2iuG$ieaNg|w`-Wh-6HHf?Q_P=6wp1{K~ zc#j+pw>LjLiuAjlj-;QSEG)_36_cOHTBX%@Dx;jpXuhYg0`&b_gD z7cu0&1pxrOoP&r$S`;gya!3@LQs84HWi!f66SBq%1Ff-vNu7QX)V>8lh@)^0A_}%( ztFgBfQuU8whG+pmbfp&WprE`G7;0Jv=f*|CROJko(3^pT`f zm1&j`D`#yvd8L?#?9v(FkfVIGpqCpLCOcM%m5GWvk`&Wd(&-?Z50O`$tfSP{Anc9l z%omnlwk2B$;NO^y?yla{PMayr>42TLJLO{#;ISkj3x?@ElDLCWEk2ovWzf`8cWWUk zfNV!{VN9VFo|p_kEal`70Iemm`E;V;&&?_{ixx;!3Oz%%5dFzSbr@5^${fJ0#n7w* zvJZ4e)DpD$P_;8#I~Ag!1{*bZgAk!LO~@LRpqqdpq{~OKt)cxmH+d|bc)3Va<{MJ= z#aI81+3LekIX?bFvwIkS%xrGFr~1z)JD@zdPc=J_K-oMBG9E>C207JNBn!FKKVVi{ z_`_u0l`m^nlYjDLeM&hfo)`y}C+Oql6#Lneyk)jWAF7fKA0G$rN1hT*9}#H+OCXAJ zO9I5kt{mdR;30-mYicGJmz*Z7Hq^49l)Z{nJR?e@1`}GSlZ0-`qQrsN*j3zNJ~UnnK{Fk!jGI5?MusYF7u-A*Vr057v^J1G?G)pF+7n+KGrHd0|hJNsdSW z92wTSuwnJsl&9ncI|%7E@g7e$_g+ZQo*(*tI+6Gr61iV-pp{G@x_txzg5auj22|3H zu!Fz_q!6;(i~!(A03)nWHPlYWg0yWj9u=N?O6`~ZC(U;DIQga7z0({;4u(7OUUUD5 zc4Mai9K4+}wC$k9#5Cr!|)t4%hcD@hFMYSbPAtVzFc)EyJB@qQ6 z6wg{5x!@vNE`dyG_C^yBk*erSCit=FAW@b9gOVsO2_$Qw9>S5%fJ>4*QW?JV5XhXN zY@+H1kTD1-!n_LBMOrqc(@g*seFs#B%&M3mSF!^mbrqqfl|7|s#>=CuHrR;U`Q$GXXl%)|Cc~SU)9XHbh&&Uk0P7Qt2V6ST0+{)o;}?Ogj@%ORx)xP_z$ zS&t-8(tJwDZ1q$~xmCd@4n1jQZkY{_5x+DWAL0%RyC0YZ^gn3UBf5^^+&K5j4O8Ib z@tiZF*P0tpQ5OvAt&c-)A+dD-^Ay(mbJ-!@-%R7Syg3@9naum8s~>CsE%wa}^ImYV z{NmIC{L|R`hJ7b)-rUQuFB9g4f5FY=H|drzZ^B&@FT=h}_(eCzZ`#dM1NrB%_f6pS zuHlzqUncyjoBM0H|BS!pAm$I$UEz6o|2+0fn0V$O{u<`bIiF`v?HTOLgr70-4ECK2 zb6+yuW`Q`z1^~a}a+G&r9PQ z_ROi>#-e-^Jh(4_y>DWE+in|vA@;V3{&l$ZG+v3lYZwn-0(;Yhe*ZM~OPJun-8=~2 z#(UuW9r@GP`-bOH+XLJ!%XBK%8{7_i8 zHx0i`_X%Nc$&1DNWTlop_e(?A?Sptm*U@@r$;7(JNi)8JA|S+$jgr1MEaRqO#yR+o z?(0rM8{P}NLMe@eM0!0 z)eXZQ+RcM_M)w;1%SV^#_f9yE!DsNvy7vvA=PYHP%g3*TFUhp-obZ#nw+-`l-hFg= ztM3_pN_QOGHax)FhF_)I-|IUkF=lTY<~iIwi05?g3uE@KVa(n&{G{$};nBTqc)Y$$ z_pUI0?;6JM%XDuF>-VN%{k}q%x9oK%aq}I1=E*%_cw$8%zdm8^AK0A} zMveRWeBJxPp6gA+!0sA;QunrS&h?(*oa?<4eoEIT{GyY1$q7GU*mrjx$DMaLzx4MG z;yK;>hGX=e;k*j>PWVaP+lGB9Z~m3=rM&q|_pI(+!|3{iZ@d!teT6RbZwd>$X;|1R zbbZ5a3j5~XH2gB%Cxpl9?N?%s;P#VyPWQerZ|_~hykvI`Kck!9ACzTfk1r-p+hoJL zhKF?9a7S|zlCp=+a2#mF@V@Zg(;eZRJ)B=2#6oWAY99B?%Fcb|wzxNMkvq-oJHoHh z&1v5_7Jg#l;wpF80v44*^c7U#o( zY2Q2b_JG#b<_hUga(R}HX+f8W0N+FIEL?HpRVtyff%~0+i zg^_%TE*|fFJc)ZyPU80G87!x8`yigcj$tkzA+-6-of>(^X7pRXi$&)ZhBbHB#1l6c zw~sfKV$#7fuZ|)frPhg)i@q}S4+&$rEu*%*%wP&!` z1N-$W@eKA-yI6C-AHEXLU~>dFPxu;rjo|6{8v2~-NL3$)6c3PA+j&_^3>(NDtiJ+| zQmwR~n1~{>s%ONkxt7_+Z1?ECwISHk!A1%~na%zJqki{DqAuzv5Fc;e>t`vSvSy?MgV zV6}SlgrC9sOLp^>cn0gAv74{NGuZqR-!uFSHh zY#2ko^A3+PlkQ;k2mXfPXRu@Qwuxu3*yP4O+c0PK3d3l=!mtnb1%`93pMS!m_>vRO zQ@eM<&tUV=?!6MvV8`k0SK=A$sNO!|XRtllTkr5Q*q-dIU%F?oco1JR|MX|=87!Jg z)(memgJz@d#nQ@cEGkQLM<9yIT_c=x8;i(Y6VG7Pe1YM;_t~+NQ}J#;IN>J@^MLrE zNyMrA?1Z1e#->Iqhqwc5nJDPsjHSqUK0m=uxqW$)@d@k55u|+^D_*Hzn|KE6ZRCc2 zY4{necbt3wrQv6=WAgq9KY{f@VWb1HXOss+-AhqQx3H_{E2EH;!bcI!z75Y{IfdIM zp1^7aUpEXtgUx-y*t^W0Q69`JqO#t-xo1o~fqlxv6E`;&c;30~S-o@j+%S!OC7!`9 z4fUL?Q63}XRxGm}aR+;NxKan9-#2{j^NxupZY~Ept{K25h2J);-Y60caQKL!_Q#xdHcgT-TdJ5_UE<%Zr_WA1K~4cj2ve+DJRv7T^cL3 z>~<%%V4dg=Rx9n@Dd)vel*k?S5y=hp# zFEIR!2d%!~guSiXPviax^QVE&tzWu<-F?jbp}C8F#sjLi4Rc>NP2~O&cff3Ly;vs6 zRBC~=)`Ebt1RRojx4@x%zF|)x_VJNB_?h9S?(Y7nc{CgxNqUOaMkBFGj@l-Zkp0y{dlJ!Ixh!yf)U!#&v%*7z@O?!IA9>h9eQ&ilJz z0?1wLGake`Uv;NAsSkHrec(CQyC(E|*Kpo0CwbCD4En%>^KU+m)lz7QKYNFtFuYQ; z-#N4=?(V*c=bi>O?;V<_ejDs_@;Lg$nBpY;ndBkPojq}Lirg~nH2k98*+#g^Xa>hl z8?-F26G0AwL{{X6a045NQtBWFQL<_#PN?23usYS!mSiG2%9%WC_!$r0gA)LiWu1=7dGvHSvVuXFTx0 z@88{h6Ug@dKlOyUx4VYVVZ-2@Ex3F1=tQion}!dKTg=ln$`A)3tUOs}{2Cm8H%)K{ z&OqIn&Z#3^Sy1l22fey5fKToqGR~sWyE~x`Pqq+IYFkqf znf_+%Qa}-8y)?B%-OdOmB28sWWVzV$!`6OV@+>;cK666_oS*7fwEfN4ErBA)ipIn$ z*qOx=MIx3|*f*o0N@ugR-`2r`TUsauhuW5-Xirr*JDMqhI{6GTW@n59OQy-gohAs@ zC|3?9h3%(%VJ9b%7D~YqSFxgC17}|jWYXpiv}7X$;S^QcdG3^~D$bH0HZDGCC8X#u z`^+T|m`zHkp{0L`l%nkP5R*rI_))RJ*!D8mo&$%+B`g}2VVrqUMfgEP3JmK0K*%Lh z-AlwF;xq0Ht)`ZY9b-CCMG0D(0DVx%`NPuw>8_o(G0h#ln`s`#+nJuX<=sr@t$weQ z?l0=utW%TVwFH0e14*5OZuZ2MuYs!fOyp}b%?N`_iR`vYfRj<89a!y`U?VoL^acb@xZ zG*p>Qjaj(zNwZkhR)k?( z{2CAgkKhtSm85Qr{5Piew*H!a@#~SN%qx_4hv1999(9Q4_c84+)tu}bnf7o!?DsL9 zH{#7qUw?ex=Jnu&`GfKIq&WwU;WdA!J?FnC%^%MF_vs}k-E;nX(l0*g958=4-si8! zSk>@5na1+1c%j+?Y|C-Jr^c;M2FL*uvo-~K*>-P7geZM#ce)~9+p3K|)_3%OdHJwN2 zL)^dnKEi#&eZ>A-kf^KwLU?*Udfoy%`zDYh8m8UkJ7h9{Sh)BAR>{&8PGl?45P|2C zl>Q}AilRtH;mAACQIDUmozc#42ooIQ zd~*Rr{V*{yCdf{Pa1$rp7A3=UnNa>&Hsa(@04im;jwFqolM3 z3s1F#;K^I3hoC6S+n-u_rGSE)wY|fSN^}u~J&S1f%__@We^|Kq?8KBDW>^P>h6p^@ z-I?xg>k_Nn*Y+;H6bT?fv50oxj79|!jdCb$6$xrdA%J(FLDU%l z(iONFyCo~=G^IERLVV2Vwa~7WEr|jV7|`G^ja9Xk0m5aWBZ+{}D|$+G0FBN_)K#QJ z`J%OsD4)Nkb8MamOAqG%-%0yE_&V?^y~XP>ukU$>-^TR5V72}ZC(Sv(-AVs7?Y|&A zxB1QkeE3uEFSyT?#h@tuLEtYUYyNp4-jbEgtC<0;p@5y%$-`D2h$s+&0S!JB6DEjM ztE3Q=mXsOjRZ>BjNLtMd`kQy0uqqHsFqmj(M5oj11Tq{a=;a|~F0IyQQ!v6I zrGH73qUiJxgEc>7Mk)Ryujuzel0nle0nPY>unH&S@ zqTYcmnZh1KK`oKxQqcL*({HQ1$u5b{GR^p?I?~uu96U_a{N6&HX_y=SFHK|Q?M!=8 z?{(67s_%8uz8G)zdf@rpUJw6_{WYDx^!+Qof1J-BCm$@2mV@GtmOuN~+;SehOmM(b z0!7{mOu9#b*pk6S_5x~&elB&;GC-4$-Q`WTA#*RNG~odF8W4r96=cK!#kz0iY&|%8ogm&;k<4va2Y*fXjB{3B?4*5IJ~JK{ z54X?n>zdAssa@Q@hv_`_w=(T7w!h!J67O-+zP@kvPVdq7j5p=Y=2icazlfTAvv)e@ z``2{;Wcs(Bx62od!{MR!-|mNyUwm4DvgM7G7*hI20?m-#=C23$_im=~`);Q7`);Or z``_%Od7t0SG_S>9)4pgu3I5dj!;(L{*CD0kLpGeq^J{&!;mBIa_N`9$t=?(w<;`9X z{JxuM{l1%N-sLwt>AcJDWqLfn-AUv5-AwEG%}nzrfr{UgXpG^IGn{MgCb6eW6K-8ZAL{GQVG z+bS~5N}$38G(U@Cr&q*g?3U~g!W=HK8eyhC1wQQs)RNVX=xj1Hmb03)A0(V#D4PU{ zEMi?VSmrDdM@W>Z@ll9QasQQR@0Xbx!&`5GeLmiuL9B}e)Z6}R+Ox>P^~^XUj)%pd z$34ZV~(iDHT`sb#GD|%M?J}X_)5W`)8^8ANvM_C zH^vG|(KxCqIKff`&4YDvN1%B4I>SIHZLOLALDSx6?zhL_d3@!Q?!$R6({rlx7WK_~ zkCVpj+nL7lo0-lzznSTr^IMtTbN*|(zasqWmOn`Z9t*2~oqp$EM8)3g7cs}+-ww~g zTj;<3Igg%;=fi&o0Q0{(&Pj2ID%hEYjySpWtZd1u;=(*I@#x47T;4;-)WL~Iv_bSM zTEu4TmXsZ;IZR>|GBEuq@M$lgmaH;6a?WNc1cXMkFW5qVRLhMif2*I{}UpqKd-&0O>?+i$DLFhi=51&6%2 zRa6WMVg7m3_|Ozb)}OC#TF-B0I-cLlw7)9v_IkXdX@2+o%$7ej`BnVFzli*`_-lF| z7YD)0qjPPVJcs0A(G=z1TOYJfS-jkKi9vF_NQTDyfo3{%x3#qNH)EHA072GOU4B%e zEAq@60uvkv>+qQ^J^i+d1SPQp<*5P9PO{R{$DB--6(BH&M|}7WUYuNd6~J?{DIC5U zmhHDyWU{4=FuZ`-GVT&>pMi5_aPXwf;SnExREm>ZMF^TAfEAV5)S#JuTgNoGcQef` zzMW~G$a|f1-1-8|f%7KqTjcp$$Z{TsZ{NVO4}lA6=naHdZaQsR2z)}hWk-gV((d+u zs{Mp`M}05uE&Q(W=e2*^I7j4xetY5`oISb%0H(~jetY5{8~=%RRn+^n_!;%>iTiBz z^&7^Y)85HAZ8O8lkTAB$_4d->Kl;13_C_xZz#zf}7Z#OGE%GVU{+#R+1( zvx09P%1^X??a}^LyrPsJPu$}kWOvAgJYoD(v}@P#SimQr&k%p5wrA%6OdsRKy6Q4kduv7@#-n(pHU@v& zxGuSq*yVL1da3ru;^(lZ-6Ler_s4hqDdV4{{h{%mmPhuljdSeWbnl8Ip!6x)Xx$fQ zG-LT>JY>RpYak{Ioi<@hM1>@aIO@?1Bq@kWif&>o7IU{&#mChD|gI2Y5%v4 z|5W=KON_r`Vo zyT+f>&hz9na|2922RlbvH4eEDO5YhL_3DlkpK2Da>}P3fQzsqrZ;Wg3N8_*5j?Ev8 z&rNV>Ik-fg_ZGi9@u!S`inh;9H~R9~a!yZ)_mvnKZ`S4YF%J`GopJAN$`UB%u`(oM8?+m{Qga6@aer)`w+8-KUjqk*nn5hl0 zhB7;Ecy=r1Q~kj>jSq~gvgjs1*H+|5ah&|N_)E1v7Qb%(n-kC7XHCq6g+!J+&_+XMOWRMrLu#+`X` zZqJB+Z2VKS^CW*Tu3g`N-!T4C?We^1AisZ{dAIi*`&Q?Mc~~6#L@P3>S0ySaYb_@`*&C5PP|lU6*+Np z@Ib#i6t#ae4nM+ser82KC9cyQp821k&8^PodvWgZ1LKSSE#se~y;gV>*Qpr&u5k|I zgG2cgZBP2Pckz=ZIlaWEhSCwX2wgML#S?>O0oln;dsZ3aZ0=$7%H zX+I^-uuSV@3#rDFU1vROJSuye8RRr>J+kpC#M=E+`BzCIA=%(#v3j1O|$ zS;Z)*3XoX%weio!vHj&c8IP!0{l7Jj{QyXb$0pFc+=lA{!{H| zjC)!racU%<-`cfIYfrq5d&ZyB#)RKn;@T8BtjoCem8sajG5(x(Oz-(M%Tpd-TrN2b z(->ZcmQ$DfX#ADh&dq?C{>{`Hel*Tq^rRn+bM#C8Mq-OTn?ineC{Jo%6K{xP>I36i zS&#qL_;cEM6*}uWahzx{eag&4kq>)@*SLrM=f`7rKZiC=g03hfV#$LSBoDR{QKkb2q{a+^72>#x(^AwAI^ zQ8FyXipM=g3tHUepNzj!`{#%C;nCt8^F7U`RJhgL*_B*H@I`PEWPo>B0SeHC(DZ`J zSM(-UQWYM^RN-6SMVciP`~Zrd~ zltWv%qhOj!$&~bAI{YeT&S``T3eC(+hN2(Usin(Y(F9yb_$_>4Q-(6=T~0ttqI;DQ zjqnBvZZUI2P-i02yPSZQM0a6=i2@?vvXZKbOPG=(fwjskuMklR%oT$D1jsSWV&)Xj z2A6J@6C8xf!UStmbPS-!FzFfOD9eh_&FBr-q0FaajBp{)fG~O~iq0gbeyv~mKA|RRk8sEa!N>ojH+N|^ zmblwA+Qw(9Y<%2<<0!nRET$A>2;ZpG?V!llS;W!p9tEUrWidUR+E|54Oox#b92!+8 zBApnY$%I&psk&fn(cp;rY}MW@>UIw4wKm)%b z&=#k(Z3japF+uT?foJf(t$ zaNR+T4<^S1HHr745CyC1<{m#X4mI=!up_JHmEemE)m=!f(`sl_5ITHCHxam^!zg4b z@I~)JYMoX?5lTYkE4s;vmndQu=z@14wN9&{h#0CX(TIv^uyCsm8a}bjK#~Bt z8e0Tq)h>Xh_{yjND5KRyzIql4ZY(VDS-Iq8KcTI&48)PEd1S#`1cBO>$gxX}DYNsD zA~eh7e5@C9B@c}jog!Q*08T~&`sow(W1Rs&P6F8PDN>jc7af|WNgywPNJCkn2+t6S z^(*C|4B+xnJt5=_N5drXg>%A?FS({ za8|!Ncrw~vbZC;Q+r5C@aQ60t>iR|AUK+35nHEVO$8voVNI zqJ<|7*ye=M0XbN%5ECXY#C`}0N?r6SrAXG{5GJvY`cj$C$GreZe#Ok}R~nhd*c%vvqGUg@8B^-7+@=AyDGeCnUfmL-1SCacv(A}%c|e3+Z7|9QMc*-U z5C)Jvp;FAH=T}Zb;cWmQ7bc6)G4-QvDe!>@;mly=8`eg~fl!YLErp&;*)q@s&5X=R z6k3Y_YzKiXU|HQyDs+0q3=iC7^S#!GA^;k0M3Z`Dr(>JWL?YTEw#pPE)`w!jlmbCr zOyTmg6ytz}Gmr1JW`IRT(r_bUj0JZ(n-ziwihSVtz1D{!fMDTFgt~U5vf08vdlr87CkZDzDS-CANrz^AiiSy;gJ^^sC}~v+%|Uo&JVPk3ToCif zQFToAqMIlOEr?BFWR%fKVbvdmCd?V8--jXqr_rI=bZTu`V4($eHLqkU7Nc8)a4O*f zAm*}mqLTtxQT0r=n9)Tb6*wE8O$}|GMaV3x2_(rD*&mC_K^qDz)E}?!>f)$0JwId*`@$nf_KR@$v@ms&^L`c6UJJ(?juge2UeI8VS`0C z`Pg`fGS8AhPZ-a5EDTAvqqQNyTG2R(o|4(OVlFe3f<82!kua)8)X`J5kz6fRtuMSF zO)IkOb?%Nj4D`;ZW(=e@oALpu-zrqTaAgNshK(%XG6gA}><;nL0Z6BUL`7RNmQl6o z0`5W*m|)0WHKLv34m+P!wqH0y z{bq&12MvS>BS^)J0Wi2|=0j94QP5V<2(@U5iDgvHvb>UEFVjgUb2185>BPe0q>NZW zQNvkGOIIe~%EZ2wr|!_{*fcYYAgV^-GAik@N*WCTf$1fJl9ns7jHrQ+=kCabzQUUY z*}*9C4HzzMn1yZhgYg(DzG_>89~GnN_NG;HLMjdttOw_Z;@aVg#UwWTX3dyzl`Vsc zQ)l(+&X(3hSgaa5$W8@QT$F+=!zNT=vIz4Hk@Q4E2I+u|nI2t=i|j5;E*SdR*-QjL zwQeSiZY0GWu1LtK2vG_(Nh+Ht;qXKi?5G_W8eC<&*NNpbJad_{c7vWQpKs=fJ6w?w z7|~d%;W((~kr>56EmpCqI4981&=A@EXU3=gpn7v%v*5=h3#Ch;q$iRc#EL@J-s7(s3NR+3qONt?uP07)RN zW}7;QPH1ug@qrwUm84Qu=SmcGcH}aCtH?rE4>Gb2V2)KQ%PLw>;85s-rgBxuEus^M zZPQEwebEY!Mg%LmL$uN5aG4xA0(jXc)B&;%X+XC#7EQ%(7m#w*ouUdqsL-H?UiLsK ztbkIa4-%qRQ!s>32*M6iqO$aDkvZ6<<7H)xO=S>5X5mSiRnK8nQiQgQSriDXf;Iu9 z3nFNOlT8Q$jBGn*pqN0Ps;uaNwBRZ)NU?U|L6zxVDm=iukTSt<(I#1T5R^!ktGdhq z+EfV)EthHV2@`CQ>_N9O@U%)s(hs}MGEA~40E=(YCRwb}eGG(`i1IQuWRhhXd|0R8 zCy5OwhHIBr$w>Oi9%cv0=<*-|92vH(TiEew(^IcIyf8YO`utd?wP;FJW;)+?8<0(1 zW_Jh4Q^zIbO9DK4!d$nI1SD+`zcQ``>Cpra8)EfS=|m*w#|uC)Q{ZD3qzYLgwrII4 z4`JfUWweFpMKDa|g9IL#iY*$<+DJ6W>KTaFr&gkS9Wwl= z%titBaZ*7ciw1MC%;ZQ~@*vQg&+2yv&`TMUi2?>M$JGpgDhA;)pAU7A-Gi(|%Cw3W z8$7GubY&cJ1qTq_?&?%6iYws^HLNs(ufYUPoED$de=u&H-Vqc%Wy!UZObqf|-2s>q zNOcDgSY#@;Xf+gVBozU#?iMo%u?&EKvMUi~zf7m8h^0n?CYF~4ev}f(t!g_VXWa=q zt85jRr=1K}xlX8~*8RAv40OIxa0^~KlS36}mPw3&72WOuS8W8qhzq*20^X^S6D52= zl#qg(>8V6eqGcmNog?0fWmcX_-PuU&BR7RqfJY)_ zT1AQx_Yg@1v!M1!r%2XaJz-3+Oski3gkO=&?PrJINmJlc5PBFD(3L!J$u>_U^+7JO zYRsbU?vqh)ROis5fPTIKlLcavJ|K4)_K^d3p;rb>TC+pUwCuhaLGr1GC%A`&H-mpS6saPV7vwR`38(!tSGg{ z!JJ7QAHPMf%q151V8N-%GLeb6)jU%c$v)pjD-l;gZ1V`KTq`z@M0@ zFA?SK)A(KEb&5}>H#Kn3Svr4mZYSs@+OW39k|aaXkLn@IP$mmw=fet#PZJC62@)#c zYK5>v)v*-9r36(4{VB}$Z51;aCiC&_`a!_Z8lc_n8tg@ZIm4%d3 zVw55O%uCdQJFw@i_l|JE#rS_V56(~SLv$EvD9F9O-nwREdP4nZhv^hr!mWK)KVQK* zUg}VD<}vtz`RA-hz*C4eCy_&7sDpDPt#Vl3!6huneu+|85M{~E%HdjTsk!yYhgEC| zNI~45DcULrOB`HaSJj5|4jf$)LG~HYN|`c>0S+gdG&6Fl*3nr@+kj|$d!{&lz!C=+ ziOdr5Bb#96&qBB~H5g?A8LuEH`&M5>Q)bm0kl zBwE~tkSZ`MlG&s(t)Hkz4=5U}UMv!}`shVyae-w{2P?(&e$gdKz5`k@O)CQz2?=I4 z5oWDSVfBSrW)9iunZ43F?&8yph4kmDm-xM+m*%XbYVS5E&2cVwlla9TV9|>OW!05kT%{HlShRy4fvA@r!X(Kbh>BnEjuDpO z0dyk3mC#I*(WPt?*JNS0T2kjz9FQbos#vu_T4b8~wg93YS1dml4fLcs+fgXurMq8YNqwW-SD(_SQN;7?D$Uw%+PJ2_NUUBfM z1&5W-v7Qk-%-ONG1HLo=ob_)^e!BIXfCrny`Nhe5cb+7^INey{hTGeNP}&;M;sU#> zHb_M)fi8g{`wVIYoddTBoKFc`ONvwgj)W93zz$kYV4N*%^3mqbU0!ifuI0z|BEoo~> zoy3&WCLoP6Xf#>{Mv{$Uaj8+R5)Dye8%Y%ONCfc#TrhfkK&Z_YLT3eF0t&1bOGv4r z0W%xW;!>jwqzzJ&;Dv7fK>QG{RCw5_Y9%_M)21j|46zrBe9ZWXnG0H6U@2EPf@@Wt zT7tlHZ6$HUOIe%-nSJVXaFw0aW|vh)+8*ujp`pNzO?-pO#hSU%5swAM}r zpzal)duN(`()6g)ydzL_BAgOaT7C2ax47&}{20za zRk+g(CT|w8VyJl)3GsNcMH;RWgekBpn^J`ee!$Ww!^k8qBJ1569GXW4LgB?5s)UG4v!r(XCGXUVW|vKVz}2} zk-}>8vNie24F_t21wn`@CASnFYY<{ZUnN~cggMa~xK~n>NVF=hs<3_npYk>x81cx_ zqTd>c8F@OPB)NKY?FXNRb1Eo(Fs3HKt2;#+N;o^yeuSe7FQe#Va4Sg_pk|K@lniRA zQ)y?RLA2qV5UGf{hOHW6Tvbi>6Zn*Yg}TJ&%}4zO*0J=I^+6ZUPKGOMY1L#py>Bt1 zGB0Hck|*d+g)5RK#inXwEf@O%d^!h2kO3iM47-e=ldlZQUMx{~s6doucQpnEX}_1I zQkjw&Agc>X%J=X~h$t2qP@O5Je(@AyabEW6X2MkqY%K_js7^lg;&_A{4#Y$Po-Cv| znFHd26)2eH&`$m0NwERWHkwkBwHjbY1XxpcAj4W_ddXSsZxhMy;Ua3`TkGoLWx5J> zjG`=&b9ch@DLvE!T^qKLnzG36y*LGtU}B)61nDnU1^p5nGwhe;wSC^b1jEKn(eG9Mcz+c@=$N6EGV z(k@LqMaZ2cdy3Us&-`u1L44}Be2pGrd=re_N)ShfVKO;gnzq7`io4LDCW2)WB~w}!QHcY) ziV|_P!Dy8lkYrwnBtWd&vI|8Wgo^1NdQ?&>#xd3D6gv@6?u;Q*C@wM+wTA{gH5({h zUZLOxMJ=}yA0Vi*v1Cv2!F&)evYz>z1z+tvni)^&VKH5e)NQQ&gL!XGMs+&HvAJ`l z&KNR%Fs(Bqay7{7Y6B_aXX(B@RU{z6Cy*QFgYPhjj-G-;6gp$b7`V*urQ)ybE)Z7M}XomAXIgFI>{$jonSoE}wW)JepQ za@dHor+8VIIXjF9n1!%SFE6wrq*xjjwtQn+ta%q4TtKfQSzrbH0|UNiKCIC?Js0qdo}2h{=)oMvhIoQ>E19uXJ7ky zwt39CVc(y&sQ}zgo~%_Gq+F|JmZa_U#U0RObn%ceZMS;H(YMvzakVW!^U(?P120!6 z>h;5ngtedm)pTeV%6>qU9eqj>3Z8pHLW~CqU8v5iRtzHhEgb#K zNR+V`2$k=wS1QQPt47&MVsojWNPcPEX)Me{hAULqsix6t+J!n|K`V3dYF4F;(3eVz zW?se%KZLj8|BH+I;S9tX$@PYTcWB zq4np?=Rk*Z%ewE&AO{TvPImeGV78iep^gX@61?cRhN2pIz{QZK<96vOAap0EN46C( z%{#OFPMPdmxU)0RmC27F(vPf1k70oqMg9}>0j^O{-+MXMqd?os^hdaY85i6X9yLin zvQBY3AvF7m`Mt+`*6}=yS6P43e2xx3T8b^QtxRNWtql&PXGGX4!Ko5+YF^)fQdhKN zfX<9IOiUG|#V0~o0oQbPDk)kc+e&!Jjl}^&;{$lAneaGbHJMC|PGv1v-^f1l3=z#Z zCvMFY2`^AwWLrUG^M~rd6#5Y!5yw={%4$qPu2jd=k>xZO5%p#aER-!VHn@@#JebTB z5maePBTo1&{D@Als*G4L$LX@D5j59migpr2k-j&D}PS zF>LcC_($u7{REz_Vp=qM(4781TCWE|i;SsedvnL}32s>TE>XbNgDbLDHB2h@5i~68 z7f%XHsLu3@w0@-mI|vD zD72J39ja+eerW;)M%VAG_RAvv0WQK$hDHRK8dFUD8gtYl7sQD(6LMyiOTyMu%AS zw#A8d4wROXAB73N!Nw!22S46Az zA#JLeN)Wx~k)iA5P+MWNp*V}=ZIM=nEy|SqE;@~tgjqQ_B;d?SgP$m5w_?x65g=d$ zEUyR>SFZKsVs}887g>ggDozXR|FL5x2y!Di68!(KH_Qwmv-;Fg)O2bqj|jlw?m&>t zQqRa+$V{Y8FGJeNL4RuWvGGX8&fp|7(3VI~@k4#*wDPM*-)T7iT&NGjk!;D?KUDv> zqo3~Icjf2e9udd&$esxtvw_S+E}osd4tqg{o~&!fiV1p=iJ%Hh2h#dNs5

Ky4J7 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 It9OnAV--xH#4Yj*)gQZy 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 literal 132 zcmWN?OA^8$3;@tQr|1PNgm2p0kR}K-DjmTtJiWfnyW~Aue=T*+bL?8*+q^x>SpU}# ztw(?Aamt}DP`&XoYPKQn8-|nukaH1}k3fQ3F)mn(P=sv)#)C`dY*0ujQ%NB)Iq=9H OM2+^91-y%!0rdkR#V82? diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 854edb43..514bc535 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -19,6 +19,8 @@ "gpt_oss_20b", "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", + "gpt_oss_20b_pretrain_c4", + "gpt_oss_20b_lpt_c4" ] @@ -143,3 +145,27 @@ def gpt_oss_20b_lpt() -> Trainer.Config: ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) return config + + +def gpt_oss_20b_pretrain_c4() -> Trainer.Config: + """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" + config = gpt_oss_20b_pretrain() + config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" + config.training.global_batch_size = 64 + config.optimizer.lr = 4e-4 + config.lr_scheduler.min_lr_factor = 0.04 + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.initial_load_in_hf = True + config.checkpoint.initial_load_in_hf_quantized = True + return config + +def gpt_oss_20b_lpt_c4() -> Trainer.Config: + config = gpt_oss_20b_pretrain_c4() + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-c4-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), + ],) + return config \ No newline at end of file From f10f283126a56e86a8e2fbacb21724fa4bc37403 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 11:48:23 +0200 Subject: [PATCH 054/142] fix viz --- scripts/debug_observer_viz.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index 8f2a648c..6be7d54d 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -80,6 +80,25 @@ def _collect_series(layer_data: dict, key: str) -> tuple[list[int], list[float], return steps, abs_maxes, stds +def _align_baseline(run_data: dict, baseline_data: dict) -> dict: + """Return a copy of baseline_data re-keyed to match run_data's step numbers. + + BF16 and MXFP4 runs may capture at different absolute step indices (e.g. + the calibration pre-steps shift the MXFP4 counter). We match by ordinal + position (1st capture ↔ 1st capture, 2nd ↔ 2nd, …) so overlays always + show the same training epoch regardless of step numbering. + """ + run_steps = sorted(s for s in run_data if isinstance(s, int)) + base_steps = sorted(s for s in baseline_data if isinstance(s, int)) + remapped = {} + for run_step, base_step in zip(run_steps, base_steps): + remapped[run_step] = baseline_data[base_step] + # preserve the "active" gate key if present + if "active" in baseline_data: + remapped["active"] = baseline_data["active"] + return remapped + + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- @@ -145,6 +164,10 @@ def plot_layer( print("matplotlib not installed; skipping plots", file=sys.stderr) return + # Remap baseline step numbers to match the run's step numbers by ordinal + # position, so overlays work even when step counters differ between runs. + aligned_baseline = _align_baseline(layer_data, baseline_data) if baseline_data is not None else None + tensor_keys = sorted({ k for step, tensors in layer_data.items() if isinstance(step, int) @@ -164,8 +187,8 @@ def plot_layer( fig, (ax_absmax, ax_std) = plt.subplots(2, 1, figsize=(10, 6), sharex=True) fig.suptitle(f"{fqn}\n{key}", fontsize=9) _plot_time_series(ax_absmax, ax_std, steps, absmax_vals, std_vals, label="run", color="blue") - if baseline_data is not None: - bsteps, babs, bstd = _collect_series(baseline_data, key) + if aligned_baseline is not None: + bsteps, babs, bstd = _collect_series(aligned_baseline, key) if bsteps: _plot_time_series(ax_absmax, ax_std, bsteps, babs, bstd, label="baseline", color="red") ax_absmax.set_ylabel("absmax") @@ -193,8 +216,8 @@ def plot_layer( ax.set_visible(False) continue _plot_histogram(ax, t, label=f"step {step}", color="steelblue") - if baseline_data is not None and step in baseline_data: - bt = baseline_data[step].get(key) + if aligned_baseline is not None and step in aligned_baseline: + bt = aligned_baseline[step].get(key) if bt is not None: _plot_histogram(ax, bt, label="baseline", color="red") ax.set_title(f"step {step}", fontsize=7) From dcc2a25dc14bc0de9e0a613abe468960062efa15 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 11:53:12 +0200 Subject: [PATCH 055/142] debug viz --- alto/kernels/dispatch/tensor.py | 5 ++ .../fp4/fp4_common/grad_clip_config.py | 16 ++++ .../fp4/fp4_common/grad_clip_registry.py | 40 ++++++++++ alto/kernels/fp4/mxfp4/mxfp_linear.py | 18 ++++- alto/modifiers/lpt/grad_clip.py | 76 +++++++++++++++++++ scripts/debug_observer_viz.py | 48 +++++++++++- 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 alto/kernels/fp4/fp4_common/grad_clip_config.py create mode 100644 alto/kernels/fp4/fp4_common/grad_clip_registry.py create mode 100644 alto/modifiers/lpt/grad_clip.py diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index a13d8a55..133c04a3 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -211,6 +211,10 @@ class MXFP4TrainingWeightWrapperTensor(TrainingWeightWrapperBaseTensor): based on the training config. """ + # Set by GradientClippingModifier.on_initialize to wire this tensor back to + # its owning module so the backward pass can look up clipping config. + module_id: int | None = None + @classmethod def __torch_function__(cls, func, types, args, kwargs={}): # grouped_mm op override @@ -283,6 +287,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): clip_mode=config.clip_mode, use_hadamard=config.use_hadamard, use_macro_block_scaling=config.two_level_scaling == "blockwise", + module_id=getattr(B, "module_id", None), ) if bias is not None: Y = Y + bias diff --git a/alto/kernels/fp4/fp4_common/grad_clip_config.py b/alto/kernels/fp4/fp4_common/grad_clip_config.py new file mode 100644 index 00000000..19bbf64c --- /dev/null +++ b/alto/kernels/fp4/fp4_common/grad_clip_config.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +from dataclasses import dataclass + + +@dataclass +class GradClipConfig: + clip_grad_output: bool = False + grad_output_max_norm: float | None = None + grad_output_clip_value: float | None = None + + clip_grad_weight: bool = False + grad_weight_max_norm: float | None = None + grad_weight_clip_value: float | None = None diff --git a/alto/kernels/fp4/fp4_common/grad_clip_registry.py b/alto/kernels/fp4/fp4_common/grad_clip_registry.py new file mode 100644 index 00000000..b5cd69e0 --- /dev/null +++ b/alto/kernels/fp4/fp4_common/grad_clip_registry.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +import torch + +from .grad_clip_config import GradClipConfig + +# Thread-local dict: id(module) -> GradClipConfig +_registry: dict[int, GradClipConfig] = {} + + +def register(module_id: int, cfg: GradClipConfig) -> None: + _registry[module_id] = cfg + + +def deregister(module_id: int) -> None: + _registry.pop(module_id, None) + + +def get(module_id: int | None) -> GradClipConfig | None: + if module_id is None: + return None + return _registry.get(module_id) + + +def apply_clip(t: torch.Tensor, max_norm: float | None, clip_value: float | None) -> torch.Tensor: + """Apply L2-norm clipping first (if set), then element-wise value clamp (if set). + + Returns t unchanged if both are None. + """ + if max_norm is None and clip_value is None: + return t + if max_norm is not None: + norm = t.norm() + if norm > max_norm: + t = t * (max_norm / norm) + if clip_value is not None: + t = t.clamp(-clip_value, clip_value) + return t diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 72bfbf3d..9738e3f8 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -19,6 +19,7 @@ import triton.language as tl from alto.kernels.fp4.fp4_common import unwrap_weight_wrapper +from alto.kernels.fp4.fp4_common.grad_clip_registry import apply_clip, get as get_grad_clip_cfg from alto.kernels.hadamard_transform import (HadamardTransform, HadamardFactory) from alto.kernels.dge import dge_bwd from .mxfp_quantization import ( @@ -274,6 +275,7 @@ def forward( clip_mode, use_macro_block_scaling, hadamard_transform: Optional[HadamardTransform] = None, + module_id: Optional[int] = None, ): """ Forward pass for the blockwise FP8 linear operation. @@ -412,6 +414,7 @@ def forward( ctx.use_dge = use_dge ctx.clip_mode = clip_mode ctx.use_macro_block_scaling = use_macro_block_scaling + ctx.module_id = module_id return y.view(*original_shape[:-1], -1) # Reshape back to original @@ -425,6 +428,12 @@ def backward(ctx, grad_output): # committed by forward to keep the non-CDNA4 matmul below well-typed. original_dtype = ctx.original_dtype + # Site ①: clip grad_output before it enters the quantizer. + _clip_cfg = get_grad_clip_cfg(ctx.module_id) + if _clip_cfg is not None and _clip_cfg.clip_grad_output: + grad_output = apply_clip(grad_output, _clip_cfg.grad_output_max_norm, + _clip_cfg.grad_output_clip_value) + if is_cdna4(): inputs_mxfp4, input_scales, weight_mxfp4, weight_scales, x_mbs, w_mbs = ctx.saved_tensors else: @@ -541,6 +550,11 @@ def backward(ctx, grad_output): grad_inputs = grad_output_dq @ w_dq grad_weights = grad_output_m_dq.T @ x_dq + # Site ②: clip grad_weights after the wgrad GEMM, before optimizer sees it. + if _clip_cfg is not None and _clip_cfg.clip_grad_weight: + grad_weights = apply_clip(grad_weights, _clip_cfg.grad_weight_max_norm, + _clip_cfg.grad_weight_clip_value) + if ctx.use_dge: if ctx.use_2dblock_w: scale_shape = [ @@ -562,7 +576,7 @@ def backward(ctx, grad_output): ) grad_weights *= dge_bwd(w_fp4_values, torch.float4_e2m1fn_x2) - return grad_inputs.view(*original_shape[:-1], -1), grad_weights, None, None, None, None, None, None, None + return grad_inputs.view(*original_shape[:-1], -1), grad_weights, None, None, None, None, None, None, None, None def _to_mxfp4_then_scaled_mm( @@ -575,6 +589,7 @@ def _to_mxfp4_then_scaled_mm( clip_mode: str, use_hadamard: bool, use_macro_block_scaling: bool = False, + module_id: Optional[int] = None, ) -> torch.Tensor: if use_hadamard: with torch.no_grad(): @@ -591,5 +606,6 @@ def _to_mxfp4_then_scaled_mm( clip_mode, use_macro_block_scaling, hadamard_transform, + module_id, ) return y diff --git a/alto/modifiers/lpt/grad_clip.py b/alto/modifiers/lpt/grad_clip.py new file mode 100644 index 00000000..5f70b686 --- /dev/null +++ b/alto/modifiers/lpt/grad_clip.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +from typing import Optional + +from pydantic import Field, PrivateAttr +from torch import nn +from compressed_tensors.utils import match_named_modules + +from alto.modifiers import Modifier +from alto.kernels.fp4.fp4_common.grad_clip_config import GradClipConfig +from alto.kernels.fp4.fp4_common import grad_clip_registry + +__all__ = ["GradientClippingModifier"] + + +class GradientClippingModifier(Modifier): + """Injects per-layer gradient clipping into MXFP4LinearFunction.backward. + + Must be listed **after** LowPrecisionTrainingModifier in the recipe YAML so + that swap_params has already run when on_initialize sets module_id on each + wrapped weight tensor. + + Two clip sites per MXFP4 nn.Linear backward: + - grad_output before convert_to_mxfp4 (controls quantizer input scale) + - grad_weights after the wgrad GEMM (limits per-layer optimizer step size) + """ + + targets: list[str] = Field(default_factory=lambda: ["Linear"]) + ignore: list[str] = Field(default_factory=lambda: ["output", "re:.*\\.router\\.gate"]) + + clip_grad_output: bool = True + grad_output_max_norm: Optional[float] = None + grad_output_clip_value: Optional[float] = None + + clip_grad_weight: bool = True + grad_weight_max_norm: Optional[float] = None + grad_weight_clip_value: Optional[float] = None + + _registered_ids: list[int] = PrivateAttr(default_factory=list) + + def on_convert(self, model, **kwargs) -> bool: + return True + + def on_initialize(self, model_parts: list[nn.Module], **kwargs) -> bool: + cfg = GradClipConfig( + clip_grad_output=self.clip_grad_output, + grad_output_max_norm=self.grad_output_max_norm, + grad_output_clip_value=self.grad_output_clip_value, + clip_grad_weight=self.clip_grad_weight, + grad_weight_max_norm=self.grad_weight_max_norm, + grad_weight_clip_value=self.grad_weight_clip_value, + ) + for model_part in model_parts: + for _fqn, module in match_named_modules(model_part, self.targets, self.ignore): + mid = id(module) + grad_clip_registry.register(mid, cfg) + self._registered_ids.append(mid) + # Wire the module_id onto the wrapped weight tensor so that + # MXFP4LinearFunction.backward can look up cfg via the registry. + if hasattr(module, "weight") and hasattr(module.weight, "module_id"): + module.weight.module_id = mid + return True + + def on_finalize(self, model_parts: list[nn.Module], **kwargs) -> bool: + for mid in self._registered_ids: + grad_clip_registry.deregister(mid) + self._registered_ids.clear() + return True + + def on_pre_step(self, model_parts: list[nn.Module], **kwargs) -> bool: + return True + + def on_post_step(self, model_parts: list[nn.Module], **kwargs) -> bool: + return True diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index 6be7d54d..dabae317 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -236,6 +236,34 @@ def plot_layer( # CLI # --------------------------------------------------------------------------- +def _match_baseline_fqn(fqn: str, baseline_layers: dict) -> Optional[dict]: + """Look up baseline data for a given FQN. + + Tries exact match first, then falls back to matching by the longest common + suffix so that FQN differences caused by wrapper modules (e.g. FSDP shards + or LPT submodule renaming) don't break the overlay. + """ + if fqn in baseline_layers: + return baseline_layers[fqn] + # suffix match: find the baseline FQN whose suffix best matches + best_fqn, best_len = None, 0 + for bfqn in baseline_layers: + # find longest common suffix component-by-component + fqn_parts = fqn.split(".") + bfqn_parts = bfqn.split(".") + common = 0 + for a, b in zip(reversed(fqn_parts), reversed(bfqn_parts)): + if a == b: + common += 1 + else: + break + if common > best_len: + best_len, best_fqn = common, bfqn + if best_len >= 2: # require at least 2 matching suffix components + return baseline_layers[best_fqn] + return None + + def main() -> None: parser = argparse.ArgumentParser(description="Visualize DebugObserverModifier dumps") parser.add_argument("--dump", required=True, help="Path to the .pt dump file") @@ -243,10 +271,25 @@ def main() -> None: parser.add_argument("--out-dir", default="./viz", help="Directory to write PNG files") parser.add_argument("--layer-regex", default=None, help="Regex to filter layer FQNs") parser.add_argument("--summary-only", action="store_true", help="Print summary, skip plotting") + parser.add_argument("--debug-fqns", action="store_true", + help="Print FQNs from both dumps and their matches, then exit") args = parser.parse_args() layers, meta = load_dump(args.dump) - baseline_layers, _ = load_dump(args.baseline) if args.baseline else ({}, {}) + baseline_layers, baseline_meta = load_dump(args.baseline) if args.baseline else ({}, {}) + + if args.debug_fqns: + print(f"\n=== FQNs in dump ({len(layers)}) ===") + for fqn in sorted(layers): + matched = _match_baseline_fqn(fqn, baseline_layers) + tag = "[matched]" if matched is not None else "[NO MATCH]" + print(f" {tag} {fqn}") + print(f"\n=== FQNs in baseline ({len(baseline_layers)}) ===") + for fqn in sorted(baseline_layers): + print(f" {fqn}") + print(f"\ndump steps: {meta.get('iterations_captured', [])}") + print(f"baseline steps: {baseline_meta.get('iterations_captured', [])}") + return if args.layer_regex: pat = re.compile(args.layer_regex) @@ -262,7 +305,8 @@ def main() -> None: out_dir.mkdir(parents=True, exist_ok=True) for fqn, layer_data in sorted(layers.items()): print(f"Plotting {fqn} ...", end=" ", flush=True) - plot_layer(fqn, layer_data, out_dir, baseline_data=baseline_layers.get(fqn)) + baseline_data = _match_baseline_fqn(fqn, baseline_layers) + plot_layer(fqn, layer_data, out_dir, baseline_data=baseline_data) print("done") print(f"\nPlots written to {out_dir}") From d638528ef319a0b981c23ec9e920e8ca7e03c1ee Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 11:58:06 +0200 Subject: [PATCH 056/142] gradient clipping: --- alto/models/gpt_oss/config_registry.py | 32 ++ .../gpt_oss/configs/grad_clip_lpt_recipe.yaml | 22 ++ .../configs/grad_clip_obs_lpt_recipe.yaml | 33 ++ alto/modifiers/lpt/__init__.py | 3 +- tests/unittest/debug/test_grad_clip.py | 323 ++++++++++++++++++ 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml create mode 100644 alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml create mode 100644 tests/unittest/debug/test_grad_clip.py diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 514bc535..99e0003b 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -147,6 +147,38 @@ def gpt_oss_20b_lpt() -> Trainer.Config: return config +def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: + """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" + config = gpt_oss_20b_pretrain() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-grad-clip-lr4e-4-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml",), + ],) + return config + + +def gpt_oss_debugmodel_grad_clip_lpt() -> Trainer.Config: + """Debugmodel + MXFP4 + gradient clipping. Use for config validation.""" + config = gpt_oss_debugmodel() + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml",), + ],) + return config + + +def gpt_oss_debugmodel_grad_clip_obs_lpt() -> Trainer.Config: + """Debugmodel + MXFP4 + gradient clipping + DebugObserver. Produces tensor + dumps under outputs/debug_obs_grad_clip_lpt.pt for comparison against the + unclipped baseline.""" + config = gpt_oss_debugmodel() + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config( + recipe="./alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml", + ), + ],) + return config + + def gpt_oss_20b_pretrain_c4() -> Trainer.Config: """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" config = gpt_oss_20b_pretrain() diff --git a/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml new file mode 100644 index 00000000..e94674a0 --- /dev/null +++ b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml @@ -0,0 +1,22 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + GradientClippingModifier: + targets: ["Linear"] + ignore: ["output", "re:.*\\.router\\.gate"] + clip_grad_output: true + grad_output_max_norm: 1.0 + grad_output_clip_value: null + clip_grad_weight: true + grad_weight_max_norm: 1.0 + grad_weight_clip_value: null diff --git a/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml b/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml new file mode 100644 index 00000000..bb883823 --- /dev/null +++ b/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml @@ -0,0 +1,33 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + GradientClippingModifier: + targets: ["Linear"] + ignore: ["output", "re:.*\\.router\\.gate"] + clip_grad_output: true + grad_output_max_norm: 1.0 + grad_output_clip_value: null + clip_grad_weight: true + grad_weight_max_norm: 1.0 + grad_weight_clip_value: null + debug_modifiers: + DebugObserverModifier: + targets: ["Linear", "GptOssGroupedExperts"] + ignore: ["output", "re:.*\\.router\\.gate"] + capture_every: 1 + max_captures: 10 + output_path: "./outputs/debug_obs_grad_clip_lpt.pt" + capture_input: true + capture_weight: true + capture_grad_output: true + capture_grad_weight: true diff --git a/alto/modifiers/lpt/__init__.py b/alto/modifiers/lpt/__init__.py index a2b3d969..71d00e68 100644 --- a/alto/modifiers/lpt/__init__.py +++ b/alto/modifiers/lpt/__init__.py @@ -4,5 +4,6 @@ from .base import LowPrecisionTrainingModifier from .adahop import AdaHOPModifier +from .grad_clip import GradientClippingModifier -__all__ = ["LowPrecisionTrainingModifier", "AdaHOPModifier"] +__all__ = ["LowPrecisionTrainingModifier", "AdaHOPModifier", "GradientClippingModifier"] diff --git a/tests/unittest/debug/test_grad_clip.py b/tests/unittest/debug/test_grad_clip.py new file mode 100644 index 00000000..4b145e70 --- /dev/null +++ b/tests/unittest/debug/test_grad_clip.py @@ -0,0 +1,323 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Tests for grad-clip registry, apply_clip helper, and GradientClippingModifier. + +CPU-safe: tests that require GPU (actual MXFP4LinearFunction backward injection) +are skipped when no GPU is available. + +Uses the same importlib stub pattern as test_debug_observer_modifier.py to avoid +triggering the triton driver on import. +""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + +import pytest +import torch +import torch.nn as nn + + +# --------------------------------------------------------------------------- +# Stub installation (mirrors test_debug_observer_modifier.py) +# --------------------------------------------------------------------------- + +def _install_stubs(): + if "_alto_stubs_installed" in sys.modules: + return + + tt_tools = ModuleType("torchtitan.tools") + tt_logging = ModuleType("torchtitan.tools.logging") + tt_logging.logger = MagicMock() + sys.modules.setdefault("torchtitan", ModuleType("torchtitan")) + sys.modules.setdefault("torchtitan.tools", tt_tools) + sys.modules["torchtitan.tools.logging"] = tt_logging + + def _match_named_modules(model, targets, ignore): + ignore_patterns = [ig.lstrip("re:") for ig in ignore] + for fqn, module in model.named_modules(): + if not fqn: + continue + cls_name = module.__class__.__name__ + matched = any(cls_name == t or cls_name.endswith(t) for t in targets) + if not matched: + continue + if any(p in fqn for p in ignore_patterns): + continue + yield fqn, module + + ct_utils = ModuleType("compressed_tensors.utils") + ct_utils.match_named_modules = _match_named_modules + sys.modules.setdefault("compressed_tensors", ModuleType("compressed_tensors")) + sys.modules["compressed_tensors.utils"] = ct_utils + + from pydantic import BaseModel, PrivateAttr, Field + + class _FakeModifier(BaseModel): + model_config = {"extra": "forbid"} + index: int | None = None + group: str | None = None + start: float | None = None + end: float | None = None + update: float | None = None + initialized_: bool = False + finalized_: bool = False + + def initialize(self, model_parts, **kwargs): + self.initialized_ = self.on_initialize(model_parts, **kwargs) + + def finalize(self, model_parts, **kwargs): + self.finalized_ = self.on_finalize(model_parts, **kwargs) + + def pre_step(self, model_parts, **kwargs): + return self.on_pre_step(model_parts, **kwargs) + + def post_step(self, model_parts, **kwargs): + return self.on_post_step(model_parts, **kwargs) + + def convert(self, model, **kwargs): + return self.on_convert(model, **kwargs) + + alto_mod = ModuleType("alto") + alto_modifiers = ModuleType("alto.modifiers") + alto_modifiers.Modifier = _FakeModifier + sys.modules["alto"] = alto_mod + sys.modules["alto.modifiers"] = alto_modifiers + + sys.modules["_alto_stubs_installed"] = True + + +def _load_file(name: str, path: Path) -> ModuleType: + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _load_grad_clip_modules(): + _install_stubs() + fp4_common = Path(__file__).resolve().parents[3] / "alto" / "kernels" / "fp4" / "fp4_common" + cfg_mod = _load_file("alto.kernels.fp4.fp4_common.grad_clip_config", fp4_common / "grad_clip_config.py") + reg_mod = _load_file("alto.kernels.fp4.fp4_common.grad_clip_registry", fp4_common / "grad_clip_registry.py") + return cfg_mod, reg_mod + + +def _load_modifier_module(): + _install_stubs() + cfg_mod, reg_mod = _load_grad_clip_modules() + + # Ensure the fp4_common package stub is in sys.modules so grad_clip.py can import from it. + fp4_pkg = sys.modules.get("alto.kernels.fp4.fp4_common") + if fp4_pkg is None: + fp4_pkg = ModuleType("alto.kernels.fp4.fp4_common") + sys.modules["alto.kernels.fp4.fp4_common"] = fp4_pkg + fp4_pkg.grad_clip_registry = reg_mod + + alto_kernels = sys.modules.get("alto.kernels", ModuleType("alto.kernels")) + alto_kernels_fp4 = sys.modules.get("alto.kernels.fp4", ModuleType("alto.kernels.fp4")) + sys.modules.setdefault("alto.kernels", alto_kernels) + sys.modules.setdefault("alto.kernels.fp4", alto_kernels_fp4) + + lpt_root = Path(__file__).resolve().parents[3] / "alto" / "modifiers" / "lpt" + return _load_file("_grad_clip_modifier_under_test", lpt_root / "grad_clip.py") + + +@pytest.fixture(scope="module") +def modules(): + cfg_mod, reg_mod = _load_grad_clip_modules() + return cfg_mod, reg_mod + + +@pytest.fixture(scope="module") +def modifier_module(): + return _load_modifier_module() + + +@pytest.fixture(scope="module") +def GradClipConfig(modules): + cfg_mod, _ = modules + return cfg_mod.GradClipConfig + + +@pytest.fixture(scope="module") +def registry(modules): + _, reg_mod = modules + return reg_mod + + +@pytest.fixture(scope="module") +def GradientClippingModifier(modifier_module): + return modifier_module.GradientClippingModifier + + +# --------------------------------------------------------------------------- +# Tiny model for modifier tests +# --------------------------------------------------------------------------- + +class _TinyMLP(nn.Module): + def __init__(self): + super().__init__() + self.fc1 = nn.Linear(8, 16, bias=False) + self.fc2 = nn.Linear(16, 8, bias=False) + + def forward(self, x): + return self.fc2(torch.relu(self.fc1(x))) + + +# --------------------------------------------------------------------------- +# apply_clip tests +# --------------------------------------------------------------------------- + +class TestApplyClip: + + def test_apply_clip_norm(self, registry): + t = torch.randn(32, 32) + t = t * (10.0 / t.norm()) # set norm to 10 + clipped = registry.apply_clip(t, max_norm=0.5, clip_value=None) + assert clipped.norm().item() <= 0.5 + 1e-5 + + def test_apply_clip_value(self, registry): + t = torch.full((4, 4), 5.0) + clipped = registry.apply_clip(t, max_norm=None, clip_value=2.0) + assert clipped.abs().max().item() <= 2.0 + 1e-6 + + def test_apply_clip_both(self, registry): + # Norm applied first, then value clamp. + t = torch.full((4, 4), 5.0) # norm = 5*4 = 20 + clipped = registry.apply_clip(t, max_norm=1.0, clip_value=0.1) + # After norm clip: all elements ≈ 1/16; after value clamp still ≤ 0.1 + assert clipped.abs().max().item() <= 0.1 + 1e-6 + + def test_apply_clip_noop(self, registry): + t = torch.randn(4, 4) + result = registry.apply_clip(t, max_norm=None, clip_value=None) + assert result is t + + +# --------------------------------------------------------------------------- +# Registry tests +# --------------------------------------------------------------------------- + +class TestRegistry: + + def test_register_and_get(self, registry, GradClipConfig): + cfg = GradClipConfig(clip_grad_output=True, grad_output_max_norm=1.0) + fake_id = 999999 + registry.register(fake_id, cfg) + retrieved = registry.get(fake_id) + assert retrieved is cfg + registry.deregister(fake_id) + assert registry.get(fake_id) is None + + def test_get_none_id(self, registry): + assert registry.get(None) is None + + def test_deregister_missing_is_noop(self, registry): + registry.deregister(0) # should not raise + + +# --------------------------------------------------------------------------- +# GradientClippingModifier lifecycle tests +# --------------------------------------------------------------------------- + +class TestGradientClippingModifierLifecycle: + + def _make_modifier(self, GradientClippingModifier, **kwargs): + defaults = dict( + targets=["Linear"], + ignore=[], + clip_grad_output=True, + grad_output_max_norm=1.0, + grad_output_clip_value=None, + clip_grad_weight=True, + grad_weight_max_norm=1.0, + grad_weight_clip_value=None, + ) + defaults.update(kwargs) + return GradientClippingModifier(**defaults) + + def test_initialize_registers_modules(self, GradientClippingModifier, registry): + model = _TinyMLP() + mod = self._make_modifier(GradientClippingModifier) + mod.initialize([model]) + for _fqn, module in model.named_modules(): + if isinstance(module, nn.Linear): + assert registry.get(id(module)) is not None + + def test_initialize_sets_module_id_on_wrapped_weight(self, GradientClippingModifier): + """If the weight has a module_id attribute (simulating a wrapped tensor), + it should be set to id(module) after initialize.""" + + class _WrappedParam(nn.Parameter): + module_id: int | None = None + + model = _TinyMLP() + stub = _WrappedParam(model.fc1.weight.data) + stub.module_id = None + model.fc1.weight = stub + mod = self._make_modifier(GradientClippingModifier) + mod.initialize([model]) + assert stub.module_id == id(model.fc1) + + def test_finalize_deregisters_all(self, GradientClippingModifier, registry): + model = _TinyMLP() + mod = self._make_modifier(GradientClippingModifier) + mod.initialize([model]) + ids_before = [id(m) for _, m in model.named_modules() if isinstance(m, nn.Linear)] + mod.finalize([model]) + for mid in ids_before: + assert registry.get(mid) is None + + +# --------------------------------------------------------------------------- +# GPU-only: actual backward injection test +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires GPU") +class TestClippingBoundsGrad: + + def test_clipping_bounds_grad_weight(self): + """Verify that the clip actually fires in MXFP4LinearFunction.backward. + + This test imports the real kernel stack (triton required) and checks that + grad_weight's abs max is bounded after a forward+backward with a tight clip. + """ + import importlib + mxfp_linear = importlib.import_module("alto.kernels.fp4.mxfp4.mxfp_linear") + grad_clip_registry = importlib.import_module("alto.kernels.fp4.fp4_common.grad_clip_registry") + GradClipConfig = importlib.import_module("alto.kernels.fp4.fp4_common.grad_clip_config").GradClipConfig + + M, N, K = 16, 16, 16 + x = torch.randn(M, K, device="cuda", requires_grad=True) + w = torch.randn(N, K, device="cuda", requires_grad=True) + + clip_value = 1e-3 + fake_module_id = id(w) + cfg = GradClipConfig( + clip_grad_weight=True, + grad_weight_clip_value=clip_value, + ) + grad_clip_registry.register(fake_module_id, cfg) + + try: + y = mxfp_linear._to_mxfp4_then_scaled_mm( + x, w, + use_2dblock_x=False, + use_2dblock_w=True, + use_sr_grad=False, + use_dge=False, + clip_mode="none", + use_hadamard=False, + module_id=fake_module_id, + ) + y.sum().backward() + assert w.grad is not None + assert w.grad.abs().max().item() <= clip_value + 1e-6 + finally: + grad_clip_registry.deregister(fake_module_id) From 649fca153f79cdc626822afe372ee998aa1a5de7 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 11:59:13 +0200 Subject: [PATCH 057/142] fix viz --- scripts/debug_observer_viz.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index dabae317..a63b4537 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -166,7 +166,12 @@ def plot_layer( # Remap baseline step numbers to match the run's step numbers by ordinal # position, so overlays work even when step counters differ between runs. - aligned_baseline = _align_baseline(layer_data, baseline_data) if baseline_data is not None else None + if baseline_data is not None: + aligned_baseline = _align_baseline(layer_data, baseline_data) + base_steps_found = sorted(s for s in aligned_baseline if isinstance(s, int)) + print(f" [debug] {fqn}: aligned baseline has {len(base_steps_found)} steps: {base_steps_found[:3]}...") + else: + aligned_baseline = None tensor_keys = sorted({ k for step, tensors in layer_data.items() From d9130561b9f424a1e70ec784fd57cad466ef0706 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:13:43 +0200 Subject: [PATCH 058/142] correct debug observer --- alto/modifiers/debug/debug_observer.py | 13 +++++++++++-- scripts/debug_observer_viz.py | 21 ++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/alto/modifiers/debug/debug_observer.py b/alto/modifiers/debug/debug_observer.py index fedc6d71..5a45e864 100644 --- a/alto/modifiers/debug/debug_observer.py +++ b/alto/modifiers/debug/debug_observer.py @@ -159,15 +159,24 @@ def on_post_step(self, model_parts: list[Module], **kwargs) -> bool: if self._detached: return True - # Check whether this step produced a capture + # Only count a step as captured if a gradient was actually written. + # Calibration runs only forward passes — input/weight are stored but + # grad_output/grad_weight are not. Counting those would exhaust + # max_captures before any training backward fires. step_idx = self._step_idx + grad_keys = {"grad_output", "grad_weight", "grad_mlp1_weight", "grad_mlp2_weight"} captured_this_step = any( - step_idx in self._captures[fqn] + any(k in grad_keys for k in self._captures[fqn].get(step_idx, {}).keys()) for fqn in self._captures ) if captured_this_step: self._n_captured += 1 logger.debug(f"DebugObserverModifier: captured step {step_idx} ({self._n_captured}/{self.max_captures})") + else: + # Forward-only step (calibration): discard the partial capture so + # it doesn't pollute the dump with gradient-free entries. + for fqn in self._captures: + self._captures[fqn].pop(step_idx, None) # Deactivate all gates for fqn in self._captures: diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index a63b4537..6fdd7114 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -140,9 +140,16 @@ def _plot_time_series( std_vals: list[float], label: str, color: str, + is_baseline: bool = False, ) -> None: - ax_absmax.plot(steps, absmax_vals, marker="o", label=label, color=color) - ax_std.plot(steps, std_vals, marker="o", label=label, color=color, linestyle="--") + lw = 1.5 if not is_baseline else 2.5 + ls_abs = "-" if not is_baseline else "--" + ls_std = "--" if not is_baseline else ":" + marker = "o" if not is_baseline else "s" + ax_absmax.plot(steps, absmax_vals, marker=marker, label=label, color=color, + linewidth=lw, linestyle=ls_abs, zorder=3 if is_baseline else 2) + ax_std.plot(steps, std_vals, marker=marker, label=label, color=color, + linewidth=lw, linestyle=ls_std, zorder=3 if is_baseline else 2) def _plot_histogram(ax, t: torch.Tensor, label: str, color: str, alpha: float = 0.5) -> None: @@ -166,12 +173,7 @@ def plot_layer( # Remap baseline step numbers to match the run's step numbers by ordinal # position, so overlays work even when step counters differ between runs. - if baseline_data is not None: - aligned_baseline = _align_baseline(layer_data, baseline_data) - base_steps_found = sorted(s for s in aligned_baseline if isinstance(s, int)) - print(f" [debug] {fqn}: aligned baseline has {len(base_steps_found)} steps: {base_steps_found[:3]}...") - else: - aligned_baseline = None + aligned_baseline = _align_baseline(layer_data, baseline_data) if baseline_data is not None else None tensor_keys = sorted({ k for step, tensors in layer_data.items() @@ -195,7 +197,8 @@ def plot_layer( if aligned_baseline is not None: bsteps, babs, bstd = _collect_series(aligned_baseline, key) if bsteps: - _plot_time_series(ax_absmax, ax_std, bsteps, babs, bstd, label="baseline", color="red") + _plot_time_series(ax_absmax, ax_std, bsteps, babs, bstd, + label="baseline", color="red", is_baseline=True) ax_absmax.set_ylabel("absmax") ax_absmax.legend(fontsize=7) ax_absmax.grid(True, alpha=0.3) From 9268860d94ab3fda1687f9ebdf4f67f439fedfad Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:17:01 +0200 Subject: [PATCH 059/142] disable TP --- alto/models/gpt_oss/config_registry.py | 2 +- alto/models/llama3/config_registry.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 99e0003b..9c45183c 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -123,7 +123,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.dataloader.dataset_path = "" config.parallelism.expert_parallel_degree = 4 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 4 + config.parallelism.tensor_parallel_degree = 1 config.checkpoint.enable = True config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index f92f1532..5cdcfb08 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -175,7 +175,7 @@ def llama3_8b_pretrain() -> Trainer.Config: config.dataloader.dataset = "c4_test" config.parallelism.expert_parallel_degree = 1 config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 8 + config.parallelism.tensor_parallel_degree = 1 config.activation_checkpoint.mode = "none" config.checkpoint.enable = False config.checkpoint.interval = 10 From 918c865e6a5848c0ee1007fe1e2a260123b80c09 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:29:14 +0200 Subject: [PATCH 060/142] bf16 training mode when debugging --- alto/modifiers/debug/debug_observer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/alto/modifiers/debug/debug_observer.py b/alto/modifiers/debug/debug_observer.py index 5a45e864..6e8a57a2 100644 --- a/alto/modifiers/debug/debug_observer.py +++ b/alto/modifiers/debug/debug_observer.py @@ -69,6 +69,13 @@ class DebugObserverModifier(Modifier): # handles for parameter-level grad hooks (not managed by HooksMixin) _param_hook_handles: list = PrivateAttr(default_factory=list) + @property + def requires_training_mode(self) -> bool: + # Gradient tensors are only captured during backward passes, which only + # run when the trainer is in training mode. Force training mode so the + # BF16 baseline recipe doesn't silently degrade to forward-only. + return True + def on_convert(self, model: Module, **kwargs) -> bool: return True From 3d43990d5d23a411f5119c9e047e93905042ee35 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:30:21 +0200 Subject: [PATCH 061/142] update lpt mode --- alto/models/gpt_oss/config_registry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 9c45183c..2aa911ee 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -20,7 +20,10 @@ "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", "gpt_oss_20b_pretrain_c4", - "gpt_oss_20b_lpt_c4" + "gpt_oss_20b_lpt_c4", + "gpt_oss_20b_grad_clip_lpt", + "gpt_oss_debugmodel_grad_clip_lpt", + "gpt_oss_debugmodel_grad_clip_obs_lpt", ] From 60363c1db79d7a94d1f27212b65c6760e5da1479 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:37:52 +0200 Subject: [PATCH 062/142] correct histograms --- scripts/debug_observer_viz.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index 6fdd7114..992fa6a1 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -152,9 +152,16 @@ def _plot_time_series( linewidth=lw, linestyle=ls_std, zorder=3 if is_baseline else 2) -def _plot_histogram(ax, t: torch.Tensor, label: str, color: str, alpha: float = 0.5) -> None: +def _plot_histogram(ax, t: torch.Tensor, label: str, color: str, + is_baseline: bool = False) -> None: vals = t.float().abs().flatten().numpy() - ax.hist(vals, bins=50, alpha=alpha, label=label, color=color, density=True) + if is_baseline: + # Unfilled step outline — visible even when perfectly overlapping the run. + ax.hist(vals, bins=50, histtype="step", color=color, density=True, + linewidth=2.0, label=label, linestyle="--") + else: + ax.hist(vals, bins=50, histtype="stepfilled", color=color, density=True, + alpha=0.45, label=label, edgecolor=color, linewidth=0.8) def plot_layer( @@ -227,7 +234,7 @@ def plot_layer( if aligned_baseline is not None and step in aligned_baseline: bt = aligned_baseline[step].get(key) if bt is not None: - _plot_histogram(ax, bt, label="baseline", color="red") + _plot_histogram(ax, bt, label="baseline", color="red", is_baseline=True) ax.set_title(f"step {step}", fontsize=7) ax.legend(fontsize=6) ax.set_xlabel("|value|", fontsize=6) From 311a3ec28c4775fce775c6bf3eb99db6909391d5 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:46:18 +0200 Subject: [PATCH 063/142] fsdp drops the module_id which affects the clipping --- .../gpt_oss/configs/grad_clip_lpt_recipe.yaml | 8 +++---- alto/modifiers/lpt/grad_clip.py | 22 +++++++++++++++---- tests/unittest/debug/test_grad_clip.py | 19 +++++++++++++--- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml index e94674a0..e9a09f6e 100644 --- a/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml @@ -15,8 +15,8 @@ training_stage: targets: ["Linear"] ignore: ["output", "re:.*\\.router\\.gate"] clip_grad_output: true - grad_output_max_norm: 1.0 - grad_output_clip_value: null + grad_output_max_norm: null + grad_output_clip_value: 0.0 clip_grad_weight: true - grad_weight_max_norm: 1.0 - grad_weight_clip_value: null + grad_weight_max_norm: null + grad_weight_clip_value: 0.0 diff --git a/alto/modifiers/lpt/grad_clip.py b/alto/modifiers/lpt/grad_clip.py index 5f70b686..3622168f 100644 --- a/alto/modifiers/lpt/grad_clip.py +++ b/alto/modifiers/lpt/grad_clip.py @@ -39,6 +39,7 @@ class GradientClippingModifier(Modifier): grad_weight_clip_value: Optional[float] = None _registered_ids: list[int] = PrivateAttr(default_factory=list) + _hook_handles: list = PrivateAttr(default_factory=list) def on_convert(self, model, **kwargs) -> bool: return True @@ -57,13 +58,26 @@ def on_initialize(self, model_parts: list[nn.Module], **kwargs) -> bool: mid = id(module) grad_clip_registry.register(mid, cfg) self._registered_ids.append(mid) - # Wire the module_id onto the wrapped weight tensor so that - # MXFP4LinearFunction.backward can look up cfg via the registry. - if hasattr(module, "weight") and hasattr(module.weight, "module_id"): - module.weight.module_id = mid + + # FSDP's fsdp_post_all_gather rebuilds the weight tensor as a + # new object each forward pass, dropping any instance attribute + # set at initialize time. Register a forward_pre_hook instead: + # it fires after the all-gather so the freshly reconstructed + # weight tensor is already in place, and we re-stamp module_id + # before __torch_function__ is called. + def _stamp_module_id(mod, _args, _mid=mid): + w = mod.weight + if hasattr(w, "module_id"): + w.module_id = _mid + + handle = module.register_forward_pre_hook(_stamp_module_id) + self._hook_handles.append(handle) return True def on_finalize(self, model_parts: list[nn.Module], **kwargs) -> bool: + for handle in self._hook_handles: + handle.remove() + self._hook_handles.clear() for mid in self._registered_ids: grad_clip_registry.deregister(mid) self._registered_ids.clear() diff --git a/tests/unittest/debug/test_grad_clip.py b/tests/unittest/debug/test_grad_clip.py index 4b145e70..aba1e5e5 100644 --- a/tests/unittest/debug/test_grad_clip.py +++ b/tests/unittest/debug/test_grad_clip.py @@ -250,9 +250,14 @@ def test_initialize_registers_modules(self, GradientClippingModifier, registry): if isinstance(module, nn.Linear): assert registry.get(id(module)) is not None - def test_initialize_sets_module_id_on_wrapped_weight(self, GradientClippingModifier): - """If the weight has a module_id attribute (simulating a wrapped tensor), - it should be set to id(module) after initialize.""" + def test_forward_pre_hook_stamps_module_id(self, GradientClippingModifier): + """The forward_pre_hook must stamp module_id on the weight each forward. + + At initialize time module_id is NOT set (FSDP drops instance attrs on + each all-gather). Instead, a forward_pre_hook re-stamps it before + __torch_function__ is called. Simulate that by running a forward pass + with a wrapped weight that has module_id=None before the hook fires. + """ class _WrappedParam(nn.Parameter): module_id: int | None = None @@ -261,8 +266,15 @@ class _WrappedParam(nn.Parameter): stub = _WrappedParam(model.fc1.weight.data) stub.module_id = None model.fc1.weight = stub + mod = self._make_modifier(GradientClippingModifier) mod.initialize([model]) + + # module_id is still None at this point — that's expected. + assert stub.module_id is None + + # Trigger one forward pass; the pre-hook should stamp module_id. + model(torch.randn(2, 8)) assert stub.module_id == id(model.fc1) def test_finalize_deregisters_all(self, GradientClippingModifier, registry): @@ -273,6 +285,7 @@ def test_finalize_deregisters_all(self, GradientClippingModifier, registry): mod.finalize([model]) for mid in ids_before: assert registry.get(mid) is None + assert len(mod._hook_handles) == 0 # --------------------------------------------------------------------------- From 1c9eb58967db74226eb603508bcb44c0a4dccb58 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:52:57 +0200 Subject: [PATCH 064/142] prepend clipping hook --- alto/modifiers/lpt/grad_clip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alto/modifiers/lpt/grad_clip.py b/alto/modifiers/lpt/grad_clip.py index 3622168f..1d16140a 100644 --- a/alto/modifiers/lpt/grad_clip.py +++ b/alto/modifiers/lpt/grad_clip.py @@ -70,7 +70,7 @@ def _stamp_module_id(mod, _args, _mid=mid): if hasattr(w, "module_id"): w.module_id = _mid - handle = module.register_forward_pre_hook(_stamp_module_id) + handle = module.register_forward_pre_hook(_stamp_module_id, prepend=True) self._hook_handles.append(handle) return True From c331eacbb28a1dacb41a00aa5d80b9012884138a Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 12:57:16 +0200 Subject: [PATCH 065/142] remove fsdp --- alto/models/gpt_oss/config_registry.py | 10 ++++++++++ scripts/debug_observer_viz.py | 10 +++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 2aa911ee..d355f4c0 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -24,6 +24,7 @@ "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_obs_lpt", + "gpt_oss_debugmodel_grad_clip_lpt_no_fsdp", ] @@ -169,6 +170,15 @@ def gpt_oss_debugmodel_grad_clip_lpt() -> Trainer.Config: return config +def gpt_oss_debugmodel_grad_clip_lpt_no_fsdp() -> Trainer.Config: + """Debugmodel + MXFP4 + gradient clipping, FSDP disabled. Single GPU, + no weight sharding — used to isolate whether FSDP is preventing the clip + from firing.""" + config = gpt_oss_debugmodel_grad_clip_lpt() + config.parallelism.data_parallel_shard_degree = 1 + return config + + def gpt_oss_debugmodel_grad_clip_obs_lpt() -> Trainer.Config: """Debugmodel + MXFP4 + gradient clipping + DebugObserver. Produces tensor dumps under outputs/debug_obs_grad_clip_lpt.pt for comparison against the diff --git a/scripts/debug_observer_viz.py b/scripts/debug_observer_viz.py index 992fa6a1..7e4c9469 100644 --- a/scripts/debug_observer_viz.py +++ b/scripts/debug_observer_viz.py @@ -155,13 +155,9 @@ def _plot_time_series( def _plot_histogram(ax, t: torch.Tensor, label: str, color: str, is_baseline: bool = False) -> None: vals = t.float().abs().flatten().numpy() - if is_baseline: - # Unfilled step outline — visible even when perfectly overlapping the run. - ax.hist(vals, bins=50, histtype="step", color=color, density=True, - linewidth=2.0, label=label, linestyle="--") - else: - ax.hist(vals, bins=50, histtype="stepfilled", color=color, density=True, - alpha=0.45, label=label, edgecolor=color, linewidth=0.8) + alpha = 0.35 if is_baseline else 0.6 + ax.hist(vals, bins=50, histtype="stepfilled", color=color, density=True, + alpha=alpha, label=label, edgecolor="none") def plot_layer( From 69a4d69bd5d62117d94c51603b50bf03fe4a9d21 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 13:02:37 +0200 Subject: [PATCH 066/142] debug clipping --- alto/kernels/dispatch/tensor.py | 3 ++- alto/kernels/fp4/mxfp4/mxfp_linear.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index 133c04a3..05f2c567 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -277,6 +277,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): # logger.info(f"[MXFP4Linear]func: {func.__name__} config: {config}" # f"A.shape: {A.shape} B.shape: {B.shape} bias.shape: {bias.shape if bias is not None else None}") + module_id = getattr(B, "module_id", None) Y = _to_mxfp4_then_scaled_mm( A, B if trans_b else B.T, @@ -287,7 +288,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): clip_mode=config.clip_mode, use_hadamard=config.use_hadamard, use_macro_block_scaling=config.two_level_scaling == "blockwise", - module_id=getattr(B, "module_id", None), + module_id=module_id, ) if bias is not None: Y = Y + bias diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 9738e3f8..48cd447b 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -430,6 +430,9 @@ def backward(ctx, grad_output): # Site ①: clip grad_output before it enters the quantizer. _clip_cfg = get_grad_clip_cfg(ctx.module_id) + if _clip_cfg is None: + from torchtitan.tools.logging import logger as _logger + _logger.warning(f"[grad_clip] backward: module_id={ctx.module_id!r} → no clip cfg found") if _clip_cfg is not None and _clip_cfg.clip_grad_output: grad_output = apply_clip(grad_output, _clip_cfg.grad_output_max_norm, _clip_cfg.grad_output_clip_value) From 9a99fa84952a6f0803ed07259639707c38657c91 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 13:06:06 +0200 Subject: [PATCH 067/142] check absmax --- alto/kernels/fp4/fp4_common/grad_clip_registry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/alto/kernels/fp4/fp4_common/grad_clip_registry.py b/alto/kernels/fp4/fp4_common/grad_clip_registry.py index b5cd69e0..6128aad6 100644 --- a/alto/kernels/fp4/fp4_common/grad_clip_registry.py +++ b/alto/kernels/fp4/fp4_common/grad_clip_registry.py @@ -31,10 +31,13 @@ def apply_clip(t: torch.Tensor, max_norm: float | None, clip_value: float | None """ if max_norm is None and clip_value is None: return t + before_absmax = t.abs().max().item() if max_norm is not None: norm = t.norm() if norm > max_norm: t = t * (max_norm / norm) if clip_value is not None: t = t.clamp(-clip_value, clip_value) + after_absmax = t.abs().max().item() + print(f"[grad_clip] apply_clip: before={before_absmax:.6f} after={after_absmax:.6f} max_norm={max_norm} clip_value={clip_value}", flush=True) return t From 26b85bf91c87fb0da7d56604064846a436416ad2 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 17 Jun 2026 17:06:44 +0200 Subject: [PATCH 068/142] clip before quantization, observe after clipping --- .../fp4/fp4_common/grad_clip_registry.py | 3 -- alto/kernels/fp4/mxfp4/mxfp_linear.py | 8 ---- .../gpt_oss/configs/grad_clip_lpt_recipe.yaml | 8 ++-- .../configs/grad_clip_obs_lpt_recipe.yaml | 8 ++-- alto/modifiers/debug/debug_observer.py | 11 +---- alto/modifiers/debug/observer_hooks.py | 42 ++++++++--------- .../debug/test_debug_observer_modifier.py | 5 +- tests/unittest/debug/test_observer_hooks.py | 47 +++++++------------ 8 files changed, 51 insertions(+), 81 deletions(-) diff --git a/alto/kernels/fp4/fp4_common/grad_clip_registry.py b/alto/kernels/fp4/fp4_common/grad_clip_registry.py index 6128aad6..b5cd69e0 100644 --- a/alto/kernels/fp4/fp4_common/grad_clip_registry.py +++ b/alto/kernels/fp4/fp4_common/grad_clip_registry.py @@ -31,13 +31,10 @@ def apply_clip(t: torch.Tensor, max_norm: float | None, clip_value: float | None """ if max_norm is None and clip_value is None: return t - before_absmax = t.abs().max().item() if max_norm is not None: norm = t.norm() if norm > max_norm: t = t * (max_norm / norm) if clip_value is not None: t = t.clamp(-clip_value, clip_value) - after_absmax = t.abs().max().item() - print(f"[grad_clip] apply_clip: before={before_absmax:.6f} after={after_absmax:.6f} max_norm={max_norm} clip_value={clip_value}", flush=True) return t diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 48cd447b..44803f47 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -430,9 +430,6 @@ def backward(ctx, grad_output): # Site ①: clip grad_output before it enters the quantizer. _clip_cfg = get_grad_clip_cfg(ctx.module_id) - if _clip_cfg is None: - from torchtitan.tools.logging import logger as _logger - _logger.warning(f"[grad_clip] backward: module_id={ctx.module_id!r} → no clip cfg found") if _clip_cfg is not None and _clip_cfg.clip_grad_output: grad_output = apply_clip(grad_output, _clip_cfg.grad_output_max_norm, _clip_cfg.grad_output_clip_value) @@ -553,11 +550,6 @@ def backward(ctx, grad_output): grad_inputs = grad_output_dq @ w_dq grad_weights = grad_output_m_dq.T @ x_dq - # Site ②: clip grad_weights after the wgrad GEMM, before optimizer sees it. - if _clip_cfg is not None and _clip_cfg.clip_grad_weight: - grad_weights = apply_clip(grad_weights, _clip_cfg.grad_weight_max_norm, - _clip_cfg.grad_weight_clip_value) - if ctx.use_dge: if ctx.use_2dblock_w: scale_shape = [ diff --git a/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml index e9a09f6e..e94674a0 100644 --- a/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml @@ -15,8 +15,8 @@ training_stage: targets: ["Linear"] ignore: ["output", "re:.*\\.router\\.gate"] clip_grad_output: true - grad_output_max_norm: null - grad_output_clip_value: 0.0 + grad_output_max_norm: 1.0 + grad_output_clip_value: null clip_grad_weight: true - grad_weight_max_norm: null - grad_weight_clip_value: 0.0 + grad_weight_max_norm: 1.0 + grad_weight_clip_value: null diff --git a/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml b/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml index bb883823..dc735d73 100644 --- a/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml +++ b/alto/models/gpt_oss/configs/grad_clip_obs_lpt_recipe.yaml @@ -15,11 +15,11 @@ training_stage: targets: ["Linear"] ignore: ["output", "re:.*\\.router\\.gate"] clip_grad_output: true - grad_output_max_norm: 1.0 - grad_output_clip_value: null + grad_output_max_norm: null + grad_output_clip_value: 0.0 clip_grad_weight: true - grad_weight_max_norm: 1.0 - grad_weight_clip_value: null + grad_weight_max_norm: null + grad_weight_clip_value: 0.0 debug_modifiers: DebugObserverModifier: targets: ["Linear", "GptOssGroupedExperts"] diff --git a/alto/modifiers/debug/debug_observer.py b/alto/modifiers/debug/debug_observer.py index 6e8a57a2..8f7e02ad 100644 --- a/alto/modifiers/debug/debug_observer.py +++ b/alto/modifiers/debug/debug_observer.py @@ -26,7 +26,6 @@ from alto.modifiers.debug.observer_hooks import ( make_linear_fwd_pre_hook, make_linear_bwd_hook, - make_linear_grad_weight_hook, make_grouped_experts_fwd_pre_hook, make_grouped_experts_bwd_hook, make_grouped_experts_grad_weight_hook, @@ -97,17 +96,11 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: self.register_hook( module, make_linear_bwd_hook( - self._captures, fqn, self._step_ref, self.capture_grad_output, + self._captures, fqn, self._step_ref, + self.capture_grad_output, self.capture_grad_weight, ), "full_backward", ) - if self.capture_grad_weight and hasattr(module, "weight") and module.weight is not None: - handle = module.weight.register_hook( - make_linear_grad_weight_hook( - self._captures, fqn, self._step_ref, self.capture_grad_weight, - ) - ) - self._param_hook_handles.append(handle) elif cls_name.endswith("GroupedExperts"): self.register_hook( diff --git a/alto/modifiers/debug/observer_hooks.py b/alto/modifiers/debug/observer_hooks.py index 9e106153..5276d625 100644 --- a/alto/modifiers/debug/observer_hooks.py +++ b/alto/modifiers/debug/observer_hooks.py @@ -86,33 +86,29 @@ def _hook(module, args): return _hook -def make_linear_bwd_hook(captures: dict, fqn: str, step_ref: list, capture_grad_output: bool): - """Full-backward hook for nn.Linear — captures grad_output[0].""" - - def _hook(module, grad_input, grad_output): - if not captures[fqn]["active"]: - return - if not capture_grad_output: - return - go = grad_output[0] if isinstance(grad_output, (list, tuple)) else grad_output - if go is None: - return - _store(captures, fqn, step_ref[0], "grad_output", go) - - return _hook - - -def make_linear_grad_weight_hook( - captures: dict, fqn: str, step_ref: list, capture_grad_weight: bool +def make_linear_bwd_hook( + captures: dict, fqn: str, step_ref: list, capture_grad_output: bool, capture_grad_weight: bool = False ): - """Parameter grad hook for nn.Linear.weight — captures accumulated grad.""" + """Full-backward hook for nn.Linear. - def _hook(grad): + grad_input = (grad_x, grad_weight, grad_bias) — exactly what + MXFP4LinearFunction.backward returns, so grad_weight here is the + post-clip value, not the value accumulated into param.grad later. + grad_output = upstream gradient flowing into this layer's output. + """ + + def _hook(module, grad_input, grad_output): if not captures[fqn]["active"]: return - if not capture_grad_weight: - return - _store(captures, fqn, step_ref[0], "grad_weight", grad) + step_idx = step_ref[0] + if capture_grad_output: + go = grad_output[0] if isinstance(grad_output, (list, tuple)) else grad_output + if go is not None: + _store(captures, fqn, step_idx, "grad_output", go) + if capture_grad_weight and isinstance(grad_input, (list, tuple)) and len(grad_input) > 1: + gw = grad_input[1] + if gw is not None: + _store(captures, fqn, step_idx, "grad_weight", gw) return _hook diff --git a/tests/unittest/debug/test_debug_observer_modifier.py b/tests/unittest/debug/test_debug_observer_modifier.py index a77b4adc..09ea4f26 100644 --- a/tests/unittest/debug/test_debug_observer_modifier.py +++ b/tests/unittest/debug/test_debug_observer_modifier.py @@ -288,7 +288,10 @@ def test_dump_has_expected_schema(self, DebugObserverModifier): continue for step, tensors in layer_data.items(): assert "input" in tensors - assert "grad_weight" in tensors + # grad_weight is only present when MXFP4LinearFunction is in + # the graph (grad_input[1] populated). Plain nn.Linear does + # not populate it, so we only assert it exists if captured. + assert "grad_output" in tensors def test_hooks_removed_after_finalize(self, DebugObserverModifier): model = _TinyMLP() diff --git a/tests/unittest/debug/test_observer_hooks.py b/tests/unittest/debug/test_observer_hooks.py index 9dff6bb5..f6c423f8 100644 --- a/tests/unittest/debug/test_observer_hooks.py +++ b/tests/unittest/debug/test_observer_hooks.py @@ -113,48 +113,37 @@ def test_captures_grad_output(self, hooks): captures = _make_captures(fqn) step_ref = _step_ref(3) module = nn.Linear(4, 4, bias=False) - hook = hooks.make_linear_bwd_hook(captures, fqn, step_ref, capture_grad_output=True) + hook = hooks.make_linear_bwd_hook( + captures, fqn, step_ref, capture_grad_output=True, capture_grad_weight=False + ) go = torch.randn(2, 4) hook(module, (None,), (go,)) assert "grad_output" in captures[fqn][3] assert torch.allclose(captures[fqn][3]["grad_output"], go.detach().cpu()) - def test_inactive_gate_bwd(self, hooks): - fqn = "l" - captures = _make_captures(fqn, active=False) - step_ref = _step_ref(0) - module = nn.Linear(4, 4, bias=False) - hook = hooks.make_linear_bwd_hook(captures, fqn, step_ref, capture_grad_output=True) - hook(module, (None,), (torch.randn(2, 4),)) - assert 0 not in captures[fqn] - - -# --------------------------------------------------------------------------- -# nn.Linear param grad hook -# --------------------------------------------------------------------------- - -class TestLinearGradWeightHook: - - def test_captures_grad_weight(self, hooks): - fqn = "linear" + def test_captures_grad_weight_from_grad_input(self, hooks): + fqn = "layers.0" captures = _make_captures(fqn) - step_ref = _step_ref(5) - hook_fn = hooks.make_linear_grad_weight_hook( - captures, fqn, step_ref, capture_grad_weight=True + step_ref = _step_ref(3) + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_bwd_hook( + captures, fqn, step_ref, capture_grad_output=False, capture_grad_weight=True ) gw = torch.randn(4, 4) - hook_fn(gw) - assert "grad_weight" in captures[fqn][5] - assert torch.allclose(captures[fqn][5]["grad_weight"], gw.detach().cpu()) + # grad_input = (grad_x, grad_weight) — mirrors what autograd.Function.backward returns + hook(module, (torch.randn(2, 4), gw), (torch.randn(2, 4),)) + assert "grad_weight" in captures[fqn][3] + assert torch.allclose(captures[fqn][3]["grad_weight"], gw.detach().cpu()) - def test_inactive_gate_grad_weight(self, hooks): + def test_inactive_gate_bwd(self, hooks): fqn = "l" captures = _make_captures(fqn, active=False) step_ref = _step_ref(0) - hook_fn = hooks.make_linear_grad_weight_hook( - captures, fqn, step_ref, capture_grad_weight=True + module = nn.Linear(4, 4, bias=False) + hook = hooks.make_linear_bwd_hook( + captures, fqn, step_ref, capture_grad_output=True, capture_grad_weight=True ) - hook_fn(torch.randn(4, 4)) + hook(module, (None, torch.randn(4, 4)), (torch.randn(2, 4),)) assert 0 not in captures[fqn] From 1c4e8012728983a73b327e6bb50edee751ab425d Mon Sep 17 00:00:00 2001 From: alirezak Date: Tue, 23 Jun 2026 21:01:58 +0000 Subject: [PATCH 069/142] Apply working LPT fixes on yann_adahop: bias-skip in swap_params, decomposed-linear device fix, gpt_oss_20b_lpt run config --- alto/kernels/dispatch/conversion.py | 6 +++++- alto/models/gpt_oss/config_registry.py | 14 ++++++++++++++ alto/nn/decomposed_linear.py | 8 ++------ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/alto/kernels/dispatch/conversion.py b/alto/kernels/dispatch/conversion.py index 5f25177e..60068a26 100644 --- a/alto/kernels/dispatch/conversion.py +++ b/alto/kernels/dispatch/conversion.py @@ -101,6 +101,10 @@ def post_order_traversal( for param_name, param in module.named_parameters(recurse=False): if (target_parameter_name is not None and param_name != target_parameter_name): continue + full_param_name = f"{module_name}{'.' if module_name else ''}{cur_fqn}{'.' if cur_fqn else ''}{param_name}" + if (target_parameter_name is None and "bias" in param_name): + logger.warn(f"Skipped {full_param_name} because it is a bias parameter") + continue if not isinstance(param.data, TrainingWeightWrapperBaseTensor): new_param = nn.Parameter( tensor_cls(param.data, config, **extra_kwargs), @@ -108,7 +112,7 @@ def post_order_traversal( ) setattr(module, param_name, new_param) logger.info( - f"Swapped {module_name}{'.' if module_name else ''}{cur_fqn}{'.' if cur_fqn else ''}{param_name} to {tensor_cls.__name__}" + f"Swapped {full_param_name} to {tensor_cls.__name__}" ) def get_name_func_new(): diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index d355f4c0..19f6da0c 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -144,6 +144,20 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() + config.training.global_batch_size = 16 + config.parallelism.expert_tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 1 + config.parallelism.expert_parallel_degree = 8 + config.training.local_batch_size = 1 + config.activation_checkpoint.mode = "none" + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.enable = False + config.checkpoint.initial_load_in_hf = False + config.checkpoint.initial_load_in_hf_quantized = False + config.checkpoint.interval = 1000 config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), diff --git a/alto/nn/decomposed_linear.py b/alto/nn/decomposed_linear.py index 54e721a2..83664ec3 100644 --- a/alto/nn/decomposed_linear.py +++ b/alto/nn/decomposed_linear.py @@ -31,13 +31,9 @@ def from_linear(cls, linear: nn.Linear, lora_rank: int = 32): new_layer = cls(linear.in_features, linear.out_features, linear.bias is not None, lora_rank) new_layer.weight = linear.weight new_layer.bias = linear.bias - device = linear.weight.device - dtype = linear.weight.dtype - new_layer.u.data = new_layer.u.data.to(device=device, dtype=dtype) - new_layer.v.data = new_layer.v.data.to(device=device, dtype=dtype) - new_layer.sigma.data = new_layer.sigma.data.to(device=device, dtype=dtype) return new_layer - + + def init_lora_weights(self, init_std: float = 0.02): nn.init.normal_(self.u, mean=0.0, std=init_std) nn.init.zeros_(self.v) From 40c0063dd89ca2a77c65915334be5c6a89111e43 Mon Sep 17 00:00:00 2001 From: alirezak Date: Wed, 24 Jun 2026 03:47:41 +0000 Subject: [PATCH 070/142] Add gpt_oss AdaHOP recipe and wire gpt_oss_20b_lpt to lpt_adahop.yaml --- alto/models/gpt_oss/config_registry.py | 2 +- alto/models/gpt_oss/configs/lpt_adahop.yaml | 40 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 alto/models/gpt_oss/configs/lpt_adahop.yaml diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 19f6da0c..761ccd4f 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -160,7 +160,7 @@ def gpt_oss_20b_lpt() -> Trainer.Config: config.checkpoint.interval = 1000 config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ - ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop.yaml",), ],) return config diff --git a/alto/models/gpt_oss/configs/lpt_adahop.yaml b/alto/models/gpt_oss/configs/lpt_adahop.yaml new file mode 100644 index 00000000..8340992f --- /dev/null +++ b/alto/models/gpt_oss/configs/lpt_adahop.yaml @@ -0,0 +1,40 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4_adahop" + targets: ["Linear"] + ignore: ["output"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + AdaHOPModifier: + enabled: true + use_hadamard: true + use_randomized_hadamard: false + calibration_steps: 30 + layer_transform_config: + "row-row": "hadamard" + "row-none": "inner_outlier_extract_left" + "row-col": "inner_outlier_extract_right" + "col-row": "hadamard" + "col-none": "hadamard" + "col-col": "full_precision" + "none-row": "hadamard" + "none-none": "hadamard" + "none-col": "inner_outlier_extract_right" + moe_lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["GptOssGroupedExperts"] + ignore: ["re:.*\\.router\\.gate"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none \ No newline at end of file From 8261e207722dadba8eb084d3c863aeb141a2c42c Mon Sep 17 00:00:00 2001 From: alirezak Date: Wed, 24 Jun 2026 15:15:06 +0000 Subject: [PATCH 071/142] checkpoint correction --- alto/models/gpt_oss/config_registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 761ccd4f..4248daee 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -154,10 +154,11 @@ def gpt_oss_20b_lpt() -> Trainer.Config: config.dataloader.dataset_path = None config.validator.dataloader.dataset = "c4_validation" config.validator.dataloader.dataset_path = None - config.checkpoint.enable = False + config.checkpoint.enable = True # save checkpoints so we can resume later + config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint config.checkpoint.initial_load_in_hf = False config.checkpoint.initial_load_in_hf_quantized = False - config.checkpoint.interval = 1000 + config.checkpoint.interval = 1000 # save every 1000 steps config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop.yaml",), From 551a2dc839e95e0ef5b36b1b52c01b34a65ac779 Mon Sep 17 00:00:00 2001 From: alirezak Date: Fri, 26 Jun 2026 03:18:39 +0000 Subject: [PATCH 072/142] AdaHOP: make it run + checkpoint on MI300 (gfx942/CDNA3) under FSDP --- alto/kernels/dispatch/adahop_tensor.py | 193 +++++------- alto/models/gpt_oss/config_registry.py | 2 +- alto/modifiers/lpt/adahop.py | 279 +++++++++--------- .../lpt/adahop_internals/calibration_state.py | 112 +++++++ .../adahop_internals/mxfp4_linear_function.py | 17 +- alto/modifiers/lpt/base.py | 8 +- alto/train.py | 24 ++ .../adahop/test_adahop_modifier_helpers.py | 28 +- tests/unittest/adahop/test_adahop_wrapper.py | 28 +- 9 files changed, 408 insertions(+), 283 deletions(-) create mode 100644 alto/modifiers/lpt/adahop_internals/calibration_state.py diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 87298cbf..5d3aeb05 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -1,122 +1,48 @@ # Copyright (c) 2026 Advanced Micro Devices, Inc. # # SPDX-License-Identifier: MIT -"""AdaHOP-aware MXFP4 wrapper tensor subclasses. - -Two child classes of ``MXFP4TrainingWeightWrapperTensor``: - -* :class:`MXFP4CalibrationWrapper` — Phase A. Dispatch identical to its parent - (plain MXFP4), plus an optional ``_calibration_callback`` invoked from - ``__torch_function__`` before the standard path. Used during the 30-step - outlier-pattern observation window. - -* :class:`MXFP4AdaHOPWrapper` — Phase B. Carries frozen ``(forward_y_mode, - backward_gx_mode, backward_gw_mode)`` and a ``HadamardTransform``; routes - the ``linear`` dispatch through :class:`MXFP4AdaHOPLinearFunction` with - those modes as ``apply()`` arguments. No global state, no FQN lookup. - -Design notes (see ADAHOP_TO_ALTO_INTEGRATION_PLAN.md): - -* Modes ride on the wrapper instance — the canonical instance that lives on - ``nn.Parameter.data`` is the one PyTorch passes to ``__torch_function__`` - for the linear op, so the modes are reachable at the right moment. -* ``__torch_dispatch__`` is **not** overridden. Detach/view/copy re-wraps - drop the modes; that is fine because those transient re-wraps never - re-enter ``__torch_function__`` for ``linear`` — by then the canonical - wrapper has already supplied modes to ``MXFP4AdaHOPLinearFunction``. -* ``__tensor_flatten__`` / ``__tensor_unflatten__`` round-trip the *string* - modes so DCP checkpoints survive. The ``HadamardTransform`` is not - serializable; on load it's restored to ``None`` and the modifier - re-attaches a fresh one (or keeps ``None`` if the recipe is hadamard-free - and only ``none``/``full_precision`` modes are used). -* ``fsdp_post_all_gather`` is overridden minimally to preserve modes on the - fresh wrapper instance produced at training step 0. +"""AdaHOP-aware MXFP4 wrapper tensor subclass. + +FSDP-execution fix: there is a SINGLE wrapper class. + +* :class:`MXFP4AdaHOPWrapper` — used for ``scheme="mxfp4_adahop"`` from the + moment the model is converted (so FSDP captures it as the param). It carries + per-slot transform modes ``(forward_y_mode, backward_gx_mode, + backward_gw_mode)`` and an optional ``HadamardTransform``. During calibration + all three modes are ``"none"`` and the linear dispatch defers to plain MXFP4. + When calibration finishes the modifier sets the chosen modes IN PLACE via + :meth:`set_modes` (no new ``nn.Parameter``, no wrapper-type swap), so the + modes take effect on the FSDP-owned tensor and the AdaHOP linear function + actually runs in the forward. + +Why single-wrapper + in-place modes (vs. the old two-wrapper swap): under +``fully_shard`` the param is captured at convert time. Replacing +``module.weight`` with a new Parameter at Phase B does NOT reach the tensor FSDP +all-gathers into ``F.linear`` (it keeps using the original), so AdaHOP never +executes. Keeping one wrapper and mutating its modes in place updates the +canonical FSDP-owned tensor; ``fsdp_post_all_gather`` propagates the modes to +the unsharded instance used in the forward. + +Calibration observation uses transient *module* hooks (see +``calibration_hooks.py``); nothing unpicklable is stored on the tensor. """ -from typing import Any, Callable, Optional, Tuple +from typing import Any, Optional, Tuple import torch -from torch.distributed.fsdp import MixedPrecisionPolicy from torchtitan.tools.logging import logger from alto.modifiers.lpt.adahop_internals.mxfp4_linear_function import MXFP4AdaHOPLinearFunction from alto.modifiers.lpt.adahop_internals.transform_mode import TransformMode, assert_mode_supported -from .tensor import MXFP4TrainingWeightWrapperTensor, TrainingWeightWrapperBaseTensor, gemm_ops - -CalibrationCallback = Callable[[torch.Tensor, torch.Tensor], None] - - -class MXFP4CalibrationWrapper(MXFP4TrainingWeightWrapperTensor): - """Phase-A wrapper: standard MXFP4 dispatch plus an optional observation hook.""" - - @staticmethod - def __new__(cls, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): - return super().__new__(cls, tensor, config) - - def __init__(self, tensor, config, *, calibration_callback: Optional[CalibrationCallback] = None): - super().__init__(tensor, config) - self._calibration_callback = calibration_callback - - def attach_calibration_callback(self, cb: Optional[CalibrationCallback]) -> None: - self._calibration_callback = cb - - def __repr__(self): - cb_state = "attached" if self._calibration_callback is not None else "none" - return (f"MXFP4CalibrationWrapper(shape={tuple(self.shape)}, dtype={self.dtype}, " - f"callback={cb_state})") - - @classmethod - def __torch_function__(cls, func, types, args, kwargs={}): - if func.__name__ in gemm_ops: - x, weight = _extract_x_w(func, args) - if isinstance(weight, cls) and weight._calibration_callback is not None: - # Detach to avoid building a graph on the observation path. - weight._calibration_callback(x.detach(), weight._data.detach()) - return super().__torch_function__(func, types, args, kwargs) - - @classmethod - def __torch_dispatch__(cls, func, types, args, kwargs={}): - # Belt-and-suspenders: even though discovery now attaches to the - # canonical wrapper instance, autograd-internal detach/view/clone - # rewraps will produce fresh MXFP4CalibrationWrapper instances via - # the parent's `cls(data, config)` path with cb=None. Propagate the - # callback from any input MXFP4CalibrationWrapper that has one onto - # every output MXFP4CalibrationWrapper. Harmless when no callback - # is in flight (calibration disabled or Phase-B already complete). - src_cb = None - for a in args: - if isinstance(a, MXFP4CalibrationWrapper) and a._calibration_callback is not None: - src_cb = a._calibration_callback - break - out = super().__torch_dispatch__(func, types, args, kwargs) - if src_cb is not None: - import torch.utils._pytree as pytree - pytree.tree_map_only( - MXFP4CalibrationWrapper, - lambda t: setattr(t, "_calibration_callback", src_cb) or t, - out, - ) - return out - - def fsdp_post_all_gather( - self, - all_gather_outputs: Tuple[torch.Tensor, ...], - metadata: Any, - param_dtype: torch.dtype, - *, - out: Optional[torch.Tensor] = None, - ): - if out is not None: - if isinstance(out, MXFP4CalibrationWrapper): - out._calibration_callback = self._calibration_callback - return super().fsdp_post_all_gather(all_gather_outputs, metadata, param_dtype, out=out) - (data,) = all_gather_outputs - output = type(self)(data, self.config, calibration_callback=self._calibration_callback) - return output, (data,) +from .tensor import MXFP4TrainingWeightWrapperTensor, gemm_ops class MXFP4AdaHOPWrapper(MXFP4TrainingWeightWrapperTensor): - """Phase-B wrapper: frozen per-slot modes baked in at construction.""" + """Single AdaHOP wrapper: per-slot modes (default ``"none"`` == plain MXFP4). + + During calibration all modes are ``"none"``; the modifier later flips them + in place via :meth:`set_modes`. + """ @staticmethod def __new__( @@ -150,6 +76,36 @@ def __init__( self._backward_gx_mode = backward_gx_mode self._backward_gw_mode = backward_gw_mode + def set_modes( + self, + forward_y_mode: TransformMode, + backward_gx_mode: TransformMode, + backward_gw_mode: TransformMode, + hadamard_transform: Optional[Any] = None, + ) -> None: + """Set per-slot modes (and optionally the Hadamard transform) IN PLACE. + + Used at the Phase-A -> Phase-B transition, so no new ``nn.Parameter`` is + created and the modes take effect on the FSDP-owned tensor. + """ + assert_mode_supported(forward_y_mode, "forward_y") + assert_mode_supported(backward_gx_mode, "backward_gx") + assert_mode_supported(backward_gw_mode, "backward_gw") + self._forward_y_mode = forward_y_mode + self._backward_gx_mode = backward_gx_mode + self._backward_gw_mode = backward_gw_mode + if hadamard_transform is not None: + self._hadamard_transform = hadamard_transform + + @property + def is_calibrating(self) -> bool: + """True while all slots are ``"none"`` (== plain MXFP4 / Phase A).""" + return ( + self._forward_y_mode == "none" + and self._backward_gx_mode == "none" + and self._backward_gw_mode == "none" + ) + def _adahop_kwargs(self) -> dict: return { "hadamard_transform": self._hadamard_transform, @@ -169,6 +125,11 @@ def __torch_function__(cls, func, types, args, kwargs={}): if func.__name__ in gemm_ops: x, weight, bias, trans_b = _extract_x_w_with_bias(func, args) assert isinstance(weight, cls), f"weight should be a {cls.__name__} for {func.__name__}" + # During calibration (all slots "none") behave EXACTLY like plain + # MXFP4: defer to the parent dispatch. Avoids the AdaHOP linear + # function's "none" path (unused by the recipe post-Phase-B). + if weight.is_calibrating: + return super().__torch_function__(func, types, args, kwargs) operand_w = weight._data if trans_b else weight._data.T y = MXFP4AdaHOPLinearFunction.apply( x, @@ -204,11 +165,6 @@ def __tensor_unflatten__(cls, inner_tensors, flatten_spec, outer_size, outer_str backward_gx_mode=flatten_spec["backward_gx_mode"], backward_gw_mode=flatten_spec["backward_gw_mode"], ) - logger.info(f"[AdaHOP] Restored MXFP4AdaHOPWrapper from checkpoint: " - f"forward_y={instance._forward_y_mode}, " - f"backward_gx={instance._backward_gx_mode}, " - f"backward_gw={instance._backward_gw_mode}, " - f"hadamard_transform=None (will be re-bound by modifier on first use)") return instance def fsdp_post_all_gather( @@ -219,7 +175,7 @@ def fsdp_post_all_gather( *, out: Optional[torch.Tensor] = None, ): - # Step 1+: `out` is pre-allocated. Preserve our modes onto it if it's a sibling instance. + # Step 1+: `out` is pre-allocated. Preserve our modes onto it. if out is not None: if isinstance(out, MXFP4AdaHOPWrapper): out._hadamard_transform = self._hadamard_transform @@ -233,15 +189,6 @@ def fsdp_post_all_gather( return output, (data,) -def _extract_x_w(func, args): - """Return ``(x, weight)`` for a gemm-family op. Mirrors parent dispatch.""" - if func.__name__ == "addmm.default": - _, A, B = args[0], args[1], args[2] - else: - A, B = args[0], args[1] - return A, B - - def _extract_x_w_with_bias(func, args): """Return ``(x, weight, bias, trans_b)`` for a gemm-family op.""" trans_b = func.__name__ == "linear" @@ -251,3 +198,9 @@ def _extract_x_w_with_bias(func, args): A, B = args[0], args[1] bias = args[2] if len(args) > 2 else None return A, B, bias, trans_b + + +# Allowlist the wrapper for DCP checkpoint load (PyTorch >= 2.6 defaults +# torch.load to weights_only=True, which rejects unknown tensor-subclass globals). +# Mirrors the base-wrapper registration in dispatch/tensor.py. +torch.serialization.add_safe_globals([MXFP4AdaHOPWrapper]) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 4248daee..0bf7efb3 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -158,7 +158,7 @@ def gpt_oss_20b_lpt() -> Trainer.Config: config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint config.checkpoint.initial_load_in_hf = False config.checkpoint.initial_load_in_hf_quantized = False - config.checkpoint.interval = 1000 # save every 1000 steps + config.checkpoint.interval = 500 # Save at step interval config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop.yaml",), diff --git a/alto/modifiers/lpt/adahop.py b/alto/modifiers/lpt/adahop.py index 5853cd44..cd167c42 100644 --- a/alto/modifiers/lpt/adahop.py +++ b/alto/modifiers/lpt/adahop.py @@ -3,30 +3,36 @@ # SPDX-License-Identifier: MIT """AdaHOP calibration + per-slot Hadamard mode selection modifier. -Orchestrates the two-phase swap described in ADAHOP_TO_ALTO_INTEGRATION_PLAN.md: - -1. **Phase A** is set up by ``LowPrecisionTrainingModifier`` when ``scheme="mxfp4_adahop"`` - is used in the recipe — all targeted weights wrapped in - :class:`MXFP4CalibrationWrapper`. Identical dispatch to plain MXFP4. -2. **Phase A → Phase B transition** is owned by this modifier: - - * ``on_initialize`` walks the model, finds every ``MXFP4CalibrationWrapper`` - weight, attaches an outlier-pattern observation callback (forward path), - and registers a backward hook on the parent linear module for - ``grad_output`` observation. The layer FQN is closed over in both - closures so the wrapper itself stays FQN-agnostic. - * ``on_pre_step`` opens a fresh per-step pattern dict and increments the - step counter. - * ``on_post_step`` does nothing until step == ``calibration_steps``, then: - aggregates per-layer majority patterns, maps pattern pairs to per-slot - ``TransformMode`` triples via the recipe's ``layer_transform_config``, - and re-swaps every calibration wrapper as - :class:`MXFP4AdaHOPWrapper` carrying the frozen modes. Observation - hooks are removed. - -A ``transform_config_path`` shortcut accepts a pre-baked JSON -(``{layer_fqn: {forward_y, backward_gx, backward_gw}}``) and skips -calibration entirely — the re-swap happens at ``on_initialize`` instead. +Strategy-2 design (see /home/alirezak/han_branch/porting_notes.txt). A SINGLE +wrapper class (:class:`MXFP4AdaHOPWrapper`) is used from model-conversion time. +During calibration its modes are all ``"none"`` (== plain MXFP4); when +calibration finishes the modes are set IN PLACE (no wrapper-type swap, no new +``nn.Parameter``), so optimizer state references stay valid. + +Lifecycle: + +1. ``LowPrecisionTrainingModifier`` (scheme=``"mxfp4_adahop"``) wraps every + targeted weight in :class:`MXFP4AdaHOPWrapper` (modes ``"none"``). +2. This modifier owns the Phase-A -> Phase-B transition: + + * ``on_convert`` configures the HadamardFactory. + * ``on_initialize`` discovers the wrapped linears. (If ``transform_config_path`` + is set, pre-baked modes are applied here and calibration is skipped.) + * ``on_pre_step`` — on the FIRST call (which happens AFTER + ``checkpointer.load`` in the forge trainer), it inspects the checkpointed + :class:`CalibrationStateManager`: + - calibration already completed -> apply the restored modes in place and + skip calibration (clean resume); + - otherwise -> arm transient *module* observation hooks and begin + calibration. It then opens a fresh per-step pattern bucket. + * ``on_post_step`` aggregates after ``calibration_steps``, maps patterns to + per-slot modes, applies them in place, records them in the + CalibrationStateManager (so they ride the next checkpoint), writes the + JSON artifact, and removes the observation hooks. + +Calibration observation uses transient forward-pre / full-backward hooks on the +``nn.Linear`` modules — no closures are stored on tensors, so checkpoints remain +picklable. """ from typing import Any, Callable, Dict, List, Optional @@ -41,12 +47,19 @@ from alto.modifiers.lpt.adahop_internals.calibration_hooks import ( load_modes_from_json, make_backward_hook, - make_forward_callback, + make_forward_pre_hook, write_modes_json, ) +from alto.modifiers.lpt.adahop_internals.calibration_state import ( + get_calibration_modes, + is_calibration_completed, + set_calibration_result, +) __all__ = ["AdaHOPModifier"] +_NONE_MODES = {"forward_y": "none", "backward_gx": "none", "backward_gw": "none"} + class AdaHOPModifier(Modifier): """Outlier-pattern-aware Hadamard calibration over MXFP4-wrapped linears.""" @@ -74,18 +87,19 @@ class AdaHOPModifier(Modifier): _per_step_patterns: List[Dict[str, Dict[str, str]]] = PrivateAttr(default_factory=list) _fqn_to_wrapper: Dict[str, Any] = PrivateAttr(default_factory=dict) _fqn_to_module: Dict[str, nn.Module] = PrivateAttr(default_factory=dict) - _backward_handles: List[Any] = PrivateAttr(default_factory=list) - _hadamard_transform: Any = PrivateAttr(default=None) + _handles: List[Any] = PrivateAttr(default_factory=list) _phase_b_done: bool = PrivateAttr(default=False) + _resume_decided: bool = PrivateAttr(default=False) + _calibration_armed: bool = PrivateAttr(default=False) @property def requires_training_mode(self) -> bool: return True def on_convert(self, model: Module, **kwargs) -> bool: - # The Phase-A wrapper swap is performed by LowPrecisionTrainingModifier - # when scheme="mxfp4_adahop". Configure the HadamardFactory here so - # the transform object is available at re-swap time. + # The wrapper swap (to MXFP4AdaHOPWrapper, modes "none") is performed by + # LowPrecisionTrainingModifier when scheme="mxfp4_adahop". Configure the + # HadamardFactory here so the transform object is available later. if not self.enabled: logger.info("[AdaHOP] Modifier disabled (enabled=False); pass-through only.") return True @@ -106,48 +120,51 @@ def on_convert(self, model: Module, **kwargs) -> bool: def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: if not self.enabled: return True - from alto.kernels.dispatch.adahop_tensor import MXFP4CalibrationWrapper + from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper - self._collect_calibration_wrappers(model_parts, MXFP4CalibrationWrapper) + self._collect_wrappers(model_parts, MXFP4AdaHOPWrapper) n_wrappers = len(self._fqn_to_wrapper) - logger.info(f"[AdaHOP] Phase A: discovered {n_wrappers} MXFP4CalibrationWrapper-wrapped linears.") + logger.info(f"[AdaHOP] Discovered {n_wrappers} MXFP4AdaHOPWrapper-wrapped linears.") if n_wrappers > 0: sample = list(self._fqn_to_wrapper.keys())[:5] logger.info(f"[AdaHOP] First {len(sample)} FQNs: {', '.join(sample)}" f"{' ...' if n_wrappers > 5 else ''}") + elif not self._fqn_to_wrapper: + logger.warning("[AdaHOP] No MXFP4AdaHOPWrapper found; " + "is LowPrecisionTrainingModifier(scheme='mxfp4_adahop') in the recipe?") + # Manual pre-baked modes override: applied now (before checkpointer.load), + # in place. Calibration is then skipped entirely. if self.transform_config_path is not None: modes_by_fqn = load_modes_from_json(self.transform_config_path) - logger.info(f"[AdaHOP] Phase B (JSON-load): {len(modes_by_fqn)} modes loaded from " - f"{self.transform_config_path}; re-swap starting (calibration skipped).") + logger.info(f"[AdaHOP] Pre-baked modes: {len(modes_by_fqn)} loaded from " + f"{self.transform_config_path}; applying in place (calibration skipped).") self._log_mode_table(modes_by_fqn, aggregated=None) - self._do_phase_b_reswap(modes_by_fqn) - return True - - if not self._fqn_to_wrapper: - logger.warning("[AdaHOP] No MXFP4CalibrationWrapper found; " - "is LowPrecisionTrainingModifier(scheme='mxfp4_adahop') in the recipe?") - return True - - from alto._adahop_bridge import detect_outlier_pattern - - for fqn, wrapper in self._fqn_to_wrapper.items(): - wrapper.attach_calibration_callback(make_forward_callback(self, fqn, detect_outlier_pattern)) - - for fqn, module in self._fqn_to_module.items(): - handle = module.register_full_backward_hook(make_backward_hook(self, fqn, detect_outlier_pattern)) - self._backward_handles.append(handle) - - logger.info(f"[AdaHOP] Calibration armed: {n_wrappers} layers, {self.calibration_steps} steps. " - f"Forward callbacks attached. Backward hooks registered. " - f"Patterns will be observed every step.") + self._apply_modes_in_place(modes_by_fqn) + self._resume_decided = True + # Otherwise: defer the calibrate-vs-resume decision to the first + # on_pre_step, which runs AFTER checkpointer.load() so the restored + # CalibrationStateManager is visible. return True def on_pre_step(self, model_parts: list[Module], **kwargs) -> bool: if not self.enabled or self._phase_b_done: return True - if self.transform_config_path is not None: - return True + + # First step after checkpointer.load(): decide resume vs. calibrate. + if not self._resume_decided: + self._resume_decided = True + if is_calibration_completed(): + modes_by_fqn = get_calibration_modes() + logger.info(f"[AdaHOP] Calibration restored from checkpoint " + f"({len(modes_by_fqn)} layers). Applying modes in place; " + f"skipping re-calibration.") + self._log_mode_table(modes_by_fqn, aggregated=None) + self._apply_modes_in_place(modes_by_fqn) + return True + # Fresh calibration. + self._arm_calibration() + if self._step_idx >= self.calibration_steps: return True # Open a fresh dict for this step's pattern observations. @@ -158,7 +175,7 @@ def on_pre_step(self, model_parts: list[Module], **kwargs) -> bool: def on_post_step(self, model_parts: list[Module], **kwargs) -> bool: if not self.enabled or self._phase_b_done: return True - if self.transform_config_path is not None: + if not self._calibration_armed: return True self._step_idx += 1 @@ -189,11 +206,14 @@ def on_post_step(self, model_parts: list[Module], **kwargs) -> bool: write_modes_json(effective_dump_path, aggregated, modes_by_fqn) logger.info(f"[AdaHOP] JSON dump: wrote {len(modes_by_fqn)} layer modes to {effective_dump_path}") - logger.info("[AdaHOP] Phase B re-swap starting...") - self._do_phase_b_reswap(modes_by_fqn) + logger.info("[AdaHOP] Phase B (in-place mode application) starting...") + self._apply_modes_in_place(modes_by_fqn) self._detach_observation_hooks() - logger.info(f"[AdaHOP] Phase B complete. Forward path now uses MXFP4AdaHOPLinearFunction " - f"with frozen modes for {len(modes_by_fqn)} layers. Observation hooks detached.") + # Record into the checkpointable calibration state so the next checkpoint + # carries the modes and a resume can skip calibration. + set_calibration_result(modes_by_fqn, self._step_idx) + logger.info(f"[AdaHOP] Phase B complete. Modes set in place for {len(modes_by_fqn)} layers; " + f"recorded in CalibrationStateManager. Observation hooks detached.") return True def on_finalize(self, model_parts: list[Module], **kwargs) -> bool: @@ -225,24 +245,47 @@ def _log_mode_table( # ------------------------------------------------------------------ helpers - def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: - # CRITICAL: attach to the canonical instance F.linear will see. - # - # No-FSDP path: `module.weight` IS the subclass (Parameter of a - # tensor subclass IS the subclass). Use that directly. - # `module.weight.data` triggers a detach in __torch_dispatch__ and - # returns a FRESH wrapper (different Python object); callbacks on - # that transient are invisible to F.linear. + def _arm_calibration(self) -> None: + """Register transient module observation hooks and begin Phase A.""" + if self._calibration_armed: + return + from alto._adahop_bridge import detect_outlier_pattern + + for fqn, module in self._fqn_to_module.items(): + h_fwd = module.register_forward_pre_hook( + make_forward_pre_hook(self, fqn, detect_outlier_pattern) + ) + h_bwd = module.register_full_backward_hook( + make_backward_hook(self, fqn, detect_outlier_pattern) + ) + self._handles.append(h_fwd) + self._handles.append(h_bwd) + self._calibration_armed = True + logger.info(f"[AdaHOP] Calibration armed: {len(self._fqn_to_module)} layers, " + f"{self.calibration_steps} steps. Module forward-pre + backward hooks registered.") + + def _make_ht_resolver(self) -> Callable[[torch.device], Any]: + if not self.use_hadamard: + return lambda _dev: None + from alto._adahop_bridge import HadamardFactory + ht_cache: Dict[torch.device, Any] = {} + + def _get_ht(device): + if device not in ht_cache: + ht_cache[device] = HadamardFactory.create_transform(device=device) + return ht_cache[device] + + return _get_ht + + def _collect_wrappers(self, model_parts, wrapper_cls) -> None: + # Attach to the canonical instance F.linear will see (see long comment + # below). Same discovery logic as before; only the target class changed + # to the single MXFP4AdaHOPWrapper. # - # FSDP path: fully_shard swaps module.weight for a sharded DTensor - # whose _local_tensor is a FSDP-owned wrapper, distinct from any - # wrapper reachable through module.weight at discovery time. The - # canonical instance lives at - # fsdp_state._fsdp_param_group.fsdp_params[i]._sharded_local_tensor - # (a property returning `cast(DTensor, sharded_param)._local_tensor`). - # That's what FSDP later passes as `self` into fsdp_post_all_gather - # and where state must be stamped for FSDP's all-gather rewrap to - # propagate it onto the unsharded param used in forward. + # No-FSDP path: `module.weight` IS the subclass. FSDP path: the + # canonical instance lives at fsdp_param._sharded_local_tensor and is + # the one FSDP later passes into fsdp_post_all_gather; in-place mode + # updates there propagate to the unsharded param used in forward. from torch.distributed.tensor import DTensor try: from torch.distributed.fsdp._fully_shard._fsdp_state import _get_module_fsdp_state @@ -252,9 +295,6 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: self._fqn_to_wrapper.clear() self._fqn_to_module.clear() - # Pre-build a map from nn.Module → FSDPParam by walking every FSDP - # state and its param group. Each FSDPParam knows its origin module - # via _module_info.module and parameter name via _module_info.param_name. module_param_to_fsdp_param = {} for part in model_parts: for _m in part.modules(): @@ -278,7 +318,7 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: fp = module_param_to_fsdp_param.get((id(module), "weight")) if fp is not None: inner = fp._sharded_local_tensor - elif isinstance(weight, cal_wrapper_cls): + elif isinstance(weight, wrapper_cls): inner = weight else: raw = weight.data @@ -287,74 +327,31 @@ def _collect_calibration_wrappers(self, model_parts, cal_wrapper_cls) -> None: else: inner = raw - if not isinstance(inner, cal_wrapper_cls): + if not isinstance(inner, wrapper_cls): continue self._fqn_to_wrapper[module_fqn] = inner self._fqn_to_module[module_fqn] = module - def _do_phase_b_reswap(self, modes_by_fqn: Dict[str, Dict[str, str]]) -> None: - from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper - - ht = None - if self.use_hadamard: - from alto._adahop_bridge import HadamardFactory - # Lazy construction; the modifier doesn't know the device until - # weights actually live somewhere, so build per-device on demand. - ht_cache: Dict[torch.device, Any] = {} - - def _get_ht(device): - if device not in ht_cache: - ht_cache[device] = HadamardFactory.create_transform(device=device) - return ht_cache[device] - - ht_resolver: Callable[[torch.device], Any] = _get_ht - else: - ht_resolver = lambda _dev: None # noqa: E731 - - from torch.distributed.tensor import DTensor - + def _apply_modes_in_place(self, modes_by_fqn: Dict[str, Dict[str, str]]) -> None: + """Set per-slot modes on every wrapper IN PLACE (no Parameter swap).""" + ht_resolver = self._make_ht_resolver() + n = 0 for fqn, wrapper in self._fqn_to_wrapper.items(): - modes = modes_by_fqn.get(fqn, {"forward_y": "none", "backward_gx": "none", "backward_gw": "none"}) - module = self._fqn_to_module[fqn] - old_param = module.weight - data = wrapper._data - ht = ht_resolver(data.device) - new_wrapper = MXFP4AdaHOPWrapper( - data, - wrapper.config, - hadamard_transform=ht, + modes = modes_by_fqn.get(fqn, _NONE_MODES) + ht = ht_resolver(wrapper._data.device) + wrapper.set_modes( forward_y_mode=modes.get("forward_y", "none"), backward_gx_mode=modes.get("backward_gx", "none"), backward_gw_mode=modes.get("backward_gw", "none"), + hadamard_transform=ht, ) - if isinstance(old_param.data, DTensor): - # Preserve FSDP sharding: rebuild the DTensor around the new local wrapper - # using the same device mesh and placements as the existing param. - old_dt = old_param.data - new_dt = DTensor.from_local( - new_wrapper, - device_mesh=old_dt.device_mesh, - placements=old_dt.placements, - run_check=False, - shape=old_dt.shape, - stride=old_dt.stride(), - ) - module.weight = nn.Parameter(new_dt, requires_grad=old_param.requires_grad) - else: - module.weight = nn.Parameter(new_wrapper, requires_grad=old_param.requires_grad) - ht_state = "attached" if ht is not None else "none" - logger.info(f" [AdaHOP] re-swapped {fqn}: MXFP4CalibrationWrapper → " - f"MXFP4AdaHOPWrapper(forward_y={new_wrapper._forward_y_mode}, " - f"backward_gx={new_wrapper._backward_gx_mode}, " - f"backward_gw={new_wrapper._backward_gw_mode}, " - f"hadamard={ht_state})") - + n += 1 + ht_state = "attached" if self.use_hadamard else "none" + logger.info(f"[AdaHOP] Applied modes in place for {n} layers (hadamard={ht_state}).") self._phase_b_done = True def _detach_observation_hooks(self) -> None: - for handle in self._backward_handles: + for handle in self._handles: handle.remove() - self._backward_handles.clear() - for wrapper in self._fqn_to_wrapper.values(): - if hasattr(wrapper, "attach_calibration_callback"): - wrapper.attach_calibration_callback(None) + self._handles.clear() + self._calibration_armed = False diff --git a/alto/modifiers/lpt/adahop_internals/calibration_state.py b/alto/modifiers/lpt/adahop_internals/calibration_state.py new file mode 100644 index 00000000..fd6aa9dc --- /dev/null +++ b/alto/modifiers/lpt/adahop_internals/calibration_state.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Checkpointable AdaHOP calibration state. + +Strategy-2 port (see /home/alirezak/han_branch/porting_notes.txt). Mirrors the +upstream adahop design +(3rdparty/adahop/torchtitan/experiments/kernels/mxfp4/transform_config.py: +``CalibrationStateManager`` + global state dict), adapted to ALTO. + +The per-layer transform modes and the "calibration completed" flag live in a +module-level dict here -- NOT on any tensor subclass. ``CalibrationStateManager`` +implements the PyTorch DCP ``Stateful`` interface so this state is saved and +restored alongside model/optimizer state. The bytes are produced with +``pickle.dumps`` (same approach upstream uses) so DCP treats the value as an +opaque non-tensor blob. + +Lifecycle: + * Fresh run: state starts empty (completed=False). The AdaHOPModifier observes + patterns for ``calibration_steps`` and then calls :func:`set_calibration_result` + which records the modes and flips completed=True. + * Resume: CheckpointManager.load() calls ``load_state_dict`` which repopulates + the module-level dict BEFORE the modifier inspects it on the first training + step, so the modifier applies the stored modes and skips re-calibration. +""" + +import pickle +from typing import Any, Dict + +from torch.distributed.checkpoint.stateful import Stateful + +__all__ = [ + "CalibrationStateManager", + "get_adahop_calibration_state", + "is_calibration_completed", + "get_calibration_modes", + "set_calibration_result", + "reset_calibration_state", +] + + +# Module-level calibration state. Plain, fully-picklable Python objects only. +_STATE: Dict[str, Any] = { + "completed": False, # bool: has Phase B (mode selection) finished? + "step_idx": 0, # int: calibration steps observed so far + "modes_by_fqn": {}, # {fqn: {forward_y, backward_gx, backward_gw}} +} + + +def get_calibration_state_dict() -> Dict[str, Any]: + """Return the live module-level calibration state dict (mutable).""" + return _STATE + + +def is_calibration_completed() -> bool: + return bool(_STATE.get("completed", False)) + + +def get_calibration_modes() -> Dict[str, Dict[str, str]]: + return _STATE.get("modes_by_fqn", {}) + + +def set_calibration_result(modes_by_fqn: Dict[str, Dict[str, str]], step_idx: int) -> None: + """Record the aggregated per-layer modes and mark calibration complete.""" + _STATE["modes_by_fqn"] = dict(modes_by_fqn) + _STATE["step_idx"] = int(step_idx) + _STATE["completed"] = True + + +def set_step_idx(step_idx: int) -> None: + _STATE["step_idx"] = int(step_idx) + + +def reset_calibration_state() -> None: + """Reset to the fresh-run defaults (used by tests).""" + _STATE["completed"] = False + _STATE["step_idx"] = 0 + _STATE["modes_by_fqn"] = {} + + +class CalibrationStateManager(Stateful): + """DCP ``Stateful`` wrapper around the module-level calibration state. + + Registered into ``CheckpointManager.states`` (from ``alto/train.py``) under + the key ``"adahop_calibration"`` so calibration results round-trip with the + checkpoint. The payload is pickled to keep DCP handling simple (opaque bytes, + same pattern as the upstream reference). + """ + + _STATE_KEY = "adahop_calibration_data" + + def state_dict(self) -> Dict[str, Any]: + return {self._STATE_KEY: pickle.dumps(_STATE)} + + def load_state_dict(self, state_dict: Dict[str, Any]) -> None: + if not state_dict or self._STATE_KEY not in state_dict: + return + loaded = pickle.loads(state_dict[self._STATE_KEY]) + if isinstance(loaded, dict): + _STATE.update(loaded) + + +# Process-wide singleton so the modifier and the Trainer (alto/train.py) share +# one instance/state. +_singleton: CalibrationStateManager | None = None + + +def get_adahop_calibration_state() -> CalibrationStateManager: + global _singleton + if _singleton is None: + _singleton = CalibrationStateManager() + return _singleton diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index 060b90b8..fd76266a 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -51,9 +51,18 @@ def _blockwise_mxfp4_gemm_or_dequant( output_dtype: torch.dtype, trans_a: bool = False, trans_b: bool = False, + a_axis: int = -1, + b_axis: int = -1, ) -> torch.Tensor: """Triton MXFP4 GEMM on cdna4, dequant-then-bf16-matmul fallback on cdna3. - Mirrors ``alto/kernels/fp4/mxfp4/mxfp_linear.py:321-353``.""" + Mirrors ``alto/kernels/fp4/mxfp4/mxfp_linear.py:321-402``. + + ``a_axis`` / ``b_axis`` give the axis each operand was PACKED along by + ``convert_to_mxfp4`` (== the GEMM contraction axis K). The cdna3 dequant must + unpack along that same axis; the cdna4 kernel infers it from the scale layout. + Defaults (-1) are correct for the forward; the backward GEMMs pack the weight + / activation along axis 0, so those operands need axis=0. Mismatching this + leaves the packed dim half-size, e.g. ``(8192x32) @ (16x5760)``.""" if is_cdna4(): return torch.ops.torchtitan.blockwise_mxfp4_gemm( a_mxfp4, @@ -65,10 +74,10 @@ def _blockwise_mxfp4_gemm_or_dequant( output_dtype=output_dtype, ) a_dq = torch.ops.torchtitan.convert_from_mxfp4( - a_mxfp4, a_scale, output_dtype, axis=-1, is_2d_block=False, + a_mxfp4, a_scale, output_dtype, axis=a_axis, is_2d_block=False, ) b_dq = torch.ops.torchtitan.convert_from_mxfp4( - b_mxfp4, b_scale, output_dtype, axis=-1, is_2d_block=False, + b_mxfp4, b_scale, output_dtype, axis=b_axis, is_2d_block=False, ) if trans_a: a_dq = a_dq.T @@ -464,6 +473,7 @@ def _backward_gx( grad_inputs = _blockwise_mxfp4_gemm_or_dequant( g_mxfp4, g_scale, w, w_scale, output_dtype=original_dtype, + b_axis=0, # weight packed along axis 0 (= contraction axis "out") ) if mode == "outer_hadamard": grad_inputs = hadamard_transform(hadamard_transform(grad_inputs, left_mul=True)) @@ -526,6 +536,7 @@ def _backward_gw( grad_weights = _blockwise_mxfp4_gemm_or_dequant( g_mxfp4, g_scale, x, x_scale, trans_a=True, output_dtype=original_dtype, + a_axis=0, b_axis=0, # grad_output and x both packed along axis 0 (= "M") ) if mode == "outer_hadamard": grad_weights = hadamard_transform(hadamard_transform(grad_weights, left_mul=True)) diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index cbcacaca..73b2f571 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -154,8 +154,12 @@ def _wrapper_cls_for_scheme(self, scheme_obj): back to ``swap_params``' default (looked up from the config precision).""" scheme_name = getattr(self, "_scheme_tag", {}).get(scheme_obj) if scheme_name == "mxfp4_adahop": - from alto.kernels.dispatch.adahop_tensor import MXFP4CalibrationWrapper - return MXFP4CalibrationWrapper + # Single-wrapper design: wrap with the AdaHOP wrapper from convert + # time (modes default "none" == plain MXFP4 during calibration). The + # AdaHOPModifier flips the modes in place at Phase B so they take + # effect on the FSDP-owned tensor. + from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper + return MXFP4AdaHOPWrapper return None def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: diff --git a/alto/train.py b/alto/train.py index cec58aec..e3956e86 100644 --- a/alto/train.py +++ b/alto/train.py @@ -84,6 +84,30 @@ class Trainer(ForgeTrainer): def __init__(self, config: TitanTrainer.Config): super().__init__(config) + # AdaHOP (Strategy-2 port): if an AdaHOPModifier is in the recipe, + # register its checkpointable calibration state with the checkpointer so + # per-layer transform modes are saved/restored alongside model/optimizer + # state. CheckpointManager.states is a live, mutable dict iterated by + # save()/load(); injecting here (after super().__init__, before + # train()->checkpointer.load) keeps the whole change inside alto/. + try: + from alto.modifiers.lpt.adahop import AdaHOPModifier + from alto.modifiers.lpt.adahop_internals.calibration_state import ( + get_adahop_calibration_state, + ) + has_adahop = any( + isinstance(m, AdaHOPModifier) + for conv in self.model_converters.converters + if isinstance(conv, ModelOptConverter) + for m in getattr(conv, "recipe", None).modifiers + ) if not self.model_converters.is_empty() else False + if has_adahop and getattr(self, "checkpointer", None) is not None: + self.checkpointer.states["adahop_calibration"] = get_adahop_calibration_state() + logger.info("[AdaHOP] Registered CalibrationStateManager with checkpointer " + "(states key='adahop_calibration').") + except Exception as e: # never let this break trainer construction + logger.warning(f"[AdaHOP] Could not register calibration state with checkpointer: {e}") + self.training_mode = True self.enable_data_cache = False diff --git a/tests/unittest/adahop/test_adahop_modifier_helpers.py b/tests/unittest/adahop/test_adahop_modifier_helpers.py index 34fd864a..dc7dc048 100644 --- a/tests/unittest/adahop/test_adahop_modifier_helpers.py +++ b/tests/unittest/adahop/test_adahop_modifier_helpers.py @@ -35,21 +35,31 @@ def helpers(): return _load_helpers() -def test_forward_callback_writes_x_and_w_patterns(helpers): - modifier = SimpleNamespace(_per_step_patterns=[{}]) +class _Det: + """Stand-in tensor that echoes a string from .detach().""" + + def __init__(self, v): + self.v = v + + def detach(self): + return self.v - def detect(t): - return t # echo back; tests pass strings as "tensors" - cb = helpers.make_forward_callback(modifier, "layers.0.wq", detect) - cb("row", "col") +def test_forward_pre_hook_writes_x_and_w_patterns(helpers): + # module.weight.data._data is the underlying tensor the hook inspects. + weight = SimpleNamespace(data=SimpleNamespace(_data=_Det("col"))) + module = SimpleNamespace(weight=weight) + modifier = SimpleNamespace(_per_step_patterns=[{}]) + + hook = helpers.make_forward_pre_hook(modifier, "layers.0.wq", lambda t: t) + hook(module, (_Det("row"),)) assert modifier._per_step_patterns[-1]["layers.0.wq"] == {"x": "row", "w": "col"} -def test_forward_callback_noop_when_no_step_dict(helpers): +def test_forward_pre_hook_noop_when_no_step_dict(helpers): modifier = SimpleNamespace(_per_step_patterns=[]) - cb = helpers.make_forward_callback(modifier, "layers.0.wq", lambda t: t) - cb("row", "col") + hook = helpers.make_forward_pre_hook(modifier, "layers.0.wq", lambda t: t) + hook(object(), (_Det("row"),)) # early-returns before touching module assert modifier._per_step_patterns == [] diff --git a/tests/unittest/adahop/test_adahop_wrapper.py b/tests/unittest/adahop/test_adahop_wrapper.py index ea427506..cb8b4602 100644 --- a/tests/unittest/adahop/test_adahop_wrapper.py +++ b/tests/unittest/adahop/test_adahop_wrapper.py @@ -35,7 +35,7 @@ def _import_alto_kernel_free(name: str, path_parts: list[str]): pytest.importorskip("triton") # adahop_tensor imports through alto.kernels.fp4 -> triton try: - from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper, MXFP4CalibrationWrapper + from alto.kernels.dispatch.adahop_tensor import MXFP4AdaHOPWrapper from alto.kernels.dispatch.config import TrainingOpConfig except RuntimeError as exc: # ALTO's kernels init triton at import; on a no-GPU box that raises @@ -55,13 +55,27 @@ def mxfp4_config(): ) -def test_calibration_wrapper_accepts_optional_callback(mxfp4_config): +def test_wrapper_defaults_to_calibrating_none_modes(mxfp4_config): t = torch.randn(8, 8) - w = MXFP4CalibrationWrapper(t, mxfp4_config) - assert w._calibration_callback is None - seen = [] - w.attach_calibration_callback(lambda x, ww: seen.append((x.shape, ww.shape))) - assert w._calibration_callback is not None + w = MXFP4AdaHOPWrapper(t, mxfp4_config) + # Default (Phase-A) state: all modes "none" == plain MXFP4. + assert w._forward_y_mode == "none" + assert w._backward_gx_mode == "none" + assert w._backward_gw_mode == "none" + assert w.is_calibrating is True + + +def test_wrapper_set_modes_in_place(mxfp4_config): + t = torch.randn(8, 8) + w = MXFP4AdaHOPWrapper(t, mxfp4_config) + w.set_modes( + forward_y_mode="hadamard", + backward_gx_mode="none", + backward_gw_mode="full_precision", + ) + assert w._forward_y_mode == "hadamard" + assert w._backward_gw_mode == "full_precision" + assert w.is_calibrating is False def test_adahop_wrapper_rejects_unsupported_mode(mxfp4_config): From 6a49876e8b2a70b1994c72472f4b1c3aaead03f9 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Mon, 6 Jul 2026 14:03:02 +0000 Subject: [PATCH 073/142] moe patterns --- .gitignore | 3 + 3rdparty/adahop | 2 +- .../hadamard_transform/hadamards.safetensors | Bin 1436901 -> 132 bytes alto/models/gpt_oss/config_registry.py | 120 +++++- alto/modifiers/debug/moe_pattern_hooks.py | 222 +++++++++++ alto/modifiers/debug/moe_pattern_observer.py | 351 +++++++++++++++++ scripts/moe_pattern_viz.py | 367 ++++++++++++++++++ .../unittest/debug/test_moe_pattern_hooks.py | 248 ++++++++++++ 8 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 alto/modifiers/debug/moe_pattern_hooks.py create mode 100644 alto/modifiers/debug/moe_pattern_observer.py create mode 100644 scripts/moe_pattern_viz.py create mode 100644 tests/unittest/debug/test_moe_pattern_hooks.py diff --git a/.gitignore b/.gitignore index 74a24a16..7c3bcdb3 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,6 @@ __marimo__/ datasets/ /models/ comm_traces/ + +docker/ +gpt_*/ \ No newline at end of file diff --git a/3rdparty/adahop b/3rdparty/adahop index 46fbab57..63561b22 160000 --- a/3rdparty/adahop +++ b/3rdparty/adahop @@ -1 +1 @@ -Subproject commit 46fbab57df595620c27abc7966d5b14092a51bb9 +Subproject commit 63561b22e9d377af3da0963862db5b05e7c10fe3 diff --git a/alto/kernels/hadamard_transform/hadamards.safetensors b/alto/kernels/hadamard_transform/hadamards.safetensors index 9624e008623e86678a2da7f27000106e03055257..bd00063f140e39e04a7acb988692417d22cf668b 100644 GIT binary patch literal 132 zcmWN?OA^8$3;@tQr|1PNgm2p0kR}K-DjmTtJiWfnyW~Aue=T*+bL?8*+q^x>SpU}# ztw(?Aamt}DP`&XoYPKQn8-|nukaH1}k3fQ3F)mn(P=sv)#)C`dY*0ujQ%NB)Iq=9H OM2+^91-y%!0rdkR#V82? literal 1436901 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

Ky4J7 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 It9OnAV--xH#4Yj*)gQZy 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 diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 0bf7efb3..77ad4ee5 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -19,12 +19,18 @@ "gpt_oss_20b", "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", + "gpt_oss_20b_adahop", "gpt_oss_20b_pretrain_c4", "gpt_oss_20b_lpt_c4", "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_obs_lpt", "gpt_oss_debugmodel_grad_clip_lpt_no_fsdp", + "gpt_oss_debugmodel_lpt_1gpu_ckpt", + "gpt_oss_debugmodel_obs_lpt_no_fsdp", + "gpt_oss_debugmodel_moe_pattern_obs", + "gpt_oss_debugmodel_moe_pattern_obs_no_fsdp", + "gpt_oss_20b_moe_pattern_obs", ] @@ -61,6 +67,45 @@ def gpt_oss_debugmodel_obs_lpt() -> Trainer.Config: return config +def gpt_oss_debugmodel_lpt_1gpu_ckpt() -> Trainer.Config: + """Single-GPU (no FSDP/EP/TP) MXFP4 debugmodel training that writes full, + resumable checkpoints. Pairs with debug_train_gpt_oss_1gpu_ckpt.sh: run this + on a login-node GPU to produce checkpoints we later load for per-expert + stats. Model quality is irrelevant — we only need loadable checkpoints.""" + config = gpt_oss_debugmodel_lpt() + # Force all sharding degrees to 1 (login node can't do FSDP). + config.parallelism.data_parallel_shard_degree = 1 + config.parallelism.data_parallel_replicate_degree = 1 + config.parallelism.expert_parallel_degree = 1 + config.parallelism.expert_tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 1 + config.compile.enable = False + # Full (resumable) checkpoints, keep them all so any step can be loaded later. + config.checkpoint.enable = True + config.checkpoint.interval = 50 + config.checkpoint.keep_latest_k = 0 + return config + + +def gpt_oss_debugmodel_obs_lpt_no_fsdp() -> Trainer.Config: + """Single-GPU (no FSDP/EP/TP) MXFP4 + DebugObserver. Loads a checkpoint + produced by gpt_oss_debugmodel_lpt_1gpu_ckpt, runs a few steps, and dumps + per-expert inputs/weights/grads to ./outputs/debug_obs_lpt.pt. Pairs with + debug_stats_gpt_oss_1gpu.sh, which sets --checkpoint.initial_load_path.""" + config = gpt_oss_debugmodel_obs_lpt() + config.parallelism.data_parallel_shard_degree = 1 + config.parallelism.data_parallel_replicate_degree = 1 + config.parallelism.expert_parallel_degree = 1 + config.parallelism.expert_tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 1 + config.compile.enable = False + # Load pretrained weights only; the stats script points initial_load_path at + # a step-N checkpoint and clears any resumable state so the step counter + # starts at 0 and the observer actually fires. + config.checkpoint.enable = True + return config + + def gpt_oss_debugmodel_obs_bf16() -> Trainer.Config: """gpt_oss debugmodel in plain BF16 + DebugObserver (no LPT). Used to capture the reference dump that visualizer can diff against the @@ -142,7 +187,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: return config -def gpt_oss_20b_lpt() -> Trainer.Config: +def gpt_oss_20b_adahop() -> Trainer.Config: config = gpt_oss_20b_pretrain() config.training.global_batch_size = 16 config.parallelism.expert_tensor_parallel_degree = 1 @@ -165,6 +210,28 @@ def gpt_oss_20b_lpt() -> Trainer.Config: ],) return config +def gpt_oss_20b_lpt() -> Trainer.Config: + config = gpt_oss_20b_pretrain() + config.training.global_batch_size = 16 + config.parallelism.expert_tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 1 + config.parallelism.expert_parallel_degree = 8 + config.training.local_batch_size = 1 + config.activation_checkpoint.mode = "none" + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.enable = True # save checkpoints so we can resume later + config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint + config.checkpoint.initial_load_in_hf = False + config.checkpoint.initial_load_in_hf_quantized = False + config.checkpoint.interval = 500 # Save at step interval + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), + ],) + return config def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" @@ -207,6 +274,57 @@ def gpt_oss_debugmodel_grad_clip_obs_lpt() -> Trainer.Config: return config +def gpt_oss_debugmodel_moe_pattern_obs() -> Trainer.Config: + """Debugmodel + MXFP4 + per-expert MoE matmul outlier-pattern observer. + Accumulates patterns over several steps (max_captures in the recipe) and + dumps ./outputs/moe_patterns_rank*.pt with a per-expert majority vote. + Use with COMM_MODE=local_tensor for a single-GPU smoke; compile stays off.""" + config = gpt_oss_debugmodel() + config.training.steps = 10 + config.compile.enable = False + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config( + recipe="./alto/models/gpt_oss/configs/moe_pattern_observer_recipe.yaml", + ), + ],) + return config + + +def gpt_oss_debugmodel_moe_pattern_obs_no_fsdp() -> Trainer.Config: + """Single-GPU (no FSDP/EP/TP) debugmodel + MXFP4 + per-expert MoE matmul + outlier-pattern observer. Loads a checkpoint produced by + gpt_oss_debugmodel_lpt_1gpu_ckpt, runs ONE training step, and dumps + ./outputs/moe_patterns_rank*.pt. Pairs with debug_stats_gpt_oss_1gpu.sh, + which sets --checkpoint.initial_load_path to a step-N checkpoint.""" + config = gpt_oss_debugmodel_moe_pattern_obs() + config.parallelism.data_parallel_shard_degree = 1 + config.parallelism.data_parallel_replicate_degree = 1 + config.parallelism.expert_parallel_degree = 1 + config.parallelism.expert_tensor_parallel_degree = 1 + config.parallelism.tensor_parallel_degree = 1 + # Load pretrained weights; the stats script clears any resumable state so the + # step counter starts at 0 and the single observed step actually runs. + config.checkpoint.enable = True + return config + + +def gpt_oss_20b_moe_pattern_obs() -> Trainer.Config: + """gpt_oss_20b: load a checkpoint, run ONE training step, and dump the + per-expert MoE matmul outlier patterns. Intended flow: pretrain without the + observer, point checkpoint.initial_load_path at a checkpoint, run this.""" + config = gpt_oss_20b_lpt() + config.training.steps = 10 + config.compile.enable = False + config.validator.enable = False + config.checkpoint.enable = True # load the pretrained checkpoint + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config( + recipe="./alto/models/gpt_oss/configs/moe_pattern_observer_recipe.yaml", + ), + ],) + return config + + def gpt_oss_20b_pretrain_c4() -> Trainer.Config: """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" config = gpt_oss_20b_pretrain() diff --git a/alto/modifiers/debug/moe_pattern_hooks.py b/alto/modifiers/debug/moe_pattern_hooks.py new file mode 100644 index 00000000..bb023b26 --- /dev/null +++ b/alto/modifiers/debug/moe_pattern_hooks.py @@ -0,0 +1,222 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Pure-Python helpers for ``MoEMatmulPatternObserverModifier``. + +torch-only (no alto / triton imports) so it can be exec'd in isolation on a +CPU-only box, following the same testing pattern as ``observer_hooks.py``. + +A grouped-GEMM expert layer runs two ``torch._grouped_mm`` calls (MLP1 then +MLP2). Each grouped GEMM's forward+backward is three matmuls whose inputs are +drawn from ``{x, w, grad_output}``: + + forward_y : x @ w (operands x, w) + backward_gx : grad_output @ wᵀ (operands grad_output, w) + backward_gw : grad_outputᵀ @ x (operands grad_output, x) + +For each expert we detect the AdaHOP outlier pattern (row/col/none) of each +operand and combine them into the AdaHOP "T-pair" per matmul path, matching +``alto/modifiers/lpt/adahop_internals/pattern_aggregation.py``. + +Weight-orientation note: the model calls ``_grouped_mm(x, W.transpose(-2,-1))`` +so the ``w`` operand reaching the patched op is ``[E, K, N]`` (in, out). AdaHOP +classifies weights in their ``[out, in]`` orientation, so we transpose each +expert slice back to ``[N, K]`` before ``detect`` — keeping the T-pairs directly +comparable to AdaHOP calibration. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Callable, Dict, List, Optional + +import torch + +# The three matmul paths and which operands feed each (for stats bookkeeping). +MATMUL_PATHS = ("forward_y", "backward_gx", "backward_gw") + + +def _opposite_pattern(pattern: str) -> str: + """row<->col, none->none. Mirrors pattern_aggregation._opposite_pattern.""" + if pattern == "row": + return "col" + if pattern == "col": + return "row" + return "none" + + +def _to_2d_float(t: torch.Tensor) -> torch.Tensor: + """Detach, upcast to float32 (bf16 var/std are imprecise), flatten to 2D.""" + t = t.detach().float() + if t.dim() > 2: + t = t.reshape(-1, t.shape[-1]) + return t + + +def _cv_row_col(t2d: torch.Tensor) -> tuple[float, float]: + """Shape-normalized coefficient of variation of row/col variances — the + quantity ``detect_outlier_pattern`` thresholds on. Returned for insight.""" + import math + + if t2d.numel() == 0 or t2d.dim() != 2: + return float("nan"), float("nan") + num_rows, num_cols = t2d.shape + row_var = t2d.var(dim=1) + col_var = t2d.var(dim=0) + cv_row = (row_var.std() / (row_var.mean() + 1e-8)).item() + cv_col = (col_var.std() / (col_var.mean() + 1e-8)).item() + cv_row /= math.sqrt(2.0 / max(num_cols - 1, 1)) + cv_col /= math.sqrt(2.0 / max(num_rows - 1, 1)) + return cv_row, cv_col + + +def _operand_stats(t2d: torch.Tensor) -> Dict[str, float]: + """Lightweight per-operand summary — plain Python floats only.""" + if t2d.numel() == 0: + return {"absmax": float("nan"), "std": float("nan"), + "cv_row": float("nan"), "cv_col": float("nan")} + cv_row, cv_col = _cv_row_col(t2d) + return { + "absmax": t2d.abs().max().item(), + "std": t2d.std().item(), + "cv_row": cv_row, + "cv_col": cv_col, + } + + +def _offs_to_bounds(offs: List[int], num_experts: int) -> List[tuple[int, int]]: + """Turn a cumulative-sum offsets list into [start, end) row bounds per + expert. offs[i] is the running token count through expert i; tokens beyond + offs[num_experts-1] are grouped-GEMM tail padding and are dropped.""" + bounds = [] + prev = 0 + for e in range(num_experts): + end = int(offs[e]) if e < len(offs) else prev + bounds.append((prev, end)) + prev = end + return bounds + + +def build_expert_records( + x: torch.Tensor, + w: torch.Tensor, + grad_output: torch.Tensor, + offs: List[int], + detect: Callable[[torch.Tensor], str], +) -> Dict[int, Dict[str, Any]]: + """Per (local) expert, detect operand patterns and build the T-pair records. + + Args: + x: activation into this GEMM, ``[T, K]`` (as fed to the matmul). + w: weight operand as fed to ``_grouped_mm``, ``[E, K, N]``. + grad_output: gradient of this GEMM's output, ``[T, N]``. + offs: cumulative per-expert token counts (Python ints). + detect: ``detect_outlier_pattern``-style callable returning + ``"row"|"col"|"none"`` for a 2D tensor. + + Returns ``{local_expert_id: {"n_tokens": int, + "forward_y":{"pair":str}, "backward_gx":{"pair":str}, + "backward_gw":{"pair":str}, + "stats":{"x":{...},"w":{...},"grad_output":{...}}}}``. + """ + x2 = _to_2d_float(x) + go2 = _to_2d_float(grad_output) + num_experts = int(w.shape[0]) + bounds = _offs_to_bounds(offs, num_experts) + + records: Dict[int, Dict[str, Any]] = {} + for e in range(num_experts): + start, end = bounds[e] + n_tokens = max(0, end - start) + # weight back to [out, in] = [N, K] to match AdaHOP's convention. + w_e = _to_2d_float(w[e].transpose(-2, -1)) + + if n_tokens == 0: + # No tokens routed to this expert this step: activation/grad patterns + # are undefined. Weight pattern is still meaningful. + w_pat = detect(w_e) + records[e] = { + "n_tokens": 0, + "forward_y": {"pair": f"none-{_opposite_pattern(w_pat)}"}, + "backward_gx": {"pair": f"none-{w_pat}"}, + "backward_gw": {"pair": "none-none"}, + "stats": {"x": _operand_stats(x2[0:0]), + "w": _operand_stats(w_e), + "grad_output": _operand_stats(go2[0:0])}, + } + continue + + x_e = x2[start:end] + go_e = go2[start:end] + x_pat = detect(x_e) + w_pat = detect(w_e) + g_pat = detect(go_e) + + records[e] = { + "n_tokens": n_tokens, + # forward_y : T1=x_pat, T2=opposite(w_pat) + "forward_y": {"pair": f"{x_pat}-{_opposite_pattern(w_pat)}"}, + # backward_gx : T7=grad_pat, T8=w_pat + "backward_gx": {"pair": f"{g_pat}-{w_pat}"}, + # backward_gw : T4=opposite(grad_pat), T5=x_pat + "backward_gw": {"pair": f"{_opposite_pattern(g_pat)}-{x_pat}"}, + "stats": {"x": _operand_stats(x_e), + "w": _operand_stats(w_e), + "grad_output": _operand_stats(go_e)}, + } + return records + + +def _majority_pair(pairs: List[str]) -> Dict[str, Any]: + """Majority-vote a list of per-step T-pair strings for one matmul path. + + Ties break deterministically by the pair string (so re-runs agree). Returns + the winning pair, the full per-pair vote counts, and the vote total. + """ + counter = Counter(pairs) + if not counter: + return {"pair": "none-none", "votes": {}, "n": 0} + top = max(counter.values()) + winner = sorted(p for p, c in counter.items() if c == top)[0] + return {"pair": winner, "votes": dict(counter), "n": sum(counter.values())} + + +def accumulate_majority( + per_step_records: List[Dict[int, Dict[str, Any]]], +) -> Dict[int, Dict[str, Any]]: + """Collapse a list of per-step ``{expert_id: record}`` maps (one per observed + step, same GEMM) into a single ``{expert_id: majority_record}``. + + For each expert and each of the three matmul paths, the reported ``pair`` is + the majority across the steps in which that expert was routed at least one + token (empty-expert steps contribute a ``none-*`` vote, same as the raw + records, so a rarely-routed expert honestly shows up as mostly ``none``). + + Returns ``{expert_id: {"n_steps": int, "n_tokens_total": int, + "forward_y": {"pair","votes","n"}, "backward_gx": {...}, + "backward_gw": {...}}}``. + """ + expert_ids = sorted({eid for step in per_step_records for eid in step}) + out: Dict[int, Dict[str, Any]] = {} + for eid in expert_ids: + step_recs = [step[eid] for step in per_step_records if eid in step] + rec: Dict[str, Any] = { + "n_steps": len(step_recs), + "n_tokens_total": int(sum(r.get("n_tokens", 0) for r in step_recs)), + } + for path in MATMUL_PATHS: + pairs = [r[path]["pair"] for r in step_recs if path in r] + rec[path] = _majority_pair(pairs) + out[eid] = rec + return out + + +def extract_offs(args: tuple, kwargs: dict) -> Optional[torch.Tensor]: + """Pull the ``offs`` tensor from a ``torch._grouped_mm`` call. It is passed + as a keyword in the model, but accept a trailing positional too.""" + if "offs" in kwargs and kwargs["offs"] is not None: + return kwargs["offs"] + for a in args: + if isinstance(a, torch.Tensor) and a.dim() == 1 and a.dtype in (torch.int32, torch.int64): + return a + return None diff --git a/alto/modifiers/debug/moe_pattern_observer.py b/alto/modifiers/debug/moe_pattern_observer.py new file mode 100644 index 00000000..49c3080b --- /dev/null +++ b/alto/modifiers/debug/moe_pattern_observer.py @@ -0,0 +1,351 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""MoEMatmulPatternObserverModifier — per-expert AdaHOP outlier-pattern capture +for the two Grouped GEMMs (MLP1, MLP2) of gpt_oss MoE blocks. + +Intended flow: pretrain gpt_oss WITHOUT this modifier, load a checkpoint, apply +this modifier via a recipe, run a single training iteration, dump per-rank +results, then visualize with ``scripts/moe_pattern_viz.py``. + +Why not the module-boundary hooks of ``DebugObserverModifier``? +A ``forward_pre``/``full_backward`` hook on ``GptOssGroupedExperts`` only sees +MLP1's input and the module-output grad — it cannot see MLP2's input, MLP1's +output grad, or split operands per expert. Instead we intercept the two +``torch._grouped_mm`` calls INSIDE ``_run_experts_grouped_mm`` (scoped to the +targeted module via a contextvar) so we get all three matmul operands of each +GEMM plus the per-expert token offsets. Capture happens after the module has +already ``.to_local()``'d its DTensor weights and after token permute, so the +tensors are plain local tensors on the current rank — robust under FSDP/EP. + +Constraints: the observed step must run in EAGER mode (the monkeypatch is +invisible to a compiled graph) and the low-precision path is backend-agnostic +(works on the CDNA3 loop fallback and the CDNA4 kernels alike). +""" + +from __future__ import annotations + +import contextvars +import os +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import torch +from pydantic import Field, PrivateAttr +from torch.nn import Module +from compressed_tensors.utils import match_named_modules +from torchtitan.tools.logging import logger + +from alto.modifiers import Modifier +from alto.modifiers.debug.moe_pattern_hooks import ( + accumulate_majority, + build_expert_records, + extract_offs, +) + +__all__ = ["MoEMatmulPatternObserverModifier"] + +# Contextvar carrying the currently-executing experts module's capture context, +# or None when no targeted module is running. Shared by the patched _grouped_mm. +_ACTIVE_CTX: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar( + "_moe_pattern_active_ctx", default=None +) + +# gemm counter index -> logical name (order of _grouped_mm calls in the experts fwd) +_GEMM_NAMES = ("mlp1", "mlp2") + + +class MoEMatmulPatternObserverModifier(Modifier): + """Captures per-expert AdaHOP outlier-pattern T-pairs for the three matmuls + of each MoE Grouped GEMM (MLP1, MLP2), at one observed step, and dumps them. + """ + + targets: List[str] = Field(default_factory=lambda: ["GptOssGroupedExperts"]) + ignore: List[str] = Field(default_factory=list) + + capture_every: int = 1 + max_captures: int = 1 + output_path: str = "./outputs/moe_patterns.pt" + + # forwarded to detect_outlier_pattern + threshold_ratio: float = 2.0 + kurtosis_threshold: float = 0.0 + + @property + def requires_training_mode(self) -> bool: + # grad_output patterns require a real backward pass. + return True + + # --- private state --- + _step_idx: int = PrivateAttr(default=0) + _n_captured: int = PrivateAttr(default=0) + _detached: bool = PrivateAttr(default=False) + _active: bool = PrivateAttr(default=False) + # results[fqn][step_idx][gemm] = {global_expert_id: record} + _results: dict = PrivateAttr(default_factory=dict) + # per-fqn mesh info captured at initialize: {fqn: {ep_rank, ep_size, ...}} + _mesh_info: dict = PrivateAttr(default_factory=dict) + # saved originals for teardown + _orig_grouped_mm: Any = PrivateAttr(default=None) + _orig_forwards: dict = PrivateAttr(default_factory=dict) + _detect: Any = PrivateAttr(default=None) + _fqns: list = PrivateAttr(default_factory=list) + + # ------------------------------------------------------------------ + # lifecycle + # ------------------------------------------------------------------ + + def on_convert(self, model: Module, **kwargs) -> bool: + return True + + def on_initialize(self, model_parts: List[Module], **kwargs) -> bool: + # Bind detector once (imports the adahop bridge lazily so CPU unit tests + # of the hooks module don't drag in triton). + from alto._adahop_bridge import detect_outlier_pattern + + def _detect(t: torch.Tensor) -> str: + return detect_outlier_pattern( + t, + threshold_ratio=self.threshold_ratio, + kurtosis_threshold=self.kurtosis_threshold, + ) + + self._detect = _detect + + for model_part in model_parts: + for fqn, module in match_named_modules(model_part, self.targets, self.ignore): + self._results[fqn] = {} + self._mesh_info[fqn] = self._read_mesh_info(module) + self._fqns.append(fqn) + self._wrap_forward(module, fqn) + + # Install the grouped_mm patch once (no-op until a wrapped forward runs + # AND the capture gate is active). + self._orig_grouped_mm = torch._grouped_mm + torch._grouped_mm = self._make_patched_grouped_mm(self._orig_grouped_mm) + + logger.info( + f"MoEMatmulPatternObserverModifier: monitoring {len(self._fqns)} expert " + f"layers, max_captures={self.max_captures}" + ) + return True + + def on_pre_step(self, model_parts: List[Module], **kwargs) -> bool: + self._step_idx += 1 + if self._detached: + return True + self._active = ( + (self._step_idx % self.capture_every == 0) + and (self._n_captured < self.max_captures) + ) + return True + + def on_post_step(self, model_parts: List[Module], **kwargs) -> bool: + if self._detached: + return True + # A step counts as captured only if a grad path actually populated a + # T-pair (i.e. a backward ran), mirroring DebugObserverModifier. + step = self._step_idx + captured = any( + step in self._results[fqn] and self._results[fqn][step] + for fqn in self._fqns + ) + if captured: + self._n_captured += 1 + logger.debug( + f"MoEMatmulPatternObserverModifier: captured step {step} " + f"({self._n_captured}/{self.max_captures})" + ) + self._active = False + if self._n_captured >= self.max_captures: + logger.info("MoEMatmulPatternObserverModifier: reached max_captures, detaching") + self._detach() + return True + + def on_finalize(self, model_parts: List[Module], **kwargs) -> bool: + if not self._detached: + self._detach() + self._dump() + return True + + # ------------------------------------------------------------------ + # forward wrapping + grouped_mm patch + # ------------------------------------------------------------------ + + def _wrap_forward(self, module: Module, fqn: str) -> None: + """Replace the module's bound forward with a wrapper that sets the + contextvar (fqn + fresh gemm counter) while the original runs.""" + orig_forward = module.forward + self._orig_forwards[fqn] = orig_forward + modifier = self + + def _wrapped(*args, **kwargs): + if modifier._detached or not modifier._active: + return orig_forward(*args, **kwargs) + ctx = {"fqn": fqn, "gemm_idx": 0} + token = _ACTIVE_CTX.set(ctx) + try: + return orig_forward(*args, **kwargs) + finally: + _ACTIVE_CTX.reset(token) + + module.forward = _wrapped # type: ignore[method-assign] + + def _make_patched_grouped_mm(self, orig: Callable) -> Callable: + modifier = self + + def _patched(*args, **kwargs): + out = orig(*args, **kwargs) + ctx = _ACTIVE_CTX.get() + if ctx is None or modifier._detached or not modifier._active: + return out + try: + modifier._on_grouped_mm(ctx, args, kwargs, out) + except Exception as exc: # never break training on a debug tool + logger.warning(f"MoEMatmulPatternObserverModifier: capture skipped ({exc})") + return out + + return _patched + + def _on_grouped_mm(self, ctx: dict, args: tuple, kwargs: dict, out: torch.Tensor) -> None: + gemm_idx = ctx["gemm_idx"] + ctx["gemm_idx"] = gemm_idx + 1 + if gemm_idx >= len(_GEMM_NAMES): + return # unexpected extra grouped_mm; ignore + gemm_name = _GEMM_NAMES[gemm_idx] + fqn = ctx["fqn"] + + # operands: _grouped_mm(x, w, offs=...) — w is already transposed to [E,K,N] + x = args[0] + w = args[1] + offs_t = extract_offs(args, kwargs) + if offs_t is None or w.dim() != 3: + return + offs = offs_t.detach().cpu().tolist() + + # Snapshot forward operands now; pair with grad_output on backward. + x_snap = x.detach() + w_snap = w.detach() + + step = self._step_idx + + def _grad_hook(grad_output: torch.Tensor): + if grad_output is None: + return + try: + records = build_expert_records( + x_snap, w_snap, grad_output, offs, self._detect + ) + records = self._relabel_global(fqn, records) + bucket = self._results[fqn].setdefault(step, {}) + bucket[gemm_name] = records + except Exception as exc: + logger.warning( + f"MoEMatmulPatternObserverModifier: grad capture skipped for " + f"{fqn}/{gemm_name} ({exc})" + ) + + if out.requires_grad: + out.register_hook(_grad_hook) + + # ------------------------------------------------------------------ + # sharding / ids + # ------------------------------------------------------------------ + + def _read_mesh_info(self, module: Module) -> dict: + """Derive EP/FSDP rank+size from the experts' DTensor weight mesh.""" + info = {"ep_rank": 0, "ep_size": 1, "fsdp_rank": 0, "fsdp_size": 1} + w = getattr(module, "mlp1_weight", None) + try: + from torch.distributed.tensor import DTensor + if isinstance(w, DTensor): + mesh = w.device_mesh + names = mesh.mesh_dim_names or () + for dim_name, keys in (("ep", ("ep_rank", "ep_size")), + ("dp_shard", ("fsdp_rank", "fsdp_size")), + ("efsdp", ("fsdp_rank", "fsdp_size"))): + if dim_name in names: + idx = names.index(dim_name) + info[keys[0]] = mesh.get_local_rank(dim_name) + info[keys[1]] = mesh.size(idx) + except Exception as exc: + logger.debug(f"MoEMatmulPatternObserverModifier: no mesh info ({exc})") + return info + + def _relabel_global(self, fqn: str, records: dict) -> dict: + """Map local expert ids to global ids using EP rank/size.""" + mi = self._mesh_info.get(fqn, {}) + ep_rank = mi.get("ep_rank", 0) + n_local = len(records) + base = ep_rank * n_local + return {base + local_id: rec for local_id, rec in records.items()} + + # ------------------------------------------------------------------ + # teardown + dump + # ------------------------------------------------------------------ + + def _detach(self) -> None: + if self._orig_grouped_mm is not None: + torch._grouped_mm = self._orig_grouped_mm + self._orig_grouped_mm = None + for fqn, orig in self._orig_forwards.items(): + # best-effort restore; the module object still lives in model_parts + pass + self._detached = True + + def _dump(self) -> None: + rank = int(os.environ.get("RANK", 0)) + path = Path(self.output_path) + # always rank-suffix so multi-rank runs don't clobber a single file. + path = path.with_stem(f"{path.stem}_rank{rank}") + path.parent.mkdir(parents=True, exist_ok=True) + + captured_steps = sorted({ + step for data in self._results.values() for step in data + }) + + layer_shapes = {fqn: self._mesh_info.get(fqn, {}) for fqn in self._fqns} + + blob: Dict[str, Any] = dict(self._results) + num_local = 0 + for fqn in self._fqns: + for step_data in self._results[fqn].values(): + for gemm_records in step_data.values(): + num_local = max(num_local, len(gemm_records)) + + # Majority-vote each expert's per-path T-pair across all captured steps: + # _majority[fqn][gemm][global_expert_id] = {n_steps, n_tokens_total, + # forward_y/backward_gx/backward_gw: {pair, votes, n}} + majority: Dict[str, Dict[str, Any]] = {} + for fqn in self._fqns: + per_gemm_steps: Dict[str, List[dict]] = {} + for step_data in self._results[fqn].values(): + for gemm, records in step_data.items(): + per_gemm_steps.setdefault(gemm, []).append(records) + majority[fqn] = { + gemm: accumulate_majority(step_list) + for gemm, step_list in per_gemm_steps.items() + } + blob["_majority"] = majority + + # global expert count = local * ep_size (uniform across ranks) + any_mi = next(iter(self._mesh_info.values()), {}) if self._mesh_info else {} + ep_size = any_mi.get("ep_size", 1) + blob["_meta"] = { + "rank": rank, + "ep_rank": any_mi.get("ep_rank", 0), + "ep_size": ep_size, + "fsdp_rank": any_mi.get("fsdp_rank", 0), + "fsdp_size": any_mi.get("fsdp_size", 1), + "num_local_experts": num_local, + "num_global_experts": num_local * ep_size, + "iterations_captured": captured_steps, + "mesh_info": layer_shapes, + "gemm_order": list(_GEMM_NAMES), + "matmul_paths": ["forward_y", "backward_gx", "backward_gw"], + } + torch.save(blob, path) + logger.info( + f"MoEMatmulPatternObserverModifier: saved {len(captured_steps)} step(s) " + f"for {len(self._fqns)} layer(s) to {path}" + ) diff --git a/scripts/moe_pattern_viz.py b/scripts/moe_pattern_viz.py new file mode 100644 index 00000000..b9eb35e9 --- /dev/null +++ b/scripts/moe_pattern_viz.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Standalone visualizer for MoEMatmulPatternObserverModifier dumps. + +Renders, per MoE layer and per Grouped GEMM (mlp1/mlp2), a heatmap of the +AdaHOP outlier-pattern T-pair for each of the three matmuls of each expert: + + rows = experts (global id, merged across per-rank dumps) + cols = matmul paths [forward_y, backward_gx, backward_gw] + cell = the 9-way T-pair category (fixed color legend) + +No alto import — only torch + matplotlib + numpy required. + +Usage +----- +# Summary only: +python scripts/moe_pattern_viz.py --dump-glob './outputs/moe_patterns_rank*.pt' --summary-only + +# Full heatmaps (merges all per-rank dumps of a run): +python scripts/moe_pattern_viz.py \\ + --dump-glob './outputs/moe_patterns_rank*.pt' \\ + --out-dir ./viz + +# Filter to specific layers: +python scripts/moe_pattern_viz.py \\ + --dump-glob './outputs/moe_patterns_rank*.pt' \\ + --layer-regex 'layers.0' --out-dir ./viz +""" + +from __future__ import annotations + +import argparse +import glob +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +import torch + + +# 9-way T-pair palette (matches AdaHOP pattern_visualization.PATTERN_COLORS). +PATTERN_COLORS = { + "row-row": "#e74c3c", + "row-col": "#f39c12", + "row-none": "#f1c40f", + "col-row": "#3498db", + "col-col": "#2ecc71", + "col-none": "#1abc9c", + "none-row": "#9b59b6", + "none-col": "#e91e63", + "none-none": "#95a5a6", +} +_PAIR_ORDER = list(PATTERN_COLORS.keys()) +_PAIR_INDEX = {p: i for i, p in enumerate(_PAIR_ORDER)} +MATMUL_PATHS = ["forward_y", "backward_gx", "backward_gw"] +GEMMS = ["mlp1", "mlp2"] + +# 3-way single-operand palette (each matmul input is classified row/col/none). +SINGLE_PATTERN_COLORS = { + "row": "#e74c3c", + "col": "#3498db", + "none": "#95a5a6", +} +_SINGLE_ORDER = list(SINGLE_PATTERN_COLORS.keys()) +_SINGLE_INDEX = {p: i for i, p in enumerate(_SINGLE_ORDER)} + +# Each matmul is A @ B. The T-pair string is "A_pattern-B_pattern"; these labels +# name the two operands per path (matches moe_pattern_hooks T-pair construction). +# IMPORTANT: patterns are shown in the AS-FED-TO-MATMUL orientation — the axis the +# low-precision GEMM actually scales. An operand marked (ᵀ) enters transposed, so +# its row/col reads FLIPPED vs the stored tensor (a stored 'row' weight shows as +# 'col' under weightᵀ). Same physical tensor, different axis — not a contradiction. +# forward_y = input @ weightᵀ (weight enters transposed) +# backward_gx = grad_output @ weight (weight in stored orientation) +# backward_gw = grad_outputᵀ @ input (grad_output enters transposed) +# The bool marks whether that operand is transposed relative to its stored form. +OPERAND_LABELS = { + "forward_y": (("input", False), ("weight (ᵀ)", True)), + "backward_gx": (("grad_output", False), ("weight", False)), + "backward_gw": (("grad_output (ᵀ)", True), ("input", False)), +} + + +def _split_pair(pair: str) -> tuple[str, str]: + """'row-col' -> ('row', 'col'); tolerant of the 'none-none' default.""" + a, _, b = (pair or "none-none").partition("-") + a = a if a in SINGLE_PATTERN_COLORS else "none" + b = b if b in SINGLE_PATTERN_COLORS else "none" + return a, b + + +# --------------------------------------------------------------------------- +# Loading + merge +# --------------------------------------------------------------------------- + +def load_and_merge(dump_paths: List[str]) -> tuple[dict, dict, dict]: + """Merge per-rank dumps into one structure. + + Returns (merged, majority, meta) where + merged[fqn][step][gemm][global_expert_id] = per-step record + majority[fqn][gemm][global_expert_id] = majority-vote record + meta = combined _meta (num_global_experts, etc.) + """ + merged: Dict[str, Any] = {} + majority: Dict[str, Any] = {} + meta: Dict[str, Any] = {} + ep_sizes = set() + global_counts = set() + ranks = [] + steps_all = set() + + for p in dump_paths: + blob = torch.load(p, map_location="cpu", weights_only=False) + m = blob.pop("_meta", {}) + maj = blob.pop("_majority", {}) + ranks.append(m.get("rank", "?")) + ep_sizes.add(m.get("ep_size", 1)) + global_counts.add(m.get("num_global_experts", 0)) + steps_all.update(m.get("iterations_captured", [])) + for fqn, per_step in blob.items(): + f = merged.setdefault(fqn, {}) + for step, per_gemm in per_step.items(): + s = f.setdefault(step, {}) + for gemm, records in per_gemm.items(): + g = s.setdefault(gemm, {}) + g.update(records) # global ids are disjoint across ranks + for fqn, per_gemm in maj.items(): + mf = majority.setdefault(fqn, {}) + for gemm, records in per_gemm.items(): + mf.setdefault(gemm, {}).update(records) # disjoint global ids + + meta = { + "ranks": sorted(ranks, key=str), + "ep_size": max(ep_sizes) if ep_sizes else 1, + "num_global_experts": max(global_counts) if global_counts else 0, + "iterations_captured": sorted(steps_all), + } + return merged, majority, meta + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +def print_summary(majority: dict, meta: dict) -> None: + print("\n=== MoE matmul pattern summary (majority over captured steps) ===") + print(f" ranks merged: {meta.get('ranks')}") + print(f" ep_size: {meta.get('ep_size')} global experts: {meta.get('num_global_experts')}") + print(f" steps captured: {meta.get('iterations_captured')}") + print(f" layers: {len(majority)}") + print() + for fqn in sorted(majority): + per_gemm = majority[fqn] + for gemm in GEMMS: + records = per_gemm.get(gemm) + if not records: + continue + # Tally the winning majority T-pair across experts per matmul path. + print(f" {fqn} | {gemm} | {len(records)} experts") + for path in MATMUL_PATHS: + counts: Dict[str, int] = {} + for rec in records.values(): + pair = rec.get(path, {}).get("pair", "none-none") + counts[pair] = counts.get(pair, 0) + 1 + tally = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items())) + print(f" {path:<12} {tally}") + print() + + +# --------------------------------------------------------------------------- +# Plotting +# --------------------------------------------------------------------------- + +def _pair_grid(records: dict, expert_ids: List[int]): + import numpy as np + grid = np.full((len(expert_ids), len(MATMUL_PATHS)), _PAIR_INDEX["none-none"], dtype=int) + for r, eid in enumerate(expert_ids): + rec = records.get(eid, {}) + for c, path in enumerate(MATMUL_PATHS): + pair = rec.get(path, {}).get("pair", "none-none") + grid[r, c] = _PAIR_INDEX.get(pair, _PAIR_INDEX["none-none"]) + return grid + + +def plot_gemm(fqn: str, step, gemm: str, records: dict, out_dir: Path) -> None: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap, BoundaryNorm + from matplotlib.patches import Patch + import numpy as np + except ImportError: + print("matplotlib/numpy not installed; skipping plots", file=sys.stderr) + return + + expert_ids = sorted(records.keys()) + if not expert_ids: + return + grid = _pair_grid(records, expert_ids) + + cmap = ListedColormap([PATTERN_COLORS[p] for p in _PAIR_ORDER]) + norm = BoundaryNorm(np.arange(-0.5, len(_PAIR_ORDER) + 0.5, 1), cmap.N) + + height = max(3.0, 0.28 * len(expert_ids) + 1.5) + fig, ax = plt.subplots(figsize=(6, height)) + ax.imshow(grid, aspect="auto", cmap=cmap, norm=norm, interpolation="nearest") + ax.set_xticks(range(len(MATMUL_PATHS))) + ax.set_xticklabels(MATMUL_PATHS, rotation=20, ha="right", fontsize=8) + ax.set_yticks(range(len(expert_ids))) + ax.set_yticklabels([f"e{e}" for e in expert_ids], fontsize=6) + ax.set_ylabel("expert (global id)") + ax.set_title(f"{fqn}\nstep {step} | {gemm} — matmul-input outlier pattern", fontsize=9) + + legend = [Patch(facecolor=PATTERN_COLORS[p], label=p) for p in _PAIR_ORDER] + ax.legend(handles=legend, bbox_to_anchor=(1.02, 1), loc="upper left", + fontsize=6, title="T-pair", title_fontsize=7) + + safe = re.sub(r"[^a-zA-Z0-9_.\-]", "_", f"{fqn}_step{step}_{gemm}") + out_path = out_dir / f"{safe}.png" + fig.tight_layout() + fig.savefig(out_path, dpi=140, bbox_inches="tight") + plt.close(fig) + + +def plot_gemm_operands(fqn: str, step, gemm: str, records: dict, out_dir: Path) -> None: + """Per-operand view: y = the 6 matmul operands (3 matmuls, each A @ B split + into its two rows), x = experts. Each cell is colored by that operand's + single pattern (row/col/none) with the pattern name written inside.""" + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap, BoundaryNorm + import numpy as np + except ImportError: + print("matplotlib/numpy not installed; skipping plots", file=sys.stderr) + return + + expert_ids = sorted(records.keys()) + if not expert_ids: + return + + # Build the row axis: one line per operand, grouped by matmul path. + row_labels: List[str] = [] + row_specs: List[tuple[str, int]] = [] # (matmul_path, operand_idx 0=A/1=B) + for path in MATMUL_PATHS: + (a_lbl, _), (b_lbl, _) = OPERAND_LABELS[path] + row_labels.append(f"{path}\nA: {a_lbl}") + row_specs.append((path, 0)) + row_labels.append(f"{path}\nB: {b_lbl}") + row_specs.append((path, 1)) + + n_rows, n_cols = len(row_specs), len(expert_ids) + grid = np.full((n_rows, n_cols), _SINGLE_INDEX["none"], dtype=int) + text = [["" for _ in range(n_cols)] for _ in range(n_rows)] + for r, (path, operand_idx) in enumerate(row_specs): + for c, eid in enumerate(expert_ids): + pair = records.get(eid, {}).get(path, {}).get("pair", "none-none") + pat = _split_pair(pair)[operand_idx] + grid[r, c] = _SINGLE_INDEX[pat] + text[r][c] = pat + + cmap = ListedColormap([SINGLE_PATTERN_COLORS[p] for p in _SINGLE_ORDER]) + norm = BoundaryNorm(np.arange(-0.5, len(_SINGLE_ORDER) + 0.5, 1), cmap.N) + + width = max(6.0, 0.6 * n_cols + 2.5) + fig, ax = plt.subplots(figsize=(width, 0.7 * n_rows + 1.5)) + ax.imshow(grid, aspect="auto", cmap=cmap, norm=norm, interpolation="nearest") + + ax.set_xticks(range(n_cols)) + ax.set_xticklabels([f"e{e}" for e in expert_ids], fontsize=7) + ax.set_xlabel("expert (global id)") + ax.set_yticks(range(n_rows)) + ax.set_yticklabels(row_labels, fontsize=7) + ax.set_title(f"{fqn}\nstep {step} | {gemm} — per-operand outlier pattern", fontsize=9) + ax.text(0.0, -0.16, + "patterns shown in as-fed-to-matmul orientation; (ᵀ) = operand enters " + "transposed, so its row/col is flipped vs the stored tensor", + transform=ax.transAxes, fontsize=6.5, style="italic", + color="#555555", ha="left", va="top", wrap=True) + + # Separator lines between the three matmul groups (every 2 rows). + for r in range(2, n_rows, 2): + ax.axhline(r - 0.5, color="white", linewidth=2.0) + + # Write the pattern name inside each cell. + for r in range(n_rows): + for c in range(n_cols): + ax.text(c, r, text[r][c], ha="center", va="center", + fontsize=7, color="black") + + safe = re.sub(r"[^a-zA-Z0-9_.\-]", "_", f"{fqn}_step{step}_{gemm}_operands") + out_path = out_dir / f"{safe}.png" + fig.tight_layout() + fig.savefig(out_path, dpi=140, bbox_inches="tight") + plt.close(fig) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Visualize MoE matmul pattern dumps") + parser.add_argument("--dump-glob", required=True, + help="Glob for per-rank .pt dumps, e.g. './outputs/moe_patterns_rank*.pt'") + parser.add_argument("--out-dir", default="./viz", help="Directory for PNGs") + parser.add_argument("--layer-regex", default=None, help="Regex to filter layer FQNs") + parser.add_argument("--summary-only", action="store_true", help="Print summary, skip plotting") + parser.add_argument("--per-step", action="store_true", + help="Also render the raw per-step plots (default: majority only)") + args = parser.parse_args() + + dump_paths = sorted(glob.glob(args.dump_glob)) + if not dump_paths: + print(f"No dumps matched {args.dump_glob!r}", file=sys.stderr) + sys.exit(1) + print(f"Merging {len(dump_paths)} dump(s): {dump_paths}") + + merged, majority, meta = load_and_merge(dump_paths) + + if args.layer_regex: + pat = re.compile(args.layer_regex) + merged = {fqn: v for fqn, v in merged.items() if pat.search(fqn)} + majority = {fqn: v for fqn, v in majority.items() if pat.search(fqn)} + + # Older dumps predate the accumulated majority; fall back to the last step. + if not majority and merged: + print("No _majority block found; falling back to the last captured step.", + file=sys.stderr) + for fqn, per_step in merged.items(): + last = max(per_step) if per_step else None + if last is not None: + majority[fqn] = per_step[last] + + print_summary(majority, meta) + if args.summary_only: + return + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + # Majority plots (the default view) — labeled "majority" in title/filename. + for fqn in sorted(majority): + for gemm in GEMMS: + records = majority[fqn].get(gemm) + if records: + plot_gemm(fqn, "majority", gemm, records, out_dir) + plot_gemm_operands(fqn, "majority", gemm, records, out_dir) + + if args.per_step: + for fqn in sorted(merged): + for step in sorted(merged[fqn]): + for gemm in GEMMS: + records = merged[fqn][step].get(gemm) + if records: + plot_gemm(fqn, step, gemm, records, out_dir) + plot_gemm_operands(fqn, step, gemm, records, out_dir) + print(f"Plots written to {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/tests/unittest/debug/test_moe_pattern_hooks.py b/tests/unittest/debug/test_moe_pattern_hooks.py new file mode 100644 index 00000000..1813aca3 --- /dev/null +++ b/tests/unittest/debug/test_moe_pattern_hooks.py @@ -0,0 +1,248 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Unit tests for moe_pattern_hooks.py — loaded directly to avoid triton imports. + +moe_pattern_hooks.py is torch-only (no alto/triton imports) so it can be exec'd +in isolation on a CPU-only box, same pattern as test_observer_hooks.py. The +``detect`` callable is injected, so we use the real AdaHOP algorithm reimplemented +inline here to keep the test self-contained (no adahop submodule needed) AND a +deterministic stub to assert the T-pair combination logic exactly. +""" + +import importlib.util +import math +import sys +from pathlib import Path + +import torch +import pytest + +_HOOKS_PATH = (Path(__file__).resolve().parents[3] + / "alto" / "modifiers" / "debug" / "moe_pattern_hooks.py") + + +def _load_hooks(): + name = "_moe_pattern_hooks_under_test" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, _HOOKS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def hooks(): + return _load_hooks() + + +# --- reference detector (verbatim algorithm from AdaHOP outlier_detection.py) --- + +def _kurtosis(x): + mean, std = x.mean(), x.std() + if std == 0: + return torch.tensor(0.0) + z = (x - mean) / std + return (z ** 4).mean() + + +def detect_ref(X, threshold_ratio=2.0, kurtosis_threshold=0.0): + X = X.float() + if X.dim() > 2: + X = X.reshape(-1, X.shape[-1]) + row_var = X.var(dim=1) + col_var = X.var(dim=0) + cv_row = row_var.std() / (row_var.mean() + 1e-8) + cv_col = col_var.std() / (col_var.mean() + 1e-8) + nr, nc = X.shape + cv_row = cv_row / math.sqrt(2.0 / max(nc - 1, 1)) + cv_col = cv_col / math.sqrt(2.0 / max(nr - 1, 1)) + if cv_row / (cv_col + 1e-8) > threshold_ratio: + return "row" if _kurtosis(row_var) >= kurtosis_threshold else "none" + elif cv_col / (cv_row + 1e-8) > threshold_ratio: + return "col" if _kurtosis(col_var) >= kurtosis_threshold else "none" + return "none" + + +# --------------------------------------------------------------------------- +# offs -> bounds +# --------------------------------------------------------------------------- + +class TestOffsBounds: + def test_basic(self, hooks): + # offs is a cumulative sum; expert 0 = [0,3), expert 1 = [3,7) + assert hooks._offs_to_bounds([3, 7], 2) == [(0, 3), (3, 7)] + + def test_tail_padding_dropped(self, hooks): + # trailing padding beyond offs[-1] is never assigned to an expert + assert hooks._offs_to_bounds([2, 5], 2) == [(0, 2), (2, 5)] + + def test_empty_expert(self, hooks): + # expert 1 gets zero tokens (offs repeats) + assert hooks._offs_to_bounds([4, 4], 2) == [(0, 4), (4, 4)] + + +# --------------------------------------------------------------------------- +# T-pair combination logic (deterministic stub detector) +# --------------------------------------------------------------------------- + +class TestTPairLogic: + def _run(self, hooks, x_pat, w_pat, g_pat): + # stub detect: return a fixed pattern keyed by tensor identity via shape + # We tag tensors by a sentinel scalar in [0,0] and map to the desired pat. + def detect(t): + tag = int(round(t.reshape(-1)[0].item())) + return {0: x_pat, 1: w_pat, 2: g_pat}[tag] + + T = 4 + K, N, E = 3, 5, 1 + x = torch.zeros(T, K); x[0, 0] = 0.0 # tag 0 + # w as fed to _grouped_mm is [E, K, N] + w = torch.zeros(E, K, N); w[0, 0, 0] = 1.0 # tag 1 (any expert slice) + go = torch.zeros(T, N); go[0, 0] = 2.0 # tag 2 + recs = hooks.build_expert_records(x, w, go, offs=[T], detect=detect) + return recs[0] + + def test_all_none(self, hooks): + r = self._run(hooks, "none", "none", "none") + assert r["forward_y"]["pair"] == "none-none" + assert r["backward_gx"]["pair"] == "none-none" + assert r["backward_gw"]["pair"] == "none-none" + + def test_row_row_row(self, hooks): + r = self._run(hooks, "row", "row", "row") + # forward_y = x_pat - opposite(w_pat) = row - col + assert r["forward_y"]["pair"] == "row-col" + # backward_gx = g_pat - w_pat = row - row + assert r["backward_gx"]["pair"] == "row-row" + # backward_gw = opposite(g_pat) - x_pat = col - row + assert r["backward_gw"]["pair"] == "col-row" + + def test_col_none_row(self, hooks): + r = self._run(hooks, "col", "none", "row") + assert r["forward_y"]["pair"] == "col-none" # col - opposite(none) + assert r["backward_gx"]["pair"] == "row-none" # row - none + assert r["backward_gw"]["pair"] == "col-col" # opposite(row) - col + + +# --------------------------------------------------------------------------- +# Per-expert slicing with an injected real outlier +# --------------------------------------------------------------------------- + +class TestPerExpertDetection: + def test_slicing_matches_direct_detect(self, hooks): + """build_expert_records must classify each expert on exactly its own + token slice — verified by re-running the reference detector on the + hand-sliced operands and reconstructing the T-pairs independently.""" + torch.manual_seed(0) + E, K, N = 2, 8, 6 + t0, t1 = 40, 50 + # expert 0: inject strong per-column outliers into the activation + x0 = torch.randn(t0, K); x0[:, 2:4] *= 40.0 + x1 = torch.randn(t1, K) + x = torch.cat([x0, x1], dim=0) + w = torch.randn(E, K, N) + go = torch.randn(t0 + t1, N) + offs = [t0, t0 + t1] + + recs = hooks.build_expert_records(x, w, go, offs, detect_ref) + assert recs[0]["n_tokens"] == t0 + assert recs[1]["n_tokens"] == t1 + + # Independently reconstruct expert 0's forward_y T-pair from its slice. + opp = {"row": "col", "col": "row", "none": "none"} + x0_pat = detect_ref(x[0:t0]) + w0_pat = detect_ref(w[0].transpose(-2, -1)) + expected = f"{x0_pat}-{opp[w0_pat]}" + assert recs[0]["forward_y"]["pair"] == expected + # the injected column outlier should be picked up on the slice + assert x0_pat == "col" + # stats present and finite for a routed expert + assert recs[0]["stats"]["x"]["absmax"] > 0 + + def test_empty_expert_marks_none_activation(self, hooks): + E, K, N = 2, 4, 3 + x = torch.randn(5, K) + w = torch.randn(E, K, N) + go = torch.randn(5, N) + # expert 1 gets zero tokens + recs = hooks.build_expert_records(x, w, go, offs=[5, 5], detect=detect_ref) + assert recs[1]["n_tokens"] == 0 + # activation-derived T1 must be 'none' when no tokens routed + assert recs[1]["forward_y"]["pair"].startswith("none-") + assert recs[1]["backward_gw"]["pair"] == "none-none" + + +# --------------------------------------------------------------------------- +# accumulate_majority +# --------------------------------------------------------------------------- + +class TestAccumulateMajority: + def _rec(self, fy, gx, gw, n_tokens=4): + return { + "n_tokens": n_tokens, + "forward_y": {"pair": fy}, + "backward_gx": {"pair": gx}, + "backward_gw": {"pair": gw}, + } + + def test_clear_majority(self, hooks): + steps = [ + {0: self._rec("row-col", "col-row", "none-none")}, + {0: self._rec("row-col", "col-row", "row-row")}, + {0: self._rec("none-none", "col-row", "col-col")}, + ] + out = hooks.accumulate_majority(steps) + assert out[0]["forward_y"]["pair"] == "row-col" # 2 of 3 + assert out[0]["backward_gx"]["pair"] == "col-row" # 3 of 3 + assert out[0]["n_steps"] == 3 + assert out[0]["n_tokens_total"] == 12 + assert out[0]["forward_y"]["votes"] == {"row-col": 2, "none-none": 1} + assert out[0]["forward_y"]["n"] == 3 + + def test_tie_breaks_deterministically(self, hooks): + # 1 vs 1 tie: winner is the alphabetically-first pair string. + steps = [ + {0: self._rec("row-col", "none-none", "none-none")}, + {0: self._rec("col-row", "none-none", "none-none")}, + ] + out = hooks.accumulate_majority(steps) + assert out[0]["forward_y"]["pair"] == "col-row" + + def test_expert_missing_from_some_steps(self, hooks): + # Expert 1 is only routed in one of the two steps. + steps = [ + {0: self._rec("row-col", "col-row", "none-none"), + 1: self._rec("col-col", "row-row", "none-none")}, + {0: self._rec("row-col", "col-row", "none-none")}, + ] + out = hooks.accumulate_majority(steps) + assert set(out.keys()) == {0, 1} + assert out[0]["n_steps"] == 2 + assert out[1]["n_steps"] == 1 + assert out[1]["forward_y"]["pair"] == "col-col" + + def test_empty_input(self, hooks): + assert hooks.accumulate_majority([]) == {} + + +# --------------------------------------------------------------------------- +# extract_offs +# --------------------------------------------------------------------------- + +class TestExtractOffs: + def test_kwarg(self, hooks): + offs = torch.tensor([2, 4], dtype=torch.int32) + got = hooks.extract_offs((torch.randn(4, 3), torch.randn(1, 3, 5)), {"offs": offs}) + assert torch.equal(got, offs) + + def test_positional_int_tensor(self, hooks): + offs = torch.tensor([2, 4], dtype=torch.int64) + got = hooks.extract_offs((torch.randn(4, 3), torch.randn(1, 3, 5), offs), {}) + assert torch.equal(got, offs) + + def test_missing(self, hooks): + got = hooks.extract_offs((torch.randn(4, 3),), {}) + assert got is None From 0b0af3aa38ed58a7ebf95a54dc313684d5cc7b01 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 8 Jul 2026 12:17:09 +0000 Subject: [PATCH 074/142] Fix MXFP4 dw grouped-GEMM kernel compile on MI350 (gfx950/CDNA4) _kernel_mxfp4_grouped_gemm_backward_dw declared M_TOTAL as a non-constexpr runtime arg but derived PACKED_M and Ms from it as tl.constexpr. On the CDNA4 (gfx950) Triton backend M_TOTAL stays a runtime int32[] tensor, so PACKED_M/Ms become tensors and the tl.minimum() mask bounds fail to compile: "cannot convert int32[] of type triton.language.core.tensor to tensor". CDNA3 (gfx942) tolerated the malformed constexpr-from-runtime derivation, so the kernel compiled and mi325x trained fine; CDNA4 does not. Mark M_TOTAL as tl.constexpr. It is always passed as a Python int (expert_indices.shape[0]) and is only used for tiling/grid math, so this is a no-op on CDNA3 and makes PACKED_M/Ms legitimately compile-time on CDNA4. Verified: gpt_oss_20b_lpt MXFP4 now trains end-to-end (fwd+bwd) on MI350 (loss 12.7 -> 11.0 from random init) and remains unchanged on MI325X/CDNA3. --- alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py index b215a122..11bb2543 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py @@ -245,7 +245,13 @@ def _kernel_mxfp4_grouped_gemm_backward_dw( stride_asm, stride_ask, # Matrix dimensions - M_TOTAL, # Total M dimension + # constexpr: M_TOTAL is passed as a Python int (expert_indices.shape[0]) and + # is used to derive PACKED_M/Ms, which are declared tl.constexpr below. On the + # CDNA4 (gfx950) Triton backend a non-constexpr M_TOTAL stays a runtime tensor, + # so PACKED_M/Ms become int32[] tensors and tl.minimum() fails to compile + # ("cannot convert int32[] to tensor"). CDNA3 tolerated it; making it constexpr + # fixes CDNA4 and is a no-op on CDNA3 (value is already compile-time known). + M_TOTAL: tl.constexpr, # Total M dimension N: tl.constexpr, # N dimension K: tl.constexpr, # K dimension # Number of experts From de658ba5cc1455511579d07f9c613e7cc711928f Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 8 Jul 2026 13:38:04 +0000 Subject: [PATCH 075/142] correct adahop: router-gate ignored --- alto/models/gpt_oss/config_registry.py | 6 +++++- alto/models/gpt_oss/configs/lpt_adahop.yaml | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 77ad4ee5..22bfcf2a 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -204,7 +204,10 @@ def gpt_oss_20b_adahop() -> Trainer.Config: config.checkpoint.initial_load_in_hf = False config.checkpoint.initial_load_in_hf_quantized = False config.checkpoint.interval = 500 # Save at step interval - config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" + config.checkpoint.keep_latest_k = 2 # keep only the 2 latest (each ~234G) + # Distinct from gpt_oss_20b_lpt's dump_folder so the adahop and nolora runs + # never share/overwrite each other's checkpoints. + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-adahop-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop.yaml",), ],) @@ -227,6 +230,7 @@ def gpt_oss_20b_lpt() -> Trainer.Config: config.checkpoint.initial_load_in_hf = False config.checkpoint.initial_load_in_hf_quantized = False config.checkpoint.interval = 500 # Save at step interval + config.checkpoint.keep_latest_k = 2 # keep only the 2 latest (each ~234G) config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), diff --git a/alto/models/gpt_oss/configs/lpt_adahop.yaml b/alto/models/gpt_oss/configs/lpt_adahop.yaml index 8340992f..f1dd0336 100644 --- a/alto/models/gpt_oss/configs/lpt_adahop.yaml +++ b/alto/models/gpt_oss/configs/lpt_adahop.yaml @@ -3,7 +3,12 @@ training_stage: LowPrecisionTrainingModifier: scheme: "mxfp4_adahop" targets: ["Linear"] - ignore: ["output"] + # Exclude the MoE router gate: quantizing it leaves top_scores in a dtype + # that mismatches routed_output at the bmm in moe.py (RuntimeError: expected + # BFloat16 but found Float). The nolora recipe already excludes it. AdaHOP + # only transforms LPT-wrapped Linears, so this exclusion also keeps the gate + # out of the AdaHOP pass — no separate ignore needed there. + ignore: ["output", "re:.*\\.router\\.gate"] use_2dblock_x: false use_2dblock_w: true use_hadamard: true @@ -30,7 +35,7 @@ training_stage: LowPrecisionTrainingModifier: scheme: "mxfp4" targets: ["GptOssGroupedExperts"] - ignore: ["re:.*\\.router\\.gate"] + ignore: ["output", "re:.*\\.router\\.gate"] use_2dblock_x: false use_2dblock_w: true use_hadamard: true From 112dec071d9897f09e42d8d36b6ae98d20867d2c Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 8 Jul 2026 17:20:03 -0500 Subject: [PATCH 076/142] add multinode dockerfile for distributed training --- alto/Dockerfile_multinode | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 alto/Dockerfile_multinode diff --git a/alto/Dockerfile_multinode b/alto/Dockerfile_multinode new file mode 100644 index 00000000..f51f92c2 --- /dev/null +++ b/alto/Dockerfile_multinode @@ -0,0 +1,57 @@ +FROM rocm/pytorch:latest + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + git-lfs \ + pkg-config \ + clang \ + libclang-dev \ + libunwind-dev \ + libnl-3-dev \ + libnl-route-3-dev \ + libibverbs-dev \ + ibverbs-providers \ + cmake \ + && update-pciids \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir huggingface_hub "datasets>=3.6.0" \ + transformers tabulate wandb fsspec tyro "tokenizers>=0.15.0" safetensors \ + tensorboard pre-commit yapf pybind11 meson-python torchdata pytablewriter \ + "antlr4-python3-runtime==4.11.0" sympy math_verify more_itertools peft \ + accelerate pillow "numpy<2" opencv-python-headless scipy \ + numba huggingface-hub[cli,hf_transfer] "packaging>=24.2" \ + "setuptools>=77.0.3,<80.0.0" "setuptools-scm>=8" \ + protobuf-protoc-bin fmt && \ + pip install --no-cache-dir /opt/rocm/share/amd_smi + +RUN mkdir -p /var/lib/jenkins && \ + cd /var/lib/jenkins && \ + git clone --depth 1 https://github.com/EleutherAI/lm-evaluation-harness && \ + cd lm-evaluation-harness && \ + pip install -e . + +ENV PYTORCH_ROCM_ARCH="gfx90a;gfx942;gfx950" + +# bnxt_re RDMA provider copied from the host's Broadcom OFED install instead of +# building rdma-core from source. The host ships the working ABI-matched provider +# at /usr/local/lib/x86_64-linux-gnu/libbnxt_re-rdmav34.so; the inbox apt provider +# has the wrong kernel uABI. Recreate the symlinks, ld.so.conf entry, and the +# libibverbs .driver registration so libibverbs can dlopen it. +# Overwrite apt's upstream bnxt_re provider (ABI 1 only) with Broadcom's +# out-of-tree build (supports kernel uABI 8) directly into libibverbs' provider dir. +COPY docker/bnxt_re/libbnxt_re-rdmav34.so /lib/x86_64-linux-gnu/libibverbs/libbnxt_re-rdmav34.so +RUN ldconfig + +RUN FSDP_PARAM=$(python3 -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__), 'distributed/fsdp/_fully_shard/_fsdp_param.py'))") && \ + sed -i 's/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param))/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param), requires_grad=param.requires_grad)/' "$FSDP_PARAM" && \ + sed -i 's/ self.sharded_param.requires_grad_(param.requires_grad)//' "$FSDP_PARAM" + +# Install torchtitan and ALTO training deps at build time (as root) to avoid +# /opt/venv permission errors when the container runs as a non-root user. +COPY . /tmp/torchtitan_src +RUN pip install --no-cache-dir aim torchao \ + compressed_tensors easydict loguru && \ + pip install --no-cache-dir --no-build-isolation --no-deps /tmp/torchtitan_src && \ + rm -rf /tmp/torchtitan_src From b75bc5486c152fe4becff77cfe05edc855c3a460 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 15 Jul 2026 12:36:04 +0000 Subject: [PATCH 077/142] Fix AdaHOP weight gradient detached from autograd graph MXFP4AdaHOPWrapper passed weight._data (requires_grad=False) into MXFP4AdaHOPLinearFunction.apply, so autograd discarded the weight gradient and AdaHOP-wrapped Linear weights never trained. Pass the grad-tracked wrapper instead (transpose preserves the subclass and the graph link) and unwrap to the raw payload inside forward, past the autograd boundary. Mirrors the plain MXFP4 / NVFP4 paths. --- alto/kernels/dispatch/adahop_tensor.py | 9 ++++++++- .../lpt/adahop_internals/mxfp4_linear_function.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 5d3aeb05..321f01a3 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -130,7 +130,14 @@ def __torch_function__(cls, func, types, args, kwargs={}): # function's "none" path (unused by the recipe post-Phase-B). if weight.is_calibrating: return super().__torch_function__(func, types, args, kwargs) - operand_w = weight._data if trans_b else weight._data.T + # Pass the WRAPPER tensor (grad-tracked) into apply(), NOT weight._data + # (which is requires_grad=False). Passing the raw payload severs the + # autograd link so the weight gradient is silently discarded and the + # Linear never updates. Transpose is subclass- and graph-preserving + # (see _ops_to_preserve_subclass in tensor.py), so weight.T keeps the + # link; the payload is unwrapped inside MXFP4AdaHOPLinearFunction.forward, + # past the autograd boundary. Mirrors the plain MXFP4 / NVFP4 paths. + operand_w = weight if trans_b else weight.T y = MXFP4AdaHOPLinearFunction.apply( x, operand_w, diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index fd76266a..5e0e808b 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -37,6 +37,7 @@ import torch from alto.kernels.fp4.mxfp4.mxfp_quantization import is_cdna4 +from alto.kernels.fp4.fp4_common.tensor_wrappers import unwrap_weight_wrapper from .transform_mode import TransformMode, assert_mode_supported HadamardTransformType = "HadamardTransform" # type-hint placeholder; avoid hard import @@ -111,6 +112,13 @@ def forward( assert_mode_supported(backward_gx_mode, "backward_gx") assert_mode_supported(backward_gw_mode, "backward_gw") + # The wrapper (grad-tracked) is passed into apply() so the weight gradient + # reaches the Parameter; unwrap to the plain payload HERE, inside forward + # (past the autograd boundary), for the quant math. Mirrors the plain + # MXFP4 path (mxfp_linear.py). Safe: forward runs in no-grad, so the graph + # input recorded by apply() is still the wrapper. + weight = unwrap_weight_wrapper(weight) + original_shape = x.shape original_dtype = x.dtype x = x.reshape(-1, original_shape[-1]) From b23158d8e5945b28b6dd7c70efed31b9a0cb89cf Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 15 Jul 2026 12:43:42 +0000 Subject: [PATCH 078/142] Pass use_sr to inner_outlier_extract_right for unbiased grad quant Thread use_sr_grad into the two inner_outlier_extract_right_cdna4 call sites (_backward_gx, _backward_gw) so the kernel stochastic-rounds the gradient operand, matching the baseline. Bumps the adahop submodule to the commit that adds the use_sr kernel parameter. Verified on CDNA3: grad_w mean bias drops ~13e-6 -> ~3e-6 (unbiased) for backward_gw = inner_outlier_extract_right. --- 3rdparty/adahop | 2 +- alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/3rdparty/adahop b/3rdparty/adahop index 63561b22..38b47508 160000 --- a/3rdparty/adahop +++ b/3rdparty/adahop @@ -1 +1 @@ -Subproject commit 63561b22e9d377af3da0963862db5b05e7c10fe3 +Subproject commit 38b475083df70366912c73d72069910acc2cb5c7 diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index 5e0e808b..b8dae603 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -460,6 +460,7 @@ def _backward_gx( trans_b=False, k=OUTLIER_K, original_dtype=original_dtype, + use_sr=use_sr_grad, # grad_output is A -> stochastic-round its quant ) # hadamard / outer_hadamard / none @@ -522,6 +523,7 @@ def _backward_gw( trans_b=False, k=OUTLIER_K, original_dtype=original_dtype, + use_sr=use_sr_grad, # grad_output is A -> stochastic-round its quant ) # hadamard / inner_outlier_extract_left (same path per AdaHOP) / From f1f1746dab0c4f5ab5456c0e1df4ed188ce6993f Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 15 Jul 2026 12:48:42 +0000 Subject: [PATCH 079/142] Add AdaHOP investigation configs + randomized Hadamard for lpt_adahop Adds three experiment configs and their recipes used to isolate the AdaHOP underperformance root cause: - gpt_oss_20b_adahop_hadamard (+ lpt_adahop_all_hadamard.yaml): all slots forced to hadamard, to separate mode-selection effects from the transform math. - gpt_oss_20b_lpt_no2dw (+ lpt_recipe_no2dw.yaml): plain MXFP4 with 2D weight scaling disabled, to test the weight-quant-identity hypothesis. - gpt_oss_20b_lpt_fresh: plain MXFP4 baseline control, fresh from step 1. Also flips lpt_adahop.yaml to use_randomized_hadamard: true to match the plain-MXFP4 baseline's randomized Hadamard default. --- alto/models/gpt_oss/config_registry.py | 37 +++++++++++++++++++++ alto/models/gpt_oss/configs/lpt_adahop.yaml | 7 +++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 22bfcf2a..036bfcce 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -19,7 +19,10 @@ "gpt_oss_20b", "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", + "gpt_oss_20b_lpt_fresh", + "gpt_oss_20b_lpt_no2dw", "gpt_oss_20b_adahop", + "gpt_oss_20b_adahop_hadamard", "gpt_oss_20b_pretrain_c4", "gpt_oss_20b_lpt_c4", "gpt_oss_20b_grad_clip_lpt", @@ -213,6 +216,20 @@ def gpt_oss_20b_adahop() -> Trainer.Config: ],) return config +def gpt_oss_20b_adahop_hadamard() -> Trainer.Config: + """Phase-2 Run A: AdaHOP with every slot forced to `hadamard` (no outlier + extraction, no full_precision). Identical to gpt_oss_20b_adahop except the + recipe's layer_transform_config maps all pattern-pairs to "hadamard". + Isolates whether the AdaHOP training regression comes from mode SELECTION + (S3) rather than the transform math. Distinct dump_folder so it never + collides with the calibrated adahop or the nolora runs.""" + config = gpt_oss_20b_adahop() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-adahop-allhadamard-randomized-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop_all_hadamard.yaml",), + ],) + return config + def gpt_oss_20b_lpt() -> Trainer.Config: config = gpt_oss_20b_pretrain() config.training.global_batch_size = 16 @@ -237,6 +254,26 @@ def gpt_oss_20b_lpt() -> Trainer.Config: ],) return config +def gpt_oss_20b_lpt_fresh() -> Trainer.Config: + """Plain MXFP4 baseline, fresh from step 1, own dump folder — the control for + the weight-identity experiment (2D weight scaling ON). Distinct dump_folder + from gpt_oss_20b_lpt so it never resumes an existing checkpoint.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-2dw-fresh-outputs" + return config + +def gpt_oss_20b_lpt_no2dw() -> Trainer.Config: + """Plain MXFP4 with 2D weight scaling DISABLED (use_2dblock_w: false). Tests + the AdaHOP root-cause hypothesis: breaking the baseline's single-2D-Q(W) + identity (W re-quantized per-axis instead) should degrade plain MXFP4 toward + AdaHOP's ~5.9 val@768. Fresh from step 1, own dump folder.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-no2dw-fresh-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_no2dw.yaml",), + ],) + return config + def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" config = gpt_oss_20b_pretrain() diff --git a/alto/models/gpt_oss/configs/lpt_adahop.yaml b/alto/models/gpt_oss/configs/lpt_adahop.yaml index f1dd0336..ce21cb85 100644 --- a/alto/models/gpt_oss/configs/lpt_adahop.yaml +++ b/alto/models/gpt_oss/configs/lpt_adahop.yaml @@ -19,7 +19,12 @@ training_stage: AdaHOPModifier: enabled: true use_hadamard: true - use_randomized_hadamard: false + # Randomized Hadamard (random sign/permutation) to match the plain-MXFP4 + # baseline, which keeps HadamardFactory's randomized=True default. A fixed + # (deterministic) Hadamard decorrelates outliers less well; the small + # per-step difference compounds over training into a widening val-loss gap + # vs plain MXFP4 (S5). See plan keen-questing-ember. + use_randomized_hadamard: true calibration_steps: 30 layer_transform_config: "row-row": "hadamard" From a16f8b3d3761de6d07ef60a2978769f20f443319 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 15 Jul 2026 12:51:30 +0000 Subject: [PATCH 080/142] Add AdaHOP numerical-parity test and resume-divergence diagnostic test_adahop_numerical_parity.py verifies the two AdaHOP backward fixes on GPU: the weight-gradient autograd link and stochastic rounding on the inner_outlier_extract_right path (reports grad_w bias SR-on vs SR-off and which CDNA branch ran). check_adahop_resume_divergence.py is a standalone diagnostic for whether the calibration state restores identically across ranks on resume. --- scripts/check_adahop_resume_divergence.py | 173 +++++++++++ .../adahop/test_adahop_numerical_parity.py | 276 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 scripts/check_adahop_resume_divergence.py create mode 100644 tests/unittest/adahop/test_adahop_numerical_parity.py diff --git a/scripts/check_adahop_resume_divergence.py b/scripts/check_adahop_resume_divergence.py new file mode 100644 index 00000000..2a06a45f --- /dev/null +++ b/scripts/check_adahop_resume_divergence.py @@ -0,0 +1,173 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Diagnostic: does the AdaHOP calibration blob restore identically on every rank? + +Hypothesis under test +--------------------- +On resume, adahop jobs deadlock on the first training step inside the MoE +``ALLTOALL_BASE`` collective. The suspected cause is that the checkpointed +calibration state -- a plain pickled Python blob restored by DCP +(alto/modifiers/lpt/adahop_internals/calibration_state.py) -- is NOT delivered +identically to all ranks. If rank 0 sees ``completed=True`` (and takes the +"apply modes, skip calibration" branch) while some other rank sees +``completed=False`` (and takes ``_arm_calibration()``), the two branches issue +different collectives and the expert-parallel all-to-all hangs forever. Raising +the NCCL timeout cannot fix a control-flow divergence. + +This script reproduces ONLY the calibration-state load path -- no model, no +training, no MoE -- so it finishes in seconds instead of hanging for 30 min. +Every rank loads the blob, then all ranks all-gather (completed, step_idx, +n_modes, modes_hash) and rank 0 reports whether they agree. + +Run with torchrun so every rank participates, and DO NOT filter rank output: + + cd /home/ybouquet/projects/ALTO + CKPT=./gpt_oss_20b-pretrain-subset-mxfp4-adahop-srfix-mi300x-outputs/checkpoint/step-500 \ + torchrun --nproc_per_node=8 --rdzv_backend c10d --rdzv_endpoint="localhost:0" \ + scripts/check_adahop_resume_divergence.py + +Each rank also writes /tmp/adahop_divergence_rank.txt so results survive any +output filtering. +""" + +import hashlib +import json +import os +import sys + +import torch +import torch.distributed as dist +import torch.distributed.checkpoint as dcp + + +def _modes_hash(modes_by_fqn) -> str: + """Deterministic hash of the per-layer modes dict (order-independent).""" + blob = json.dumps(modes_by_fqn, sort_keys=True, default=str) + return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16] + + +def main() -> int: + ckpt = os.environ.get("CKPT") + if not ckpt: + print("ERROR: set CKPT=", file=sys.stderr) + return 2 + if not os.path.isdir(ckpt): + print(f"ERROR: CKPT is not a directory: {ckpt}", file=sys.stderr) + return 2 + + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + torch.cuda.set_device(local_rank) + # RCCL/NCCL share the "nccl" backend name on ROCm. + dist.init_process_group(backend="nccl") + rank = dist.get_rank() + world = dist.get_world_size() + + # Import AFTER process group init so any module-level state is fresh. + from alto.modifiers.lpt.adahop_internals.calibration_state import ( + get_adahop_calibration_state, + is_calibration_completed, + get_calibration_modes, + reset_calibration_state, + ) + + # Start every rank from the fresh-run default so what we observe after the + # load is purely what DCP delivered -- not leftover state. + reset_calibration_state() + + before_completed = is_calibration_completed() + + # Reproduce EXACTLY the training resume path: register the Stateful manager + # under the same key the trainer uses ("adahop_calibration") and let DCP + # populate the module-level _STATE via load_state_dict. + manager = get_adahop_calibration_state() + state = {"adahop_calibration": manager} + + load_error = "" + try: + dcp.load(state, checkpoint_id=ckpt) + except Exception as e: # noqa: BLE001 -- we want to see per-rank failures + load_error = f"{type(e).__name__}: {e}" + + from alto.modifiers.lpt.adahop_internals.calibration_state import get_calibration_state_dict + completed = is_calibration_completed() + modes = get_calibration_modes() + n_modes = len(modes) + step_idx = get_calibration_state_dict().get("step_idx", 0) + h = _modes_hash(modes) if modes else "" + + # Which training branch WOULD this rank take on step 501? + branch = "APPLY_MODES(skip calib)" if completed else "ARM_CALIBRATION" + + line = (f"[rank {rank}/{world}] before_completed={before_completed} " + f"after_completed={completed} step_idx={step_idx} " + f"n_modes={n_modes} modes_hash={h} branch={branch} " + f"load_error={load_error or ''}") + + # Persist per-rank so nothing is lost to output filtering. Default to /tmp; + # set DIVERGENCE_OUT_DIR to a bind-mounted dir to keep files after the + # container is torn down. + out_dir = os.environ.get("DIVERGENCE_OUT_DIR", "/tmp") + os.makedirs(out_dir, exist_ok=True) + with open(os.path.join(out_dir, f"adahop_divergence_rank{rank}.txt"), "w") as f: + f.write(line + "\n") + print(line, flush=True) + + # Gather a compact tuple from every rank to rank 0 for a verdict. + record = { + "rank": rank, + "completed": bool(completed), + "step_idx": int(step_idx), + "n_modes": int(n_modes), + "modes_hash": h, + "branch": branch, + "load_error": load_error, + } + gathered = [None] * world + dist.all_gather_object(gathered, record) + + rc = 0 + if rank == 0: + print("\n==================== VERDICT ====================", flush=True) + for r in sorted(gathered, key=lambda x: x["rank"]): + print(f" rank {r['rank']}: completed={r['completed']} " + f"step_idx={r['step_idx']} n_modes={r['n_modes']} hash={r['modes_hash']} " + f"branch={r['branch']} err={r['load_error'] or ''}", + flush=True) + + completed_set = {r["completed"] for r in gathered} + hash_set = {r["modes_hash"] for r in gathered} + branch_set = {r["branch"] for r in gathered} + err_ranks = [r["rank"] for r in gathered if r["load_error"]] + + print("\n ---- analysis ----", flush=True) + if err_ranks: + print(f" ✗ LOAD FAILED on ranks {err_ranks} -- blob not restorable there.", flush=True) + rc = 1 + if len(branch_set) > 1: + print(f" ✗ DIVERGENCE CONFIRMED: ranks disagree on the step-501 branch " + f"{sorted(branch_set)}. This is the deadlock: different ranks issue " + f"different collectives.", flush=True) + rc = 1 + elif len(completed_set) > 1: + print(f" ✗ DIVERGENCE: 'completed' flag differs across ranks {completed_set}.", flush=True) + rc = 1 + elif len(hash_set) > 1: + print(f" ⚠ modes agree on branch but DIFFER in content across ranks " + f"{sorted(hash_set)} -- would not deadlock on control flow but numerics " + f"diverge per rank.", flush=True) + rc = 1 + else: + print(f" ✓ ALL RANKS AGREE: completed={completed_set.pop()}, " + f"identical modes_hash, same branch. Calibration-state restore is NOT " + f"the divergence source -- look elsewhere (e.g. Hadamard seed, " + f"or a genuinely different collective ordering).", flush=True) + print("=================================================", flush=True) + + dist.barrier() + dist.destroy_process_group() + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unittest/adahop/test_adahop_numerical_parity.py b/tests/unittest/adahop/test_adahop_numerical_parity.py new file mode 100644 index 00000000..8b307f6b --- /dev/null +++ b/tests/unittest/adahop/test_adahop_numerical_parity.py @@ -0,0 +1,276 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT +"""Phase-1 numerical parity test: AdaHOP per-slot modes vs plain MXFP4. + +Motivation +---------- +On identical MI350X hardware, ``gpt_oss_20b_adahop`` trains WORSE than plain +``gpt_oss_20b_lpt`` even though AdaHOP is supposed to be a strict superset +(Hadamard incoherence processing + outlier extraction). The validation-loss +gap *widens* over training, which points at biased gradients rather than a +one-off noisier forward. + +This test isolates the numerical behaviour of the two autograd paths for a +single Linear, so we can see *which output* (``y`` / ``grad_x`` / ``grad_w``) +and *which AdaHOP mode* diverges from the bf16 reference more than the plain +MXFP4 baseline does. + +Suspects being probed (see plan keen-questing-ember): + * S1 — AdaHOP ``hadamard`` mode drops 2D-block weight scaling (forward/y). + * S2 — ``inner_outlier_extract_right`` (dominant backward_gw mode) quantizes + grad_output with NO stochastic rounding → biased grad_w. + * S4 — ``inner_outlier_extract_left`` (backward_gx) SR propagation. + +Interpretation +-------------- +For an *unbiased* quantizer, averaging over many random gradients drives the +mean relative error of grad_w toward ~0 (the ``_bias`` metrics below). A +quantizer that silently drops stochastic rounding will show a grad_w mean-bias +that does NOT shrink with averaging and is materially larger than the plain +MXFP4 baseline's. That is the fingerprint of S2. + +Requires a real GPU (MI350X/CDNA4 for the fused kernels; CDNA3 exercises the +QDQ fallbacks). Skips on CPU-only boxes. +""" + +import pytest + +torch = pytest.importorskip("torch") +pytest.importorskip("triton") + +if not torch.cuda.is_available(): + pytest.skip("AdaHOP numerical parity test requires a GPU", allow_module_level=True) + +try: + from alto.kernels.fp4.mxfp4.mxfp_linear import MXFP4LinearFunction + from alto.modifiers.lpt.adahop_internals.mxfp4_linear_function import ( + MXFP4AdaHOPLinearFunction, + ) + from alto._adahop_bridge import HadamardFactory +except RuntimeError as exc: # triton driver init can fail even with a GPU present + pytest.skip(f"kernel import failed: {exc}", allow_module_level=True) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Shapes divisible by 32 (MXFP4 block) and >= OUTLIER_K=64 on the extracted +# axis so outlier extraction has room. gpt_oss_20b wq is 2880 -> 4096. +M, K, N = 2048, 2880, 4096 +DTYPE = torch.bfloat16 +DEVICE = "cuda" + + +def _rel_err(approx: torch.Tensor, ref: torch.Tensor) -> float: + """Frobenius relative error ||approx - ref|| / ||ref||.""" + approx = approx.float() + ref = ref.float() + return (torch.linalg.vector_norm(approx - ref) / + torch.linalg.vector_norm(ref).clamp_min(1e-12)).item() + + +def _mean_bias(approx: torch.Tensor, ref: torch.Tensor) -> float: + """Normalized mean signed error — detects a *systematic* offset (bias) + that stochastic rounding is supposed to cancel. Near 0 = unbiased.""" + approx = approx.float() + ref = ref.float() + return ((approx - ref).mean() / ref.abs().mean().clamp_min(1e-12)).item() + + +def _make_inputs(seed: int): + g = torch.Generator(device=DEVICE).manual_seed(seed) + x = torch.randn(M, K, generator=g, device=DEVICE, dtype=DTYPE) + w = torch.randn(N, K, generator=g, device=DEVICE, dtype=DTYPE) * 0.02 + # Inject genuine column outliers into x so the "col" pattern / outlier + # extraction paths have something real to act on (mirrors what the + # calibration classifier detected on the real run: x=col on most layers). + x[:, ::128] *= 25.0 + grad_y = torch.randn(M, N, generator=g, device=DEVICE, dtype=DTYPE) + return x, w, grad_y + + +def _reference(x, w, grad_y): + """bf16 ground truth for y, grad_x, grad_w.""" + xr = x.detach().float().requires_grad_(True) + wr = w.detach().float().requires_grad_(True) + y = xr @ wr.T + y.backward(grad_y.float()) + return y.detach(), xr.grad.detach(), wr.grad.detach() + + +def _run_baseline(x, w, grad_y): + """Plain MXFP4 path with the recipe's flags (2dblock_w, hadamard, sr_grad).""" + xin = x.detach().clone().requires_grad_(True) + win = w.detach().clone().requires_grad_(True) + with torch.no_grad(): + ht = HadamardFactory.create_transform(device=win.device) + y = MXFP4LinearFunction.apply( + xin, win, + False, # use_2dblock_x + True, # use_2dblock_w + True, # use_sr_grad + False, # use_dge + "none", # clip_mode + False, # use_macro_block_scaling + ht, + None, # module_id + ) + y.backward(grad_y) + return y.detach(), xin.grad.detach(), win.grad.detach() + + +def _run_adahop(x, w, grad_y, fy, gx, gw, use_sr_grad=True): + """AdaHOP path with explicit per-slot modes and SR toggle.""" + xin = x.detach().clone().requires_grad_(True) + win = w.detach().clone().requires_grad_(True) + with torch.no_grad(): + ht = HadamardFactory.create_transform(device=win.device) + y = MXFP4AdaHOPLinearFunction.apply( + xin, win, + use_sr_grad, + ht, + fy, gx, gw, + ) + y.backward(grad_y) + return y.detach(), xin.grad.detach(), win.grad.detach() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_adahop_none_matches_baseline(): + """Sanity: AdaHOP with all slots 'none' must track plain MXFP4 closely. + A large gap here means the AdaHOP autograd Function has a plumbing bug + independent of any transform (rules S1/S2 in vs out).""" + x, w, grad_y = _make_inputs(seed=0) + y_ref, gx_ref, gw_ref = _reference(x, w, grad_y) + + y_b, gx_b, gw_b = _run_baseline(x, w, grad_y) + y_a, gx_a, gw_a = _run_adahop(x, w, grad_y, "none", "none", "none") + + for name, b, a in (("y", y_b, y_a), ("grad_x", gx_b, gx_a), ("grad_w", gw_b, gw_a)): + eb, ea = _rel_err(b, y_ref if name == "y" else (gx_ref if name == "grad_x" else gw_ref)), \ + _rel_err(a, y_ref if name == "y" else (gx_ref if name == "grad_x" else gw_ref)) + print(f"[none] {name:7s} baseline_relerr={eb:.4f} adahop_relerr={ea:.4f}") + # AdaHOP 'none' should not be materially worse than baseline. + assert ea <= eb * 1.5 + 0.02, ( + f"AdaHOP none-mode {name} rel-err {ea:.4f} >> baseline {eb:.4f}: " + "plumbing bug independent of transforms") + + +def test_grad_w_bias_across_modes(): + """Core S2 probe. Averages grad_w over many random gradients and reports + the *mean signed bias* per backward_gw mode. Unbiased quantization (SR + on) → bias shrinks toward 0. If 'inner_outlier_extract_right' shows a + persistent bias much larger than the baseline / 'hadamard' modes, SR was + silently dropped on that path (S2).""" + n_avg = 16 + modes = ["hadamard", "inner_outlier_extract_right", "full_precision"] + + baseline_bias = 0.0 + acc_bias = {m: 0.0 for m in modes} + acc_relerr = {m: 0.0 for m in modes} + baseline_relerr = 0.0 + + for i in range(n_avg): + x, w, grad_y = _make_inputs(seed=100 + i) + _, _, gw_ref = _reference(x, w, grad_y) + + _, _, gw_b = _run_baseline(x, w, grad_y) + baseline_bias += _mean_bias(gw_b, gw_ref) / n_avg + baseline_relerr += _rel_err(gw_b, gw_ref) / n_avg + + for m in modes: + # Keep the forward + gx legs on 'hadamard' so we isolate backward_gw. + _, _, gw_a = _run_adahop(x, w, grad_y, "hadamard", "hadamard", m) + acc_bias[m] += _mean_bias(gw_a, gw_ref) / n_avg + acc_relerr[m] += _rel_err(gw_a, gw_ref) / n_avg + + print(f"\n[grad_w bias over {n_avg} draws]") + print(f" baseline(mxfp4,sr) bias={baseline_bias:+.5f} relerr={baseline_relerr:.4f}") + for m in modes: + print(f" gw={m:28s} bias={acc_bias[m]:+.5f} relerr={acc_relerr[m]:.4f}") + + # The dominant real-run mode. If its |bias| is an order of magnitude worse + # than baseline, that's the widening-gap culprit (S2). + ioe = abs(acc_bias["inner_outlier_extract_right"]) + base = abs(baseline_bias) + print(f" => inner_outlier_extract_right |bias|={ioe:.5f} vs baseline |bias|={base:.5f}") + # Not a hard assert on the ratio (we want the number reported even when it + # passes); flag only an egregious systematic bias. + assert ioe < 0.05, ( + f"inner_outlier_extract_right grad_w has a large systematic bias " + f"({ioe:.5f}); stochastic rounding likely dropped on this path (S2)") + + +def test_forward_y_hadamard_vs_baseline(): + """S1 probe: compare forward-y rel-err of AdaHOP 'hadamard' mode against + the baseline (which keeps 2D-block weight scaling). AdaHOP should be + <= baseline; if it's worse, the 1D-only iht_quantization is a real + forward precision downgrade.""" + x, w, grad_y = _make_inputs(seed=7) + y_ref, _, _ = _reference(x, w, grad_y) + + y_b, _, _ = _run_baseline(x, w, grad_y) + y_a, _, _ = _run_adahop(x, w, grad_y, "hadamard", "hadamard", "hadamard") + + eb = _rel_err(y_b, y_ref) + ea = _rel_err(y_a, y_ref) + print(f"\n[forward y] baseline_relerr={eb:.4f} adahop_hadamard_relerr={ea:.4f}") + assert ea <= eb * 1.5 + 0.02, ( + f"AdaHOP hadamard forward-y rel-err {ea:.4f} >> baseline {eb:.4f} (S1)") + + +def _which_extract_branch(): + """Report which inner_outlier_extract branch this GPU runs, so the test + output states what was actually verified (CDNA3 QDQ vs CDNA4 fused).""" + try: + from alto.kernels.fp4.mxfp4.mxfp_quantization import is_cdna4 + return "CDNA4 (fused/dot_scaled)" if is_cdna4() else "CDNA3 (pytorch QDQ)" + except Exception: + return "unknown" + + +def test_sr_reduces_grad_w_bias_outlier_right(): + """The SR fix: threading use_sr into inner_outlier_extract_right's grad + quantization should make grad_w UNBIASED (mean signed error -> 0 with + averaging). With SR off (the pre-fix behavior), grad_w carries a persistent + systematic bias. This directly verifies the fix on whatever branch this GPU + runs (CDNA3 pytorch-QDQ or CDNA4 fused) — both were patched. + + backward_gw = inner_outlier_extract_right is the dominant real-run mode + (86/96 layers in the calibrated run), so this is the path that matters. + """ + branch = _which_extract_branch() + n_avg = 32 + bias_sr_on = 0.0 + bias_sr_off = 0.0 + relerr_sr_on = 0.0 + for i in range(n_avg): + x, w, grad_y = _make_inputs(seed=500 + i) + _, _, gw_ref = _reference(x, w, grad_y) + # Isolate backward_gw = inner_outlier_extract_right; keep fwd + gx on hadamard. + _, _, gw_on = _run_adahop(x, w, grad_y, "hadamard", "hadamard", + "inner_outlier_extract_right", use_sr_grad=True) + _, _, gw_off = _run_adahop(x, w, grad_y, "hadamard", "hadamard", + "inner_outlier_extract_right", use_sr_grad=False) + bias_sr_on += _mean_bias(gw_on, gw_ref) / n_avg + bias_sr_off += _mean_bias(gw_off, gw_ref) / n_avg + relerr_sr_on += _rel_err(gw_on, gw_ref) / n_avg + + print(f"\n[SR fix / grad_w, backward_gw=inner_outlier_extract_right, {branch}]") + print(f" SR on : mean_bias={bias_sr_on:+.6f} relerr={relerr_sr_on:.4f}") + print(f" SR off: mean_bias={bias_sr_off:+.6f}") + print(f" => |bias| SR on={abs(bias_sr_on):.6f} SR off={abs(bias_sr_off):.6f}") + + # The SR fix must actually change the result (proves use_sr is threaded through + # to the kernel's grad quantization on this branch) AND make it less biased. + assert abs(bias_sr_on) != abs(bias_sr_off), ( + "SR on/off produced identical grad_w bias -> use_sr is NOT reaching the " + f"outlier kernel's grad quant on {branch}") + assert abs(bias_sr_on) <= abs(bias_sr_off) + 1e-6, ( + f"SR did not reduce grad_w bias on {branch}: " + f"|bias| on={abs(bias_sr_on):.6f} off={abs(bias_sr_off):.6f}") From 3dc43640c6562cbf8ae238f324eb89523867e17a Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Wed, 15 Jul 2026 12:36:04 +0000 Subject: [PATCH 081/142] Fix AdaHOP weight gradient detached from autograd graph MXFP4AdaHOPWrapper passed weight._data (requires_grad=False) into MXFP4AdaHOPLinearFunction.apply, so autograd discarded the weight gradient and AdaHOP-wrapped Linear weights never trained. Pass the grad-tracked wrapper instead (transpose preserves the subclass and the graph link) and unwrap to the raw payload inside forward, past the autograd boundary. Mirrors the plain MXFP4 / NVFP4 paths. --- alto/kernels/dispatch/adahop_tensor.py | 9 ++++++++- .../lpt/adahop_internals/mxfp4_linear_function.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/alto/kernels/dispatch/adahop_tensor.py b/alto/kernels/dispatch/adahop_tensor.py index 5d3aeb05..321f01a3 100644 --- a/alto/kernels/dispatch/adahop_tensor.py +++ b/alto/kernels/dispatch/adahop_tensor.py @@ -130,7 +130,14 @@ def __torch_function__(cls, func, types, args, kwargs={}): # function's "none" path (unused by the recipe post-Phase-B). if weight.is_calibrating: return super().__torch_function__(func, types, args, kwargs) - operand_w = weight._data if trans_b else weight._data.T + # Pass the WRAPPER tensor (grad-tracked) into apply(), NOT weight._data + # (which is requires_grad=False). Passing the raw payload severs the + # autograd link so the weight gradient is silently discarded and the + # Linear never updates. Transpose is subclass- and graph-preserving + # (see _ops_to_preserve_subclass in tensor.py), so weight.T keeps the + # link; the payload is unwrapped inside MXFP4AdaHOPLinearFunction.forward, + # past the autograd boundary. Mirrors the plain MXFP4 / NVFP4 paths. + operand_w = weight if trans_b else weight.T y = MXFP4AdaHOPLinearFunction.apply( x, operand_w, diff --git a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py index fd76266a..5e0e808b 100644 --- a/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py +++ b/alto/modifiers/lpt/adahop_internals/mxfp4_linear_function.py @@ -37,6 +37,7 @@ import torch from alto.kernels.fp4.mxfp4.mxfp_quantization import is_cdna4 +from alto.kernels.fp4.fp4_common.tensor_wrappers import unwrap_weight_wrapper from .transform_mode import TransformMode, assert_mode_supported HadamardTransformType = "HadamardTransform" # type-hint placeholder; avoid hard import @@ -111,6 +112,13 @@ def forward( assert_mode_supported(backward_gx_mode, "backward_gx") assert_mode_supported(backward_gw_mode, "backward_gw") + # The wrapper (grad-tracked) is passed into apply() so the weight gradient + # reaches the Parameter; unwrap to the plain payload HERE, inside forward + # (past the autograd boundary), for the quant math. Mirrors the plain + # MXFP4 path (mxfp_linear.py). Safe: forward runs in no-grad, so the graph + # input recorded by apply() is still the wrapper. + weight = unwrap_weight_wrapper(weight) + original_shape = x.shape original_dtype = x.dtype x = x.reshape(-1, original_shape[-1]) From abc0b9c5998545030afd079e8f11da6ef113438c Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 22 Jul 2026 21:48:40 +0000 Subject: [PATCH 082/142] working training script --- scripts/train_gptoss20b.sh | 177 +++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 scripts/train_gptoss20b.sh diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh new file mode 100644 index 00000000..5ecb407c --- /dev/null +++ b/scripts/train_gptoss20b.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Run ALTO GPT-OSS 20B BF16 training on the current node. +# +# Example: +# NGPU=4 CONFIG=gpt_oss_debugmodel TRAINING_STEPS=20 \ +# bash ~/ALTO/train_gptoss.sh + +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" +IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7.2-patch}" +CONTAINER="${CONTAINER:-alto_gpt_oss_bf16}" + +NGPU="${NGPU:-8}" +MODULE="${MODULE:-gpt_oss}" +CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" +TRAINING_STEPS="${TRAINING_STEPS:-15000}" + +MODEL_REPO="${MODEL_REPO:-openai/gpt-oss-20b}" +MODEL_DIR="${MODEL_DIR:-$HOME/models/gpt-oss-20b}" +HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" +HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" + +CHECKPOINT_DIR="${CHECKPOINT_DIR:-$HOME/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" +LOG_FILE="${LOG_FILE:-$ALTO_DIR/gpt_oss_20b-bf16.log}" + +# Comma-separated host directories mounted at the same path in the container. +# Example: EXTRA_MOUNTS=/shared,/shared_rccl +EXTRA_MOUNTS="${EXTRA_MOUNTS:-/shared_rccl}" + +# Hardware resources are added only when they exist. +DEVICE_PATHS="${DEVICE_PATHS:-/dev/kfd /dev/dri /dev/infiniband}" +DEVICE_GROUPS="${DEVICE_GROUPS:-render video}" + +# ----------------------------------------------------------------------------- +# Setup +# ----------------------------------------------------------------------------- + +mkdir -p \ + "$MODEL_DIR" \ + "$HF_HOME_DIR" \ + "$CHECKPOINT_DIR" \ + "$(dirname "$LOG_FILE")" + +echo "=== ALTO GPT-OSS 20B BF16 baseline ===" +echo "Node: $(hostname)" +echo "Image: $IMAGE" +echo "Config: $CONFIG" +echo "GPUs: $NGPU" +echo "Training steps: $TRAINING_STEPS" +echo "Model: $MODEL_DIR" +echo "Checkpoints: $CHECKPOINT_DIR" +echo "Log: $LOG_FILE" +echo + +docker pull "$IMAGE" + +docker_args=( + -d + --rm + --name "$CONTAINER" + --user "$(id -u):$(id -g)" + --network host + --ipc host + --cap-add SYS_PTRACE + --security-opt seccomp=unconfined + -v "$HOME:$HOME" + -v "$ALTO_DIR:/alto" + -v "$MODEL_DIR:$MODEL_DIR" + -v "$HF_HOME_DIR:/hf_home" + -v "$CHECKPOINT_DIR:$CHECKPOINT_DIR" + -v /etc/passwd:/etc/passwd:ro + -v /etc/group:/etc/group:ro + -e HOME="$HOME" + -e USER="$(id -un)" + -e HF_HOME=/hf_home + -e HF_DATASETS_CACHE=/hf_home/datasets + -e TRITON_CACHE_DIR=/tmp/triton_cache + -e TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache +) + +if [[ -f "$HF_ENV_FILE" ]]; then + docker_args+=(--env-file "$HF_ENV_FILE") +fi + +for device in $DEVICE_PATHS; do + if [[ -e "$device" ]]; then + docker_args+=(--device "$device") + fi +done + +for group in $DEVICE_GROUPS; do + gid="$(getent group "$group" | cut -d: -f3 || true)" + + if [[ -n "$gid" ]]; then + docker_args+=(--group-add "$gid") + fi +done + +if [[ -n "$EXTRA_MOUNTS" ]]; then + IFS=',' read -r -a mount_dirs <<< "$EXTRA_MOUNTS" + + for directory in "${mount_dirs[@]}"; do + if [[ ! -d "$directory" ]]; then + echo "Missing mount directory: $directory" >&2 + exit 1 + fi + + docker_args+=(-v "$directory:$directory") + done +fi + +docker run "${docker_args[@]}" "$IMAGE" sleep infinity + +cleanup() { + echo "[train] Stopping container $CONTAINER ..." + docker stop "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# ----------------------------------------------------------------------------- +# Model and dependencies +# ----------------------------------------------------------------------------- + +echo "[model] Ensuring $MODEL_REPO is available at $MODEL_DIR ..." + +docker exec "$CONTAINER" \ + hf download "$MODEL_REPO" --local-dir "$MODEL_DIR" + +echo "[train] Installing dependencies ..." + +docker exec "$CONTAINER" \ + python3 -m pip install -q torchao + +docker exec "$CONTAINER" \ + python3 -m pip install -q \ + --no-build-isolation \ + --no-deps \ + -e /alto/3rdparty/torchtitan + +# ----------------------------------------------------------------------------- +# Training +# ----------------------------------------------------------------------------- + +echo "[train] Launching $CONFIG for $TRAINING_STEPS steps on $NGPU GPUs ..." + +docker exec \ + -w /alto \ + -e PYTORCH_ALLOC_CONF=expandable_segments:True \ + -e TRANSFORMERS_OFFLINE=1 \ + "$CONTAINER" \ + torchrun \ + --standalone \ + --nproc_per_node "$NGPU" \ + --local-ranks-filter 0 \ + --tee 3 \ + -m alto.train \ + --module "$MODULE" \ + --config "$CONFIG" \ + --training.steps "$TRAINING_STEPS" \ + --comm.init_timeout_seconds 1800 \ + --hf_assets_path "$MODEL_DIR" \ + --checkpoint.interval 1000 \ + --checkpoint.keep_latest_k 2 \ + --dump_folder "$CHECKPOINT_DIR" \ + --profiling.enable_profiling \ + --profiling.profile_freq 1000 \ + --profiling.profiler_warmup 3 \ + --profiling.profiler_active 1 \ + 2>&1 | tee "$LOG_FILE" + +echo +echo "[train] Run complete." \ No newline at end of file From 16e97ef15964de8a3269c7bd1ec3ccc52d04860b Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 22 Jul 2026 21:49:05 +0000 Subject: [PATCH 083/142] update c4 with pre-tokenized dset --- alto/models/gpt_oss/config_registry.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index b22fbb3d..7ba4cac7 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -115,13 +115,11 @@ def gpt_oss_20b_pretrain_c4() -> Trainer.Config: """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" config = gpt_oss_20b_pretrain() config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" - config.dataloader.dataset = "c4" - config.dataloader.dataset_path = None - config.validator.dataloader.dataset = "c4_validation" - config.validator.dataloader.dataset_path = None - config.checkpoint.initial_load_in_hf = True - config.checkpoint.initial_load_in_hf_quantized = True - config.checkpoint.interval = 100 + config.dataloader.dataset = "megatron" + config.dataloader.dataset_path = "/shared_rccl/nfrumkin/data/c4-train.en_6_text_document.idx" + config.validator.dataloader.dataset = "megatron" + config.validator.dataloader.dataset_path = "/shared_rccl/nfrumkin/data/c4-validation-91205-samples.en_text_document.idx" + config.checkpoint.interval = 1000 return config def gpt_oss_20b_lpt() -> Trainer.Config: From bd4047ca680e846369ed1733f1665469401d19b9 Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Thu, 23 Jul 2026 21:25:14 +0000 Subject: [PATCH 084/142] checkpoint fix and dataloader for pre-tokenized c4 --- alto/models/gpt_oss/config_registry.py | 8 ++++---- alto/train.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 0bf7efb3..2f9a9311 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -150,10 +150,10 @@ def gpt_oss_20b_lpt() -> Trainer.Config: config.parallelism.expert_parallel_degree = 8 config.training.local_batch_size = 1 config.activation_checkpoint.mode = "none" - config.dataloader.dataset = "c4" - config.dataloader.dataset_path = None - config.validator.dataloader.dataset = "c4_validation" - config.validator.dataloader.dataset_path = None + config.dataloader.dataset = "megatron" + config.dataloader.dataset_path = "/hf_home_shared/data/c4-train.en_6_text_document.idx" + config.validator.dataloader.dataset = "megatron" + config.validator.dataloader.dataset_path = "/hf_home_shared/data/c4-validation-91205-samples.en_text_document.idx" config.checkpoint.enable = True # save checkpoints so we can resume later config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint config.checkpoint.initial_load_in_hf = False diff --git a/alto/train.py b/alto/train.py index e3956e86..42d11698 100644 --- a/alto/train.py +++ b/alto/train.py @@ -108,6 +108,10 @@ def __init__(self, config: TitanTrainer.Config): except Exception as e: # never let this break trainer construction logger.warning(f"[AdaHOP] Could not register calibration state with checkpointer: {e}") + self.checkpointer.states["dataloader"] = self.dataloader + + self.ntokens_seen = 0 + self.training_mode = True self.enable_data_cache = False @@ -133,6 +137,22 @@ def __init__(self, config: TitanTrainer.Config): logger.info("data replay buffer disabled") self.enable_data_cache = False + def state_dict(self) -> dict[str, Any]: + sd = super().state_dict() + sd["ntokens_seen"] = self.ntokens_seen + return sd + + def load_state_dict(self, state_dict: dict[str, Any]): + super().load_state_dict(state_dict) + self.ntokens_seen = state_dict.get("ntokens_seen", 0) + + def batch_generator( + self, data_iterable: Iterable[tuple[dict[str, torch.Tensor], torch.Tensor]] + ) -> Iterable[tuple[dict[str, torch.Tensor], torch.Tensor]]: + for input_dict, labels in super().batch_generator(data_iterable): + self.ntokens_seen += labels.numel() + yield input_dict, labels + def cache_input(self, microbatches: list[tuple[dict[str, torch.Tensor], torch.Tensor]]): if self.enable_data_cache: self._input_cache = microbatches From ed7f35ce846d49db3ae505497b8fe98800816424 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 03:28:45 +0000 Subject: [PATCH 085/142] add gptoss20b training script --- scripts/train_gptoss20b.sh | 111 ++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 45 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 5ecb407c..a917a1f5 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -5,43 +5,68 @@ # NGPU=4 CONFIG=gpt_oss_debugmodel TRAINING_STEPS=20 \ # bash ~/ALTO/train_gptoss.sh + +######## Download the C4 dataset + +### OPTION 1: +# # Create desired download directory with the right permission +# cd /data/gpt_oss_20b + +# # Download training and validation data +# bash <(curl -s https://raw.githubusercontent.com/mlcommons/r2-downloader/refs/heads/main/mlc-r2-downloader.sh) \ +# -d data https://training.mlcommons-storage.org/metadata/llama-3-1-8b-preprocessed-c4-dataset.uri + +### OPTION 2: +# C4_CACHE="$HF_HOME_SHARED/datasets/allenai___c4" +# if [ -d "$C4_CACHE" ] && [ -n "$(ls -A "$C4_CACHE" 2>/dev/null)" ]; then +# echo "[train] C4 dataset already cached, skipping download." +# else +# echo "[train] Downloading C4 dataset (this may take a while) ..." +# docker exec "$CONTAINER" bash -c " +# python3 -c \" +# from datasets import load_dataset +# load_dataset('allenai/c4', 'en', split='train') +# load_dataset('allenai/c4', 'en', split='validation') +# \" +# " +# fi + + + set -euo pipefail # ----------------------------------------------------------------------------- # Configuration # ----------------------------------------------------------------------------- -ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" -IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7.2-patch}" -CONTAINER="${CONTAINER:-alto_gpt_oss_bf16}" - +### Machine-specific args NGPU="${NGPU:-8}" +HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location +HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token + +### Run-specific args +ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # The repository location +CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" + +### Other modifiable args +RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time MODULE="${MODULE:-gpt_oss}" CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" +CONTAINER="${CONTAINER:-alto_gpt_oss_bf16}" # container name (for user readability) -MODEL_REPO="${MODEL_REPO:-openai/gpt-oss-20b}" -MODEL_DIR="${MODEL_DIR:-$HOME/models/gpt-oss-20b}" -HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" -HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" - -CHECKPOINT_DIR="${CHECKPOINT_DIR:-$HOME/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" -LOG_FILE="${LOG_FILE:-$ALTO_DIR/gpt_oss_20b-bf16.log}" -# Comma-separated host directories mounted at the same path in the container. -# Example: EXTRA_MOUNTS=/shared,/shared_rccl -EXTRA_MOUNTS="${EXTRA_MOUNTS:-/shared_rccl}" - -# Hardware resources are added only when they exist. -DEVICE_PATHS="${DEVICE_PATHS:-/dev/kfd /dev/dri /dev/infiniband}" -DEVICE_GROUPS="${DEVICE_GROUPS:-render video}" +# default Docker image from Han Wang +IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7.2-patch}" # ----------------------------------------------------------------------------- # Setup # ----------------------------------------------------------------------------- + + mkdir -p \ - "$MODEL_DIR" \ "$HF_HOME_DIR" \ "$CHECKPOINT_DIR" \ "$(dirname "$LOG_FILE")" @@ -52,7 +77,7 @@ echo "Image: $IMAGE" echo "Config: $CONFIG" echo "GPUs: $NGPU" echo "Training steps: $TRAINING_STEPS" -echo "Model: $MODEL_DIR" +echo "Model directory: $HF_HOME_DIR" echo "Checkpoints: $CHECKPOINT_DIR" echo "Log: $LOG_FILE" echo @@ -68,6 +93,7 @@ docker_args=( --ipc host --cap-add SYS_PTRACE --security-opt seccomp=unconfined + --env-file "$HF_ENV_FILE" -v "$HOME:$HOME" -v "$ALTO_DIR:/alto" -v "$MODEL_DIR:$MODEL_DIR" @@ -81,11 +107,12 @@ docker_args=( -e HF_DATASETS_CACHE=/hf_home/datasets -e TRITON_CACHE_DIR=/tmp/triton_cache -e TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache + ) -if [[ -f "$HF_ENV_FILE" ]]; then - docker_args+=(--env-file "$HF_ENV_FILE") -fi +# Hardware resources are added only when they exist. +DEVICE_PATHS="${DEVICE_PATHS:-/dev/kfd /dev/dri /dev/infiniband}" +DEVICE_GROUPS="${DEVICE_GROUPS:-render video}" for device in $DEVICE_PATHS; do if [[ -e "$device" ]]; then @@ -101,18 +128,6 @@ for group in $DEVICE_GROUPS; do fi done -if [[ -n "$EXTRA_MOUNTS" ]]; then - IFS=',' read -r -a mount_dirs <<< "$EXTRA_MOUNTS" - - for directory in "${mount_dirs[@]}"; do - if [[ ! -d "$directory" ]]; then - echo "Missing mount directory: $directory" >&2 - exit 1 - fi - - docker_args+=(-v "$directory:$directory") - done -fi docker run "${docker_args[@]}" "$IMAGE" sleep infinity @@ -125,22 +140,29 @@ trap cleanup EXIT # ----------------------------------------------------------------------------- # Model and dependencies # ----------------------------------------------------------------------------- +MODEL_DIR="${MODEL_DIR:-$HF_HOME_DIR/models/gpt-oss-20b}" +echo "[model] Ensuring tokenizer is available at $MODEL_DIR ..." + +if [[ -f "$MODEL_DIR/tokenizer.json" ]]; then + echo "[model] Tokenizer already present, skipping download." +else + echo "[model] Downloading tokenizer ..." + docker exec "$CONTAINER" \ + hf download openai/gpt-oss-20b \ + --include "tokenizer*" "special_tokens_map.json" "config.json" \ + --local-dir "$MODEL_DIR" +fi -echo "[model] Ensuring $MODEL_REPO is available at $MODEL_DIR ..." - -docker exec "$CONTAINER" \ - hf download "$MODEL_REPO" --local-dir "$MODEL_DIR" echo "[train] Installing dependencies ..." -docker exec "$CONTAINER" \ - python3 -m pip install -q torchao - -docker exec "$CONTAINER" \ +docker exec "$CONTAINER" bash -c " + python3 -m pip install -q torchao && python3 -m pip install -q \ --no-build-isolation \ --no-deps \ -e /alto/3rdparty/torchtitan +" # ----------------------------------------------------------------------------- # Training @@ -164,7 +186,7 @@ docker exec \ --training.steps "$TRAINING_STEPS" \ --comm.init_timeout_seconds 1800 \ --hf_assets_path "$MODEL_DIR" \ - --checkpoint.interval 1000 \ + --checkpoint.interval 100 \ --checkpoint.keep_latest_k 2 \ --dump_folder "$CHECKPOINT_DIR" \ --profiling.enable_profiling \ @@ -173,5 +195,4 @@ docker exec \ --profiling.profiler_active 1 \ 2>&1 | tee "$LOG_FILE" -echo echo "[train] Run complete." \ No newline at end of file From 6d268caf22ef6104f311746bba3ffbfb50599acd Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 03:39:12 +0000 Subject: [PATCH 086/142] fix mount of data directory into docker --- scripts/train_gptoss20b.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index a917a1f5..f5518d1b 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -7,11 +7,12 @@ ######## Download the C4 dataset +######## make sure to update config_registry.py with appropriate data location -### OPTION 1: + +### OPTION 1: # # Create desired download directory with the right permission # cd /data/gpt_oss_20b - # # Download training and validation data # bash <(curl -s https://raw.githubusercontent.com/mlcommons/r2-downloader/refs/heads/main/mlc-r2-downloader.sh) \ # -d data https://training.mlcommons-storage.org/metadata/llama-3-1-8b-preprocessed-c4-dataset.uri @@ -31,8 +32,6 @@ # " # fi - - set -euo pipefail # ----------------------------------------------------------------------------- @@ -42,6 +41,7 @@ set -euo pipefail ### Machine-specific args NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location +DATA_DIR="${DATA_DIR:-/shared_rccl}" # exposte data directory into container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args @@ -64,8 +64,6 @@ IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7 # Setup # ----------------------------------------------------------------------------- - - mkdir -p \ "$HF_HOME_DIR" \ "$CHECKPOINT_DIR" \ @@ -96,7 +94,7 @@ docker_args=( --env-file "$HF_ENV_FILE" -v "$HOME:$HOME" -v "$ALTO_DIR:/alto" - -v "$MODEL_DIR:$MODEL_DIR" + -v "$DATA_DIR:$DATA_DIR" -v "$HF_HOME_DIR:/hf_home" -v "$CHECKPOINT_DIR:$CHECKPOINT_DIR" -v /etc/passwd:/etc/passwd:ro @@ -107,7 +105,6 @@ docker_args=( -e HF_DATASETS_CACHE=/hf_home/datasets -e TRITON_CACHE_DIR=/tmp/triton_cache -e TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache - ) # Hardware resources are added only when they exist. @@ -128,7 +125,6 @@ for group in $DEVICE_GROUPS; do fi done - docker run "${docker_args[@]}" "$IMAGE" sleep infinity cleanup() { From 532be0ba107429e3caa35c7273f2db87a368dcbb Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 15:25:20 +0000 Subject: [PATCH 087/142] clean up script --- scripts/train_gptoss20b.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index f5518d1b..9d7f34d1 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -5,18 +5,18 @@ # NGPU=4 CONFIG=gpt_oss_debugmodel TRAINING_STEPS=20 \ # bash ~/ALTO/train_gptoss.sh - -######## Download the C4 dataset -######## make sure to update config_registry.py with appropriate data location +######## STEP 0: Install ALTO repository and update ALTO_DIR below +# git clone --recurse-submodules https://github.com/AMD-AGI/ALTO.git +######## STEP 1: Download the C4 dataset +######## make sure to update config_registry.py with appropriate data location ### OPTION 1: # # Create desired download directory with the right permission # cd /data/gpt_oss_20b # # Download training and validation data # bash <(curl -s https://raw.githubusercontent.com/mlcommons/r2-downloader/refs/heads/main/mlc-r2-downloader.sh) \ # -d data https://training.mlcommons-storage.org/metadata/llama-3-1-8b-preprocessed-c4-dataset.uri - ### OPTION 2: # C4_CACHE="$HF_HOME_SHARED/datasets/allenai___c4" # if [ -d "$C4_CACHE" ] && [ -n "$(ls -A "$C4_CACHE" 2>/dev/null)" ]; then @@ -45,12 +45,12 @@ DATA_DIR="${DATA_DIR:-/shared_rccl}" # exposte data directory into container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args -ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # The repository location +ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # The repository location for installing 3rdparty/torchtitan CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" - -### Other modifiable args RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time + +### Other modifiable args MODULE="${MODULE:-gpt_oss}" CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" From 2ba2e16b9035601a299375be84bc186e644ddf71 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 16:48:56 +0000 Subject: [PATCH 088/142] add faster docker cleanup after ctrl-c --- scripts/train_gptoss20b.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 9d7f34d1..3c2f0c58 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -128,10 +128,24 @@ done docker run "${docker_args[@]}" "$IMAGE" sleep infinity cleanup() { + status=$? + + # Prevent cleanup from being triggered recursively. + trap - EXIT INT TERM + + echo echo "[train] Stopping container $CONTAINER ..." - docker stop "$CONTAINER" >/dev/null 2>&1 || true + + docker stop --time 3 "$CONTAINER" >/dev/null 2>&1 || + docker kill "$CONTAINER" >/dev/null 2>&1 || + true + + exit "$status" } + trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # ----------------------------------------------------------------------------- # Model and dependencies From b155dcec7ea3a60c41d9a2ca40b11ea2198ae7c6 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 16:50:52 +0000 Subject: [PATCH 089/142] add more description to run script --- scripts/train_gptoss20b.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 3c2f0c58..a44a2f68 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -61,7 +61,7 @@ CONTAINER="${CONTAINER:-alto_gpt_oss_bf16}" # container name (for user readabili IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7.2-patch}" # ----------------------------------------------------------------------------- -# Setup +# Docker Setup # ----------------------------------------------------------------------------- mkdir -p \ @@ -125,8 +125,10 @@ for group in $DEVICE_GROUPS; do fi done +# start docker contrainer docker run "${docker_args[@]}" "$IMAGE" sleep infinity +# make sure docker is gracefully stopped quickly on exit cleanup() { status=$? @@ -148,7 +150,7 @@ trap 'exit 130' INT trap 'exit 143' TERM # ----------------------------------------------------------------------------- -# Model and dependencies +# Load model and install additional docker dependencies # ----------------------------------------------------------------------------- MODEL_DIR="${MODEL_DIR:-$HF_HOME_DIR/models/gpt-oss-20b}" echo "[model] Ensuring tokenizer is available at $MODEL_DIR ..." From 1a515919081ca042e011a10db498ceaeb3ee06c2 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 17:22:04 +0000 Subject: [PATCH 090/142] add notes to run script about ALTO dir --- scripts/train_gptoss20b.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index a44a2f68..b38ed07f 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -41,11 +41,12 @@ set -euo pipefail ### Machine-specific args NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location -DATA_DIR="${DATA_DIR:-/shared_rccl}" # exposte data directory into container +DATA_DIR="${DATA_DIR:-/shared_rccl}" # exposte data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args -ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # The repository location for installing 3rdparty/torchtitan +# *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct +ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # expose repo dir to container CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time From 9892cddec8ef7906009c67c0effce247ca0d22c0 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 19:52:37 +0000 Subject: [PATCH 091/142] add description on how to view loss curves --- scripts/train_gptoss20b.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index b38ed07f..684464e4 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -31,6 +31,14 @@ # \" # " # fi + +####### Viewing Loss Curves +# tensorboard events are saved in the checkpointing directory, one can +# view these by using the following command: +# tensorboard --logdir $CHECKPOINT_DIR --host 127.0.0.1 --port 6006 +# +# If running on remote machine, you will want to forward the port to the local machine: +# ssh -L 6006:localhost:6006 nfrumkin@useocpslog-002 set -euo pipefail @@ -46,7 +54,7 @@ HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args # *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct -ALTO_DIR="${ALTO_DIR:-$HOME/ALTO}" # expose repo dir to container +ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" # expose repo dir to container CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time @@ -199,8 +207,6 @@ docker exec \ --training.steps "$TRAINING_STEPS" \ --comm.init_timeout_seconds 1800 \ --hf_assets_path "$MODEL_DIR" \ - --checkpoint.interval 100 \ - --checkpoint.keep_latest_k 2 \ --dump_folder "$CHECKPOINT_DIR" \ --profiling.enable_profiling \ --profiling.profile_freq 1000 \ From 7aed68a0d04042cbbba328f92be72a20d8d3bdba Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 23:50:04 +0000 Subject: [PATCH 092/142] rename container and data dir --- scripts/train_gptoss20b.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 684464e4..d422baf7 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -49,7 +49,7 @@ set -euo pipefail ### Machine-specific args NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location -DATA_DIR="${DATA_DIR:-/shared_rccl}" # exposte data dir to container +DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args @@ -63,7 +63,7 @@ LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname MODULE="${MODULE:-gpt_oss}" CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" -CONTAINER="${CONTAINER:-alto_gpt_oss_bf16}" # container name (for user readability) +CONTAINER="${CONTAINER:-alto_gpt_oss}" # container name (for user readability) # default Docker image from Han Wang From 2d02ae35193dd9a62037772daf8f834b03191c24 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 23:51:01 +0000 Subject: [PATCH 093/142] rename container --- scripts/train_gptoss20b.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index d422baf7..b24addc3 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -63,7 +63,7 @@ LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname MODULE="${MODULE:-gpt_oss}" CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" -CONTAINER="${CONTAINER:-alto_gpt_oss}" # container name (for user readability) +CONTAINER="${CONTAINER:-alto}" # container name (for user readability) # default Docker image from Han Wang From 5dc525ec88d720119397ab51e026cfd814b4a241 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 24 Jul 2026 23:50:04 +0000 Subject: [PATCH 094/142] rename container and data dir --- scripts/train_gptoss20b.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index b24addc3..5d501d0e 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -50,6 +50,7 @@ set -euo pipefail NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container +DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args @@ -63,7 +64,7 @@ LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname MODULE="${MODULE:-gpt_oss}" CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" -CONTAINER="${CONTAINER:-alto}" # container name (for user readability) +CONTAINER="${CONTAINER:-$CONFIG}" # container name (for user readability) # default Docker image from Han Wang From 8ad51a3e0d15776307d779b2fb4e5baa59864991 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 13:04:11 +0000 Subject: [PATCH 095/142] fix printout so it's not always saying "bf16" --- scripts/train_gptoss20b.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 5d501d0e..72661d87 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run ALTO GPT-OSS 20B BF16 training on the current node. +# Run ALTO GPT-OSS 20B training on the current node. # # Example: # NGPU=4 CONFIG=gpt_oss_debugmodel TRAINING_STEPS=20 \ @@ -50,7 +50,6 @@ set -euo pipefail NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container -DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args @@ -79,7 +78,7 @@ mkdir -p \ "$CHECKPOINT_DIR" \ "$(dirname "$LOG_FILE")" -echo "=== ALTO GPT-OSS 20B BF16 baseline ===" +echo "=== ALTO GPT-OSS 20B ===" echo "Node: $(hostname)" echo "Image: $IMAGE" echo "Config: $CONFIG" From 3420846cc009a17042178db99c8aabf976a10741 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 13:19:31 +0000 Subject: [PATCH 096/142] add experiment plotting script --- scripts/plot_val_loss.py | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 scripts/plot_val_loss.py diff --git a/scripts/plot_val_loss.py b/scripts/plot_val_loss.py new file mode 100644 index 00000000..f2327095 --- /dev/null +++ b/scripts/plot_val_loss.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Plot step vs. validation loss from one or more slurm .out logs. + +Parses lines like: + ... validate step: 768 loss: 4.9572 memory: ... +(ANSI color codes are stripped before matching.) + +Alongside the plot, a table on the right lists each method's loss at every +step (union of all steps across files; blank where a method has no datapoint). + +Usage: + python3 plot_val_loss.py [ ...] [-o output.png] + +A custom legend/column name can be given per file with "path=label" syntax, e.g.: + python3 plot_val_loss.py run_a.out=baseline run_b.out="lr 3e-4" +Files given without "=label" fall back to their basename. +""" +import argparse +import os +import re +import sys +import textwrap + +import matplotlib.pyplot as plt + +# strip ANSI escape sequences, then pull step + loss +ANSI = re.compile(r"\x1b\[[0-9;]*m") +VAL = re.compile(r"validate step:\s*(\d+)\s+loss:\s*([\d.]+)") + + +def parse(path): + steps, losses = [], [] + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + m = VAL.search(ANSI.sub("", line)) + if m: + steps.append(int(m.group(1))) + losses.append(float(m.group(2))) + return steps, losses + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("logfiles", nargs="+", + help='one or more slurm .out logs; use "path=label" to set a custom legend name') + ap.add_argument("-o", "--output", help="output image path (default: val_loss.png)") + args = ap.parse_args() + + out = args.output or "val_loss.png" + + # figure: plot on the left, table on the right + fig, (ax, ax_tbl) = plt.subplots( + 1, 2, figsize=(15, 7), gridspec_kw={"width_ratios": [3, 1.4]} + ) + + labels = [] # column labels, in input order + loss_by_step = {} # label -> {step: loss} + colors = {} # label -> line color + total = 0 + + for entry in args.logfiles: + # allow "path=custom legend label"; only split on the first "=" + if "=" in entry: + path, label = entry.split("=", 1) + else: + path, label = entry, os.path.basename(entry) + + steps, losses = parse(path) + if not steps: + print(f"warning: no validation datapoints found in {path}", file=sys.stderr) + continue + total += len(steps) + + (line,) = ax.plot(steps, losses, marker="o", linewidth=1.5, label=label) + labels.append(label) + loss_by_step[label] = dict(zip(steps, losses)) + colors[label] = line.get_color() + + if total == 0: + sys.exit("No validation datapoints found in any input file") + + ax.set_xlabel("step") + ax.set_ylabel("validation loss") + ax.set_title("Validation loss on GPT-OSS 20B") + ax.grid(True, alpha=0.3) + ax.legend() + + # --- table of losses per step for each method --- + all_steps = sorted({s for d in loss_by_step.values() for s in d}) + # wrap long method names so they don't overflow their table column + wrapped = ["\n".join(textwrap.wrap(lab, width=14)) or lab for lab in labels] + col_labels = ["step"] + wrapped + cell_text = [] + for s in all_steps: + row = [str(s)] + for lab in labels: + v = loss_by_step[lab].get(s) + row.append(f"{v:.4f}" if v is not None else "") + cell_text.append(row) + + ax_tbl.axis("off") + table = ax_tbl.table( + cellText=cell_text, + colLabels=col_labels, + cellLoc="center", + loc="center", + ) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1, 1.3) + + # give the header row enough height for the tallest wrapped label + max_lines = max(lbl.count("\n") + 1 for lbl in col_labels) + base_h = table[0, 0].get_height() + # color the header cells to match each method's line + for c, (raw, lab) in enumerate(zip(["step"] + labels, col_labels)): + cell = table[0, c] + cell.set_height(base_h * max_lines) + cell.set_text_props(weight="bold") + if raw in colors: + cell.set_facecolor(colors[raw]) + cell.set_text_props(weight="bold", color="white") + + fig.tight_layout() + fig.savefig(out, dpi=150) + print(f"Wrote {out} ({total} validation datapoints across {len(args.logfiles)} file(s))") + + +if __name__ == "__main__": + main() From 447a4064695f6a965085e1df1999e77477da07af Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 17:54:18 +0000 Subject: [PATCH 097/142] make sure node's python env is not leaked to container, make checkpointing dir unique for each run --- scripts/train_gptoss20b.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 72661d87..f98d40f2 100644 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -55,13 +55,13 @@ HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args # *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" # expose repo dir to container -CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/gpt_oss_20b-pretrain-bf16}" +CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/$CONFIG_$RUN_ID}" LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time ### Other modifiable args MODULE="${MODULE:-gpt_oss}" -CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" CONTAINER="${CONTAINER:-$CONFIG}" # container name (for user readability) @@ -114,6 +114,7 @@ docker_args=( -e HF_DATASETS_CACHE=/hf_home/datasets -e TRITON_CACHE_DIR=/tmp/triton_cache -e TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache + -e PYTHONNOUSERSITE=1 ) # Hardware resources are added only when they exist. From 478b632a67ab252c273648d3c2d68e1016deca1e Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 18:00:18 +0000 Subject: [PATCH 098/142] update plotting structure and add training grad norm & loss --- plotting/lpt.toml | 24 ++++ plotting/plot_training_stats.py | 248 ++++++++++++++++++++++++++++++++ scripts/plot_val_loss.py | 130 ----------------- 3 files changed, 272 insertions(+), 130 deletions(-) create mode 100644 plotting/lpt.toml create mode 100644 plotting/plot_training_stats.py delete mode 100644 scripts/plot_val_loss.py diff --git a/plotting/lpt.toml b/plotting/lpt.toml new file mode 100644 index 00000000..56334f35 --- /dev/null +++ b/plotting/lpt.toml @@ -0,0 +1,24 @@ +# Example config for plot_val_loss.py +# python3 plot_val_loss.py -c runs.example.toml +# +# `output` and `title` are optional (both overridable with -o / -t on the CLI). +# Relative `file` paths are resolved from THIS config file's directory. + +output = "plotting/plots/lpt.png" +title = "Low-Precision Training on GPT-OSS 20b" + +[[runs]] +file = "../slurm-207989.out" +name = "bf16" + +[[runs]] +file = "../slurm-207639.out" +name = "lpt_recipe" + +[[runs]] +file = "../slurm-208076.out" +name = "midmax" + +[[runs]] +file = "../slurm-208070.out" +name = "deoscillation" \ No newline at end of file diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py new file mode 100644 index 00000000..25a0793d --- /dev/null +++ b/plotting/plot_training_stats.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Plot step vs. validation loss from one or more slurm .out logs. + +Parses lines like: + ... validate step: 768 loss: 4.9572 memory: ... +(ANSI color codes are stripped before matching.) + +Alongside the plot, a table on the right lists each method's loss at every +step (union of all steps across files; blank where a method has no datapoint). + +Usage: + # inline on the command line + python3 plot_training_stats.py [ ...] [-o output.png] + # or from a .toml config + python3 plot_training_stats.py -c runs.toml + +A custom legend/column name can be given per file with "path=label" syntax, e.g.: + python3 plot_training_stats.py run_a.out=baseline run_b.out="lr 3e-4" +Files given without "=label" fall back to their basename. + +TOML config format (see runs.example.toml): + output = "val_loss.png" # optional, overridden by -o + title = "Validation loss vs. step" # optional + + [[runs]] + file = "slurm-207639.out" + name = "baseline" + + [[runs]] + file = "slurm-207504.out" + name = "lr 3e-4" +""" +import argparse +import os +import re +import sys +import textwrap + +try: + import tomllib # Python >= 3.11 +except ModuleNotFoundError: # Python <= 3.10 + try: + import tomli as tomllib + except ModuleNotFoundError: + tomllib = None + +import matplotlib +matplotlib.use("Agg") # save-to-file only; avoids loading/blocking on a GUI backend +import matplotlib.pyplot as plt + +plt.style.use("dark_background") + +# strip ANSI escape sequences, then pull step + loss +ANSI = re.compile(r"\x1b\[[0-9;]*m") +VAL = re.compile(r"validate step:\s*(\d+)\s+loss:\s*([\d.]+)") +# training lines look like "step: 2 loss: 12.68598 grad_norm: 1.3970 ..." +# (exclude "validate step:" via a negative lookbehind) +TRAIN = re.compile(r"(? {val_step: val_loss} (table data) + colors = {} # label -> line color + total_val = 0 # validation datapoints (table) + + for path, label in runs: + if not os.path.exists(path): + # keep a placeholder so the run still shows in the legend + table + print(f"warning: file not found, showing empty entry: {path}", file=sys.stderr) + (line,) = ax.plot([], [], linewidth=1.2, label=f"{label} (missing)") + ax_grad.plot([], [], linewidth=1.2, color=line.get_color()) + labels.append(label) + loss_by_step[label] = {} + colors[label] = line.get_color() + continue + + tr_steps, tr_losses, grad_norms, val_steps, val_losses = parse(path) + + # curves plot TRAINING loss (no per-point marker — too dense) + (line,) = ax.plot(tr_steps, tr_losses, linewidth=1.2, label=label) + color = line.get_color() + + # grad-norm curve below, in the matching color (skip steps w/o grad_norm) + gsteps = [s for s, g in zip(tr_steps, grad_norms) if g is not None] + gvals = [g for g in grad_norms if g is not None] + ax_grad.plot(gsteps, gvals, linewidth=1.2, color=color, label=label) + + labels.append(label) + loss_by_step[label] = dict(zip(val_steps, val_losses)) + colors[label] = color + total_val += len(val_steps) + + if not labels: + sys.exit("No datapoints found in any input file") + + ax.set_xlabel("step") + ax.set_ylabel("training loss") + ax.set_title("Training Loss", fontweight="bold") + ax.grid(True, alpha=0.3) + ax.legend() + + ax_grad.set_xlabel("step") + ax_grad.set_ylabel("grad norm") + ax_grad.set_title("Gradient Norm", fontweight="bold") + ax_grad.grid(True, alpha=0.3) + ax_grad.sharex(ax) + + # --- table of VALIDATION losses per step for each method --- + all_steps = sorted({s for d in loss_by_step.values() for s in d}) + # wrap long method names so they don't overflow their table column + wrapped = ["\n".join(textwrap.wrap(lab, width=14)) or lab for lab in labels] + col_labels = ["step"] + wrapped + cell_text = [] + for s in all_steps: + row = [str(s)] + for lab in labels: + v = loss_by_step[lab].get(s) + row.append(f"{v:.4f}" if v is not None else "") + cell_text.append(row) + + ax_tbl.axis("off") + ax_tbl.set_title("Validation Loss", fontweight="bold") + table = ax_tbl.table( + cellText=cell_text, + colLabels=col_labels, + cellLoc="center", + loc="center", + ) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1, 1.3) + + # dark theme: transparent cells with light-gray borders + white text + for cell in table.get_celld().values(): + cell.set_edgecolor("gray") + cell.set_facecolor("none") + cell.set_text_props(color="white") + + # give the header row enough height for the tallest wrapped label + max_lines = max(lbl.count("\n") + 1 for lbl in col_labels) + base_h = table[0, 0].get_height() + # color the header cells to match each method's line + for c, (raw, lab) in enumerate(zip(["step"] + labels, col_labels)): + cell = table[0, c] + cell.set_height(base_h * max_lines) + cell.set_text_props(weight="bold") + if raw in colors: + # method line colors are light pastels on the dark theme, + # so black header text reads better than white + cell.set_facecolor(colors[raw]) + cell.set_text_props(weight="bold", color="black") + + # figure-level supertitle above both the plot subtitle and the table title + fig.suptitle(suptitle, fontsize=16, fontweight="bold") + fig.tight_layout(rect=(0, 0, 1, 0.96)) # leave room for the suptitle + fig.savefig(out, dpi=150) + print(f"Wrote {out} ({total_val} validation datapoints in table across {len(runs)} file(s))") + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_val_loss.py b/scripts/plot_val_loss.py deleted file mode 100644 index f2327095..00000000 --- a/scripts/plot_val_loss.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Plot step vs. validation loss from one or more slurm .out logs. - -Parses lines like: - ... validate step: 768 loss: 4.9572 memory: ... -(ANSI color codes are stripped before matching.) - -Alongside the plot, a table on the right lists each method's loss at every -step (union of all steps across files; blank where a method has no datapoint). - -Usage: - python3 plot_val_loss.py [ ...] [-o output.png] - -A custom legend/column name can be given per file with "path=label" syntax, e.g.: - python3 plot_val_loss.py run_a.out=baseline run_b.out="lr 3e-4" -Files given without "=label" fall back to their basename. -""" -import argparse -import os -import re -import sys -import textwrap - -import matplotlib.pyplot as plt - -# strip ANSI escape sequences, then pull step + loss -ANSI = re.compile(r"\x1b\[[0-9;]*m") -VAL = re.compile(r"validate step:\s*(\d+)\s+loss:\s*([\d.]+)") - - -def parse(path): - steps, losses = [], [] - with open(path, encoding="utf-8", errors="replace") as f: - for line in f: - m = VAL.search(ANSI.sub("", line)) - if m: - steps.append(int(m.group(1))) - losses.append(float(m.group(2))) - return steps, losses - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("logfiles", nargs="+", - help='one or more slurm .out logs; use "path=label" to set a custom legend name') - ap.add_argument("-o", "--output", help="output image path (default: val_loss.png)") - args = ap.parse_args() - - out = args.output or "val_loss.png" - - # figure: plot on the left, table on the right - fig, (ax, ax_tbl) = plt.subplots( - 1, 2, figsize=(15, 7), gridspec_kw={"width_ratios": [3, 1.4]} - ) - - labels = [] # column labels, in input order - loss_by_step = {} # label -> {step: loss} - colors = {} # label -> line color - total = 0 - - for entry in args.logfiles: - # allow "path=custom legend label"; only split on the first "=" - if "=" in entry: - path, label = entry.split("=", 1) - else: - path, label = entry, os.path.basename(entry) - - steps, losses = parse(path) - if not steps: - print(f"warning: no validation datapoints found in {path}", file=sys.stderr) - continue - total += len(steps) - - (line,) = ax.plot(steps, losses, marker="o", linewidth=1.5, label=label) - labels.append(label) - loss_by_step[label] = dict(zip(steps, losses)) - colors[label] = line.get_color() - - if total == 0: - sys.exit("No validation datapoints found in any input file") - - ax.set_xlabel("step") - ax.set_ylabel("validation loss") - ax.set_title("Validation loss on GPT-OSS 20B") - ax.grid(True, alpha=0.3) - ax.legend() - - # --- table of losses per step for each method --- - all_steps = sorted({s for d in loss_by_step.values() for s in d}) - # wrap long method names so they don't overflow their table column - wrapped = ["\n".join(textwrap.wrap(lab, width=14)) or lab for lab in labels] - col_labels = ["step"] + wrapped - cell_text = [] - for s in all_steps: - row = [str(s)] - for lab in labels: - v = loss_by_step[lab].get(s) - row.append(f"{v:.4f}" if v is not None else "") - cell_text.append(row) - - ax_tbl.axis("off") - table = ax_tbl.table( - cellText=cell_text, - colLabels=col_labels, - cellLoc="center", - loc="center", - ) - table.auto_set_font_size(False) - table.set_fontsize(8) - table.scale(1, 1.3) - - # give the header row enough height for the tallest wrapped label - max_lines = max(lbl.count("\n") + 1 for lbl in col_labels) - base_h = table[0, 0].get_height() - # color the header cells to match each method's line - for c, (raw, lab) in enumerate(zip(["step"] + labels, col_labels)): - cell = table[0, c] - cell.set_height(base_h * max_lines) - cell.set_text_props(weight="bold") - if raw in colors: - cell.set_facecolor(colors[raw]) - cell.set_text_props(weight="bold", color="white") - - fig.tight_layout() - fig.savefig(out, dpi=150) - print(f"Wrote {out} ({total} validation datapoints across {len(args.logfiles)} file(s))") - - -if __name__ == "__main__": - main() From 72edc28daffda35883a30365138ff897e814f7e1 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 18:06:13 +0000 Subject: [PATCH 099/142] add M+Adam implementation and configs for M+Adam + Deosc --- alto/components/__init__.py | 3 + alto/components/m_adam.py | 440 ++++++++++++++++++ alto/models/gpt_oss/config_registry.py | 85 +++- .../gpt_oss/configs/lpt_recipe_deosc.yaml | 28 ++ .../gpt_oss/configs/lpt_recipe_midmax.yaml | 19 +- 5 files changed, 551 insertions(+), 24 deletions(-) create mode 100644 alto/components/m_adam.py create mode 100644 alto/models/gpt_oss/configs/lpt_recipe_deosc.yaml diff --git a/alto/components/__init__.py b/alto/components/__init__.py index eb0ad187..96870f51 100644 --- a/alto/components/__init__.py +++ b/alto/components/__init__.py @@ -3,12 +3,15 @@ # SPDX-License-Identifier: MIT from .converter import ModelOptConverter +from .m_adam import MAdamOptimizersContainer, m_adam from .optimizer import DeOscillationConfig, enable_de_oscillation from .state_dict_adapter_mixin import StateDictAdapterMixin __all__ = [ "DeOscillationConfig", "enable_de_oscillation", + "MAdamOptimizersContainer", + "m_adam", "ModelOptConverter", "StateDictAdapterMixin", ] diff --git a/alto/components/m_adam.py b/alto/components/m_adam.py new file mode 100644 index 00000000..06f8df11 --- /dev/null +++ b/alto/components/m_adam.py @@ -0,0 +1,440 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +"""``m_adam``: a hybrid additive/multiplicative optimizer and its +torchtitan :class:`OptimizersContainer` wiring. + +``m_adam`` decomposes every weight via ``torch.frexp`` into a mantissa and +an exponent (``w = m * 2**e``) and applies two coupled updates each step: + +* an **AdamW** (additive) update on the mantissa, and +* a **Madam-style** (multiplicative, RMSProp-normalized) update on the + exponent -- i.e. gradient descent on the log2-magnitude. + +Net effect (with ``weight_decay_e == 0``):: + + w_new ~= w * 2**(delta_e) + delta_w_AdamW + \\_ multiplicative (Madam) \\_ additive (AdamW) + +This explicit mantissa/exponent split lets the exponent (dynamic range) +and mantissa (precision) be controlled with independent learning rates, +weight decays, and -- for the exponent -- an independent schedule, which +is why it is a natural fit for low-precision / quantization-aware training. + +The :class:`MAdamOptimizersContainer` adapts ``m_adam`` to torchtitan's +``config.optimizer.build(...)`` path so it can be selected from a model's +``config_registry`` just like the built-in ``Adam``/``AdamW``. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Iterable, Optional + +import torch +import torch.nn as nn +from torch.optim.optimizer import Optimizer +from torchtitan.components.optimizer import OptimizersContainer + +__all__ = [ + "m_adam", + "MAdamOptimizersContainer", +] + + +def _rms(x: torch.Tensor) -> torch.Tensor: + return x.pow(2).mean().sqrt() + + +def _sched_value( + mode: str, + *, + base_lr: float, + t: int, + total_steps: Optional[int], + warmup_steps: int, + min_lr_ratio: float, + logcosine_alpha: float, +) -> float: + if warmup_steps > 0 and t < warmup_steps: + return base_lr * (float(t) / float(max(1, warmup_steps))) + if total_steps is None or mode == "constant": + return base_lr + + T = max(1, total_steps - max(0, warmup_steps)) + p = min(1.0, max(0.0, (t - warmup_steps) / T)) + rmin = float(min_lr_ratio) + + if mode == "linear": + return base_lr * (rmin + (1.0 - rmin) * (1.0 - p)) + if mode == "cosine": + c = 0.5 * (1.0 + math.cos(math.pi * p)) + return base_lr * (rmin + (1.0 - rmin) * c) + if mode == "logcosine": + a = float(logcosine_alpha) + g = math.exp(-a * (1.0 - math.cos(math.pi * p))) + g1 = math.exp(-2.0 * a) + s = (g - g1) / (1.0 - g1 + 1e-12) + return base_lr * (rmin + (1.0 - rmin) * s) + + return base_lr + + +class m_adam(Optimizer): + def __init__( + self, + params: Iterable[torch.nn.Parameter], + lr_m: float = 1e-3, + lr_e: float = 1e-2, + beta1: float = 0.9, + beta2: float = 0.999, + eps: float = 1e-8, + p_scale: float = 3.0, + g_bound: float = 20.0, + *, + weight_decay_m: float = 0.0, + weight_decay_e: float = 0.0, + abs_clamp: bool = False, + abs_clamp_floor: float = 0.0, + clip_e_final: bool = False, + e_final_min: float = -60.0, + e_final_max: float = 60.0, + use_de_step_cap: bool = True, + de_step_cap: float = 0.5, + tie_e_to_m: bool = False, + sched_e: str = "constant", + total_steps_e: Optional[int] = None, + warmup_steps_e: int = 0, + min_lr_ratio_e: float = 0.0, + logcosine_alpha_e: float = 6.0, + ): + if not (0.0 <= lr_m and 0.0 <= lr_e): + raise ValueError("Learning rates must be non-negative.") + if not (0.0 <= beta1 < 1.0 and 0.0 <= beta2 < 1.0): + raise ValueError("betas must be in [0,1).") + + ratio_e_init = float(lr_e) / max(1e-20, float(lr_m)) + + defaults = dict( + lr=lr_m, + beta1=beta1, + beta2=beta2, + eps=eps, + p_scale=p_scale, + g_bound=g_bound, + weight_decay_m=weight_decay_m, + weight_decay_e=weight_decay_e, + abs_clamp=abs_clamp, + abs_clamp_floor=abs_clamp_floor, + clip_e_final=clip_e_final, + e_final_min=e_final_min, + e_final_max=e_final_max, + use_de_step_cap=use_de_step_cap, + de_step_cap=de_step_cap, + tie_e_to_m=tie_e_to_m, + ratio_e_init=ratio_e_init, + sched_e=sched_e, + total_steps_e=total_steps_e, + warmup_steps_e=warmup_steps_e, + min_lr_ratio_e=min_lr_ratio_e, + lr_e_base=float(lr_e), + logcosine_alpha_e=float(logcosine_alpha_e), + t=0, + last_lr_e=float(lr_e), + last_lr_m=float(lr_m), + ) + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure: Optional[callable] = None): + loss = closure() if closure is not None else None + + for grp in self.param_groups: + t = int(grp.get("t", 0)) + + lr_m = float(grp["lr"]) + grp["last_lr_m"] = lr_m + + if grp["tie_e_to_m"]: + lr_e = lr_m * float(grp["ratio_e_init"]) + else: + lr_e = _sched_value( + mode=grp["sched_e"], + base_lr=float(grp["lr_e_base"]), + t=t, + total_steps=grp["total_steps_e"], + warmup_steps=int(grp["warmup_steps_e"]), + min_lr_ratio=float(grp["min_lr_ratio_e"]), + logcosine_alpha=float(grp["logcosine_alpha_e"]), + ) + + grp["last_lr_e"] = float(lr_e) + + b1 = float(grp["beta1"]) + b2 = float(grp["beta2"]) + eps = float(grp["eps"]) + gmax = float(grp["g_bound"]) + + wd_m = float(grp["weight_decay_m"]) + wd_e = float(grp["weight_decay_e"]) + + clamp_w = bool(grp["abs_clamp"]) + w_floor0 = float(grp["abs_clamp_floor"]) + + clip_e = bool(grp["clip_e_final"]) + emin = float(grp["e_final_min"]) + emax = float(grp["e_final_max"]) + + cap_de = bool(grp["use_de_step_cap"]) + de_cap = float(grp["de_step_cap"]) + + for p in grp["params"]: + if p.grad is None: + continue + + dt = p.data.dtype + w = p.data + g = ( + p.grad.data.to(dt) + if p.grad.data.dtype != dt + else p.grad.data + ) + + st = self.state[p] + + if not st: + st["step_m"] = 0 + st["step_e"] = 0 + st["w_exp_avg"] = torch.zeros_like(w, dtype=dt) + st["w_exp_avg_sq"] = torch.zeros_like(w, dtype=dt) + st["exp_avg_sq"] = torch.zeros_like(w, dtype=dt) + + init = _rms(w.float()).item() + st["max"] = max( + grp["p_scale"] * (init + 1e-12), + w_floor0, + ) + + mw = st["w_exp_avg"] + vw = st["w_exp_avg_sq"] + ve = st["exp_avg_sq"] + + wf = w.float() + gw = g.float() + + m, e = torch.frexp(wf) + e_use = ( + torch.clamp(e, min=emin, max=emax) + if clip_e + else e + ) + + st["step_e"] += 1 + se = st["step_e"] + + e_stats = torch.clamp( + e_use, + min=-60.0, + max=60.0, + ).to(wf.dtype) + + w_cur = m * torch.exp2(e_stats) + ge = gw * w_cur * math.log(2.0) + + ve_f = ve.float() + ge_clip = ge.clamp(-1e19, 1e19) + + ve_f.mul_(b2).addcmul_( + ge_clip, + ge_clip, + value=1.0 - b2, + ) + + den = ( + ve_f / (1.0 - b2**se) + ).sqrt_().clamp_(min=eps) + + ge_n = (ge / den).clamp_(-gmax, gmax) + dw_e = -lr_e * ge_n + + w_floor = max( + 1e-8, + float(_rms(wf)) * 1e-6, + ) + + dabs = w_cur.abs().clamp_min(w_floor) + + r = (dw_e / dabs).clamp( + min=-0.75 + 1e-6, + max=0.75, + ) + + de = torch.log1p(r) / math.log(2.0) + + if cap_de: + de = de.clamp( + min=-de_cap, + max=de_cap, + ) + + e_new = ( + e_use.to(wf.dtype) * (1.0 - lr_e * wd_e) + + de + ) + + if clip_e: + e_new = torch.clamp( + e_new, + min=emin, + max=emax, + ) + + ve.copy_(ve_f.to(dt)) + + st["step_m"] += 1 + sm = st["step_m"] + + exp_scale = torch.exp2(-e_use.to(wf.dtype)) + gm = gw * torch.exp2(e_use.to(wf.dtype)) + gm_dt = gm.to(dt) + gm_scaled = gm_dt * exp_scale + + mw.mul_(b1).add_( + gm_scaled, + alpha=1.0 - b1, + ) + + vw.mul_(b2).addcmul_( + gm_scaled, + gm_scaled, + value=1.0 - b2, + ) + + mh = mw.float() / (1.0 - b1**sm) + vh = vw.float() / (1.0 - b2**sm) + + dw = -lr_m * mh / (vh.sqrt() + eps) + + if wd_m != 0.0: + w_adamw = ( + wf * (1.0 - lr_m * wd_m) + + dw + ) + else: + w_adamw = wf + dw + + d_w = w_adamw - wf + d_m = d_w * torch.exp2(-e_new) + m_new = m + d_m + w_new = m_new * torch.exp2(e_new) + + if clamp_w: + w_new.clamp_( + -st["max"], + st["max"], + ) + + w.copy_(w_new.to(dt)) + + grp["t"] = t + 1 + + return loss + + +class MAdamOptimizersContainer(OptimizersContainer): + """:class:`OptimizersContainer` that builds :class:`m_adam`. + + Selectable from a model ``config_registry`` via:: + + config.optimizer = MAdamOptimizersContainer.Config(lr=1e-3, lr_e=1e-2) + + The standard ``lr`` field maps to ``m_adam``'s ``lr_m`` (the additive + AdamW branch), so the usual torchtitan LR scheduler drives ``lr_m`` + for free; the exponent learning rate ``lr_e`` is scheduled + independently via ``sched_e`` (or tied to ``lr_m`` with + ``tie_e_to_m``). + + Note: the inherited ``weight_decay`` and ``implementation`` fields are + unused -- ``m_adam`` has decoupled ``weight_decay_m`` / ``weight_decay_e`` + and does not support the fused/foreach implementations. + """ + + @dataclass(kw_only=True, slots=True) + class Config(OptimizersContainer.Config): + name: str = "m_adam" + lr: float = 1e-3 + """Learning rate for the additive (AdamW / mantissa) branch (m_adam ``lr_m``).""" + + lr_e: float = 1e-2 + """Learning rate for the multiplicative (Madam / exponent) branch.""" + + beta1: float = 0.9 + beta2: float = 0.999 + eps: float = 1e-8 + + p_scale: float = 3.0 + """Magnitude-clamp bound as a multiple of the initial weight RMS (used only when ``abs_clamp``).""" + + g_bound: float = 20.0 + """Clamp on the RMS-normalized exponent gradient.""" + + weight_decay_m: float = 0.0 + weight_decay_e: float = 0.0 + + abs_clamp: bool = False + """Clamp weights to +/- p_scale * rms(init) (the original Madam safety net; off by default).""" + abs_clamp_floor: float = 0.0 + + clip_e_final: bool = False + e_final_min: float = -60.0 + e_final_max: float = 60.0 + + use_de_step_cap: bool = True + de_step_cap: float = 0.5 + + tie_e_to_m: bool = False + """If set, lr_e = lr_m * (lr_e / lr_m at init), so lr_e tracks the lr_m schedule.""" + + sched_e: str = "constant" + """Schedule for lr_e: 'constant' | 'linear' | 'cosine' | 'logcosine'.""" + total_steps_e: Optional[int] = None + warmup_steps_e: int = 0 + min_lr_ratio_e: float = 0.0 + logcosine_alpha_e: float = 6.0 + + @staticmethod + def _resolve_optimizer_cls(name: str) -> type: + if name != "m_adam": + raise NotImplementedError( + f"MAdamOptimizersContainer only builds 'm_adam', got {name!r}." + ) + return m_adam + + @staticmethod + def _build_optimizer_kwargs(config: "MAdamOptimizersContainer.Config") -> dict[str, Any]: + return { + "lr_m": config.lr, + "lr_e": config.lr_e, + "beta1": config.beta1, + "beta2": config.beta2, + "eps": config.eps, + "p_scale": config.p_scale, + "g_bound": config.g_bound, + "weight_decay_m": config.weight_decay_m, + "weight_decay_e": config.weight_decay_e, + "abs_clamp": config.abs_clamp, + "abs_clamp_floor": config.abs_clamp_floor, + "clip_e_final": config.clip_e_final, + "e_final_min": config.e_final_min, + "e_final_max": config.e_final_max, + "use_de_step_cap": config.use_de_step_cap, + "de_step_cap": config.de_step_cap, + "tie_e_to_m": config.tie_e_to_m, + "sched_e": config.sched_e, + "total_steps_e": config.total_steps_e, + "warmup_steps_e": config.warmup_steps_e, + "min_lr_ratio_e": config.min_lr_ratio_e, + "logcosine_alpha_e": config.logcosine_alpha_e, + } diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index f684a7c7..f8b87926 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -10,17 +10,20 @@ ) from alto.components.converter import ModelOptConverter +from alto.components.m_adam import MAdamOptimizersContainer __all__ = [ "gpt_oss_debugmodel", "gpt_oss_debugmodel_lpt", "gpt_oss_20b", "gpt_oss_20b_pretrain", - "gpt_oss_20b_lpt", - "gpt_oss_20b_lpt_c4", - "gpt_oss_20b_lpt_c4_midmax", - "gpt_oss_20b_lpt_c4_lowrank", "gpt_oss_20b_pretrain_c4", + "gpt_oss_20b_lpt", + "gpt_oss_20b_lpt_midmax", + "gpt_oss_20b_lpt_lowrank", + "gpt_oss_20b_lpt_deosc", + "gpt_oss_lpt_madam", + "gpt_oss_lpt_madam_stable", ] @@ -44,6 +47,7 @@ def gpt_oss_debugmodel_lpt() -> Trainer.Config: return config + def gpt_oss_20b() -> Trainer.Config: config = gpt_oss_20b_orig() config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" @@ -115,14 +119,19 @@ def gpt_oss_20b_pretrain_c4() -> Trainer.Config: config = gpt_oss_20b_pretrain() config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" config.dataloader.dataset = "megatron" - config.dataloader.dataset_path = "/shared_rccl/nfrumkin/data/c4-train.en_6_text_document.idx" + config.dataloader.dataset_path = "/shared_inference/alirezak/hf_home/data/c4-train.en_6_text_document.idx" config.validator.dataloader.dataset = "megatron" - config.validator.dataloader.dataset_path = "/shared_rccl/nfrumkin/data/c4-validation-91205-samples.en_text_document.idx" - config.checkpoint.interval = 1000 + config.validator.dataloader.dataset_path = "/shared_inference/alirezak/hf_home/data/c4-validation-91205-samples.en_text_document.idx" + config.checkpoint.enable = True + config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint + config.checkpoint.initial_load_in_hf = False + config.checkpoint.initial_load_in_hf_quantized = False + config.checkpoint.interval = 1000 # Save at step interval + config.checkpoint.last_save_model_only = False # save full ckpt at final step (model+optim+dataloader) so training can resume return config def gpt_oss_20b_lpt() -> Trainer.Config: - config = gpt_oss_20b_pretrain() + config = gpt_oss_20b_pretrain_c4() config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), @@ -130,22 +139,56 @@ def gpt_oss_20b_lpt() -> Trainer.Config: return config -def gpt_oss_20b_lpt_c4() -> Trainer.Config: - """gpt_oss_20b_lpt using HuggingFace C4 dataset (no Megatron binary files required).""" +def gpt_oss_lpt_madam() -> Trainer.Config: + """Debug-model smoke test for the custom ``m_adam`` optimizer. + + ``lr`` maps to m_adam's additive (AdamW) branch ``lr_m`` and is driven + by the usual LR scheduler; ``lr_e`` is the multiplicative (exponent) + branch learning rate. + """ config = gpt_oss_20b_lpt() - config.dataloader.dataset = "c4" - config.dataloader.dataset_path = None - config.validator.dataloader.dataset = "c4_validation" - config.validator.dataloader.dataset_path = None - config.checkpoint.initial_load_in_hf = True - config.checkpoint.initial_load_in_hf_quantized = True - config.checkpoint.interval = 100 + config.optimizer = MAdamOptimizersContainer.Config( + lr=1e-4, + lr_e=1e-4, + tie_e_to_m=True, + beta1=0.9, + beta2=0.95, + eps=1e-5, + weight_decay_m=0.1, + ) + return config + + +def gpt_oss_lpt_madam_stable() -> Trainer.Config: + """``gpt_oss_lpt_madam`` with a damped exponent branch. + + Same baseline-matched additive branch as ``gpt_oss_lpt_madam``, but the + multiplicative (exponent) branch is turned down to suppress the loss + spikes seen when it runs as loud as the mantissa branch: + + * ``lr_e`` dropped to 0.3x ``lr_m`` so the additive branch leads and the + exponent only fine-tunes magnitudes, and + * ``de_step_cap`` tightened from 0.5 to 0.2 (max per-step magnitude change + ~2**0.2 ~= 1.15x instead of ~1.41x). + """ + config = gpt_oss_lpt_madam() + config.optimizer.lr_e = 3e-5 + config.optimizer.de_step_cap = 0.2 return config -def gpt_oss_20b_lpt_c4_midmax() -> Trainer.Config: +def gpt_oss_20b_lpt_deosc() -> Trainer.Config: + """weight deoscillation config.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-deosc" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_deosc.yaml",), + ],) + return config + +def gpt_oss_20b_lpt_midmax() -> Trainer.Config: """gpt_oss_20b_lpt_c4 with midmax scale selection for MXFP4 quantization.""" - config = gpt_oss_20b_lpt_c4() + config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-midmax-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml",), @@ -153,9 +196,9 @@ def gpt_oss_20b_lpt_c4_midmax() -> Trainer.Config: return config -def gpt_oss_20b_lpt_c4_lowrank() -> Trainer.Config: +def gpt_oss_20b_lpt_lowrank() -> Trainer.Config: """gpt_oss_20b_lpt_c4 with low-rank (lora_rank=32) correction for MXFP4 quantization.""" - config = gpt_oss_20b_lpt_c4() + config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-lowrank-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_lowrank.yaml",), diff --git a/alto/models/gpt_oss/configs/lpt_recipe_deosc.yaml b/alto/models/gpt_oss/configs/lpt_recipe_deosc.yaml new file mode 100644 index 00000000..56605f78 --- /dev/null +++ b/alto/models/gpt_oss/configs/lpt_recipe_deosc.yaml @@ -0,0 +1,28 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + # targets: ["Linear"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + + # differential gradient estimation, disabled by default + use_dge: false + + # choices: none, static, dynamic + clip_mode: none + + # 2-level scaling, disabled by default + # NVFP4 requires tensorwise scaling or you will get overflow issues + two_level_scaling: none + + # weight de-oscillation, disabled by default + deosc_step: 2000 + deosc_period: 200 + deosc_ratio: 4.0 diff --git a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml index 378f853e..eab85517 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml @@ -4,12 +4,25 @@ training_stage: scheme: "mxfp4" targets: ["Linear", "GptOssGroupedExperts"] # targets: ["Linear"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] use_2dblock_x: false - use_2dblock_w: false - use_hadamard: false - use_sr_grad: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + + # differential gradient estimation, disabled by default use_dge: false + + # choices: none, static, dynamic clip_mode: none + + # 2-level scaling, disabled by default + # NVFP4 requires tensorwise scaling or you will get overflow issues two_level_scaling: none use_midmax: true + # weight de-oscillation, disabled by default + # deosc_step: 2000 + # deosc_period: 200 + # deosc_ratio: 4.0 From c454c3d5b9e2e2041029bd85079db04607750449 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 18:06:45 +0000 Subject: [PATCH 100/142] add checkpointing files to .gitignore --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index 74a24a16..d66cee24 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,14 @@ __marimo__/ datasets/ /models/ comm_traces/ + + +# Slurm +*.out + +# Checkpoints +*.distcp +gptoss_chkpt/ +plotting/plots + +*.safetensors From 9bd02832fc16b5c108d64993efb34bed434192af Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 28 Jul 2026 18:08:03 +0000 Subject: [PATCH 101/142] Ali's checkpointing fix --- alto/train.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/alto/train.py b/alto/train.py index 6babc881..127dc7ec 100644 --- a/alto/train.py +++ b/alto/train.py @@ -85,6 +85,11 @@ class Trainer(ForgeTrainer): def __init__(self, config: TitanTrainer.Config): super().__init__(config) + # The Forge engine builds the checkpointer with dataloader=None + self.checkpointer.states["dataloader"] = self.dataloader + + self.ntokens_seen = 0 + self.training_mode = True self.enable_data_cache = False @@ -110,6 +115,22 @@ def __init__(self, config: TitanTrainer.Config): logger.info("data replay buffer disabled") self.enable_data_cache = False + def state_dict(self) -> dict[str, Any]: + sd = super().state_dict() + sd["ntokens_seen"] = self.ntokens_seen + return sd + + def load_state_dict(self, state_dict: dict[str, Any]): + super().load_state_dict(state_dict) + self.ntokens_seen = state_dict.get("ntokens_seen", 0) + + def batch_generator( + self, data_iterable: Iterable[tuple[dict[str, torch.Tensor], torch.Tensor]] + ) -> Iterable[tuple[dict[str, torch.Tensor], torch.Tensor]]: + for input_dict, labels in super().batch_generator(data_iterable): + self.ntokens_seen += labels.numel() + yield input_dict, labels + def cache_input(self, microbatches: list[tuple[dict[str, torch.Tensor], torch.Tensor]]): if self.enable_data_cache: self._input_cache = microbatches From b1e6f2a6d9466b2ceb5744b4a29fc47c36c6f323 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 29 Jul 2026 14:44:03 +0000 Subject: [PATCH 102/142] standardize config names --- alto/models/gpt_oss/config_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index f8b87926..1d5b3b0e 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -139,7 +139,7 @@ def gpt_oss_20b_lpt() -> Trainer.Config: return config -def gpt_oss_lpt_madam() -> Trainer.Config: +def gpt_oss_20b_lpt_madam() -> Trainer.Config: """Debug-model smoke test for the custom ``m_adam`` optimizer. ``lr`` maps to m_adam's additive (AdamW) branch ``lr_m`` and is driven @@ -159,7 +159,7 @@ def gpt_oss_lpt_madam() -> Trainer.Config: return config -def gpt_oss_lpt_madam_stable() -> Trainer.Config: +def gpt_oss_20b_lpt_madam_stable() -> Trainer.Config: """``gpt_oss_lpt_madam`` with a damped exponent branch. Same baseline-matched additive branch as ``gpt_oss_lpt_madam``, but the From 5aa8694707a63210902c2cac734b3110ae2763ce Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 29 Jul 2026 14:46:36 +0000 Subject: [PATCH 103/142] fix name of config in madam_stable --- alto/models/gpt_oss/config_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 1d5b3b0e..be0c9f45 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -171,7 +171,7 @@ def gpt_oss_20b_lpt_madam_stable() -> Trainer.Config: * ``de_step_cap`` tightened from 0.5 to 0.2 (max per-step magnitude change ~2**0.2 ~= 1.15x instead of ~1.41x). """ - config = gpt_oss_lpt_madam() + config = gpt_oss_20b_lpt_madam() config.optimizer.lr_e = 3e-5 config.optimizer.de_step_cap = 0.2 return config From 94a28dfab9d01d93708e3c00f37138b0da6d2906 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 29 Jul 2026 14:47:21 +0000 Subject: [PATCH 104/142] fix __all__ with correct configs --- alto/models/gpt_oss/config_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index be0c9f45..24a29a60 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -22,8 +22,8 @@ "gpt_oss_20b_lpt_midmax", "gpt_oss_20b_lpt_lowrank", "gpt_oss_20b_lpt_deosc", - "gpt_oss_lpt_madam", - "gpt_oss_lpt_madam_stable", + "gpt_oss_20b_lpt_madam", + "gpt_oss_20b_lpt_madam_stable", ] From b6e2c8ce43e4732a376f31d7e48c9cb8b5b487b0 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 14:06:55 +0000 Subject: [PATCH 105/142] add compatibility with several out files --- plotting/lpt.toml | 11 +++-- plotting/plot_training_stats.py | 72 +++++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/plotting/lpt.toml b/plotting/lpt.toml index 56334f35..764e2916 100644 --- a/plotting/lpt.toml +++ b/plotting/lpt.toml @@ -16,9 +16,14 @@ file = "../slurm-207639.out" name = "lpt_recipe" [[runs]] -file = "../slurm-208076.out" +file = "../slurm-208070.out" +name = "deoscillation" + +[[runs]] +file = ["../slurm-208455.out","../slurm-208634.out"] name = "midmax" [[runs]] -file = "../slurm-208070.out" -name = "deoscillation" \ No newline at end of file +file = ["../slurm-208293.out"] +name = "madam" + diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py index 25a0793d..252cc004 100644 --- a/plotting/plot_training_stats.py +++ b/plotting/plot_training_stats.py @@ -18,6 +18,10 @@ python3 plot_training_stats.py run_a.out=baseline run_b.out="lr 3e-4" Files given without "=label" fall back to their basename. +A single run may span several .out files (e.g. a resumed job). List them +comma-separated on the CLI; they are parsed in order and drawn as one curve: + python3 plot_training_stats.py part1.out,part2.out=baseline + TOML config format (see runs.example.toml): output = "val_loss.png" # optional, overridden by -o title = "Validation loss vs. step" # optional @@ -29,6 +33,12 @@ [[runs]] file = "slurm-207504.out" name = "lr 3e-4" + + # a run split across multiple files: pass a list to "file" (or "files"). + # files are concatenated in the given order into a single curve/column. + [[runs]] + file = ["slurm-208455.out", "slurm-208634.out"] + name = "midmax" """ import argparse import os @@ -81,11 +91,29 @@ def parse(path): return tr_steps, tr_losses, grad_norms, val_steps, val_losses +def parse_many(paths): + """Parse several log files and concatenate them (in order) as one run. + + Same return shape as parse(); use this when a single logical run is split + across multiple .out files (e.g. a resumed job). + """ + tr_steps, tr_losses, grad_norms, val_steps, val_losses = [], [], [], [], [] + for p in paths: + a, b, c, d, e = parse(p) + tr_steps.extend(a) + tr_losses.extend(b) + grad_norms.extend(c) + val_steps.extend(d) + val_losses.extend(e) + return tr_steps, tr_losses, grad_norms, val_steps, val_losses + + def runs_from_config(path): """Return (runs, output, title) from a .toml config. - runs is a list of (file, label) tuples. Paths are resolved relative to the - config file's directory so a config can be run from anywhere. + runs is a list of (files, label) tuples where files is a list of one or + more paths (a run may span several .out files). Paths are resolved relative + to the config file's directory so a config can be run from anywhere. """ if tomllib is None: sys.exit("reading a .toml config needs Python 3.11+ or the 'tomli' package (pip install tomli)") @@ -95,13 +123,16 @@ def runs_from_config(path): base = os.path.dirname(os.path.abspath(path)) runs = [] for i, r in enumerate(cfg.get("runs", [])): - if "file" not in r: + # accept "file" (str or list) or "files" (list); a run may span files + raw = r.get("files", r.get("file")) + if raw is None: sys.exit(f"{path}: runs[{i}] is missing required 'file' key") - fpath = r["file"] - if not os.path.isabs(fpath): - fpath = os.path.join(base, fpath) - label = r.get("name") or os.path.basename(r["file"]) - runs.append((fpath, label)) + file_list = raw if isinstance(raw, list) else [raw] + if not file_list: + sys.exit(f"{path}: runs[{i}] has an empty file list") + paths = [f if os.path.isabs(f) else os.path.join(base, f) for f in file_list] + label = r.get("name") or os.path.basename(file_list[0]) + runs.append((paths, label)) return runs, cfg.get("output"), cfg.get("title") @@ -124,12 +155,16 @@ def main(): ap.error("no runs given: pass logfiles positionally or use -c/--config") runs = [] for entry in args.logfiles: - # allow "path=custom legend label"; only split on the first "=" + # allow "path[,path2,...]=custom legend label"; split label on the + # first "=", then split the path part on "," for multi-file runs if "=" in entry: path, label = entry.split("=", 1) else: - path, label = entry, os.path.basename(entry) - runs.append((path, label)) + path, label = entry, None + paths = path.split(",") + if label is None: + label = os.path.basename(paths[0]) + runs.append((paths, label)) out = args.output or cfg_out or "val_loss.png" suptitle = args.title or cfg_title or "GPT-OSS 20b" @@ -150,10 +185,13 @@ def main(): colors = {} # label -> line color total_val = 0 # validation datapoints (table) - for path, label in runs: - if not os.path.exists(path): + for paths, label in runs: + existing = [p for p in paths if os.path.exists(p)] + missing = [p for p in paths if not os.path.exists(p)] + if missing: + print(f"warning: file(s) not found: {', '.join(missing)}", file=sys.stderr) + if not existing: # keep a placeholder so the run still shows in the legend + table - print(f"warning: file not found, showing empty entry: {path}", file=sys.stderr) (line,) = ax.plot([], [], linewidth=1.2, label=f"{label} (missing)") ax_grad.plot([], [], linewidth=1.2, color=line.get_color()) labels.append(label) @@ -161,7 +199,7 @@ def main(): colors[label] = line.get_color() continue - tr_steps, tr_losses, grad_norms, val_steps, val_losses = parse(path) + tr_steps, tr_losses, grad_norms, val_steps, val_losses = parse_many(existing) # curves plot TRAINING loss (no per-point marker — too dense) (line,) = ax.plot(tr_steps, tr_losses, linewidth=1.2, label=label) @@ -241,7 +279,9 @@ def main(): fig.suptitle(suptitle, fontsize=16, fontweight="bold") fig.tight_layout(rect=(0, 0, 1, 0.96)) # leave room for the suptitle fig.savefig(out, dpi=150) - print(f"Wrote {out} ({total_val} validation datapoints in table across {len(runs)} file(s))") + n_files = sum(len(paths) for paths, _ in runs) + print(f"Wrote {out} ({total_val} validation datapoints in table across " + f"{len(runs)} run(s), {n_files} file(s))") if __name__ == "__main__": From 9b6e455df9c894808d0391224ef90187bafed0b0 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 14:42:30 +0000 Subject: [PATCH 106/142] fix multinode dockerfile so it's compatible with OCI --- .dockerignore | 17 +++++++++++ Dockerfile.multinode | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile.multinode diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e3a3d828 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Build context for alto/Dockerfile.multinode is this directory (ALTO/). +# Keep large / irrelevant paths out of the context sent to the Docker daemon. +# gptoss_chkpt alone is ~9.6TB and must never be tarred into the build context. +.git +gptoss_chkpt/ +logs/ +comm_traces/ +plotting/ +examples/ +tests/ +slurm-*.out +*.out +error*.txt +sendit.toml +wait_and_run.sh +**/__pycache__/ +**/*.pyc diff --git a/Dockerfile.multinode b/Dockerfile.multinode new file mode 100644 index 00000000..b7308670 --- /dev/null +++ b/Dockerfile.multinode @@ -0,0 +1,68 @@ +FROM rocm/pytorch:latest + +ARG DEBIAN_FRONTEND=noninteractive + +# Port 80 to archive.ubuntu.com/security.ubuntu.com is blocked in this build +# environment, but 443 works. Rewrite every apt source (including the deb822 +# /etc/apt/sources.list.d/ubuntu.sources used on noble) from http:// to https://. +RUN find /etc/apt -type f \ + \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i 's|http://|https://|g' {} + \ + && apt-get \ + -o Acquire::ForceIPv4=true \ + -o Acquire::Retries=5 \ + -o Acquire::https::Timeout=30 \ + update \ + && apt-get install -y \ + git-lfs \ + pkg-config \ + clang \ + libclang-dev \ + libunwind-dev \ + libnl-3-dev \ + libnl-route-3-dev \ + libibverbs-dev \ + ibverbs-providers \ + cmake \ + && update-pciids \ + # && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir huggingface_hub "datasets>=3.6.0" \ + transformers tabulate wandb fsspec tyro "tokenizers>=0.15.0" safetensors \ + tensorboard pre-commit yapf pybind11 meson-python torchdata pytablewriter \ + "antlr4-python3-runtime==4.11.0" sympy math_verify more_itertools peft \ + accelerate pillow "numpy<2" opencv-python-headless scipy \ + numba huggingface-hub[cli,hf_transfer] "packaging>=24.2" \ + "setuptools>=77.0.3,<80.0.0" "setuptools-scm>=8" \ + protobuf-protoc-bin fmt + +RUN mkdir -p /var/lib/jenkins && \ + cd /var/lib/jenkins && \ + git clone --depth 1 https://github.com/EleutherAI/lm-evaluation-harness && \ + cd lm-evaluation-harness && \ + pip install -e . + +ENV PYTORCH_ROCM_ARCH="gfx90a;gfx942;gfx950" + +# bnxt_re RDMA provider copied from the host's Broadcom OFED install instead of +# building rdma-core from source. The host ships the working ABI-matched provider +# at /usr/local/lib/x86_64-linux-gnu/libbnxt_re-rdmav34.so; the inbox apt provider +# has the wrong kernel uABI. Recreate the symlinks, ld.so.conf entry, and the +# libibverbs .driver registration so libibverbs can dlopen it. +# Overwrite apt's upstream bnxt_re provider (ABI 1 only) with Broadcom's +# out-of-tree build (supports kernel uABI 8) directly into libibverbs' provider dir. +COPY alto/docker/bnxt_re/libmlx5-rdmav57.so /lib/x86_64-linux-gnu/libibverbs/libmlx5-rdmav57.so +RUN ldconfig + +RUN FSDP_PARAM=$(python3 -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__), 'distributed/fsdp/_fully_shard/_fsdp_param.py'))") && \ + sed -i 's/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param))/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param), requires_grad=param.requires_grad)/' "$FSDP_PARAM" && \ + sed -i 's/ self.sharded_param.requires_grad_(param.requires_grad)//' "$FSDP_PARAM" + +# Install torchtitan and ALTO training deps at build time (as root) to avoid +# /opt/venv permission errors when the container runs as a non-root user. +COPY . /tmp/torchtitan_src +RUN pip install --no-cache-dir aim torchao \ + compressed_tensors easydict loguru \ + poetry-core "poetry-dynamic-versioning>=1.0.0,<2.0.0" && \ + pip install --no-cache-dir --no-build-isolation --no-deps /tmp/torchtitan_src && \ + rm -rf /tmp/torchtitan_src From d90c26f867ed407aa7aa517a74eb764c0fbf174e Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 18:23:43 +0000 Subject: [PATCH 107/142] add info about testing RDMA --- RDMA.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 RDMA.md diff --git a/RDMA.md b/RDMA.md new file mode 100644 index 00000000..674bfa4b --- /dev/null +++ b/RDMA.md @@ -0,0 +1,30 @@ +# Testing RDMA between two nodes + +```bash +`ibv_devices` # list RDMA devices (e.g. mlx5_0) +`ibv_devinfo` # PortState: PORT_ACTIVE and note the link layer (IB vs. Ethernet/ROCE) +`rdma link show` # link state per device +``` + +# Raw RDMA loopback between two nodes + +```bash +# On node A (server): +ib_write_bw -d mlx5_0 -F --report_gbits + +# On node B (client), point at node A's IP: +ib_write_bw -d mlx5_0 -F --report_gbits + +``` + +You can get `` for a given RDMA interface like this: + +```bash +# Map RDMA device -> netdev: +ibdev2netdev # look for a line like: "mlx5_0 port 1 ==> rdma0 (Up)" +rdma link show # alternatively, look here. e.g. mlx5_0/1 ... netdev rdma0 + +# Then get that interface's IP: +ip -4 addr show rdma0 # look for the "inet x.x.x.x" line + +``` \ No newline at end of file From 9ff72ca947f94d75f032e1206b9f2a8c99f000d7 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 19:31:11 +0000 Subject: [PATCH 108/142] add rdma testing procedures --- Dockerfile.multinode | 9 +++++ RDMA.md | 15 ++++++++ alto/Dockerfile_multinode | 57 ------------------------------- rdma_tests/RDMA.md | 45 ++++++++++++++++++++++++ rdma_tests/test_rdma_allreduce.py | 50 +++++++++++++++++++++++++++ test_rdma_allreduce.py | 50 +++++++++++++++++++++++++++ test_rdma_via_pytorch.sh | 21 ++++++++++++ 7 files changed, 190 insertions(+), 57 deletions(-) delete mode 100644 alto/Dockerfile_multinode create mode 100644 rdma_tests/RDMA.md create mode 100644 rdma_tests/test_rdma_allreduce.py create mode 100644 test_rdma_allreduce.py create mode 100644 test_rdma_via_pytorch.sh diff --git a/Dockerfile.multinode b/Dockerfile.multinode index b7308670..57fe3a0f 100644 --- a/Dockerfile.multinode +++ b/Dockerfile.multinode @@ -5,6 +5,12 @@ ARG DEBIAN_FRONTEND=noninteractive # Port 80 to archive.ubuntu.com/security.ubuntu.com is blocked in this build # environment, but 443 works. Rewrite every apt source (including the deb822 # /etc/apt/sources.list.d/ubuntu.sources used on noble) from http:// to https://. + +# Packages +# ibverbs-utils for debug via to `rdma link show` +# iproute2 debug ip addresses such as RDMA ips +# perftest RDMA debug + RUN find /etc/apt -type f \ \( -name '*.list' -o -name '*.sources' \) \ -exec sed -i 's|http://|https://|g' {} + \ @@ -22,6 +28,9 @@ RUN find /etc/apt -type f \ libnl-3-dev \ libnl-route-3-dev \ libibverbs-dev \ + ibverbs-utils \ + iproute2 \ + perftest \ ibverbs-providers \ cmake \ && update-pciids \ diff --git a/RDMA.md b/RDMA.md index 674bfa4b..6c1346f0 100644 --- a/RDMA.md +++ b/RDMA.md @@ -1,5 +1,7 @@ # Testing RDMA between two nodes +Note: you will need `apt-get update && apt-get install -y ibverbs-utils iproute2 perftest` for tools inside docker container. + ```bash `ibv_devices` # list RDMA devices (e.g. mlx5_0) `ibv_devinfo` # PortState: PORT_ACTIVE and note the link layer (IB vs. Ethernet/ROCE) @@ -27,4 +29,17 @@ rdma link show # alternatively, look here. e.g. mlx5_0/1 .. # Then get that interface's IP: ip -4 addr show rdma0 # look for the "inet x.x.x.x" line +``` + +# Test RDMA using torchrun + +If you have terminal access to both machines, you can use `torchrun` to double check RDMA support. Look for a line in NCCL INFO outputs that says `NET/IB : Using [0]mlx5_1:1/RoCE ... [9]mlx5_9:1/RoCE`. If it falls back to TCP you will get an output like: `NCCL INFO NET/Socket ...`. + +```bash +export NCCL_DEBUG=INFO + +torchrun \ + --nnodes=2 --nproc-per-node=8 --node-rank= \ + --master-addr= --master-port=29500 \ + test_rdma_allreduce.py ``` \ No newline at end of file diff --git a/alto/Dockerfile_multinode b/alto/Dockerfile_multinode deleted file mode 100644 index f51f92c2..00000000 --- a/alto/Dockerfile_multinode +++ /dev/null @@ -1,57 +0,0 @@ -FROM rocm/pytorch:latest - -ARG DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y \ - git-lfs \ - pkg-config \ - clang \ - libclang-dev \ - libunwind-dev \ - libnl-3-dev \ - libnl-route-3-dev \ - libibverbs-dev \ - ibverbs-providers \ - cmake \ - && update-pciids \ - && rm -rf /var/lib/apt/lists/* - -RUN pip install --no-cache-dir huggingface_hub "datasets>=3.6.0" \ - transformers tabulate wandb fsspec tyro "tokenizers>=0.15.0" safetensors \ - tensorboard pre-commit yapf pybind11 meson-python torchdata pytablewriter \ - "antlr4-python3-runtime==4.11.0" sympy math_verify more_itertools peft \ - accelerate pillow "numpy<2" opencv-python-headless scipy \ - numba huggingface-hub[cli,hf_transfer] "packaging>=24.2" \ - "setuptools>=77.0.3,<80.0.0" "setuptools-scm>=8" \ - protobuf-protoc-bin fmt && \ - pip install --no-cache-dir /opt/rocm/share/amd_smi - -RUN mkdir -p /var/lib/jenkins && \ - cd /var/lib/jenkins && \ - git clone --depth 1 https://github.com/EleutherAI/lm-evaluation-harness && \ - cd lm-evaluation-harness && \ - pip install -e . - -ENV PYTORCH_ROCM_ARCH="gfx90a;gfx942;gfx950" - -# bnxt_re RDMA provider copied from the host's Broadcom OFED install instead of -# building rdma-core from source. The host ships the working ABI-matched provider -# at /usr/local/lib/x86_64-linux-gnu/libbnxt_re-rdmav34.so; the inbox apt provider -# has the wrong kernel uABI. Recreate the symlinks, ld.so.conf entry, and the -# libibverbs .driver registration so libibverbs can dlopen it. -# Overwrite apt's upstream bnxt_re provider (ABI 1 only) with Broadcom's -# out-of-tree build (supports kernel uABI 8) directly into libibverbs' provider dir. -COPY docker/bnxt_re/libbnxt_re-rdmav34.so /lib/x86_64-linux-gnu/libibverbs/libbnxt_re-rdmav34.so -RUN ldconfig - -RUN FSDP_PARAM=$(python3 -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__), 'distributed/fsdp/_fully_shard/_fsdp_param.py'))") && \ - sed -i 's/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param))/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param), requires_grad=param.requires_grad)/' "$FSDP_PARAM" && \ - sed -i 's/ self.sharded_param.requires_grad_(param.requires_grad)//' "$FSDP_PARAM" - -# Install torchtitan and ALTO training deps at build time (as root) to avoid -# /opt/venv permission errors when the container runs as a non-root user. -COPY . /tmp/torchtitan_src -RUN pip install --no-cache-dir aim torchao \ - compressed_tensors easydict loguru && \ - pip install --no-cache-dir --no-build-isolation --no-deps /tmp/torchtitan_src && \ - rm -rf /tmp/torchtitan_src diff --git a/rdma_tests/RDMA.md b/rdma_tests/RDMA.md new file mode 100644 index 00000000..6c1346f0 --- /dev/null +++ b/rdma_tests/RDMA.md @@ -0,0 +1,45 @@ +# Testing RDMA between two nodes + +Note: you will need `apt-get update && apt-get install -y ibverbs-utils iproute2 perftest` for tools inside docker container. + +```bash +`ibv_devices` # list RDMA devices (e.g. mlx5_0) +`ibv_devinfo` # PortState: PORT_ACTIVE and note the link layer (IB vs. Ethernet/ROCE) +`rdma link show` # link state per device +``` + +# Raw RDMA loopback between two nodes + +```bash +# On node A (server): +ib_write_bw -d mlx5_0 -F --report_gbits + +# On node B (client), point at node A's IP: +ib_write_bw -d mlx5_0 -F --report_gbits + +``` + +You can get `` for a given RDMA interface like this: + +```bash +# Map RDMA device -> netdev: +ibdev2netdev # look for a line like: "mlx5_0 port 1 ==> rdma0 (Up)" +rdma link show # alternatively, look here. e.g. mlx5_0/1 ... netdev rdma0 + +# Then get that interface's IP: +ip -4 addr show rdma0 # look for the "inet x.x.x.x" line + +``` + +# Test RDMA using torchrun + +If you have terminal access to both machines, you can use `torchrun` to double check RDMA support. Look for a line in NCCL INFO outputs that says `NET/IB : Using [0]mlx5_1:1/RoCE ... [9]mlx5_9:1/RoCE`. If it falls back to TCP you will get an output like: `NCCL INFO NET/Socket ...`. + +```bash +export NCCL_DEBUG=INFO + +torchrun \ + --nnodes=2 --nproc-per-node=8 --node-rank= \ + --master-addr= --master-port=29500 \ + test_rdma_allreduce.py +``` \ No newline at end of file diff --git a/rdma_tests/test_rdma_allreduce.py b/rdma_tests/test_rdma_allreduce.py new file mode 100644 index 00000000..98cbaffa --- /dev/null +++ b/rdma_tests/test_rdma_allreduce.py @@ -0,0 +1,50 @@ +import os +import time + +import torch +import torch.distributed as dist + + +def main(): + dist.init_process_group("nccl") + rank = dist.get_rank() + world = dist.get_world_size() + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + dev = torch.device("cuda", local_rank) + + if rank == 0: + print(f"[setup] world_size={world}", flush=True) + + # 1 GiB tensor (256M fp32 elements) + numel = 256 * 1024 * 1024 + x = torch.ones(numel, device=dev) + nbytes = x.element_size() * x.numel() + + # warmup + for _ in range(5): + dist.all_reduce(x) + torch.cuda.synchronize() + + iters = 20 + t0 = time.perf_counter() + for _ in range(iters): + dist.all_reduce(x) + torch.cuda.synchronize() + dt = (time.perf_counter() - t0) / iters + + # all-reduce bus-bandwidth: 2*(n-1)/n * size / time + algbw = nbytes / dt + busbw = algbw * 2 * (world - 1) / world + if rank == 0: + print( + f"[result] size={nbytes/1e9:.2f} GB time={dt*1e3:.2f} ms " + f"algbw={algbw/1e9:.1f} GB/s busbw={busbw/1e9:.1f} GB/s", + flush=True, + ) + + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/test_rdma_allreduce.py b/test_rdma_allreduce.py new file mode 100644 index 00000000..98cbaffa --- /dev/null +++ b/test_rdma_allreduce.py @@ -0,0 +1,50 @@ +import os +import time + +import torch +import torch.distributed as dist + + +def main(): + dist.init_process_group("nccl") + rank = dist.get_rank() + world = dist.get_world_size() + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + dev = torch.device("cuda", local_rank) + + if rank == 0: + print(f"[setup] world_size={world}", flush=True) + + # 1 GiB tensor (256M fp32 elements) + numel = 256 * 1024 * 1024 + x = torch.ones(numel, device=dev) + nbytes = x.element_size() * x.numel() + + # warmup + for _ in range(5): + dist.all_reduce(x) + torch.cuda.synchronize() + + iters = 20 + t0 = time.perf_counter() + for _ in range(iters): + dist.all_reduce(x) + torch.cuda.synchronize() + dt = (time.perf_counter() - t0) / iters + + # all-reduce bus-bandwidth: 2*(n-1)/n * size / time + algbw = nbytes / dt + busbw = algbw * 2 * (world - 1) / world + if rank == 0: + print( + f"[result] size={nbytes/1e9:.2f} GB time={dt*1e3:.2f} ms " + f"algbw={algbw/1e9:.1f} GB/s busbw={busbw/1e9:.1f} GB/s", + flush=True, + ) + + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/test_rdma_via_pytorch.sh b/test_rdma_via_pytorch.sh new file mode 100644 index 00000000..d3dde5f8 --- /dev/null +++ b/test_rdma_via_pytorch.sh @@ -0,0 +1,21 @@ +export NCCL_DEBUG=INFO +# export NCCL_IB_HCA=mlx5 # use your device prefix from ibdev2netdev +# export NCCL_SOCKET_IFNAME= # the bootstrap iface (e.g. enp1s0f0 or eth0) if autodetect picks the wrong one + + +torchrun \ + --nnodes=2 --nproc-per-node=8 --node-rank=0 \ + --master-addr= --master-port=29500 \ + test_rdma_allreduce.py + +10.224.1.148 + +torchrun \ + --nnodes=2 --nproc-per-node=8 --node-rank=0 \ + --master-addr=10.224.1.148 --master-port=29500 \ + test_rdma_allreduce.py + +torchrun \ + --nnodes=2 --nproc-per-node=8 --node-rank=1 \ + --master-addr=10.224.1.148 --master-port=29500 \ + test_rdma_allreduce.py \ No newline at end of file From 83925cfc136b1123ef495165326e0359d27f985b Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 19:40:27 +0000 Subject: [PATCH 109/142] docker command to pass relevant RDMA devices to image --- rdma_tests/start_container.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 rdma_tests/start_container.sh diff --git a/rdma_tests/start_container.sh b/rdma_tests/start_container.sh new file mode 100644 index 00000000..c4667210 --- /dev/null +++ b/rdma_tests/start_container.sh @@ -0,0 +1,16 @@ +docker run -it \ + --name alto_multinode \ + --device=/dev/infiniband \ + --device=/dev/dri \ + --device=/dev/kfd \ + --ulimit memlock=-1:-1 \ + --cap-add=IPC_LOCK \ + --shm-size=16g \ + --network host \ + --ipc host \ + --cap-add=IPC_LOCK \ + --ulimit memlock=-1:-1 \ + --shm-size=16g \ + -v $HOME/lpt_branch/ALTO:/alto \ + alto:multinode \ + bash \ No newline at end of file From f7e8a1c9e2cbc1d95e1c6c988fb4053f44cffb35 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 19:52:31 +0000 Subject: [PATCH 110/142] add automatic detection of libibverbs and RDMA provide modules --- Dockerfile.multinode | 16 ++--- RDMA.md | 45 ------------- rdma_tests/start_container.sh | 66 ++++++++++++++----- .../test_rdma_via_pytorch.sh | 4 +- test_rdma_allreduce.py | 50 -------------- 5 files changed, 59 insertions(+), 122 deletions(-) delete mode 100644 RDMA.md mode change 100644 => 100755 rdma_tests/start_container.sh rename test_rdma_via_pytorch.sh => rdma_tests/test_rdma_via_pytorch.sh (89%) delete mode 100644 test_rdma_allreduce.py diff --git a/Dockerfile.multinode b/Dockerfile.multinode index 57fe3a0f..be800369 100644 --- a/Dockerfile.multinode +++ b/Dockerfile.multinode @@ -53,15 +53,13 @@ RUN mkdir -p /var/lib/jenkins && \ ENV PYTORCH_ROCM_ARCH="gfx90a;gfx942;gfx950" -# bnxt_re RDMA provider copied from the host's Broadcom OFED install instead of -# building rdma-core from source. The host ships the working ABI-matched provider -# at /usr/local/lib/x86_64-linux-gnu/libbnxt_re-rdmav34.so; the inbox apt provider -# has the wrong kernel uABI. Recreate the symlinks, ld.so.conf entry, and the -# libibverbs .driver registration so libibverbs can dlopen it. -# Overwrite apt's upstream bnxt_re provider (ABI 1 only) with Broadcom's -# out-of-tree build (supports kernel uABI 8) directly into libibverbs' provider dir. -COPY alto/docker/bnxt_re/libmlx5-rdmav57.so /lib/x86_64-linux-gnu/libibverbs/libmlx5-rdmav57.so -RUN ldconfig +# RDMA providers (the libibverbs plugins, e.g. libmlx5 / libbnxt_re) are ABI-tied +# to each host's rdma-core and kernel, which vary across our machines, so they are +# deliberately NOT baked into the image. rdma_tests/start_container.sh mounts the +# host's libibverbs userspace (library + provider modules + /etc/libibverbs.d) +# read-only at runtime, so the in-container RDMA stack always matches the host it +# runs on. The ibverbs-providers/libibverbs-dev apt packages above remain as a +# self-contained fallback for same-ABI hosts (USE_HOST_RDMA=0). RUN FSDP_PARAM=$(python3 -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__), 'distributed/fsdp/_fully_shard/_fsdp_param.py'))") && \ sed -i 's/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param))/self.sharded_param = nn.Parameter(self.to_sharded_dtensor(sharded_param), requires_grad=param.requires_grad)/' "$FSDP_PARAM" && \ diff --git a/RDMA.md b/RDMA.md deleted file mode 100644 index 6c1346f0..00000000 --- a/RDMA.md +++ /dev/null @@ -1,45 +0,0 @@ -# Testing RDMA between two nodes - -Note: you will need `apt-get update && apt-get install -y ibverbs-utils iproute2 perftest` for tools inside docker container. - -```bash -`ibv_devices` # list RDMA devices (e.g. mlx5_0) -`ibv_devinfo` # PortState: PORT_ACTIVE and note the link layer (IB vs. Ethernet/ROCE) -`rdma link show` # link state per device -``` - -# Raw RDMA loopback between two nodes - -```bash -# On node A (server): -ib_write_bw -d mlx5_0 -F --report_gbits - -# On node B (client), point at node A's IP: -ib_write_bw -d mlx5_0 -F --report_gbits - -``` - -You can get `` for a given RDMA interface like this: - -```bash -# Map RDMA device -> netdev: -ibdev2netdev # look for a line like: "mlx5_0 port 1 ==> rdma0 (Up)" -rdma link show # alternatively, look here. e.g. mlx5_0/1 ... netdev rdma0 - -# Then get that interface's IP: -ip -4 addr show rdma0 # look for the "inet x.x.x.x" line - -``` - -# Test RDMA using torchrun - -If you have terminal access to both machines, you can use `torchrun` to double check RDMA support. Look for a line in NCCL INFO outputs that says `NET/IB : Using [0]mlx5_1:1/RoCE ... [9]mlx5_9:1/RoCE`. If it falls back to TCP you will get an output like: `NCCL INFO NET/Socket ...`. - -```bash -export NCCL_DEBUG=INFO - -torchrun \ - --nnodes=2 --nproc-per-node=8 --node-rank= \ - --master-addr= --master-port=29500 \ - test_rdma_allreduce.py -``` \ No newline at end of file diff --git a/rdma_tests/start_container.sh b/rdma_tests/start_container.sh old mode 100644 new mode 100755 index c4667210..8446024d --- a/rdma_tests/start_container.sh +++ b/rdma_tests/start_container.sh @@ -1,16 +1,50 @@ -docker run -it \ - --name alto_multinode \ - --device=/dev/infiniband \ - --device=/dev/dri \ - --device=/dev/kfd \ - --ulimit memlock=-1:-1 \ - --cap-add=IPC_LOCK \ - --shm-size=16g \ - --network host \ - --ipc host \ - --cap-add=IPC_LOCK \ - --ulimit memlock=-1:-1 \ - --shm-size=16g \ - -v $HOME/lpt_branch/ALTO:/alto \ - alto:multinode \ - bash \ No newline at end of file +#!/bin/bash +# Start the ALTO multinode container with host-provided RDMA support. +# +# RDMA providers (the libibverbs plugins, e.g. libmlx5 / libbnxt_re) are ABI-tied +# to the host's rdma-core AND kernel, both of which vary across our machines. So +# instead of baking a provider into the image, we mount the host's entire +# libibverbs userspace -- the library, its provider modules, and the .driver +# registration files -- read-only over the same paths. The in-container RDMA +# stack then always matches whatever kernel/NIC this particular host has. +# +# Set USE_HOST_RDMA=0 to skip the mounts and use the image's own stack. +set -euo pipefail + +IMAGE="${IMAGE:-alto:multinode}" +CONTAINER="${CONTAINER:-alto_multinode}" +ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" + +docker_args=( + -it + --rm + --name "$CONTAINER" + --network host + --ipc host + --shm-size=16g + --cap-add=IPC_LOCK + --ulimit memlock=-1:-1 + -v "$ALTO_DIR:/alto" +) + +# GPU / RDMA character devices -- add only if present on this host. +for dev in /dev/infiniband /dev/dri /dev/kfd; do + [[ -e "$dev" ]] && docker_args+=(--device="$dev") +done + +# Host RDMA userspace. Mounting the host's libibverbs.so together with its +# provider modules and driver configs keeps the library ABI self-consistent +# (host lib + host providers) while matching the host kernel's uABI. +if [[ "${USE_HOST_RDMA:-1}" == "1" && -d /etc/libibverbs.d ]]; then + for lib in $(ldconfig -p | awk '/lib(ibverbs|rdmacm|ibumad|mlx5|mlx4|bnxt_re|efa|irdma|hns|cxgb4)\.so/ {print $NF}' | sort -u); do + [[ -e "$lib" ]] || continue + docker_args+=(-v "$lib:$lib:ro") + real="$(readlink -f "$lib")" + [[ "$real" != "$lib" && -e "$real" ]] && docker_args+=(-v "$real:$real:ro") + done + for d in /usr/lib/x86_64-linux-gnu/libibverbs /usr/lib64/libibverbs /etc/libibverbs.d; do + [[ -d "$d" ]] && docker_args+=(-v "$d:$d:ro") + done +fi + +docker run "${docker_args[@]}" "$IMAGE" bash diff --git a/test_rdma_via_pytorch.sh b/rdma_tests/test_rdma_via_pytorch.sh similarity index 89% rename from test_rdma_via_pytorch.sh rename to rdma_tests/test_rdma_via_pytorch.sh index d3dde5f8..3f24eb09 100644 --- a/test_rdma_via_pytorch.sh +++ b/rdma_tests/test_rdma_via_pytorch.sh @@ -13,9 +13,9 @@ torchrun \ torchrun \ --nnodes=2 --nproc-per-node=8 --node-rank=0 \ --master-addr=10.224.1.148 --master-port=29500 \ - test_rdma_allreduce.py + rdma_tests/test_rdma_allreduce.py torchrun \ --nnodes=2 --nproc-per-node=8 --node-rank=1 \ --master-addr=10.224.1.148 --master-port=29500 \ - test_rdma_allreduce.py \ No newline at end of file + rdma_tests/test_rdma_allreduce.py \ No newline at end of file diff --git a/test_rdma_allreduce.py b/test_rdma_allreduce.py deleted file mode 100644 index 98cbaffa..00000000 --- a/test_rdma_allreduce.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import time - -import torch -import torch.distributed as dist - - -def main(): - dist.init_process_group("nccl") - rank = dist.get_rank() - world = dist.get_world_size() - local_rank = int(os.environ["LOCAL_RANK"]) - torch.cuda.set_device(local_rank) - dev = torch.device("cuda", local_rank) - - if rank == 0: - print(f"[setup] world_size={world}", flush=True) - - # 1 GiB tensor (256M fp32 elements) - numel = 256 * 1024 * 1024 - x = torch.ones(numel, device=dev) - nbytes = x.element_size() * x.numel() - - # warmup - for _ in range(5): - dist.all_reduce(x) - torch.cuda.synchronize() - - iters = 20 - t0 = time.perf_counter() - for _ in range(iters): - dist.all_reduce(x) - torch.cuda.synchronize() - dt = (time.perf_counter() - t0) / iters - - # all-reduce bus-bandwidth: 2*(n-1)/n * size / time - algbw = nbytes / dt - busbw = algbw * 2 * (world - 1) / world - if rank == 0: - print( - f"[result] size={nbytes/1e9:.2f} GB time={dt*1e3:.2f} ms " - f"algbw={algbw/1e9:.1f} GB/s busbw={busbw/1e9:.1f} GB/s", - flush=True, - ) - - dist.destroy_process_group() - - -if __name__ == "__main__": - main() From 9a166bcecd44602517046302bd59671ee3bae5c6 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 19:59:27 +0000 Subject: [PATCH 111/142] add update to test rdma backend --- rdma_tests/test_rdma_via_pytorch.sh | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/rdma_tests/test_rdma_via_pytorch.sh b/rdma_tests/test_rdma_via_pytorch.sh index 3f24eb09..102cc0f3 100644 --- a/rdma_tests/test_rdma_via_pytorch.sh +++ b/rdma_tests/test_rdma_via_pytorch.sh @@ -7,15 +7,3 @@ torchrun \ --nnodes=2 --nproc-per-node=8 --node-rank=0 \ --master-addr= --master-port=29500 \ test_rdma_allreduce.py - -10.224.1.148 - -torchrun \ - --nnodes=2 --nproc-per-node=8 --node-rank=0 \ - --master-addr=10.224.1.148 --master-port=29500 \ - rdma_tests/test_rdma_allreduce.py - -torchrun \ - --nnodes=2 --nproc-per-node=8 --node-rank=1 \ - --master-addr=10.224.1.148 --master-port=29500 \ - rdma_tests/test_rdma_allreduce.py \ No newline at end of file From 9ef986489e7cc6459b369805210f0d68e1217585 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 18:23:43 +0000 Subject: [PATCH 112/142] add info about testing RDMA --- RDMA.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 RDMA.md diff --git a/RDMA.md b/RDMA.md new file mode 100644 index 00000000..674bfa4b --- /dev/null +++ b/RDMA.md @@ -0,0 +1,30 @@ +# Testing RDMA between two nodes + +```bash +`ibv_devices` # list RDMA devices (e.g. mlx5_0) +`ibv_devinfo` # PortState: PORT_ACTIVE and note the link layer (IB vs. Ethernet/ROCE) +`rdma link show` # link state per device +``` + +# Raw RDMA loopback between two nodes + +```bash +# On node A (server): +ib_write_bw -d mlx5_0 -F --report_gbits + +# On node B (client), point at node A's IP: +ib_write_bw -d mlx5_0 -F --report_gbits + +``` + +You can get `` for a given RDMA interface like this: + +```bash +# Map RDMA device -> netdev: +ibdev2netdev # look for a line like: "mlx5_0 port 1 ==> rdma0 (Up)" +rdma link show # alternatively, look here. e.g. mlx5_0/1 ... netdev rdma0 + +# Then get that interface's IP: +ip -4 addr show rdma0 # look for the "inet x.x.x.x" line + +``` \ No newline at end of file From ffacea00e89fc537687c0fe477a23873a06ffd5a Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 30 Jul 2026 20:11:04 +0000 Subject: [PATCH 113/142] add multinode sbatch script --- scripts/multinode.sh | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 scripts/multinode.sh diff --git a/scripts/multinode.sh b/scripts/multinode.sh new file mode 100644 index 00000000..dca92f95 --- /dev/null +++ b/scripts/multinode.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +#SBATCH -A amd-arad +#SBATCH -p amd-arad-burst +#SBATCH --qos=low +#SBATCH --nodes=2 +#SBATCH --ntasks-per-node=1 +#SBATCH --gres=gpu:8 +#SBATCH --time=04:00:00 +#SBATCH --job-name=alto-multinode +#SBATCH --output=alto-multinode-%j.out +#SBATCH --requeue + +set -euo pipefail + +IMAGE="alto:multinode" +ALTO_DIR="$HOME/lpt_branch/ALTO" +GPUS_PER_NODE=8 + +# First allocated node becomes the torchrun rendezvous host. +MASTER_ADDR="$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)" +MASTER_PORT="$((20000 + SLURM_JOB_ID % 20000))" + +export IMAGE ALTO_DIR GPUS_PER_NODE MASTER_ADDR MASTER_PORT + +echo "Nodes: $(scontrol show hostnames "$SLURM_JOB_NODELIST")" +echo "Master: ${MASTER_ADDR}:${MASTER_PORT}" + +# One srun task, and therefore one Docker container, per node. +srun --kill-on-bad-exit=1 bash -c ' + set -euo pipefail + + CONTAINER="alto_${SLURM_JOB_ID}_${SLURM_NODEID}" + + cleanup() { + docker stop --time 10 "$CONTAINER" >/dev/null 2>&1 || true + } + trap cleanup EXIT INT TERM + + docker run --rm \ + --name "$CONTAINER" \ + --network=host \ + --ipc=host \ + --shm-size=128g \ + --ulimit memlock=-1 \ + --device=/dev/kfd \ + --device=/dev/dri \ + --group-add video \ + -v /dev/infiniband:/dev/infiniband \ + -v "$ALTO_DIR:/alto" \ + -v /shared:/shared \ + -w /alto \ + "$IMAGE" \ + torchrun \ + --nnodes="$SLURM_JOB_NUM_NODES" \ + --nproc-per-node="$GPUS_PER_NODE" \ + --node-rank="$SLURM_NODEID" \ + --master-addr="$MASTER_ADDR" \ + --master-port="$MASTER_PORT" \ + rdma_tests/test_rdma_allreduce.py +' \ No newline at end of file From b727ad5212ad9c1397637f99e57bb5a10235d29a Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 31 Jul 2026 18:50:31 +0000 Subject: [PATCH 114/142] add mxfp4 base to config registry --- alto/models/gpt_oss/config_registry.py | 30 ++++++++++++++------- alto/models/gpt_oss/configs/mxfp4_base.yaml | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 alto/models/gpt_oss/configs/mxfp4_base.yaml diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 24a29a60..8b3f4159 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -24,6 +24,7 @@ "gpt_oss_20b_lpt_deosc", "gpt_oss_20b_lpt_madam", "gpt_oss_20b_lpt_madam_stable", + "gpt_oss_20b_mxfp4_base" ] @@ -81,17 +82,17 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-lr4e-4-outputs" config.profiling.enable_profiling = False - config.training.steps = 1200000 + config.training.steps = 1200000 # set by mlperf config.training.local_batch_size = 1 - config.training.global_batch_size = 16 - config.training.seq_len = 8192 - config.optimizer.lr = 4e-4 - config.optimizer.weight_decay = 0.1 - config.optimizer.beta1 = 0.9 - config.optimizer.beta2 = 0.95 - config.optimizer.eps = 1e-5 - config.lr_scheduler.min_lr_factor = 0.1 - config.lr_scheduler.warmup_steps = 128 + config.training.global_batch_size = 16 # can be edited for mlperf submission + config.training.seq_len = 8192 # set by mlperf + config.optimizer.lr = 4e-4 # can be edited for mlperf + config.optimizer.weight_decay = 0.1 # set by mlperf + config.optimizer.beta1 = 0.9 # set by mlperf + config.optimizer.beta2 = 0.95 # set by mlperf + config.optimizer.eps = 1e-5 # set by mlperf + config.lr_scheduler.min_lr_factor = 0.1 # set by mlperf + config.lr_scheduler.warmup_steps = 128 # can be edited for mlperf submission config.lr_scheduler.decay_ratio = 1 - 128 / config.training.steps config.lr_scheduler.decay_type = "cosine" config.metrics.log_freq = 1 @@ -195,6 +196,15 @@ def gpt_oss_20b_lpt_midmax() -> Trainer.Config: ],) return config +def gpt_oss_20b_mxfp4_base() -> Trainer.Config: + """baseline MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-mxfp4-base" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/mxfp4_base.yaml",), + ],) + return config + def gpt_oss_20b_lpt_lowrank() -> Trainer.Config: """gpt_oss_20b_lpt_c4 with low-rank (lora_rank=32) correction for MXFP4 quantization.""" diff --git a/alto/models/gpt_oss/configs/mxfp4_base.yaml b/alto/models/gpt_oss/configs/mxfp4_base.yaml new file mode 100644 index 00000000..004d45ef --- /dev/null +++ b/alto/models/gpt_oss/configs/mxfp4_base.yaml @@ -0,0 +1,28 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + # targets: ["Linear"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] + use_2dblock_x: false + use_2dblock_w: false + use_hadamard: false + use_sr_grad: false + + # differential gradient estimation, disabled by default + use_dge: false + + # choices: none, static, dynamic + clip_mode: none + + # 2-level scaling, disabled by default + # NVFP4 requires tensorwise scaling or you will get overflow issues + two_level_scaling: none + use_midmax: false + # weight de-oscillation, disabled by default + # deosc_step: 2000 + # deosc_period: 200 + # deosc_ratio: 4.0 From 57de1191d77d6e9e1eee07791f861fcd5c2e3fb5 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 31 Jul 2026 20:32:17 +0000 Subject: [PATCH 115/142] add optional details arg and tensorboard support --- plotting/baselines.toml | 20 ++++ plotting/plot_training_stats.py | 178 +++++++++++++++++++++++++++----- 2 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 plotting/baselines.toml diff --git a/plotting/baselines.toml b/plotting/baselines.toml new file mode 100644 index 00000000..401b0284 --- /dev/null +++ b/plotting/baselines.toml @@ -0,0 +1,20 @@ +# Example config for plot_val_loss.py +# python3 plot_val_loss.py -c runs.example.toml +# +# `output` and `title` are optional (both overridable with -o / -t on the CLI). +# Relative `file` paths are resolved from THIS config file's directory. + +output = "plotting/plots/baselines.png" +title = "Reproducing Low-Precision Baselines on GPT-OSS 20b" + +[[runs]] +file = "../slurm-207989.out" +name = "bf16" + +[[runs]] +file = "../slurm-207639.out" +name = "lpt_recipe" + +[[runs]] +file = "../slurm-208070.out" +name = "deoscillation" \ No newline at end of file diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py index 252cc004..01d55201 100644 --- a/plotting/plot_training_stats.py +++ b/plotting/plot_training_stats.py @@ -1,9 +1,20 @@ #!/usr/bin/env python3 -"""Plot step vs. validation loss from one or more slurm .out logs. +"""Plot training/validation loss and grad norm from slurm .out logs and/or +TensorBoard event files. -Parses lines like: - ... validate step: 768 loss: 4.9572 memory: ... -(ANSI color codes are stripped before matching.) +Two source kinds are supported and may be freely mixed within a single plot +(and even within a single run): + + * slurm .out logs -- parsed by regex from lines like: + ... validate step: 768 loss: 4.9572 memory: ... + (ANSI color codes are stripped before matching.) + + * TensorBoard sources -- either an event file (events.out.tfevents.*) or a + directory containing them (e.g. a run's tb/ dir, whose timestamped subdirs + from resumes are read in order). Scalar tags read: + loss_metrics/global_avg_loss -> training loss + grad_norm -> gradient norm + validation_metrics/loss -> validation loss Alongside the plot, a table on the right lists each method's loss at every step (union of all steps across files; blank where a method has no datapoint). @@ -39,6 +50,12 @@ [[runs]] file = ["slurm-208455.out", "slurm-208634.out"] name = "midmax" + + # a TensorBoard run: point "file" at the tb dir (or a single event file). + # .out logs and tb sources may be mixed in the same list and across runs. + [[runs]] + file = "gpt_oss_20b-pretrain-bf16/tb" + name = "bf16 (tb)" """ import argparse import os @@ -68,6 +85,78 @@ TRAIN = re.compile(r"(? {val_step: val_loss} (table data) @@ -194,6 +295,7 @@ def main(): # keep a placeholder so the run still shows in the legend + table (line,) = ax.plot([], [], linewidth=1.2, label=f"{label} (missing)") ax_grad.plot([], [], linewidth=1.2, color=line.get_color()) + ax_val.plot([], [], linewidth=1.2, color=line.get_color()) labels.append(label) loss_by_step[label] = {} colors[label] = line.get_color() @@ -210,6 +312,11 @@ def main(): gvals = [g for g in grad_norms if g is not None] ax_grad.plot(gsteps, gvals, linewidth=1.2, color=color, label=label) + # validation-loss curve below the grad-norm plot, matching color + # (markers here since validation points are sparse) + ax_val.plot(val_steps, val_losses, linewidth=1.2, marker="o", + markersize=3, color=color, label=label) + labels.append(label) loss_by_step[label] = dict(zip(val_steps, val_losses)) colors[label] = color @@ -230,6 +337,22 @@ def main(): ax_grad.grid(True, alpha=0.3) ax_grad.sharex(ax) + ax_val.set_xlabel("step") + ax_val.set_ylabel("validation loss") + ax_val.set_title("Validation Loss", fontweight="bold") + ax_val.grid(True, alpha=0.3) + ax_val.sharex(ax) + + # --- optional details text box (top-right, above the table) --- + if ax_det is not None: + ax_det.axis("off") + ax_det.text( + 0.5, 0.98, textwrap.fill(details, width=34), + transform=ax_det.transAxes, ha="center", va="top", + fontsize=8, color="white", + bbox=dict(boxstyle="round,pad=0.5", facecolor="none", edgecolor="gray"), + ) + # --- table of VALIDATION losses per step for each method --- all_steps = sorted({s for d in loss_by_step.values() for s in d}) # wrap long method names so they don't overflow their table column @@ -279,9 +402,14 @@ def main(): fig.suptitle(suptitle, fontsize=16, fontweight="bold") fig.tight_layout(rect=(0, 0, 1, 0.96)) # leave room for the suptitle fig.savefig(out, dpi=150) + # also emit vector PDF + SVG alongside the raster output + base = os.path.splitext(out)[0] + pdf_out, svg_out = base + ".pdf", base + ".svg" + fig.savefig(pdf_out) + fig.savefig(svg_out) n_files = sum(len(paths) for paths, _ in runs) - print(f"Wrote {out} ({total_val} validation datapoints in table across " - f"{len(runs)} run(s), {n_files} file(s))") + print(f"Wrote {out}, {pdf_out} and {svg_out} ({total_val} validation " + f"datapoints in table across {len(runs)} run(s), {n_files} file(s))") if __name__ == "__main__": From 935a9d2fd143beafd96c202ea563ef67f422e881 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Tue, 4 Aug 2026 13:57:05 +0000 Subject: [PATCH 116/142] config registry modif --- alto/models/gpt_oss/config_registry.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 036bfcce..4b3b416e 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -20,6 +20,7 @@ "gpt_oss_20b_pretrain", "gpt_oss_20b_lpt", "gpt_oss_20b_lpt_fresh", + "gpt_oss_20b_lpt_1dw", "gpt_oss_20b_lpt_no2dw", "gpt_oss_20b_adahop", "gpt_oss_20b_adahop_hadamard", @@ -254,6 +255,18 @@ def gpt_oss_20b_lpt() -> Trainer.Config: ],) return config +def gpt_oss_20b_lpt_1dw() -> Trainer.Config: + """Plain MXFP4 with 1D-block weight quantization (use_2dblock_w: false): + weights are scaled per-axis (1D macro blocks) instead of the baseline's 2D + blocks. Identical to gpt_oss_20b_lpt otherwise. Own dump folder so it never + collides with the 2D-weight baseline's checkpoints.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-1dw-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_1dw.yaml",), + ],) + return config + def gpt_oss_20b_lpt_fresh() -> Trainer.Config: """Plain MXFP4 baseline, fresh from step 1, own dump folder — the control for the weight-identity experiment (2D weight scaling ON). Distinct dump_folder From 8a52d8971da93a25b485d28f1fc866f2b94b4dcd Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 4 Aug 2026 19:27:36 +0000 Subject: [PATCH 117/142] update midmax implementation w/ comments and improve readability --- alto/kernels/fp4/mxfp4/mxfp_quantization.py | 80 +++++++++++++-------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_quantization.py b/alto/kernels/fp4/mxfp4/mxfp_quantization.py index 35d3808c..2d50c929 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_quantization.py +++ b/alto/kernels/fp4/mxfp4/mxfp_quantization.py @@ -27,7 +27,7 @@ def is_cdna4(): @triton.jit def _calculate_scales( - x, + x, # raw input dtype, not e2m1 yet. likely fp16 or bf16? BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, QUANT_BLOCK_SIZE: tl.constexpr, @@ -45,52 +45,76 @@ def _calculate_scales( hp_ebits = 8 mbits = 1 sbits = 1 - target_max_pow2 = 2 + target_max_pow2 = 2 # maximum exponent value ( exp^{target_max_pow2} ) + # Reduce each quantization block to its running stats. The 2D and 1D cases + # differ only in how many intra-block axes we reduce over; the downstream + # math is shared. NEW_BLOCK_N: tl.constexpr = BLOCK_N // QUANT_BLOCK_SIZE if IS_2D_BLOCK: NEW_BLOCK_M: tl.constexpr = BLOCK_M // QUANT_BLOCK_SIZE x = x.reshape(NEW_BLOCK_M, QUANT_BLOCK_SIZE, NEW_BLOCK_N, QUANT_BLOCK_SIZE) + block_numel = QUANT_BLOCK_SIZE * QUANT_BLOCK_SIZE if USE_DYNAMIC_CLIP: - mean_squared = tl.sum(tl.sum(x * x, axis=-1), axis=-2) / (QUANT_BLOCK_SIZE * QUANT_BLOCK_SIZE) - mean = tl.sum(tl.sum(x, axis=-1), axis=-2) / (QUANT_BLOCK_SIZE * QUANT_BLOCK_SIZE) - std = tl.sqrt(mean_squared - mean * mean) - max_abs = (2.92247856 / 6.0) * std + 1e-8 - target_max_pow2 = 0 + sum_x = tl.sum(tl.sum(x, axis=-1), axis=-2) + sum_sq = tl.sum(tl.sum(x * x, axis=-1), axis=-2) else: - max_abs = tl.max(tl.abs(x), axis=-1) - max_abs = tl.max(max_abs, axis=-2) + max_abs = tl.max(tl.max(tl.abs(x), axis=-1), axis=-2) else: x = x.reshape(BLOCK_M, NEW_BLOCK_N, QUANT_BLOCK_SIZE) + block_numel = QUANT_BLOCK_SIZE if USE_DYNAMIC_CLIP: - mean_squared = tl.sum(x * x, axis=-1) / QUANT_BLOCK_SIZE - mean = tl.sum(x, axis=-1) / QUANT_BLOCK_SIZE - std = tl.sqrt(mean_squared - mean * mean) - max_abs = (2.92247856 / 6.0) * std + 1e-8 - target_max_pow2 = 0 + sum_x = tl.sum(x, axis=-1) + sum_sq = tl.sum(x * x, axis=-1) else: max_abs = tl.max(tl.abs(x), axis=-1) - max_abs = max_abs.to(x.type.element_ty) + if USE_DYNAMIC_CLIP: + # Estimate absmax from the block's std instead of its true max. + mean = sum_x / block_numel + std = tl.sqrt(sum_sq / block_numel - mean * mean) + max_abs = (2.92247856 / 6.0) * std + 1e-8 + target_max_pow2 = 0 + + # Each branch casts max_abs to the width it needs: the midmax path promotes + # to FP32 for exponent-field surgery, while round-even bitcasts in the input's + # native precision. Casting to element_ty up here would needlessly truncate + # the FP32 dynamic-clip estimate before midmax re-widens it. if USE_MIDMAX: - # Normalize absmax into [2^target_max_pow2, 2^(target_max_pow2+1)) by replacing - # its FP32 exponent field, then bump the scale by 1 if it exceeds the E2M1 - # midmax threshold (7.0), which sits between the two largest representable values. + # Pick the scale from absmax's FP32 exponent, then apply E2M1 "midmax" + # rounding: normalize absmax into [2^target_max_pow2, 2^(target_max_pow2+1)) + # by overwriting its exponent field, and bump the scale by 1 if the result + # exceeds midmax = 7.0 (the midpoint between E2M1's maxfloat 6.0 and 8.0). + FP32_MBITS = 23 + FP32_BIAS = 127 + FP32_MANT_MASK = 0x7FFFFF + FP32_EXP_MAX = 0xFF # exponent field of NaN/Inf + MIDMAX = 7.0 + + # collect exponent (in FP32) from max_abs max_abs_bits = max_abs.to(tl.float32).to(tl.int32, bitcast=True) - f32_exp = (max_abs_bits >> 23) & 0xFF - # NaN/Inf have exponent=0xFF (255); cap to 0xFE so scale stays bounded. - f32_exp = tl.where(f32_exp >= 0xFF, 0xFE, f32_exp) + f32_exp = (max_abs_bits >> FP32_MBITS) & FP32_EXP_MAX + # NaN/Inf have exponent 0xFF; cap to 0xFE so the scale stays bounded. + f32_exp = tl.where(f32_exp >= FP32_EXP_MAX, FP32_EXP_MAX - 1, f32_exp) + scales = f32_exp - target_max_pow2 - amax_scaled_bits = (max_abs_bits & 0x7FFFFF) | ((127 + target_max_pow2) << 23) + amax_scaled_bits = (max_abs_bits & FP32_MANT_MASK) | ((FP32_BIAS + target_max_pow2) << FP32_MBITS) amax_scaled = amax_scaled_bits.to(tl.float32, bitcast=True) - scales = scales + (amax_scaled > 7.0).to(tl.int32) + + scales = scales + (amax_scaled > MIDMAX).to(tl.int32) else: - # round even (adaptive) - max_abs = max_abs.to(hp_int_dtype, bitcast=True) - val_to_add = 1 << (hp_mbits - mbits - 1) - mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits + # round even (adaptive), incoming max_abs is in FP32 + max_abs = max_abs.to(x.type.element_ty).to(hp_int_dtype, bitcast=True) + # with rounding you apply value_to_add on mantissa, + # i.e. 123.2 + 0.5 --> no carry to 124 + # value_to_add is 0.5 here but below, is actually 0.25 + # so anything <0.25 away from carry will be carried + # 7 in + val_to_add = 1 << (hp_mbits - mbits - 1) + mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits + # apply carry on mantissa, collect only the carried exponent max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits - scales = max_abs - target_max_pow2 + scales = max_abs - target_max_pow2 # e8m0 po2 exponent, so applying po2 arithmetic # Today, 2**-127 returns 0 in compile+inductor+triton because it is in the # float32 denormal range. For now, manually adjust the fp scale. This is From b914268a1ab74247ce4979e6879edcedaa1240ff Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 4 Aug 2026 19:58:22 +0000 Subject: [PATCH 118/142] test build multinode docker --- Dockerfile.multinode | 37 ++++-- scripts/multinode.sh | 3 + scripts/train_gptoss_multinode.sh | 202 ++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 7 deletions(-) create mode 100755 scripts/train_gptoss_multinode.sh diff --git a/Dockerfile.multinode b/Dockerfile.multinode index be800369..66a60c7a 100644 --- a/Dockerfile.multinode +++ b/Dockerfile.multinode @@ -1,4 +1,10 @@ -FROM rocm/pytorch:latest +# torch 2.11 (+ matching torchvision/triton) is required by the bundled torchtitan, +# which uses torch>=2.11 APIs (torch.nn.attention.varlen, the +# wrap_inductor_compiled_regions inductor option, ...). There is no prebuilt +# torch 2.11 + ROCm 7.2 image, so this pins the validated ROCm 7.14 + torch 2.11.0 +# build. NOTE: this bumps ROCm 7.2 -> 7.14; the Slurm hosts' amdgpu/KFD driver must +# support a ROCm 7.14 container userspace. +FROM rocm/pytorch:rocm7.14_ubuntu24.04_py3.12_pytorch_release_2.11.0 ARG DEBIAN_FRONTEND=noninteractive @@ -33,8 +39,9 @@ RUN find /etc/apt -type f \ perftest \ ibverbs-providers \ cmake \ - && update-pciids \ - # && rm -rf /var/lib/apt/lists/* + pciutils \ + && (update-pciids || true) +# /var/lib/apt/lists is intentionally NOT removed, to keep apt usable for debugging. RUN pip install --no-cache-dir huggingface_hub "datasets>=3.6.0" \ transformers tabulate wandb fsspec tyro "tokenizers>=0.15.0" safetensors \ @@ -67,9 +74,25 @@ RUN FSDP_PARAM=$(python3 -c "import torch, os; print(os.path.join(os.path.dirnam # Install torchtitan and ALTO training deps at build time (as root) to avoid # /opt/venv permission errors when the container runs as a non-root user. -COPY . /tmp/torchtitan_src -RUN pip install --no-cache-dir aim torchao \ +COPY 3rdparty/torchtitan /tmp/torchtitan_src +# Pin the ROCm torch/torchvision/triton already in the base image via a pip +# constraints file so no transitive dependency (compressed_tensors -> transformers, +# torchao, torchtitan, ...) can pull a CUDA build of torch and the nvidia-* wheels. +# Replacing the ROCm torch breaks the pre-compiled ROCm torchvision ops at runtime +# ("RuntimeError: operator torchvision::nms does not exist" -> ABI mismatch). +# Constraints still let other packages upgrade; they just cannot move torch et al. +# torchao is also installed with --no-deps as belt-and-suspenders. meson-python/ +# pybind11/ninja are torchtitan's declared build backend (build-backend = "mesonpy") +# and must be present here because the torchtitan install uses --no-build-isolation. +# Read versions from package metadata (not `pip freeze`): the ROCm torch is a +# locally-built wheel that pip freeze renders as "torch @ file://...", which a +# "^torch==" grep would miss, producing an empty constraints file. +RUN python3 -c "import importlib.metadata as md; have={d.metadata['Name'].lower() for d in md.distributions()}; want=['torch','torchvision','triton','pytorch-triton-rocm']; open('/tmp/torch-constraints.txt','w').write(''.join(f'{p}=={md.version(p)}\n' for p in want if p.lower() in have))" && \ + cat /tmp/torch-constraints.txt && \ + pip install --no-cache-dir -c /tmp/torch-constraints.txt aim \ compressed_tensors easydict loguru \ - poetry-core "poetry-dynamic-versioning>=1.0.0,<2.0.0" && \ + poetry-core "poetry-dynamic-versioning>=1.0.0,<2.0.0" \ + meson-python pybind11 ninja tyro && \ + pip install --no-cache-dir -c /tmp/torch-constraints.txt --no-deps torchao && \ pip install --no-cache-dir --no-build-isolation --no-deps /tmp/torchtitan_src && \ - rm -rf /tmp/torchtitan_src + rm -rf /tmp/torchtitan_src /tmp/torch-constraints.txt diff --git a/scripts/multinode.sh b/scripts/multinode.sh index dca92f95..0b0e3d1f 100644 --- a/scripts/multinode.sh +++ b/scripts/multinode.sh @@ -16,6 +16,9 @@ IMAGE="alto:multinode" ALTO_DIR="$HOME/lpt_branch/ALTO" GPUS_PER_NODE=8 +cd "$ALTO_DIR" +docker build -f Dockerfile.multinode -t $IMAGE . + # First allocated node becomes the torchrun rendezvous host. MASTER_ADDR="$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)" MASTER_PORT="$((20000 + SLURM_JOB_ID % 20000))" diff --git a/scripts/train_gptoss_multinode.sh b/scripts/train_gptoss_multinode.sh new file mode 100755 index 00000000..9608d434 --- /dev/null +++ b/scripts/train_gptoss_multinode.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +#SBATCH -A amd-arad +#SBATCH -p amd-arad-burst +#SBATCH --qos=low +#SBATCH --nodes=2 +#SBATCH --ntasks-per-node=1 +#SBATCH --gres=gpu:8 +#SBATCH --time=04:00:00 +#SBATCH --job-name=alto-gptoss20b-multinode +#SBATCH --output=alto-gptoss20b-multinode-%j.out +#SBATCH --requeue +# +# Multi-node ALTO GPT-OSS 20B training. +# +# Runs one Docker container per node (from the locally-built alto:multinode +# image) and launches a single torchrun job spanning all nodes, rendezvousing +# on the first allocated node. +# +# sbatch scripts/train_gptoss_20b.sh +# +# Everything below is env-overridable, e.g.: +# TRAINING_STEPS=50 CONFIG=gpt_oss_debugmodel sbatch scripts/train_gptoss_20b.sh +# +# NOTE: CHECKPOINT_DIR, LOG_DIR, HF_HOME_DIR and ALTO_DIR must live on a +# filesystem shared across all nodes (e.g. $HOME or /shared) so every rank sees +# the same repo, model assets and checkpoints. + +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +### Machine-specific args +GPUS_PER_NODE="${GPUS_PER_NODE:-8}" +HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model / dataset cache +DATA_DIR="${DATA_DIR:-/shared_inference}" # data dir exposed to container +HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file with raw HF token + +### Run-specific args +# *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct +ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" # repo dir exposed to container +CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" +RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/${CONFIG}_$RUN_ID}" +LOG_DIR="${LOG_DIR:-$ALTO_DIR/logs}" + +### Other modifiable args +MODULE="${MODULE:-gpt_oss}" +TRAINING_STEPS="${TRAINING_STEPS:-15000}" +MODEL_DIR="${MODEL_DIR:-$HF_HOME_DIR/models/gpt-oss-20b}" + +### Docker image built from Dockerfile.multinode +IMAGE="${IMAGE:-alto:multinode}" +DOCKERFILE="${DOCKERFILE:-Dockerfile.multinode}" + +# ----------------------------------------------------------------------------- +# Build the multinode image on every node +# ----------------------------------------------------------------------------- +# Docker images are local to each node's daemon, so the image must be built on +# every allocated node -- building only on the batch node would leave the other +# nodes unable to find alto:multinode at `docker run` time. + +cd "$ALTO_DIR" + +echo "[build] Building $IMAGE from $DOCKERFILE on all nodes ..." +srun --ntasks-per-node=1 \ + bash -c "cd '$ALTO_DIR' && docker build -f '$DOCKERFILE' -t '$IMAGE' ." + +# ----------------------------------------------------------------------------- +# Rendezvous / bookkeeping +# ----------------------------------------------------------------------------- + +# First allocated node becomes the torchrun rendezvous host. +MASTER_ADDR="$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)" +MASTER_PORT="$((20000 + SLURM_JOB_ID % 20000))" + +mkdir -p "$CHECKPOINT_DIR" "$LOG_DIR" "$HF_HOME_DIR" + +echo "=== ALTO GPT-OSS 20B (multinode) ===" +echo "Nodes: $(scontrol show hostnames "$SLURM_JOB_NODELIST" | paste -sd, -)" +echo "Master: ${MASTER_ADDR}:${MASTER_PORT}" +echo "Image: $IMAGE" +echo "Config: $CONFIG" +echo "GPUs per node: $GPUS_PER_NODE" +echo "Total GPUs: $((GPUS_PER_NODE * SLURM_JOB_NUM_NODES))" +echo "Training steps: $TRAINING_STEPS" +echo "Model dir: $MODEL_DIR" +echo "Checkpoints: $CHECKPOINT_DIR" +echo "Logs: $LOG_DIR" +echo + +# ----------------------------------------------------------------------------- +# Fetch tokenizer / model config once (shared FS, so all ranks reuse it) +# ----------------------------------------------------------------------------- +if [[ -f "$MODEL_DIR/tokenizer.json" ]]; then + echo "[model] Tokenizer already present at $MODEL_DIR, skipping download." +else + echo "[model] Downloading tokenizer / config to $MODEL_DIR ..." + docker run --rm \ + --user "$(id -u):$(id -g)" \ + --network host \ + --env-file "$HF_ENV_FILE" \ + -v "$HOME:$HOME" \ + -v "$HF_HOME_DIR:/hf_home" \ + -v /etc/passwd:/etc/passwd:ro \ + -v /etc/group:/etc/group:ro \ + -e HOME="$HOME" \ + -e USER="$(id -un)" \ + -e HF_HOME=/hf_home \ + "$IMAGE" \ + hf download openai/gpt-oss-20b \ + --include "tokenizer*" "special_tokens_map.json" "config.json" \ + --local-dir "$MODEL_DIR" +fi + +# Export everything the per-node srun step needs. +export IMAGE ALTO_DIR HF_HOME_DIR DATA_DIR HF_ENV_FILE +export CONFIG MODULE TRAINING_STEPS MODEL_DIR CHECKPOINT_DIR LOG_DIR RUN_ID +export GPUS_PER_NODE MASTER_ADDR MASTER_PORT + +# ----------------------------------------------------------------------------- +# Launch: one srun task -> one container -> torchrun per node +# ----------------------------------------------------------------------------- +srun --kill-on-bad-exit=1 bash -c ' + set -euo pipefail + + CONTAINER="alto_${SLURM_JOB_ID}_${SLURM_NODEID}" + NODE_LOG="$LOG_DIR/gpt_oss_20b-${RUN_ID}-node${SLURM_NODEID}.log" + + cleanup() { + docker stop --time 10 "$CONTAINER" >/dev/null 2>&1 || true + } + trap cleanup EXIT INT TERM + + # Assemble docker args, adding hardware resources only when present on this node. + docker_args=( + --rm + --name "$CONTAINER" + --user "$(id -u):$(id -g)" + --network host + --ipc host + --shm-size 128g + --ulimit memlock=-1 + --cap-add SYS_PTRACE + --security-opt seccomp=unconfined + --env-file "$HF_ENV_FILE" + -v "$HOME:$HOME" + -v "$ALTO_DIR:/alto" + -v "$DATA_DIR:$DATA_DIR" + -v "$HF_HOME_DIR:/hf_home" + -v "$CHECKPOINT_DIR:$CHECKPOINT_DIR" + -v /shared:/shared + -v /etc/passwd:/etc/passwd:ro + -v /etc/group:/etc/group:ro + -e HOME="$HOME" + -e USER="$(id -un)" + -e HF_HOME=/hf_home + -e HF_DATASETS_CACHE=/hf_home/datasets + -e TRITON_CACHE_DIR=/tmp/triton_cache + -e TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache + -e PYTHONNOUSERSITE=1 + -e TRANSFORMERS_OFFLINE=1 + -e PYTORCH_ALLOC_CONF=expandable_segments:True + -w /alto + ) + + for device in /dev/kfd /dev/dri /dev/infiniband; do + [[ -e "$device" ]] && docker_args+=(--device "$device") + done + + for group in render video; do + gid="$(getent group "$group" | cut -d: -f3 || true)" + [[ -n "$gid" ]] && docker_args+=(--group-add "$gid") + done + + echo "[node $SLURM_NODEID] launching torchrun on $(hostname) -> $NODE_LOG" + + docker run "${docker_args[@]}" "$IMAGE" \ + torchrun \ + --nnodes "$SLURM_JOB_NUM_NODES" \ + --nproc-per-node "$GPUS_PER_NODE" \ + --node-rank "$SLURM_NODEID" \ + --master-addr "$MASTER_ADDR" \ + --master-port "$MASTER_PORT" \ + --local-ranks-filter 0 \ + --tee 3 \ + -m alto.train \ + --module "$MODULE" \ + --config "$CONFIG" \ + --training.steps "$TRAINING_STEPS" \ + --comm.init_timeout_seconds 1800 \ + --hf_assets_path "$MODEL_DIR" \ + --dump_folder "$CHECKPOINT_DIR" \ + --profiling.enable_profiling \ + --profiling.profile_freq 1000 \ + --profiling.profiler_warmup 3 \ + --profiling.profiler_active 1 \ + 2>&1 | tee "$NODE_LOG" +' + +echo "[train] Multinode run complete." From 4d0d3d35c08ee851c9a584bcdd72995d91cec2d0 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 5 Aug 2026 16:59:22 +0000 Subject: [PATCH 119/142] add correct torchao version and slurm job id into run_id --- scripts/train_gptoss20b.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) mode change 100644 => 100755 scripts/train_gptoss20b.sh diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh old mode 100644 new mode 100755 index f98d40f2..f02534f6 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -46,6 +46,15 @@ set -euo pipefail # Configuration # ----------------------------------------------------------------------------- +# If launched via sbatch, SLURM sets SLURM_JOB_ID; fold it into RUN_ID so the +# checkpoint dir and log filename are traceable back to the SLURM job. +SLURM_JOB_ID="${SLURM_JOB_ID:-${SLURM_JOBID:-}}" +if [[ -n "$SLURM_JOB_ID" ]]; then + RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-slurm${SLURM_JOB_ID}}" +else + RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +fi + ### Machine-specific args NGPU="${NGPU:-8}" HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location @@ -56,8 +65,8 @@ HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token # *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" # expose repo dir to container CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" -RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" -CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/$CONFIG_$RUN_ID}" + +CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/${CONFIG}_$RUN_ID}" LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time ### Other modifiable args @@ -80,6 +89,7 @@ mkdir -p \ echo "=== ALTO GPT-OSS 20B ===" echo "Node: $(hostname)" +[[ -n "$SLURM_JOB_ID" ]] && echo "SLURM job: $SLURM_JOB_ID" echo "Image: $IMAGE" echo "Config: $CONFIG" echo "GPUs: $NGPU" @@ -179,7 +189,7 @@ fi echo "[train] Installing dependencies ..." docker exec "$CONTAINER" bash -c " - python3 -m pip install -q torchao && + python3 -m pip install -q 'torchao==0.16.0' && python3 -m pip install -q \ --no-build-isolation \ --no-deps \ From c49fc62a7bb0e589d73fe8e1581db60a37df2859 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Tue, 4 Aug 2026 19:55:59 +0000 Subject: [PATCH 120/142] add midmax compatibility w/ bw and grouped gemm --- .../fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py | 13 ++++++++++++- .../fp4/mxfp4/mxfp_grouped_gemm/functional.py | 1 + alto/kernels/fp4/mxfp4/mxfp_linear.py | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py index b215a122..9e721f61 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py @@ -614,6 +614,7 @@ def forward( use_sr_grad=False, use_dge=False, clip_mode=False, + use_midmax=False, use_macro_block_scaling=False, hadamard_transform: Optional[HadamardTransform] = None, ): @@ -641,11 +642,13 @@ def forward( inputs_scaled, axis=-1, is_2d_block=use_2dblock_x, + use_midmax=use_midmax, ) expert_weights_mxfp4, expert_weight_scales = torch.ops.torchtitan.convert_to_mxfp4( expert_weights_scaled, axis=quant_axis_w, is_2d_block=use_2dblock_w, + use_midmax=use_midmax, ) if is_cdna4(): @@ -697,6 +700,7 @@ def forward( expert_weights_scaled, axis=requant_axis_w, is_2d_block=False, + use_midmax=use_midmax, ) if not is_cdna4(): w_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -724,6 +728,7 @@ def forward( axis=0, is_2d_block=False, clip_mode=clip_mode, + use_midmax=use_midmax, ) if not is_cdna4(): x_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -751,6 +756,7 @@ def forward( ctx.use_dge = use_dge ctx.hadamard_transform = hadamard_transform ctx.clip_mode = clip_mode + ctx.use_midmax = use_midmax ctx.use_macro_block_scaling = use_macro_block_scaling return res @@ -785,6 +791,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=True, + use_midmax=ctx.use_midmax, ) grad_output_mxfp4_m = grad_output_mxfp4 grad_output_scales_m = grad_output_scales @@ -812,6 +819,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=False, + use_midmax=ctx.use_midmax, ) if ctx.hadamard_transform is not None: grad_output = ctx.hadamard_transform(grad_output, left_mul=True) @@ -826,6 +834,7 @@ def backward(ctx, grad_output): use_sr=ctx.use_sr_grad, is_2d_block=False, clip_mode=ctx.clip_mode, + use_midmax=ctx.use_midmax, ) if not is_cdna4(): @@ -918,7 +927,7 @@ def backward(ctx, grad_output): ) grad_weights *= dge_bwd(w_fp4_values, torch.float4_e2m1fn_x2) - return grad_inputs, grad_weights, None, None, None, None, None, None, None, None, None + return grad_inputs, grad_weights, None, None, None, None, None, None, None, None, None, None def mxfp4_grouped_gemm( @@ -933,6 +942,7 @@ def mxfp4_grouped_gemm( use_dge: bool = False, use_hadamard: bool = False, clip_mode: str = "none", + use_midmax: bool = False, use_macro_block_scaling: bool = False, ) -> torch.Tensor: """ @@ -973,6 +983,7 @@ def mxfp4_grouped_gemm( use_sr_grad, use_dge, clip_mode, + use_midmax, use_macro_block_scaling, hadamard_transform, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py index b266a298..210829bf 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py @@ -33,5 +33,6 @@ def _quantize_then_mxfp_scaled_grouped_mm( use_dge=use_dge, use_hadamard=use_hadamard, clip_mode=clip_mode, + use_midmax=use_midmax, use_macro_block_scaling=use_macro_block_scaling, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 059de958..35399f81 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -446,6 +446,7 @@ def backward(ctx, grad_output): axis=-1, is_2d_block=True, use_sr=ctx.use_sr_grad, + use_midmax=ctx.use_midmax, ) grad_output_mxfp4_m = grad_output_mxfp4 grad_output_scales_m = grad_output_scales From bfdff958a8f285d63c425fcf9f80aa67cd3c2b5b Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 5 Aug 2026 19:49:38 +0000 Subject: [PATCH 121/142] instead of dedicated "midmax" path, create more general scale selection constexpr flag --- alto/kernels/dispatch/config.py | 8 +++- alto/kernels/dispatch/tensor.py | 4 +- .../mxfp4/mxfp_grouped_gemm/cg_backward.py | 22 ++++----- .../fp4/mxfp4/mxfp_grouped_gemm/functional.py | 4 +- alto/kernels/fp4/mxfp4/mxfp_linear.py | 22 ++++----- alto/kernels/fp4/mxfp4/mxfp_quantization.py | 19 +++++--- .../gpt_oss/configs/lpt_recipe_midmax.yaml | 3 +- alto/models/gpt_oss/configs/mxfp4_base.yaml | 3 +- alto/modifiers/lpt/base.py | 4 +- .../mxfp4/test_midmax_quantization.py | 48 +++++++++---------- 10 files changed, 75 insertions(+), 62 deletions(-) diff --git a/alto/kernels/dispatch/config.py b/alto/kernels/dispatch/config.py index bc90b6a1..05cf3fa6 100644 --- a/alto/kernels/dispatch/config.py +++ b/alto/kernels/dispatch/config.py @@ -48,7 +48,13 @@ class TrainingOpConfig: * NVFP4: not implemented """ - use_midmax: bool = False + blockscale_selection: Literal["default", "midmax", "uos"] = "default" + """ + block scale-selection strategy for MXFP4 quantization. + * default: round-even (adaptive) exponent selection (threshold > 7.0) + * midmax: alternative implementation of default with threshold >= 7.0 + * uos: not yet implemented + """ torch.serialization.add_safe_globals([TrainingOpConfig]) diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index 422e1466..f365186e 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -256,7 +256,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): use_hadamard=config.use_hadamard, clip_mode=config.clip_mode, use_macro_block_scaling=config.two_level_scaling == "blockwise", - use_midmax=config.use_midmax, + blockscale_selection=config.blockscale_selection, ) # linear op override @@ -288,7 +288,7 @@ def __torch_function__(cls, func, types, args, kwargs={}): clip_mode=config.clip_mode, use_hadamard=config.use_hadamard, use_macro_block_scaling=config.two_level_scaling == "blockwise", - use_midmax=config.use_midmax, + blockscale_selection=config.blockscale_selection, ) if bias is not None: Y = Y + bias diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py index 9e721f61..ea7921b5 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py @@ -614,7 +614,7 @@ def forward( use_sr_grad=False, use_dge=False, clip_mode=False, - use_midmax=False, + blockscale_selection="default", use_macro_block_scaling=False, hadamard_transform: Optional[HadamardTransform] = None, ): @@ -642,13 +642,13 @@ def forward( inputs_scaled, axis=-1, is_2d_block=use_2dblock_x, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) expert_weights_mxfp4, expert_weight_scales = torch.ops.torchtitan.convert_to_mxfp4( expert_weights_scaled, axis=quant_axis_w, is_2d_block=use_2dblock_w, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if is_cdna4(): @@ -700,7 +700,7 @@ def forward( expert_weights_scaled, axis=requant_axis_w, is_2d_block=False, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if not is_cdna4(): w_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -728,7 +728,7 @@ def forward( axis=0, is_2d_block=False, clip_mode=clip_mode, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if not is_cdna4(): x_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -756,7 +756,7 @@ def forward( ctx.use_dge = use_dge ctx.hadamard_transform = hadamard_transform ctx.clip_mode = clip_mode - ctx.use_midmax = use_midmax + ctx.blockscale_selection = blockscale_selection ctx.use_macro_block_scaling = use_macro_block_scaling return res @@ -791,7 +791,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=True, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) grad_output_mxfp4_m = grad_output_mxfp4 grad_output_scales_m = grad_output_scales @@ -819,7 +819,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=False, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) if ctx.hadamard_transform is not None: grad_output = ctx.hadamard_transform(grad_output, left_mul=True) @@ -834,7 +834,7 @@ def backward(ctx, grad_output): use_sr=ctx.use_sr_grad, is_2d_block=False, clip_mode=ctx.clip_mode, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) if not is_cdna4(): @@ -942,7 +942,7 @@ def mxfp4_grouped_gemm( use_dge: bool = False, use_hadamard: bool = False, clip_mode: str = "none", - use_midmax: bool = False, + blockscale_selection: str = "default", use_macro_block_scaling: bool = False, ) -> torch.Tensor: """ @@ -983,7 +983,7 @@ def mxfp4_grouped_gemm( use_sr_grad, use_dge, clip_mode, - use_midmax, + blockscale_selection, use_macro_block_scaling, hadamard_transform, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py index 210829bf..46f5bd7c 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/functional.py @@ -18,7 +18,7 @@ def _quantize_then_mxfp_scaled_grouped_mm( use_dge: bool, use_hadamard: bool, clip_mode: str, - use_midmax: bool = False, + blockscale_selection: str = "default", use_macro_block_scaling: bool = False, ) -> torch.Tensor: m_indices = create_indices_from_offsets_nosync(offs) @@ -33,6 +33,6 @@ def _quantize_then_mxfp_scaled_grouped_mm( use_dge=use_dge, use_hadamard=use_hadamard, clip_mode=clip_mode, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, use_macro_block_scaling=use_macro_block_scaling, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 35399f81..432dcd0a 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -272,7 +272,7 @@ def forward( use_sr_grad, use_dge, clip_mode, - use_midmax, + blockscale_selection, use_macro_block_scaling, hadamard_transform: Optional[HadamardTransform] = None, ): @@ -311,14 +311,14 @@ def forward( x_scaled, axis=-1, is_2d_block=use_2dblock_x, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4( w_scaled, axis=-1, is_2d_block=use_2dblock_w, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if is_cdna4(): @@ -366,7 +366,7 @@ def forward( w_scaled, axis=0, is_2d_block=False, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if not is_cdna4(): w_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -392,7 +392,7 @@ def forward( axis=0, is_2d_block=False, clip_mode=clip_mode, - use_midmax=use_midmax, + blockscale_selection=blockscale_selection, ) if not is_cdna4(): x_dq = torch.ops.torchtitan.convert_from_mxfp4( @@ -416,7 +416,7 @@ def forward( ctx.hadamard_transform = hadamard_transform ctx.use_dge = use_dge ctx.clip_mode = clip_mode - ctx.use_midmax = use_midmax + ctx.blockscale_selection = blockscale_selection ctx.use_macro_block_scaling = use_macro_block_scaling return y.view(*original_shape[:-1], -1) # Reshape back to original @@ -446,7 +446,7 @@ def backward(ctx, grad_output): axis=-1, is_2d_block=True, use_sr=ctx.use_sr_grad, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) grad_output_mxfp4_m = grad_output_mxfp4 grad_output_scales_m = grad_output_scales @@ -474,7 +474,7 @@ def backward(ctx, grad_output): axis=-1, use_sr=ctx.use_sr_grad, is_2d_block=False, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) if ctx.hadamard_transform is not None: @@ -490,7 +490,7 @@ def backward(ctx, grad_output): use_sr=ctx.use_sr_grad, is_2d_block=False, clip_mode=ctx.clip_mode, - use_midmax=ctx.use_midmax, + blockscale_selection=ctx.blockscale_selection, ) if not is_cdna4(): @@ -580,7 +580,7 @@ def _to_mxfp4_then_scaled_mm( clip_mode: str, use_hadamard: bool, use_macro_block_scaling: bool = False, - use_midmax: bool = False, + blockscale_selection: str = "default", ) -> torch.Tensor: if use_hadamard: with torch.no_grad(): @@ -595,7 +595,7 @@ def _to_mxfp4_then_scaled_mm( use_sr_grad, use_dge, clip_mode, - use_midmax, + blockscale_selection, use_macro_block_scaling, hadamard_transform, ) diff --git a/alto/kernels/fp4/mxfp4/mxfp_quantization.py b/alto/kernels/fp4/mxfp4/mxfp_quantization.py index 2d50c929..1dcd76d7 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_quantization.py +++ b/alto/kernels/fp4/mxfp4/mxfp_quantization.py @@ -33,7 +33,7 @@ def _calculate_scales( QUANT_BLOCK_SIZE: tl.constexpr, IS_2D_BLOCK: tl.constexpr = False, USE_DYNAMIC_CLIP: tl.constexpr = False, - USE_MIDMAX: tl.constexpr = False, + SCALE_SELECTION: tl.constexpr = "default", ): if x.type.element_ty == tl.float32: hp_int_dtype = tl.int32 @@ -80,7 +80,7 @@ def _calculate_scales( # to FP32 for exponent-field surgery, while round-even bitcasts in the input's # native precision. Casting to element_ty up here would needlessly truncate # the FP32 dynamic-clip estimate before midmax re-widens it. - if USE_MIDMAX: + if SCALE_SELECTION == "midmax": # Pick the scale from absmax's FP32 exponent, then apply E2M1 "midmax" # rounding: normalize absmax into [2^target_max_pow2, 2^(target_max_pow2+1)) # by overwriting its exponent field, and bump the scale by 1 if the result @@ -102,8 +102,13 @@ def _calculate_scales( amax_scaled = amax_scaled_bits.to(tl.float32, bitcast=True) scales = scales + (amax_scaled > MIDMAX).to(tl.int32) + elif SCALE_SELECTION == "uos": + # TODO: populate the "uos" scale-selection branch. Until it is + # implemented, selecting it is a compile-time error rather than a + # silent fallthrough to round-even. + tl.static_assert(False, "SCALE_SELECTION='uos' is not implemented yet") else: - # round even (adaptive), incoming max_abs is in FP32 + # "default": round even (adaptive), incoming max_abs is in FP32 max_abs = max_abs.to(x.type.element_ty).to(hp_int_dtype, bitcast=True) # with rounding you apply value_to_add on mantissa, # i.e. 123.2 + 0.5 --> no carry to 124 @@ -307,7 +312,7 @@ def _convert_to_mxfp4_kernel( USE_ASM: tl.constexpr, USE_STATIC_CLIP: tl.constexpr, USE_DYNAMIC_CLIP: tl.constexpr, - USE_MIDMAX: tl.constexpr, + SCALE_SELECTION: tl.constexpr, ): """ Quantizes the input tensor `x_ptr` and stores the result in `y_ptr` and the scaling factor in `s_ptr`. @@ -347,7 +352,7 @@ def _convert_to_mxfp4_kernel( QUANT_BLOCK_SIZE=QUANT_BLOCK_SIZE, IS_2D_BLOCK=IS_2D_BLOCK, USE_DYNAMIC_CLIP=USE_DYNAMIC_CLIP, - USE_MIDMAX=USE_MIDMAX, + SCALE_SELECTION=SCALE_SELECTION, ) if USE_STATIC_CLIP: @@ -450,7 +455,7 @@ def convert_to_mxfp4( philox_seed: Optional[int] = None, philox_offset: Optional[int] = None, clip_mode: str = "none", - use_midmax: bool = False, + blockscale_selection: str = "default", ) -> Tuple[torch.Tensor, torch.Tensor]: torch._check(data_hp.shape[axis] % block_size == 0) assert not is_2d_block or data_hp.size(-2) % block_size == 0 @@ -510,7 +515,7 @@ def convert_to_mxfp4( USE_ASM=use_asm, USE_STATIC_CLIP=use_static_clip, USE_DYNAMIC_CLIP=use_dynamic_clip, - USE_MIDMAX=use_midmax, + SCALE_SELECTION=blockscale_selection, ) return data_lp.reshape(new_shape).transpose(axis, -1), scales.reshape(scales_shape).transpose(axis, -1) diff --git a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml index eab85517..9871b621 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml @@ -21,7 +21,8 @@ training_stage: # 2-level scaling, disabled by default # NVFP4 requires tensorwise scaling or you will get overflow issues two_level_scaling: none - use_midmax: true + # block scale selection: "default" (round-even), "midmax", or "uos" + blockscale_selection: "midmax" # weight de-oscillation, disabled by default # deosc_step: 2000 # deosc_period: 200 diff --git a/alto/models/gpt_oss/configs/mxfp4_base.yaml b/alto/models/gpt_oss/configs/mxfp4_base.yaml index 004d45ef..4d40c402 100644 --- a/alto/models/gpt_oss/configs/mxfp4_base.yaml +++ b/alto/models/gpt_oss/configs/mxfp4_base.yaml @@ -21,7 +21,8 @@ training_stage: # 2-level scaling, disabled by default # NVFP4 requires tensorwise scaling or you will get overflow issues two_level_scaling: none - use_midmax: false + # block scale selection: "default" (round-even), "midmax", or "uos" + blockscale_selection: "default" # weight de-oscillation, disabled by default # deosc_step: 2000 # deosc_period: 200 diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index 5f80af41..82b34908 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -37,7 +37,7 @@ class LowPrecisionTrainingModifier(Modifier): use_dge: bool = False two_level_scaling: Literal["none", "tensorwise", "blockwise"] = "none" clip_mode: Literal["none", "static", "dynamic"] = "none" - use_midmax: bool = False + blockscale_selection: Literal["default", "midmax", "uos"] = "default" lora_rank: int = 0 @@ -160,7 +160,7 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: use_dge=self.use_dge, two_level_scaling=self.two_level_scaling, clip_mode=self.clip_mode, - use_midmax=self.use_midmax, + blockscale_selection=self.blockscale_selection, ) self._resolved_config[scheme_obj] = targets return self._resolved_config diff --git a/tests/unittest/mxfp4/test_midmax_quantization.py b/tests/unittest/mxfp4/test_midmax_quantization.py index 975014d3..32c300e6 100644 --- a/tests/unittest/mxfp4/test_midmax_quantization.py +++ b/tests/unittest/mxfp4/test_midmax_quantization.py @@ -48,7 +48,7 @@ def _make_block(value: float, block_size: int = 32, dtype=torch.float32) -> torc def _midmax_scale_ref(amax: float, block_size: int = 32) -> int: """ Pure-Python reference for the midmax uint8 scale given a block's amax. - Mirrors the triton kernel logic in _calculate_scales with USE_MIDMAX=True. + Mirrors the triton kernel logic in _calculate_scales with SCALE_SELECTION="midmax". """ import struct @@ -111,12 +111,12 @@ def test_midmax_scale_bump(amax, expect_bump): @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) def test_midmax_scale_matches_ref_random(dtype): """ - Triton kernel (use_midmax=True) scales must match the pure-Python reference + Triton kernel (blockscale_selection="midmax") scales must match the pure-Python reference on random data. """ torch.manual_seed(42) x = torch.randn(128, 64, dtype=dtype, device="cuda") - _, scales_triton = convert_to_mxfp4(x, use_midmax=True) + _, scales_triton = convert_to_mxfp4(x, blockscale_selection="midmax") # build reference scales block-by-block x_f32 = x.float() @@ -134,7 +134,7 @@ def test_midmax_scale_matches_ref_random(dtype): # --------------------------------------------------------------------------- -# 3. Kernel output: use_midmax=True vs use_midmax=False differ appropriately +# 3. Kernel output: blockscale_selection="midmax" vs blockscale_selection="default" differ appropriately # --------------------------------------------------------------------------- def test_midmax_differs_from_round_even_on_outliers(): @@ -145,8 +145,8 @@ def test_midmax_differs_from_round_even_on_outliers(): block_size = 32 x = torch.full((4, 64), 7.0, dtype=torch.float32, device="cuda") - _, scales_midmax = convert_to_mxfp4(x, use_midmax=True) - _, scales_round_even = convert_to_mxfp4(x, use_midmax=False) + _, scales_midmax = convert_to_mxfp4(x, blockscale_selection="midmax") + _, scales_round_even = convert_to_mxfp4(x, blockscale_selection="default") assert not torch.equal(scales_midmax, scales_round_even), \ "Expected scales to differ at amax==7.0 (midmax boundary)" @@ -157,18 +157,18 @@ def test_midmax_differs_from_round_even_on_outliers(): def test_midmax_false_matches_pytorch_ref(): """ - With use_midmax=False the triton kernel must match the existing pytorch + With blockscale_selection="default" the triton kernel must match the existing pytorch reference implementation (which implements round-even only). """ x = prepare_data((128, 64), torch.float32) - _, scales_triton = convert_to_mxfp4(x, use_midmax=False) + _, scales_triton = convert_to_mxfp4(x, blockscale_selection="default") _, scales_ref = convert_to_mxfp4_pytorch(x) assert torch.all(scales_triton == scales_ref).item(), \ "Triton round-even scales differ from pytorch reference" # --------------------------------------------------------------------------- -# 4. Quantize → dequantize roundtrip with use_midmax=True +# 4. Quantize → dequantize roundtrip with blockscale_selection="midmax" # --------------------------------------------------------------------------- @pytest.mark.parametrize("tensor_shape", [(128, 64), (4, 128, 64)]) @@ -177,12 +177,12 @@ def test_midmax_false_matches_pytorch_ref(): @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) def test_midmax_roundtrip(tensor_shape, axis, is_2d_block, dtype): """ - Roundtrip (quant → dequant) with use_midmax=True should reconstruct + Roundtrip (quant → dequant) with blockscale_selection="midmax" should reconstruct the input with reasonable accuracy (MAE within one E2M1 quantum of the block scale). """ x = prepare_data(tensor_shape, dtype) - data_lp, scales = convert_to_mxfp4(x, axis=axis, is_2d_block=is_2d_block, use_midmax=True) + data_lp, scales = convert_to_mxfp4(x, axis=axis, is_2d_block=is_2d_block, blockscale_selection="midmax") x_dq = convert_from_mxfp4(data_lp, scales, output_dtype=dtype, axis=axis, is_2d_block=is_2d_block) mae = (x.float() - x_dq.float()).abs().mean().item() @@ -198,7 +198,7 @@ def test_midmax_roundtrip(tensor_shape, axis, is_2d_block, dtype): def test_midmax_all_zeros(): """A block of all zeros must not produce NaN/Inf scales or outputs.""" x = torch.zeros(32, 32, dtype=torch.float32, device="cuda") - data_lp, scales = convert_to_mxfp4(x, use_midmax=True) + data_lp, scales = convert_to_mxfp4(x, blockscale_selection="midmax") assert not torch.any(torch.isnan(scales.float())), "NaN in scales for zero input" assert torch.all(scales >= 1).item(), "scale below minimum-normal clamp" @@ -210,7 +210,7 @@ def test_midmax_all_zeros(): def test_midmax_large_values(): """Very large values (near FP32 max) must not produce inf/nan scales.""" x = torch.full((32, 32), 1e30, dtype=torch.float32, device="cuda") - data_lp, scales = convert_to_mxfp4(x, use_midmax=True) + data_lp, scales = convert_to_mxfp4(x, blockscale_selection="midmax") assert torch.all(scales < 255).item(), "scale hit 0xFF (inf/nan exponent)" assert not torch.any(torch.isnan(scales.float())) @@ -221,8 +221,8 @@ def test_midmax_negative_values(): x_pos = torch.abs(torch.randn(64, 64, dtype=torch.float32, device="cuda")) x_neg = -x_pos - _, scales_pos = convert_to_mxfp4(x_pos, use_midmax=True) - _, scales_neg = convert_to_mxfp4(x_neg, use_midmax=True) + _, scales_pos = convert_to_mxfp4(x_pos, blockscale_selection="midmax") + _, scales_neg = convert_to_mxfp4(x_neg, blockscale_selection="midmax") assert torch.all(scales_pos == scales_neg).item(), \ "Negating all values should not change midmax scales" @@ -237,7 +237,7 @@ def test_midmax_single_outlier_block(): # First block: push amax above midmax x[0, :block_size] = 7.1 - _, scales = convert_to_mxfp4(x, use_midmax=True) + _, scales = convert_to_mxfp4(x, blockscale_selection="midmax") scale_bumped_block = scales[0, 0].item() scale_small_block = scales[0, 1].item() assert scale_bumped_block > scale_small_block, \ @@ -254,18 +254,18 @@ def test_midmax_scale_minimum_clamp(): An all-zero block exercises this path. """ x = torch.zeros(32, 64, dtype=torch.float32, device="cuda") - _, scales = convert_to_mxfp4(x, use_midmax=True) + _, scales = convert_to_mxfp4(x, blockscale_selection="midmax") assert torch.all(scales >= 1).item(), "All scales must be >= 1 (minimum-normal clamp)" # --------------------------------------------------------------------------- -# 7. Consistency: use_midmax=True gives same result across dtypes (f32 vs bf16) +# 7. Consistency: blockscale_selection="midmax" gives same result across dtypes (f32 vs bf16) # --------------------------------------------------------------------------- @pytest.mark.parametrize("use_asm", [False]) def test_midmax_dtype_consistency(use_asm): """ - Quantizing the same tensor in float32 and bfloat16 with use_midmax=True + Quantizing the same tensor in float32 and bfloat16 with blockscale_selection="midmax" should yield scales that are close (within ±1) due to bf16 precision loss. """ if use_asm and not is_cdna4(): @@ -275,8 +275,8 @@ def test_midmax_dtype_consistency(use_asm): x_f32 = torch.randn(64, 64, dtype=torch.float32, device="cuda") x_bf16 = x_f32.to(torch.bfloat16) - _, scales_f32 = convert_to_mxfp4(x_f32, use_midmax=True, use_asm=use_asm) - _, scales_bf16 = convert_to_mxfp4(x_bf16, use_midmax=True, use_asm=use_asm) + _, scales_f32 = convert_to_mxfp4(x_f32, blockscale_selection="midmax", use_asm=use_asm) + _, scales_bf16 = convert_to_mxfp4(x_bf16, blockscale_selection="midmax", use_asm=use_asm) diff = (scales_f32.int() - scales_bf16.int()).abs() assert diff.max().item() <= 1, \ @@ -288,13 +288,13 @@ def test_midmax_dtype_consistency(use_asm): # --------------------------------------------------------------------------- def test_midmax_asm_matches_non_asm(): - """On CDNA4, use_asm=True with use_midmax=True must match use_asm=False.""" + """On CDNA4, use_asm=True with blockscale_selection="midmax" must match use_asm=False.""" if not is_cdna4(): pytest.skip("ASM path requires CDNA4 hardware") x = prepare_data((128, 64), torch.float32) - data_lp_asm, scales_asm = convert_to_mxfp4(x, use_midmax=True, use_asm=True) - data_lp_ref, scales_ref = convert_to_mxfp4(x, use_midmax=True, use_asm=False) + data_lp_asm, scales_asm = convert_to_mxfp4(x, blockscale_selection="midmax", use_asm=True) + data_lp_ref, scales_ref = convert_to_mxfp4(x, blockscale_selection="midmax", use_asm=False) assert torch.all(scales_asm == scales_ref).item(), "ASM/non-ASM scales differ under midmax" assert torch.all(data_lp_asm == data_lp_ref).item(), "ASM/non-ASM fp4 values differ under midmax" From d8e36b17a7e67df41a53ebb932071d1baf8afd61 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 5 Aug 2026 20:05:57 +0000 Subject: [PATCH 122/142] make checkpoint dir have only slrum name so it auto loads when pre-empted --- scripts/train_gptoss20b.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index f02534f6..155a2d10 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -50,7 +50,7 @@ set -euo pipefail # checkpoint dir and log filename are traceable back to the SLURM job. SLURM_JOB_ID="${SLURM_JOB_ID:-${SLURM_JOBID:-}}" if [[ -n "$SLURM_JOB_ID" ]]; then - RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-slurm${SLURM_JOB_ID}}" + RUN_ID="${RUN_ID:${SLURM_JOB_ID}}" else RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" fi From 3bc75300a44255ea55f0aba224966b7abb5890f0 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Wed, 5 Aug 2026 20:13:58 +0000 Subject: [PATCH 123/142] add plotting end step for viz --- plotting/plot_training_stats.py | 67 ++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py index 01d55201..d48d92fe 100644 --- a/plotting/plot_training_stats.py +++ b/plotting/plot_training_stats.py @@ -34,8 +34,9 @@ python3 plot_training_stats.py part1.out,part2.out=baseline TOML config format (see runs.example.toml): - output = "val_loss.png" # optional, overridden by -o - title = "Validation loss vs. step" # optional + output = "val_loss.png" # optional, overridden by -o + title = "Validation loss vs. step" # optional + max_step = 5000 # optional, global step cutoff (-m) [[runs]] file = "slurm-207639.out" @@ -56,6 +57,16 @@ [[runs]] file = "gpt_oss_20b-pretrain-bf16/tb" name = "bf16 (tb)" + + # limit how far along the x-axis data is shown. a top-level `max_step` + # applies to every run; a per-run `max_step` overrides it for that run. + # (also settable on the CLI with --max-step.) + max_step = 5000 # optional, global cutoff + + [[runs]] + file = "slurm-209000.out" + name = "short view" + max_step = 2000 # optional, per-run cutoff """ import argparse import os @@ -198,12 +209,30 @@ def parse_many(paths): return tr_steps, tr_losses, grad_norms, val_steps, val_losses +def clip_to_step(data, max_step): + """Trim parsed data to steps <= max_step (no-op if max_step is None). + + `data` is the 5-tuple returned by parse()/parse_many(); training arrays + (steps/losses/grad_norms) stay aligned, as do the validation arrays. + """ + tr_steps, tr_losses, grad_norms, val_steps, val_losses = data + if max_step is None: + return data + tr = [(s, l, g) for s, l, g in zip(tr_steps, tr_losses, grad_norms) if s <= max_step] + va = [(s, l) for s, l in zip(val_steps, val_losses) if s <= max_step] + tr_steps, tr_losses, grad_norms = map(list, zip(*tr)) if tr else ([], [], []) + val_steps, val_losses = map(list, zip(*va)) if va else ([], []) + return tr_steps, tr_losses, grad_norms, val_steps, val_losses + + def runs_from_config(path): - """Return (runs, output, title, details) from a .toml config. + """Return (runs, output, title, details, max_step) from a .toml config. - runs is a list of (files, label) tuples where files is a list of one or - more paths (a run may span several .out files). Paths are resolved relative - to the config file's directory so a config can be run from anywhere. + runs is a list of (files, label, max_step) tuples where files is a list of + one or more paths (a run may span several .out files) and max_step is an + optional per-run step cutoff (None if unset). Paths are resolved relative + to the config file's directory so a config can be run from anywhere. The + returned top-level max_step is the global cutoff (None if unset). """ if tomllib is None: sys.exit("reading a .toml config needs Python 3.11+ or the 'tomli' package (pip install tomli)") @@ -222,8 +251,9 @@ def runs_from_config(path): sys.exit(f"{path}: runs[{i}] has an empty file list") paths = [f if os.path.isabs(f) else os.path.join(base, f) for f in file_list] label = r.get("name") or os.path.basename(file_list[0]) - runs.append((paths, label)) - return runs, cfg.get("output"), cfg.get("title"), cfg.get("details") + runs.append((paths, label, r.get("max_step"))) + return (runs, cfg.get("output"), cfg.get("title"), cfg.get("details"), + cfg.get("max_step")) def main(): @@ -236,13 +266,16 @@ def main(): ap.add_argument("-o", "--output", help="output image path (default: val_loss.png)") ap.add_argument("-t", "--title", help="plot title") ap.add_argument("-d", "--details", help="extra info shown in a small text box (top-right)") + ap.add_argument("-m", "--max-step", type=int, + help="only show data up to this step (applies to all runs; " + "overrides per-run/global max_step in a config)") args = ap.parse_args() - cfg_out = cfg_title = cfg_details = None + cfg_out = cfg_title = cfg_details = cfg_max_step = None if args.config: if args.logfiles: sys.exit("provide runs either positionally or via -c/--config, not both") - runs, cfg_out, cfg_title, cfg_details = runs_from_config(args.config) + runs, cfg_out, cfg_title, cfg_details, cfg_max_step = runs_from_config(args.config) else: if not args.logfiles: ap.error("no runs given: pass logfiles positionally or use -c/--config") @@ -257,11 +290,14 @@ def main(): paths = path.split(",") if label is None: label = os.path.basename(paths[0]) - runs.append((paths, label)) + runs.append((paths, label, None)) out = args.output or cfg_out or "val_loss.png" suptitle = args.title or cfg_title or "GPT-OSS 20b" details = args.details or cfg_details + # global step cutoff: CLI wins, else config's top-level max_step. A per-run + # max_step (set only via config) overrides this for that run. + global_max_step = args.max_step if args.max_step is not None else cfg_max_step # figure: stacked training-loss (top), grad-norm (middle), and # validation-loss (bottom) plots on the left, table on the right. When @@ -286,7 +322,9 @@ def main(): colors = {} # label -> line color total_val = 0 # validation datapoints (table) - for paths, label in runs: + for paths, label, run_max_step in runs: + # per-run max_step overrides the global cutoff; the global applies otherwise + max_step = run_max_step if run_max_step is not None else global_max_step existing = [p for p in paths if os.path.exists(p)] missing = [p for p in paths if not os.path.exists(p)] if missing: @@ -301,7 +339,8 @@ def main(): colors[label] = line.get_color() continue - tr_steps, tr_losses, grad_norms, val_steps, val_losses = parse_many(existing) + tr_steps, tr_losses, grad_norms, val_steps, val_losses = clip_to_step( + parse_many(existing), max_step) # curves plot TRAINING loss (no per-point marker — too dense) (line,) = ax.plot(tr_steps, tr_losses, linewidth=1.2, label=label) @@ -407,7 +446,7 @@ def main(): pdf_out, svg_out = base + ".pdf", base + ".svg" fig.savefig(pdf_out) fig.savefig(svg_out) - n_files = sum(len(paths) for paths, _ in runs) + n_files = sum(len(paths) for paths, *_ in runs) print(f"Wrote {out}, {pdf_out} and {svg_out} ({total_val} validation " f"datapoints in table across {len(runs)} run(s), {n_files} file(s))") From 866202d5770e33576b5a6bf0d753b855df969954 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 6 Aug 2026 16:52:37 +0000 Subject: [PATCH 124/142] add more scale-selection options --- alto/kernels/fp4/mxfp4/mxfp_quantization.py | 23 ++++++++++++++----- .../gpt_oss/configs/lpt_recipe_midmax.yaml | 4 ++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_quantization.py b/alto/kernels/fp4/mxfp4/mxfp_quantization.py index 1dcd76d7..92352dc9 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_quantization.py +++ b/alto/kernels/fp4/mxfp4/mxfp_quantization.py @@ -80,7 +80,7 @@ def _calculate_scales( # to FP32 for exponent-field surgery, while round-even bitcasts in the input's # native precision. Casting to element_ty up here would needlessly truncate # the FP32 dynamic-clip estimate before midmax re-widens it. - if SCALE_SELECTION == "midmax": + if SCALE_SELECTION == "midmax-legacy": # Pick the scale from absmax's FP32 exponent, then apply E2M1 "midmax" # rounding: normalize absmax into [2^target_max_pow2, 2^(target_max_pow2+1)) # by overwriting its exponent field, and bump the scale by 1 if the result @@ -102,12 +102,23 @@ def _calculate_scales( amax_scaled = amax_scaled_bits.to(tl.float32, bitcast=True) scales = scales + (amax_scaled > MIDMAX).to(tl.int32) + elif SCALE_SELECTION == "uos6": + max_abs = max_abs.to(x.type.element_ty).to(hp_int_dtype, bitcast=True) + val_to_add = 1 << (hp_mbits - mbits) # 0x00400000 for fp32, 0x0040 for bf16 + mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits + # apply carry on mantissa, collect only the carried exponent + max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits + scales = max_abs - target_max_pow2 # e8m0 po2 exponent, so applying po2 arithmetic elif SCALE_SELECTION == "uos": - # TODO: populate the "uos" scale-selection branch. Until it is - # implemented, selecting it is a compile-time error rather than a - # silent fallthrough to round-even. - tl.static_assert(False, "SCALE_SELECTION='uos' is not implemented yet") + # "default": round even (adaptive), incoming max_abs is in FP32 + max_abs = max_abs.to(x.type.element_ty).to(hp_int_dtype, bitcast=True) + val_to_add = 3 << (hp_mbits - mbits - 3) # 0x00180000 for fp32, 0x0018 for bf16 + mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits + # apply carry on mantissa, collect only the carried exponent + max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits + scales = max_abs - target_max_pow2 # e8m0 po2 exponent, so applying po2 arithmetic else: + # This is CHECK7 # "default": round even (adaptive), incoming max_abs is in FP32 max_abs = max_abs.to(x.type.element_ty).to(hp_int_dtype, bitcast=True) # with rounding you apply value_to_add on mantissa, @@ -115,7 +126,7 @@ def _calculate_scales( # value_to_add is 0.5 here but below, is actually 0.25 # so anything <0.25 away from carry will be carried # 7 in - val_to_add = 1 << (hp_mbits - mbits - 1) + val_to_add = 1 << (hp_mbits - mbits - 1) # 0x00200000 for fp32, 0x0020 for bf16 mask = ((1 << (hp_ebits + sbits)) - 1) << hp_mbits # apply carry on mantissa, collect only the carried exponent max_abs = ((max_abs + val_to_add) & mask) >> hp_mbits diff --git a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml index 9871b621..5f564a9f 100644 --- a/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml +++ b/alto/models/gpt_oss/configs/lpt_recipe_midmax.yaml @@ -21,8 +21,8 @@ training_stage: # 2-level scaling, disabled by default # NVFP4 requires tensorwise scaling or you will get overflow issues two_level_scaling: none - # block scale selection: "default" (round-even), "midmax", or "uos" - blockscale_selection: "midmax" + # block scale selection: "default", "midmax-legacy", or "uos" + blockscale_selection: "midmax-legacy" # weight de-oscillation, disabled by default # deosc_step: 2000 # deosc_period: 200 From 294f0f4da5c95a7de6a3b1340f83ca70ed0406a6 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 6 Aug 2026 17:00:47 +0000 Subject: [PATCH 125/142] add new scale selection configs --- alto/models/gpt_oss/config_registry.py | 20 +++++++++++++ .../gpt_oss/configs/lpt_recipe_uos.yaml | 29 +++++++++++++++++++ .../gpt_oss/configs/lpt_recipe_uos6.yaml | 29 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 alto/models/gpt_oss/configs/lpt_recipe_uos.yaml create mode 100644 alto/models/gpt_oss/configs/lpt_recipe_uos6.yaml diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 8b3f4159..32f80cbe 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -20,6 +20,8 @@ "gpt_oss_20b_pretrain_c4", "gpt_oss_20b_lpt", "gpt_oss_20b_lpt_midmax", + "gpt_oss_20b_lpt_uos", + "gpt_oss_20b_lpt_uos6", "gpt_oss_20b_lpt_lowrank", "gpt_oss_20b_lpt_deosc", "gpt_oss_20b_lpt_madam", @@ -196,6 +198,24 @@ def gpt_oss_20b_lpt_midmax() -> Trainer.Config: ],) return config +def gpt_oss_20b_lpt_uos() -> Trainer.Config: + """gpt_oss_20b_lpt_c4 with uos scale selection for MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_uos.yaml",), + ],) + return config + +def gpt_oss_20b_lpt_uos6() -> Trainer.Config: + """gpt_oss_20b_lpt_c4 with uos scale selection for MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos6" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe_uos6.yaml",), + ],) + return config + def gpt_oss_20b_mxfp4_base() -> Trainer.Config: """baseline MXFP4 quantization.""" config = gpt_oss_20b_lpt() diff --git a/alto/models/gpt_oss/configs/lpt_recipe_uos.yaml b/alto/models/gpt_oss/configs/lpt_recipe_uos.yaml new file mode 100644 index 00000000..81b9f795 --- /dev/null +++ b/alto/models/gpt_oss/configs/lpt_recipe_uos.yaml @@ -0,0 +1,29 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + # targets: ["Linear"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + + # differential gradient estimation, disabled by default + use_dge: false + + # choices: none, static, dynamic + clip_mode: none + + # 2-level scaling, disabled by default + # NVFP4 requires tensorwise scaling or you will get overflow issues + two_level_scaling: none + # block scale selection: "default", "midmax-legacy", or "uos" + blockscale_selection: "uos" + # weight de-oscillation, disabled by default + # deosc_step: 2000 + # deosc_period: 200 + # deosc_ratio: 4.0 diff --git a/alto/models/gpt_oss/configs/lpt_recipe_uos6.yaml b/alto/models/gpt_oss/configs/lpt_recipe_uos6.yaml new file mode 100644 index 00000000..089c3eb3 --- /dev/null +++ b/alto/models/gpt_oss/configs/lpt_recipe_uos6.yaml @@ -0,0 +1,29 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear", "GptOssGroupedExperts"] + # targets: ["Linear"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.0.*", "re:layers\\.22.*", "re:layers\\.23.*"] + ignore: ["output", "re:.*\\.router\\.gate"] + # ignore: ["output", "re:.*\\.router\\.gate", "re:layers\\.\\d+\\.attention.wq", "re:layers\\.\\d+\\.attention.wk", "re:layers\\.\\d+\\.attention.wv", "re:layers\\.\\d+\\.attention.wo"] + use_2dblock_x: false + use_2dblock_w: true + use_hadamard: true + use_sr_grad: true + + # differential gradient estimation, disabled by default + use_dge: false + + # choices: none, static, dynamic + clip_mode: none + + # 2-level scaling, disabled by default + # NVFP4 requires tensorwise scaling or you will get overflow issues + two_level_scaling: none + # block scale selection: "default", "midmax-legacy", or "uos" + blockscale_selection: "uos6" + # weight de-oscillation, disabled by default + # deosc_step: 2000 + # deosc_period: 200 + # deosc_ratio: 4.0 From 16ca725bd881bf9045dc259bf8c757c667bfbeea Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 6 Aug 2026 20:40:10 +0000 Subject: [PATCH 126/142] update blockscale selection choices --- alto/modifiers/lpt/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index 82b34908..5a8a70ee 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -37,7 +37,7 @@ class LowPrecisionTrainingModifier(Modifier): use_dge: bool = False two_level_scaling: Literal["none", "tensorwise", "blockwise"] = "none" clip_mode: Literal["none", "static", "dynamic"] = "none" - blockscale_selection: Literal["default", "midmax", "uos"] = "default" + blockscale_selection: Literal["default", "midmax-legacy", "uos", "uos6"] = "default" lora_rank: int = 0 From eba7beef88ad36e11a5ada660991c6245e113b6d Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Thu, 6 Aug 2026 21:53:32 +0000 Subject: [PATCH 127/142] assume ALTO dir is pwd --- scripts/train_gptoss20b.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 155a2d10..167e1e84 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -62,8 +62,7 @@ DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args -# *NOTE*: if you cloned multiple copies of this repo, make sure the path below is correct -ALTO_DIR="${ALTO_DIR:-$HOME/lpt_branch/ALTO}" # expose repo dir to container +ALTO_DIR="${ALTO_DIR:-$PWD}" # assume repo dir is pwd CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/${CONFIG}_$RUN_ID}" From e31673eee42a6ffeef7a5daf0d6f0cd1829bf757 Mon Sep 17 00:00:00 2001 From: Yann Bouquet Date: Fri, 7 Aug 2026 14:23:11 +0000 Subject: [PATCH 128/142] fix dtype mxfp_linear, forward only quant on llama --- .gitignore | 4 +- alto/kernels/dispatch/config.py | 9 ++ alto/kernels/dispatch/tensor.py | 38 +++++--- alto/kernels/fp4/mxfp4/mxfp4_forward_only.py | 89 +++++++++++++++++++ alto/kernels/fp4/mxfp4/mxfp_linear.py | 8 +- alto/models/llama3/config_registry.py | 49 ++++++++++ .../llama3/configs/lpt_recipe_fwdonly.yaml | 21 +++++ alto/modifiers/lpt/base.py | 19 ++++ 8 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 alto/kernels/fp4/mxfp4/mxfp4_forward_only.py create mode 100644 alto/models/llama3/configs/lpt_recipe_fwdonly.yaml diff --git a/.gitignore b/.gitignore index 7c3bcdb3..be7a79f3 100644 --- a/.gitignore +++ b/.gitignore @@ -214,4 +214,6 @@ datasets/ comm_traces/ docker/ -gpt_*/ \ No newline at end of file +gpt_*/ +*/plotting/ +docs/ diff --git a/alto/kernels/dispatch/config.py b/alto/kernels/dispatch/config.py index 7dd4862c..16e90df2 100644 --- a/alto/kernels/dispatch/config.py +++ b/alto/kernels/dispatch/config.py @@ -30,6 +30,15 @@ class TrainingOpConfig: use_sr_grad: bool use_dge: bool + full_precision_backward: bool = False + """ + Quantize only the forward GEMM; keep the backward (dgrad + wgrad) in bf16 with + the gradient left unquantized. The backward GEMMs reuse the quantize-then- + dequantize x/w from the forward (QDQ operands). Used to isolate the training + quality impact of forward-only vs. full low-precision. Currently supported only + on the dense MXFP4 linear path. + """ + clip_mode: Literal["none", "static", "dynamic"] = "none" """ clipping mode applied in MXFP4/NVFP4 quantization. diff --git a/alto/kernels/dispatch/tensor.py b/alto/kernels/dispatch/tensor.py index 530e259b..f8a50fcd 100644 --- a/alto/kernels/dispatch/tensor.py +++ b/alto/kernels/dispatch/tensor.py @@ -16,6 +16,7 @@ from torchtitan.tools.logging import logger from alto.kernels.fp4.mxfp4.mxfp_linear import _to_mxfp4_then_scaled_mm +from alto.kernels.fp4.mxfp4.mxfp4_forward_only import _mxfp4_forward_only from alto.kernels.fp4.mxfp4.mxfp_grouped_gemm.functional import _quantize_then_mxfp_scaled_grouped_mm from alto.kernels.fp4.nvfp4.nvfp_linear import _to_nvfp4_then_scaled_mm from alto.kernels.fp4.nvfp4.nvfp_grouped_gemm.functional import ( @@ -245,6 +246,10 @@ def __torch_function__(cls, func, types, args, kwargs={}): assert A_is_2d and B_is_3d and offs is not None, "Only 2d x 3d with offsets is supported for now" assert bias is None, "Bias is not supported for now" assert config.precision == "mxfp4", ("expected TrainingOpConfig with precision=mxfp4") + if config.full_precision_backward: + raise NotImplementedError( + "full_precision_backward is not implemented for the MoE grouped-mm path yet " + "(dense Linear only)") # logger.info( # f"[MXFP4GroupedMM]config: {config} A.shape: {A.shape} B.shape: {B.shape} offs.shape: {offs.shape}") @@ -282,18 +287,27 @@ def __torch_function__(cls, func, types, args, kwargs={}): # f"A.shape: {A.shape} B.shape: {B.shape} bias.shape: {bias.shape if bias is not None else None}") module_id = getattr(B, "module_id", None) - Y = _to_mxfp4_then_scaled_mm( - A, - B if trans_b else B.T, - use_2dblock_x=config.use_2dblock_x, - use_2dblock_w=config.use_2dblock_w, - use_sr_grad=config.use_sr_grad, - use_dge=config.use_dge, - clip_mode=config.clip_mode, - use_hadamard=config.use_hadamard, - use_macro_block_scaling=config.two_level_scaling == "blockwise", - module_id=module_id, - ) + if config.full_precision_backward: + Y = _mxfp4_forward_only( + A, + B if trans_b else B.T, + use_2dblock_x=config.use_2dblock_x, + use_2dblock_w=config.use_2dblock_w, + use_macro_block_scaling=config.two_level_scaling == "blockwise", + ) + else: + Y = _to_mxfp4_then_scaled_mm( + A, + B if trans_b else B.T, + use_2dblock_x=config.use_2dblock_x, + use_2dblock_w=config.use_2dblock_w, + use_sr_grad=config.use_sr_grad, + use_dge=config.use_dge, + clip_mode=config.clip_mode, + use_hadamard=config.use_hadamard, + use_macro_block_scaling=config.two_level_scaling == "blockwise", + module_id=module_id, + ) if bias is not None: Y = Y + bias return Y diff --git a/alto/kernels/fp4/mxfp4/mxfp4_forward_only.py b/alto/kernels/fp4/mxfp4/mxfp4_forward_only.py new file mode 100644 index 00000000..1378a78d --- /dev/null +++ b/alto/kernels/fp4/mxfp4/mxfp4_forward_only.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# SPDX-License-Identifier: MIT + +import torch + +from alto.kernels.fp4.fp4_common import unwrap_weight_wrapper +from .mxfp_quantization import is_cdna4 + + +class MXFP4ForwardOnlyLinearFunction(torch.autograd.Function): + """Quantize the forward GEMM only; keep the backward in bf16. + + The forward quantizes x and weight to MXFP4 and dequantizes them (QDQ), + exactly as the standard MXFP4 forward does, then runs ``y = x_dq @ w_dq.T``. + The backward is a plain bf16 matmul against the saved QDQ operands, with the + incoming gradient left unquantized: + + grad_x = grad_output @ w_dq + grad_w = grad_output.T @ x_dq + + This isolates the effect of forward-only quantization: nothing in the gradient + path is quantized. Unlike ``MXFP4LinearFunction`` this is an additive, separate + Function (the shared quantized-backward path is left untouched). + """ + + @staticmethod + def forward(ctx, x, weight, use_2dblock_x, use_2dblock_w): + assert not is_cdna4(), ( + "MXFP4ForwardOnlyLinearFunction only supports the non-CDNA4 (QDQ) path") + weight = unwrap_weight_wrapper(weight) + + original_shape = x.shape + original_dtype = x.dtype + x = x.reshape(-1, original_shape[-1]) + + x_mxfp4, x_scale = torch.ops.torchtitan.convert_to_mxfp4( + x, + axis=-1, + is_2d_block=use_2dblock_x, + ) + w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4( + weight, + axis=-1, + is_2d_block=use_2dblock_w, + ) + x_dq = torch.ops.torchtitan.convert_from_mxfp4( + x_mxfp4, + x_scale, + original_dtype, + axis=-1, + is_2d_block=use_2dblock_x, + ) + w_dq = torch.ops.torchtitan.convert_from_mxfp4( + w_mxfp4, + w_scale, + original_dtype, + axis=-1, + is_2d_block=use_2dblock_w, + ) + + y = x_dq @ w_dq.T + + ctx.save_for_backward(x_dq, w_dq) + ctx.original_shape = original_shape + return y.view(*original_shape[:-1], -1) + + @staticmethod + def backward(ctx, grad_output): + x_dq, w_dq = ctx.saved_tensors + original_shape = ctx.original_shape + + grad_output = grad_output.reshape(-1, grad_output.shape[-1]) + grad_inputs = grad_output @ w_dq + grad_weights = grad_output.T @ x_dq + + return grad_inputs.view(*original_shape[:-1], -1), grad_weights, None, None + + +def _mxfp4_forward_only( + a: torch.Tensor, + b: torch.Tensor, + use_2dblock_x: bool, + use_2dblock_w: bool, + use_macro_block_scaling: bool = False, +) -> torch.Tensor: + assert not use_macro_block_scaling, ( + "full_precision_backward does not support two_level_scaling / macro-block scaling") + return MXFP4ForwardOnlyLinearFunction.apply(a, b, use_2dblock_x, use_2dblock_w) diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 44803f47..7aa35835 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -422,11 +422,9 @@ def forward( def backward(ctx, grad_output): original_shape = grad_output.shape grad_output = grad_output.reshape(-1, original_shape[-1]) # Ensure grad_output is 2D - # PyTorch disables autocast inside autograd.Function.backward, so without FSDP's - # MixedPrecisionPolicy grad_output can arrive in a dtype that differs from the saved - # x_dq / w_dq (which were cast to ctx.original_dtype in forward). Use the dtype - # committed by forward to keep the non-CDNA4 matmul below well-typed. - original_dtype = ctx.original_dtype + # [A/B #1] Match alto_rad baseline: dequantize grad_output to the incoming + # gradient's own dtype rather than the forward-committed ctx.original_dtype. + original_dtype = grad_output.dtype # Site ①: clip grad_output before it enters the quantizer. _clip_cfg = get_grad_clip_cfg(ctx.module_id) diff --git a/alto/models/llama3/config_registry.py b/alto/models/llama3/config_registry.py index be265287..c08c2c6e 100644 --- a/alto/models/llama3/config_registry.py +++ b/alto/models/llama3/config_registry.py @@ -24,13 +24,16 @@ "llama3_1b", "llama3_1b_opt", "llama3_1b_lpt", + "llama3_1b_lpt_fwdonly", "llama3_1b_lpt_hadamard", "llama3_1b_adahop", "llama3_8b", "llama3_8b_pretrain", "llama3_8b_random_init", "llama3_8b_opt", + "llama3_8b_bf16", "llama3_8b_lpt", + "llama3_8b_lpt_fwdonly", "llama3_8b_lpt_hadamard", "llama3_8b_adahop", "llama3_1b_gptq", @@ -150,6 +153,17 @@ def llama3_1b_lpt() -> Trainer.Config: return config +def llama3_1b_lpt_fwdonly() -> Trainer.Config: + """MXFP4 forward-only: quantize the forward, keep the backward in bf16 + (gradient unquantized). Isolates the training-quality impact of forward-only + vs. full low-precision.""" + config = llama3_1b() + config.training.steps = 1000 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_recipe_fwdonly.yaml",)],) + return config + + def llama3_1b_lpt_hadamard() -> Trainer.Config: config = llama3_1b() config.training.steps = 1000 @@ -235,6 +249,23 @@ def llama3_8b_random_init() -> Trainer.Config: return config +def llama3_8b_bf16() -> Trainer.Config: + """Plain bf16 full-precision baseline (no quantization converter). The + reference point the mxfp4 / fwdonly variants compare against. Same + architecture / data / init as llama3_8b_pretrain, but with checkpointing + enabled so a long run is resumable.""" + config = llama3_8b_pretrain() + config.dump_folder = "llama3_8b-pretrain-subset-bf16-outputs" + # Fresh random init at debug.seed (1234), same as the gpt_oss runs — do NOT + # load pretrained weights. Reproducible across launches at fixed parallelism. + config.checkpoint.initial_load_path = None + config.checkpoint.initial_load_in_hf = False + config.checkpoint.enable = True + config.checkpoint.interval = 500 + config.checkpoint.keep_latest_k = 2 + return config + + def llama3_8b_lpt() -> Trainer.Config: config = llama3_8b_pretrain() config.dump_folder = "llama3_8b-mi308-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-gbs384-lr1e-4-outputs" @@ -243,6 +274,24 @@ def llama3_8b_lpt() -> Trainer.Config: return config +def llama3_8b_lpt_fwdonly() -> Trainer.Config: + """MXFP4 forward-only: quantize the forward, keep the backward in bf16 + (gradient unquantized). Isolates the training-quality impact of forward-only + vs. full low-precision. 8B is untied-weights so it runs (unlike 1B).""" + config = llama3_8b_pretrain() + config.dump_folder = "llama3_8b-pretrain-subset-mxfp4-fwdonly-outputs" + # Fresh random init at debug.seed (1234), same as the gpt_oss runs — do NOT + # load pretrained weights. Reproducible across launches at fixed parallelism. + config.checkpoint.initial_load_path = None + config.checkpoint.initial_load_in_hf = False + config.checkpoint.enable = True + config.checkpoint.interval = 500 + config.checkpoint.keep_latest_k = 2 + config.model_converters = ModelConvertersContainer.Config( + converters=[ModelOptConverter.Config(recipe="./alto/models/llama3/configs/lpt_recipe_fwdonly.yaml",)],) + return config + + def llama3_8b_lpt_hadamard() -> Trainer.Config: config = llama3_8b_pretrain() config.training.steps = 1000 diff --git a/alto/models/llama3/configs/lpt_recipe_fwdonly.yaml b/alto/models/llama3/configs/lpt_recipe_fwdonly.yaml new file mode 100644 index 00000000..a32b0d03 --- /dev/null +++ b/alto/models/llama3/configs/lpt_recipe_fwdonly.yaml @@ -0,0 +1,21 @@ +training_stage: + lpt_modifiers: + LowPrecisionTrainingModifier: + scheme: "mxfp4" + targets: ["Linear"] + ignore: ["output"] + use_2dblock_x: false + use_2dblock_w: true + # This mirrors lpt_hadamard_recipe.yaml, plus full_precision_backward. + # full_precision_backward keeps the backward (dgrad + wgrad) in bf16 with the + # gradient unquantized — only the forward is MXFP4. use_hadamard / use_sr_grad + # act ONLY on the (now-skipped) quantized backward in this codebase, so they + # are inert here; left true to show this is the hadamard recipe with the + # backward ignored. + full_precision_backward: true + use_hadamard: true + use_sr_grad: true + use_dge: false + clip_mode: none + two_level_scaling: none + lora_rank: 0 diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index d3a824bf..6ab22091 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -35,6 +35,11 @@ class LowPrecisionTrainingModifier(Modifier): use_hadamard: bool = False use_sr_grad: bool = False use_dge: bool = False + full_precision_backward: bool = False + """ + Quantize the forward only; keep the backward (dgrad + wgrad) in bf16 with the + gradient unquantized. Dense MXFP4 linear path only. + """ two_level_scaling: Literal["none", "tensorwise", "blockwise"] = "none" clip_mode: Literal["none", "static", "dynamic"] = "none" @@ -93,6 +98,19 @@ def validate_scheme(cls, value: str | dict[str, str | list[str]]) -> str | dict[ return value + @model_validator(mode="after") + def validate_full_precision_backward(self): + if self.full_precision_backward: + scheme_names = self.scheme if isinstance(self.scheme, dict) else {self.scheme: None} + for scheme_name in scheme_names: + if scheme_name not in ("mxfp4",): + raise ValueError( + f"full_precision_backward is only supported for scheme 'mxfp4', got '{scheme_name}'") + if self.use_dge: + raise ValueError("full_precision_backward is incompatible with use_dge " + "(DGE is a gradient-quantization estimator; the backward is not quantized here)") + return self + @model_validator(mode="after") def validate_lora_rank_alignment(self): if self.lora_rank <= 0: @@ -159,6 +177,7 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: use_hadamard=self.use_hadamard, use_sr_grad=self.use_sr_grad, use_dge=self.use_dge, + full_precision_backward=self.full_precision_backward, two_level_scaling=self.two_level_scaling, clip_mode=self.clip_mode, ) From e979e8316f1ef91075ec7597bd718359f150c5a3 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 7 Aug 2026 14:35:11 +0000 Subject: [PATCH 129/142] make plot only have one loss value per step --- plotting/plot_training_stats.py | 45 ++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py index 01d55201..0e1cb29b 100644 --- a/plotting/plot_training_stats.py +++ b/plotting/plot_training_stats.py @@ -195,11 +195,31 @@ def parse_many(paths): grad_norms.extend(c) val_steps.extend(d) val_losses.extend(e) + # a resumed job replays earlier steps; keep only the latest datapoint per + # step (last occurrence in concat order wins), then sort ascending by step + tr_steps, (tr_losses, grad_norms) = _dedup_latest(tr_steps, tr_losses, grad_norms) + val_steps, (val_losses,) = _dedup_latest(val_steps, val_losses) return tr_steps, tr_losses, grad_norms, val_steps, val_losses +def _dedup_latest(steps, *aligned): + """Collapse duplicate steps, keeping the last occurrence, sorted by step. + + `aligned` are lists parallel to `steps`. Returns (sorted_steps, (col, ...)) + where each col is the corresponding aligned list, filtered and reordered to + match. Iterating in order and writing into a dict makes later (resumed-run) + datapoints overwrite earlier ones at the same step. + """ + latest = {} + for i, s in enumerate(steps): + latest[s] = tuple(col[i] for col in aligned) + ordered = sorted(latest) + cols = tuple([latest[s][j] for s in ordered] for j in range(len(aligned))) + return ordered, cols + + def runs_from_config(path): - """Return (runs, output, title, details) from a .toml config. + """Return (runs, output, title, details, max_step) from a .toml config. runs is a list of (files, label) tuples where files is a list of one or more paths (a run may span several .out files). Paths are resolved relative @@ -223,7 +243,8 @@ def runs_from_config(path): paths = [f if os.path.isabs(f) else os.path.join(base, f) for f in file_list] label = r.get("name") or os.path.basename(file_list[0]) runs.append((paths, label)) - return runs, cfg.get("output"), cfg.get("title"), cfg.get("details") + return (runs, cfg.get("output"), cfg.get("title"), cfg.get("details"), + cfg.get("max_step")) def main(): @@ -236,13 +257,15 @@ def main(): ap.add_argument("-o", "--output", help="output image path (default: val_loss.png)") ap.add_argument("-t", "--title", help="plot title") ap.add_argument("-d", "--details", help="extra info shown in a small text box (top-right)") + ap.add_argument("--max-step", type=int, + help="drop datapoints past this step (x-axis / table stop here)") args = ap.parse_args() - cfg_out = cfg_title = cfg_details = None + cfg_out = cfg_title = cfg_details = cfg_max_step = None if args.config: if args.logfiles: sys.exit("provide runs either positionally or via -c/--config, not both") - runs, cfg_out, cfg_title, cfg_details = runs_from_config(args.config) + runs, cfg_out, cfg_title, cfg_details, cfg_max_step = runs_from_config(args.config) else: if not args.logfiles: ap.error("no runs given: pass logfiles positionally or use -c/--config") @@ -262,6 +285,7 @@ def main(): out = args.output or cfg_out or "val_loss.png" suptitle = args.title or cfg_title or "GPT-OSS 20b" details = args.details or cfg_details + max_step = args.max_step if args.max_step is not None else cfg_max_step # figure: stacked training-loss (top), grad-norm (middle), and # validation-loss (bottom) plots on the left, table on the right. When @@ -303,6 +327,17 @@ def main(): tr_steps, tr_losses, grad_norms, val_steps, val_losses = parse_many(existing) + # drop anything past max_step so curves + table all stop at the same x + if max_step is not None: + tr = [(s, l, g) for s, l, g in zip(tr_steps, tr_losses, grad_norms) + if s <= max_step] + tr_steps = [s for s, _, _ in tr] + tr_losses = [l for _, l, _ in tr] + grad_norms = [g for _, _, g in tr] + vl = [(s, l) for s, l in zip(val_steps, val_losses) if s <= max_step] + val_steps = [s for s, _ in vl] + val_losses = [l for _, l in vl] + # curves plot TRAINING loss (no per-point marker — too dense) (line,) = ax.plot(tr_steps, tr_losses, linewidth=1.2, label=label) color = line.get_color() @@ -330,6 +365,8 @@ def main(): ax.set_title("Training Loss", fontweight="bold") ax.grid(True, alpha=0.3) ax.legend() + if max_step is not None: + ax.set_xlim(right=max_step) # ax_grad/ax_val share this x-axis ax_grad.set_xlabel("step") ax_grad.set_ylabel("grad norm") From a387724f85f32548b7fa7f3a6429fa5e329c8797 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 7 Aug 2026 16:06:30 +0000 Subject: [PATCH 130/142] fix bug with run_id --- scripts/train_gptoss20b.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 167e1e84..ce079ec0 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -50,7 +50,7 @@ set -euo pipefail # checkpoint dir and log filename are traceable back to the SLURM job. SLURM_JOB_ID="${SLURM_JOB_ID:-${SLURM_JOBID:-}}" if [[ -n "$SLURM_JOB_ID" ]]; then - RUN_ID="${RUN_ID:${SLURM_JOB_ID}}" + RUN_ID="${RUN_ID:-${SLURM_JOB_ID}}" else RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" fi From 8ddb7a649662fe795157b0083d5f6c63f27eb944 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 7 Aug 2026 16:13:24 +0000 Subject: [PATCH 131/142] fix configs to point to original rad/lpt baseline lpt recipes --- alto/models/gpt_oss/config_registry.py | 37 +++++--------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 63a06d02..277d133b 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -27,7 +27,6 @@ "gpt_oss_20b_adahop", "gpt_oss_20b_adahop_hadamard", "gpt_oss_20b_pretrain_c4", - "gpt_oss_20b_lpt_c4", "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_obs_lpt", @@ -191,8 +190,8 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 config.validator.enable = True - config.validator.dataloader.dataset = "wikitext_test" - config.validator.dataloader.dataset_path = "" + config.validator.dataloader.dataset = "megatron" + config.validator.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-validation-91205-samples.en_text_document.idx" config.validator.freq = 768 config.validator.steps = 64 config.activation_checkpoint.mode = "none" @@ -351,7 +350,7 @@ def gpt_oss_20b_lpt_deosc() -> Trainer.Config: return config def gpt_oss_20b_lpt_midmax() -> Trainer.Config: - """gpt_oss_20b_lpt_c4 with midmax scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt with midmax scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-midmax-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -360,7 +359,7 @@ def gpt_oss_20b_lpt_midmax() -> Trainer.Config: return config def gpt_oss_20b_lpt_uos() -> Trainer.Config: - """gpt_oss_20b_lpt_c4 with uos scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt with uos scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -369,7 +368,7 @@ def gpt_oss_20b_lpt_uos() -> Trainer.Config: return config def gpt_oss_20b_lpt_uos6() -> Trainer.Config: - """gpt_oss_20b_lpt_c4 with uos scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt with uos scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos6" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -388,7 +387,7 @@ def gpt_oss_20b_mxfp4_base() -> Trainer.Config: def gpt_oss_20b_lpt_lowrank() -> Trainer.Config: - """gpt_oss_20b_lpt_c4 with low-rank (lora_rank=32) correction for MXFP4 quantization.""" + """gpt_oss_20b_lpt with low-rank (lora_rank=32) correction for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-lowrank-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -517,28 +516,4 @@ def gpt_oss_20b_moe_pattern_obs() -> Trainer.Config: recipe="./alto/models/gpt_oss/configs/moe_pattern_observer_recipe.yaml", ), ],) - return config - - -def gpt_oss_20b_pretrain_c4() -> Trainer.Config: - """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" - config = gpt_oss_20b_pretrain() - config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" - config.training.global_batch_size = 64 - config.optimizer.lr = 4e-4 - config.lr_scheduler.min_lr_factor = 0.04 - config.dataloader.dataset = "c4" - config.dataloader.dataset_path = None - config.validator.dataloader.dataset = "c4_validation" - config.validator.dataloader.dataset_path = None - config.checkpoint.initial_load_in_hf = True - config.checkpoint.initial_load_in_hf_quantized = True - return config - -def gpt_oss_20b_lpt_c4() -> Trainer.Config: - config = gpt_oss_20b_pretrain_c4() - config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-c4-outputs" - config.model_converters = ModelConvertersContainer.Config(converters=[ - ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), - ],) return config \ No newline at end of file From 88c0bb55c38faafe7ba799ead29b21bbb178a1a9 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 7 Aug 2026 16:15:56 +0000 Subject: [PATCH 132/142] make adahop configs point to original rad/lpt config files --- alto/models/gpt_oss/config_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 277d133b..626e26c8 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -255,7 +255,7 @@ def gpt_oss_20b_adahop_hadamard() -> Trainer.Config: return config def gpt_oss_20b_adahop() -> Trainer.Config: - config = gpt_oss_20b_pretrain() + config = gpt_oss_20b_pretrain_c4() config.training.global_batch_size = 16 config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 1 @@ -429,7 +429,7 @@ def gpt_oss_20b_lpt_no2dw() -> Trainer.Config: def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" - config = gpt_oss_20b_pretrain() + config = gpt_oss_20b_pretrain_c4() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-grad-clip-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml",), From 5d611db458dd52f3cee122e775053e28f56e0cdd Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Fri, 7 Aug 2026 16:45:54 +0000 Subject: [PATCH 133/142] fix bug in plotting script --- plotting/plot_training_stats.py | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/plotting/plot_training_stats.py b/plotting/plot_training_stats.py index a4ee8997..0e18adbc 100644 --- a/plotting/plot_training_stats.py +++ b/plotting/plot_training_stats.py @@ -272,8 +272,7 @@ def runs_from_config(path): paths = [f if os.path.isabs(f) else os.path.join(base, f) for f in file_list] label = r.get("name") or os.path.basename(file_list[0]) runs.append((paths, label, r.get("max_step"))) - return ((runs, cfg.get("output"), cfg.get("title"), cfg.get("details"), - cfg.get("max_step")), + return (runs, cfg.get("output"), cfg.get("title"), cfg.get("details"), cfg.get("max_step")) @@ -292,13 +291,11 @@ def main(): "overrides per-run/global max_step in a config)") args = ap.parse_args() - cfg_out = cfg_title = cfg_details = cfg_max_step = None cfg_out = cfg_title = cfg_details = cfg_max_step = None if args.config: if args.logfiles: sys.exit("provide runs either positionally or via -c/--config, not both") runs, cfg_out, cfg_title, cfg_details, cfg_max_step = runs_from_config(args.config) - runs, cfg_out, cfg_title, cfg_details, cfg_max_step = runs_from_config(args.config) else: if not args.logfiles: ap.error("no runs given: pass logfiles positionally or use -c/--config") @@ -365,28 +362,6 @@ def main(): tr_steps, tr_losses, grad_norms, val_steps, val_losses = clip_to_step( parse_many(existing), max_step) - # drop anything past max_step so curves + table all stop at the same x - if max_step is not None: - tr = [(s, l, g) for s, l, g in zip(tr_steps, tr_losses, grad_norms) - if s <= max_step] - tr_steps = [s for s, _, _ in tr] - tr_losses = [l for _, l, _ in tr] - grad_norms = [g for _, _, g in tr] - vl = [(s, l) for s, l in zip(val_steps, val_losses) if s <= max_step] - val_steps = [s for s, _ in vl] - val_losses = [l for _, l in vl] - - # drop anything past max_step so curves + table all stop at the same x - if max_step is not None: - tr = [(s, l, g) for s, l, g in zip(tr_steps, tr_losses, grad_norms) - if s <= max_step] - tr_steps = [s for s, _, _ in tr] - tr_losses = [l for _, l, _ in tr] - grad_norms = [g for _, _, g in tr] - vl = [(s, l) for s, l in zip(val_steps, val_losses) if s <= max_step] - val_steps = [s for s, _ in vl] - val_losses = [l for _, l in vl] - # curves plot TRAINING loss (no per-point marker — too dense) (line,) = ax.plot(tr_steps, tr_losses, linewidth=1.2, label=label) color = line.get_color() From fa88c9f5f2d2e760ab2b26756e08dd32d9a427fb Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Fri, 7 Aug 2026 22:18:04 +0000 Subject: [PATCH 134/142] adjustments to run script and config registery --- alto/models/gpt_oss/config_registry.py | 61 +++++--------------------- scripts/train_gptoss20b.sh | 32 +++++++------- 2 files changed, 27 insertions(+), 66 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 63a06d02..4cd72d4a 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -26,7 +26,7 @@ "gpt_oss_20b_lpt_no2dw", "gpt_oss_20b_adahop", "gpt_oss_20b_adahop_hadamard", - "gpt_oss_20b_pretrain_c4", + "gpt_oss_20b_pretrain_c4_megatron", "gpt_oss_20b_lpt_c4", "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", @@ -135,7 +135,7 @@ def gpt_oss_debugmodel_obs_bf16() -> Trainer.Config: def gpt_oss_20b() -> Trainer.Config: config = gpt_oss_20b_orig() - config.hf_assets_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" + config.hf_assets_path = "/hf_home/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" config.dump_folder = "gpt_oss_20b-outputs" config.profiling.enable_profiling = False config.training.steps = 0 @@ -148,7 +148,7 @@ def gpt_oss_20b() -> Trainer.Config: config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 1 config.checkpoint.enable = True - config.checkpoint.initial_load_path = "/huggingface/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" + config.checkpoint.initial_load_path = "/hf_home/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee/" config.checkpoint.initial_load_in_hf = True config.checkpoint.initial_load_in_hf_quantized = True config.checkpoint.interval = 100 @@ -182,8 +182,7 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.metrics.log_freq = 1 config.metrics.enable_tensorboard = True config.dataloader.dataset = "megatron" - config.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-train.en_6_text_document.idx" - config.parallelism.expert_parallel_degree = 8 + config.dataloader.dataset_path = "/data/c4-train.en_6_text_document.idx" config.parallelism.expert_parallel_degree = 8 config.parallelism.expert_tensor_parallel_degree = 1 config.parallelism.tensor_parallel_degree = 1 @@ -199,14 +198,14 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.debug.seed = 1234 return config -def gpt_oss_20b_pretrain_c4() -> Trainer.Config: - """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" +def gpt_oss_20b_pretrain_c4_megatron() -> Trainer.Config: + """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline).""" config = gpt_oss_20b_pretrain() config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" config.dataloader.dataset = "megatron" - config.dataloader.dataset_path = "/shared_inference/alirezak/hf_home/data/c4-train.en_6_text_document.idx" + config.dataloader.dataset_path = "/data/c4-train.en_6_text_document.idx" config.validator.dataloader.dataset = "megatron" - config.validator.dataloader.dataset_path = "/shared_inference/alirezak/hf_home/data/c4-validation-91205-samples.en_text_document.idx" + config.validator.dataloader.dataset_path = "/data/c4-validation-91205-samples.en_text_document.idx" config.checkpoint.enable = True config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint config.checkpoint.initial_load_in_hf = False @@ -254,49 +253,9 @@ def gpt_oss_20b_adahop_hadamard() -> Trainer.Config: ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop_all_hadamard.yaml",), ],) return config - -def gpt_oss_20b_adahop() -> Trainer.Config: - config = gpt_oss_20b_pretrain() - config.training.global_batch_size = 16 - config.parallelism.expert_tensor_parallel_degree = 1 - config.parallelism.tensor_parallel_degree = 1 - config.parallelism.expert_parallel_degree = 8 - config.training.local_batch_size = 1 - config.activation_checkpoint.mode = "none" - config.dataloader.dataset = "c4" - config.dataloader.dataset_path = None - config.validator.dataloader.dataset = "c4_validation" - config.validator.dataloader.dataset_path = None - config.checkpoint.enable = True # save checkpoints so we can resume later - config.checkpoint.initial_load_path = None # fresh run: do NOT load any checkpoint - config.checkpoint.initial_load_in_hf = False - config.checkpoint.initial_load_in_hf_quantized = False - config.checkpoint.interval = 500 # Save at step interval - config.checkpoint.keep_latest_k = 2 # keep only the 2 latest (each ~234G) - # Distinct from gpt_oss_20b_lpt's dump_folder so the adahop and nolora runs - # never share/overwrite each other's checkpoints. - config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-adahop-outputs" - config.model_converters = ModelConvertersContainer.Config(converters=[ - ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop.yaml",), - ],) - return config - -def gpt_oss_20b_adahop_hadamard() -> Trainer.Config: - """Phase-2 Run A: AdaHOP with every slot forced to `hadamard` (no outlier - extraction, no full_precision). Identical to gpt_oss_20b_adahop except the - recipe's layer_transform_config maps all pattern-pairs to "hadamard". - Isolates whether the AdaHOP training regression comes from mode SELECTION - (S3) rather than the transform math. Distinct dump_folder so it never - collides with the calibrated adahop or the nolora runs.""" - config = gpt_oss_20b_adahop() - config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-adahop-allhadamard-randomized-outputs" - config.model_converters = ModelConvertersContainer.Config(converters=[ - ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_adahop_all_hadamard.yaml",), - ],) - return config - + def gpt_oss_20b_lpt() -> Trainer.Config: - config = gpt_oss_20b_pretrain_c4() + config = gpt_oss_20b_pretrain_c4_megatron() config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index ce079ec0..5d227935 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -3,7 +3,7 @@ # # Example: # NGPU=4 CONFIG=gpt_oss_debugmodel TRAINING_STEPS=20 \ -# bash ~/ALTO/train_gptoss.sh +# bash ~/ALTO/train_gptoss20b.sh ######## STEP 0: Install ALTO repository and update ALTO_DIR below # git clone --recurse-submodules https://github.com/AMD-AGI/ALTO.git @@ -57,22 +57,22 @@ fi ### Machine-specific args NGPU="${NGPU:-8}" -HF_HOME_DIR="${HF_HOME_DIR:-$HOME/.cache/huggingface}" # HF model location -DATA_DIR="${DATA_DIR:-/shared_inference}" # exposte data dir to container +HF_HOME_DIR="${HF_HOME_DIR:-/shared_inference/alirezak/hf_home}" # HF model location +DATA_DIR="${DATA_DIR:-/shared_inference/alirezak/hf_home/data}" # expose data dir to container HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ### Run-specific args ALTO_DIR="${ALTO_DIR:-$PWD}" # assume repo dir is pwd -CONFIG="${CONFIG:-gpt_oss_20b_pretrain_c4}" +CONFIG="${CONFIG:-gpt_oss_20b_mxfp4_base}" -CHECKPOINT_DIR="${CHECKPOINT_DIR:-$ALTO_DIR/gptoss_chkpt/${CONFIG}_$RUN_ID}" -LOG_FILE="${LOG_FILE:-$ALTO_DIR/logs/gpt_oss_20b-bf16-$RUN_ID.log}" # log fname based on time +CHECKPOINT_DIR="${CHECKPOINT_DIR:-/shared_inference/alirezak/gptoss_chkpt/${USER}/${CONFIG}_$RUN_ID}" +LOG_FILE_DEFAULT="${ALTO_DIR}/logs/${CONFIG}_$(date +%Y%m%d_%H%M%S).log" +LOG_FILE="${LOG_FILE:-$LOG_FILE_DEFAULT}" # log fname based on time ### Other modifiable args MODULE="${MODULE:-gpt_oss}" TRAINING_STEPS="${TRAINING_STEPS:-15000}" -CONTAINER="${CONTAINER:-$CONFIG}" # container name (for user readability) - +CONTAINER="${CONTAINER:-${CONFIG}_${RUN_ID}}" # container name (for user readability) # default Docker image from Han Wang IMAGE="${IMAGE:-wanghanthu/torchtitan:ubuntu22.04-pytorch2.12.0dev20260217-rocm7.2-patch}" @@ -108,13 +108,14 @@ docker_args=( --network host --ipc host --cap-add SYS_PTRACE + --shm-size 512G --security-opt seccomp=unconfined --env-file "$HF_ENV_FILE" -v "$HOME:$HOME" -v "$ALTO_DIR:/alto" - -v "$DATA_DIR:$DATA_DIR" + -v "$DATA_DIR:/data" -v "$HF_HOME_DIR:/hf_home" - -v "$CHECKPOINT_DIR:$CHECKPOINT_DIR" + -v "$CHECKPOINT_DIR:/checkpoints" -v /etc/passwd:/etc/passwd:ro -v /etc/group:/etc/group:ro -e HOME="$HOME" @@ -144,7 +145,7 @@ for group in $DEVICE_GROUPS; do fi done -# start docker contrainer +# start docker container docker run "${docker_args[@]}" "$IMAGE" sleep infinity # make sure docker is gracefully stopped quickly on exit @@ -171,10 +172,11 @@ trap 'exit 143' TERM # ----------------------------------------------------------------------------- # Load model and install additional docker dependencies # ----------------------------------------------------------------------------- -MODEL_DIR="${MODEL_DIR:-$HF_HOME_DIR/models/gpt-oss-20b}" -echo "[model] Ensuring tokenizer is available at $MODEL_DIR ..." +MODEL_DIR="/hf_home/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee" +MODEL_DIR_HOST="${HF_HOME_DIR}/hub/models--openai--gpt-oss-20b/snapshots/6cee5e81ee83917806bbde320786a8fb61efebee" +echo "[model] Ensuring tokenizer is available at $MODEL_DIR_HOST ..." -if [[ -f "$MODEL_DIR/tokenizer.json" ]]; then +if [[ -f "$MODEL_DIR_HOST/tokenizer.json" ]]; then echo "[model] Tokenizer already present, skipping download." else echo "[model] Downloading tokenizer ..." @@ -217,7 +219,7 @@ docker exec \ --training.steps "$TRAINING_STEPS" \ --comm.init_timeout_seconds 1800 \ --hf_assets_path "$MODEL_DIR" \ - --dump_folder "$CHECKPOINT_DIR" \ + --dump_folder /checkpoints \ --profiling.enable_profiling \ --profiling.profile_freq 1000 \ --profiling.profiler_warmup 3 \ From 47927a8c2eb40d05919b900b4034a2c6ebcf623d Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Sun, 9 Aug 2026 22:20:38 -0600 Subject: [PATCH 135/142] Update train_gptoss20b.sh --- scripts/train_gptoss20b.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 5d227935..93dc12cb 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -65,7 +65,7 @@ HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ALTO_DIR="${ALTO_DIR:-$PWD}" # assume repo dir is pwd CONFIG="${CONFIG:-gpt_oss_20b_mxfp4_base}" -CHECKPOINT_DIR="${CHECKPOINT_DIR:-/shared_inference/alirezak/gptoss_chkpt/${USER}/${CONFIG}_$RUN_ID}" +CHECKPOINT_DIR="${CHECKPOINT_DIR:-/shared_inference/alirezak/gptoss_chkpt/${USER}/${CONFIG}}" LOG_FILE_DEFAULT="${ALTO_DIR}/logs/${CONFIG}_$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_FILE:-$LOG_FILE_DEFAULT}" # log fname based on time @@ -226,4 +226,4 @@ docker exec \ --profiling.profiler_active 1 \ 2>&1 | tee "$LOG_FILE" -echo "[train] Run complete." \ No newline at end of file +echo "[train] Run complete." From 951f444962eabe32f8cfa89d1a99abee41fa9cc8 Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Mon, 10 Aug 2026 21:33:59 +0000 Subject: [PATCH 136/142] update #steps to 17k --- scripts/train_gptoss20b.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 93dc12cb..12ee2788 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -71,7 +71,7 @@ LOG_FILE="${LOG_FILE:-$LOG_FILE_DEFAULT}" # log fname based on time ### Other modifiable args MODULE="${MODULE:-gpt_oss}" -TRAINING_STEPS="${TRAINING_STEPS:-15000}" +TRAINING_STEPS="${TRAINING_STEPS:-17000}" CONTAINER="${CONTAINER:-${CONFIG}_${RUN_ID}}" # container name (for user readability) # default Docker image from Han Wang From f2255f29d896033aa5be9b8da2b7488696ca5aad Mon Sep 17 00:00:00 2001 From: Natalia Frumkin Date: Mon, 10 Aug 2026 21:46:38 +0000 Subject: [PATCH 137/142] fix config error with ali-temp branch renaming --- alto/models/gpt_oss/config_registry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index c0c9359f..3fb819c0 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -19,14 +19,13 @@ "gpt_oss_debugmodel_obs_bf16", "gpt_oss_20b", "gpt_oss_20b_pretrain", - "gpt_oss_20b_pretrain_c4", "gpt_oss_20b_lpt", "gpt_oss_20b_lpt_fresh", "gpt_oss_20b_lpt_1dw", "gpt_oss_20b_lpt_no2dw", "gpt_oss_20b_adahop", "gpt_oss_20b_adahop_hadamard", - "gpt_oss_20b_pretrain_c4", + "gpt_oss_20b_pretrain_c4_megatron", "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_obs_lpt", @@ -388,7 +387,7 @@ def gpt_oss_20b_lpt_no2dw() -> Trainer.Config: def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" - config = gpt_oss_20b_pretrain_c4() + config = gpt_oss_20b_pretrain_c4_megatron() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-grad-clip-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml",), From 4d07a9f5f6b1b196e2f0fdd055c39852c4f8b2fe Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Thu, 13 Aug 2026 20:38:24 +0000 Subject: [PATCH 138/142] Update 3RHT implementation --- alto/kernels/hadamard_transform/transform.py | 29 ++++++-- alto/models/gpt_oss/config_registry.py | 71 +++++++++++++++++--- alto/modifiers/lpt/base.py | 4 ++ 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/alto/kernels/hadamard_transform/transform.py b/alto/kernels/hadamard_transform/transform.py index 490695de..4c1cc641 100644 --- a/alto/kernels/hadamard_transform/transform.py +++ b/alto/kernels/hadamard_transform/transform.py @@ -30,6 +30,7 @@ class HadamardFactory: block_size: int = 32 randomized: bool = True dtype: torch.dtype = torch.float32 + transform_type: str = "default" seed: Optional[int] = None generator: torch.Generator = torch.Generator() @@ -39,6 +40,7 @@ def configure( block_size: Optional[int] = None, randomized: Optional[bool] = None, dtype: Optional[torch.dtype] = None, + transform_type: Optional[str] = None, seed: Optional[int] = None, ) -> None: """ @@ -55,6 +57,8 @@ def configure( cls.randomized = randomized if dtype is not None: cls.dtype = dtype + if transform_type is not None: + cls.transform_type = transform_type if seed is not None: cls.seed = seed cls.generator.manual_seed(seed) @@ -73,10 +77,27 @@ def create_transform( :param device: Device to create the transform on :return: HadamardTransform instance """ - - weight = cls._create_weight(device) - perm = cls._create_permutation(weight) if cls.randomized else None - return HadamardTransform(weight, perm) + if cls.transform_type == "default": + weight = cls._create_weight(device) + perm = cls._create_permutation(weight) if cls.randomized else None + return HadamardTransform(weight, perm) + elif cls.transform_type == "3rht": + w = cls._create_weight(device) + p = cls._create_permutation(w) + combined = w[p][:, p] + s = torch.tensor(w.size(0), dtype=torch.float64, device=w.device).sqrt() + + w = cls._create_weight(device) + p = cls._create_permutation(w) + combined = combined @ (w[p][:, p]) / s + + w = cls._create_weight(device) + p = cls._create_permutation(w) + combined = combined @ (w[p][:, p]) / s + + return HadamardTransform(combined, perm=None) + else: + raise NotImplementedError("transform_type options are: default and 3rht") @classmethod def _create_weight( diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 3fb819c0..d2a94d2f 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -19,6 +19,7 @@ "gpt_oss_debugmodel_obs_bf16", "gpt_oss_20b", "gpt_oss_20b_pretrain", + "gpt_oss_20b_pretrain_c4", "gpt_oss_20b_lpt", "gpt_oss_20b_lpt_fresh", "gpt_oss_20b_lpt_1dw", @@ -26,6 +27,7 @@ "gpt_oss_20b_adahop", "gpt_oss_20b_adahop_hadamard", "gpt_oss_20b_pretrain_c4_megatron", + "gpt_oss_20b_lpt_c4", "gpt_oss_20b_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_lpt", "gpt_oss_debugmodel_grad_clip_obs_lpt", @@ -42,7 +44,10 @@ "gpt_oss_20b_lpt_deosc", "gpt_oss_20b_lpt_madam", "gpt_oss_20b_lpt_madam_stable", - "gpt_oss_20b_mxfp4_base" + "gpt_oss_20b_mxfp4_base", + "gpt_oss_20b_mxfp4_had_2dw_sr", + "gpt_oss_20b_mxfp4_3rht_2dw_sr", + "gpt_oss_20b_mxfp4_3rht_2dw_sr_uos" ] @@ -188,8 +193,8 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.checkpoint.interval = 1000 config.checkpoint.keep_latest_k = 2 config.validator.enable = True - config.validator.dataloader.dataset = "megatron" - config.validator.dataloader.dataset_path = "/workspace/workspace/megatron_dataset/data/c4-validation-91205-samples.en_text_document.idx" + config.validator.dataloader.dataset = "wikitext_test" + config.validator.dataloader.dataset_path = "" config.validator.freq = 768 config.validator.steps = 64 config.activation_checkpoint.mode = "none" @@ -308,7 +313,7 @@ def gpt_oss_20b_lpt_deosc() -> Trainer.Config: return config def gpt_oss_20b_lpt_midmax() -> Trainer.Config: - """gpt_oss_20b_lpt with midmax scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt_c4 with midmax scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-midmax-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -317,7 +322,7 @@ def gpt_oss_20b_lpt_midmax() -> Trainer.Config: return config def gpt_oss_20b_lpt_uos() -> Trainer.Config: - """gpt_oss_20b_lpt with uos scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt_c4 with uos scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -326,7 +331,7 @@ def gpt_oss_20b_lpt_uos() -> Trainer.Config: return config def gpt_oss_20b_lpt_uos6() -> Trainer.Config: - """gpt_oss_20b_lpt with uos scale selection for MXFP4 quantization.""" + """gpt_oss_20b_lpt_c4 with uos6 scale selection for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-uos6" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -343,9 +348,35 @@ def gpt_oss_20b_mxfp4_base() -> Trainer.Config: ],) return config +def gpt_oss_20b_mxfp4_had_2dw_sr() -> Trainer.Config: + """baseline MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-mxfp4-base" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/mxfp4_had_2dw_sr.yaml",), + ],) + return config + +def gpt_oss_20b_mxfp4_3rht_2dw_sr() -> Trainer.Config: + """baseline MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-mxfp4-base" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/mxfp4_3rht_2dw_sr.yaml",), + ],) + return config + +def gpt_oss_20b_mxfp4_3rht_2dw_sr_uos() -> Trainer.Config: + """baseline MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-mxfp4-base" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/mxfp4_3rht_2dw_sr_uos.yaml",), + ],) + return config def gpt_oss_20b_lpt_lowrank() -> Trainer.Config: - """gpt_oss_20b_lpt with low-rank (lora_rank=32) correction for MXFP4 quantization.""" + """gpt_oss_20b_lpt_c4 with low-rank (lora_rank=32) correction for MXFP4 quantization.""" config = gpt_oss_20b_lpt() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-lowrank-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ @@ -387,7 +418,7 @@ def gpt_oss_20b_lpt_no2dw() -> Trainer.Config: def gpt_oss_20b_grad_clip_lpt() -> Trainer.Config: """20b pretrain + MXFP4 + gradient clipping at the quantizer boundary.""" - config = gpt_oss_20b_pretrain_c4_megatron() + config = gpt_oss_20b_pretrain() config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4-grad-clip-lr4e-4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/grad_clip_lpt_recipe.yaml",), @@ -474,4 +505,28 @@ def gpt_oss_20b_moe_pattern_obs() -> Trainer.Config: recipe="./alto/models/gpt_oss/configs/moe_pattern_observer_recipe.yaml", ), ],) + return config + + +def gpt_oss_20b_pretrain_c4() -> Trainer.Config: + """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" + config = gpt_oss_20b_pretrain() + config.dump_folder = "gpt_oss_20b-pretrain-subset-bf16-c4-outputs" + config.training.global_batch_size = 64 + config.optimizer.lr = 4e-4 + config.lr_scheduler.min_lr_factor = 0.04 + config.dataloader.dataset = "c4" + config.dataloader.dataset_path = None + config.validator.dataloader.dataset = "c4_validation" + config.validator.dataloader.dataset_path = None + config.checkpoint.initial_load_in_hf = True + config.checkpoint.initial_load_in_hf_quantized = True + return config + +def gpt_oss_20b_lpt_c4() -> Trainer.Config: + config = gpt_oss_20b_pretrain_c4() + config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-c4-outputs" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), + ],) return config \ No newline at end of file diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index a90d45a5..073c175c 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -33,6 +33,7 @@ class LowPrecisionTrainingModifier(Modifier): use_2dblock_x: bool = False use_2dblock_w: bool = True use_hadamard: bool = False + hadamard_type: Literal["default", "3rht"] = "default" use_sr_grad: bool = False use_dge: bool = False full_precision_backward: bool = False @@ -193,6 +194,9 @@ def resolved_config(self) -> dict[TrainingOpConfig, list[str]]: return self._resolved_config def on_convert(self, model: Module, **kwargs) -> bool: + if self.use_hadamard and self.hadamard_type != "default": + from alto.kernels.hadamard_transform import HadamardFactory + HadamardFactory.configure(transform_type=self.hadamard_type) for scheme_obj, targets in self.resolved_config.items(): tensor_cls = self._wrapper_cls_for_scheme(scheme_obj) scheme_name = getattr(self, "_scheme_tag", {}).get(scheme_obj, scheme_obj.precision) From 9e213d4c55a58a9370cada00b3a19ff513e33371 Mon Sep 17 00:00:00 2001 From: "Natalia (Natasha) Frumkin" <22639621+nfrumkin@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:03:56 -0500 Subject: [PATCH 139/142] comment on legacy dataset ingestion for C4 added comments --- alto/models/gpt_oss/config_registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index d2a94d2f..561096d7 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -507,7 +507,7 @@ def gpt_oss_20b_moe_pattern_obs() -> Trainer.Config: ],) return config - +# legacy dataset ingestion using un-tokenized C4 def gpt_oss_20b_pretrain_c4() -> Trainer.Config: """gpt_oss_20b_pretrain using HuggingFace C4 dataset (bf16 baseline, no Megatron files required).""" config = gpt_oss_20b_pretrain() @@ -523,10 +523,11 @@ def gpt_oss_20b_pretrain_c4() -> Trainer.Config: config.checkpoint.initial_load_in_hf_quantized = True return config +# legacy dataset ingestion using un-tokenized C4 def gpt_oss_20b_lpt_c4() -> Trainer.Config: config = gpt_oss_20b_pretrain_c4() config.dump_folder = "gpt_oss_20b-mi300-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-rank32-c4-outputs" config.model_converters = ModelConvertersContainer.Config(converters=[ ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/lpt_recipe.yaml",), ],) - return config \ No newline at end of file + return config From 912cad89a1e1892f7e1db962aecee8166bfbc304 Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Sun, 16 Aug 2026 21:36:34 +0000 Subject: [PATCH 140/142] some optimizations --- alto/kernels/fp4/mxfp4/mxfp_quantization.py | 2 + alto/kernels/hadamard_transform/hadamard.py | 17 ++++--- alto/kernels/hadamard_transform/transform.py | 47 ++++++++++++-------- alto/modifiers/lpt/base.py | 4 ++ alto/train.py | 1 + scripts/train_gptoss20b.sh | 6 +-- 6 files changed, 50 insertions(+), 27 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_quantization.py b/alto/kernels/fp4/mxfp4/mxfp_quantization.py index 92352dc9..50cc0b95 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_quantization.py +++ b/alto/kernels/fp4/mxfp4/mxfp_quantization.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT from typing import Tuple, Optional +from functools import lru_cache import torch from torch.library import triton_op, wrap_triton import triton @@ -20,6 +21,7 @@ _quantize_e2m1 = make_quantize_e2m1() +@lru_cache(maxsize=1) def is_cdna4(): target = triton.runtime.driver.active.get_current_target() return target is not None and target.backend == "hip" and target.arch == "gfx950" diff --git a/alto/kernels/hadamard_transform/hadamard.py b/alto/kernels/hadamard_transform/hadamard.py index 8208e410..add1630e 100644 --- a/alto/kernels/hadamard_transform/hadamard.py +++ b/alto/kernels/hadamard_transform/hadamard.py @@ -22,6 +22,8 @@ REPO_PATH = Path(__file__).parent / "hadamards.safetensors" +_HADAMARD_CACHE: dict[tuple[int, torch.dtype, torch.device], torch.Tensor] = {} + __all__ = ["random_hadamard_matrix", "deterministic_hadamard_matrix", "is_pow2"] # note that hadamard matrix multiplication can be accelerated using a library such as @@ -108,21 +110,26 @@ def _fetch_hadamard_divisor( be of of size `k` such that `n / k` is a power of two. Return None if no such matrix exists. - Note: This function reopens the safetensors file every time it is called. - This is technically inefficient, but a very small runtime cost and simpler - than forcing callers to manage the file open context + Results are cached by (n, dtype, device) so the safetensors file is only + opened on the first call for each combination. :param n: size of known hadamard matrix :param dtype: data type to move fetched hadamard to :param device: device to move fetched hadamard to :return: a known hadamard matrix of size `n` if one exists, else None """ + cache_key = (n, dtype, device) + if cache_key in _HADAMARD_CACHE: + return _HADAMARD_CACHE[cache_key] + open_device = torch.device("cpu") if device.type == "meta" else device with safe_open(file_path, framework="pt", device=str(open_device)) as file: divisors = sorted((int(key) for key in file.keys()), reverse=True) for divisor in divisors: if n % divisor == 0 and is_pow2(n // divisor): - return file.get_tensor(str(divisor)).to(dtype=dtype, device=device) + result = file.get_tensor(str(divisor)).to(dtype=dtype, device=device) + _HADAMARD_CACHE[cache_key] = result + return result return None @@ -140,7 +147,7 @@ def _matmul_hadU(X: torch.Tensor) -> torch.Tensor: # Reshape diag matrix with randomized -1/+1 input = X.clone().view(-1, size, 1) - output = input.clone() + output = torch.empty_like(input) while input.shape[1] > K: input = input.view(input.shape[0], input.shape[1] // 2, 2, input.shape[2]) output = output.view(input.shape) diff --git a/alto/kernels/hadamard_transform/transform.py b/alto/kernels/hadamard_transform/transform.py index 4c1cc641..608abb26 100644 --- a/alto/kernels/hadamard_transform/transform.py +++ b/alto/kernels/hadamard_transform/transform.py @@ -33,6 +33,7 @@ class HadamardFactory: transform_type: str = "default" seed: Optional[int] = None generator: torch.Generator = torch.Generator() + _cached_transform: Optional['HadamardTransform'] = None @classmethod def configure( @@ -63,6 +64,11 @@ def configure( cls.seed = seed cls.generator.manual_seed(seed) + @classmethod + def refresh(cls) -> None: + """Clear the cached transform so the next create_transform generates a fresh one.""" + cls._cached_transform = None + @classmethod def create_transform( cls, @@ -77,28 +83,35 @@ def create_transform( :param device: Device to create the transform on :return: HadamardTransform instance """ + if cls._cached_transform is not None: + return cls._cached_transform + if cls.transform_type == "default": weight = cls._create_weight(device) perm = cls._create_permutation(weight) if cls.randomized else None - return HadamardTransform(weight, perm) + t = HadamardTransform(weight, perm) elif cls.transform_type == "3rht": + n = cls.block_size w = cls._create_weight(device) p = cls._create_permutation(w) combined = w[p][:, p] - s = torch.tensor(w.size(0), dtype=torch.float64, device=w.device).sqrt() w = cls._create_weight(device) p = cls._create_permutation(w) - combined = combined @ (w[p][:, p]) / s + combined = combined @ (w[p][:, p]) w = cls._create_weight(device) p = cls._create_permutation(w) - combined = combined @ (w[p][:, p]) / s + combined = combined @ (w[p][:, p]) - return HadamardTransform(combined, perm=None) + combined = combined / n + t = HadamardTransform(combined, perm=None) else: raise NotImplementedError("transform_type options are: default and 3rht") + cls._cached_transform = t + return t + @classmethod def _create_weight( cls, @@ -129,9 +142,13 @@ def __init__( weight: Tensor, perm: Optional[Tensor], ): - self.weight = weight - self.perm = perm - self._scale = torch.tensor(weight.size(0), dtype=torch.float64, device=weight.device).sqrt() + scale = weight.size(0) ** 0.5 + if perm is not None: + weight = weight[perm][:, perm] + if isinstance(weight, DTensor): + assert weight.placements[0] == Replicate() + weight = weight.to_local() + self.weight = (weight / scale).contiguous() def __call__(self, value: Tensor, inverse: bool = False, left_mul: bool = False) -> Tensor: """ @@ -145,19 +162,11 @@ def __call__(self, value: Tensor, inverse: bool = False, left_mul: bool = False) """ weight = self.weight - if self.perm is not None: - weight = weight[self.perm][:, self.perm] - if inverse: weight = weight.T - if isinstance(weight, DTensor): - assert weight.placements[0] == Replicate() - weight = weight.to_local() - + w = weight.to(device=value.device, dtype=value.dtype) if left_mul: - return (multihead_matmul(weight.to(device=value.device), value.to(dtype=weight.dtype)) / self._scale).to( - value.dtype) + return multihead_matmul(w, value) else: - return (multihead_matmul(value.to(dtype=weight.dtype), weight.to(device=value.device)) / self._scale).to( - value.dtype) + return multihead_matmul(value, w) diff --git a/alto/modifiers/lpt/base.py b/alto/modifiers/lpt/base.py index 073c175c..6546546c 100644 --- a/alto/modifiers/lpt/base.py +++ b/alto/modifiers/lpt/base.py @@ -245,6 +245,10 @@ def on_initialize(self, model_parts: list[Module], **kwargs) -> bool: return True def on_pre_step(self, model_parts: list[Module], **kwargs) -> bool: + if self.use_hadamard: + from alto.kernels.hadamard_transform import HadamardFactory + HadamardFactory.refresh() + trainer = kwargs.get("trainer", None) if self.deosc_step > 0: diff --git a/alto/train.py b/alto/train.py index 82e0687f..186705a5 100644 --- a/alto/train.py +++ b/alto/train.py @@ -13,6 +13,7 @@ from torchtitan.experiments.forge.example_train import Trainer as ForgeTrainer, main as forge_main from torchtitan.components.metrics import MetricsProcessor from alto.components.converter import ModelOptConverter +from alto.components.optimizer import DeOscillationConfig, enable_de_oscillation from torchtitan.tools.logging import logger diff --git a/scripts/train_gptoss20b.sh b/scripts/train_gptoss20b.sh index 12ee2788..5d227935 100755 --- a/scripts/train_gptoss20b.sh +++ b/scripts/train_gptoss20b.sh @@ -65,13 +65,13 @@ HF_ENV_FILE="${HF_ENV_FILE:-$HOME/.hf.env}" # .env file has raw HF access token ALTO_DIR="${ALTO_DIR:-$PWD}" # assume repo dir is pwd CONFIG="${CONFIG:-gpt_oss_20b_mxfp4_base}" -CHECKPOINT_DIR="${CHECKPOINT_DIR:-/shared_inference/alirezak/gptoss_chkpt/${USER}/${CONFIG}}" +CHECKPOINT_DIR="${CHECKPOINT_DIR:-/shared_inference/alirezak/gptoss_chkpt/${USER}/${CONFIG}_$RUN_ID}" LOG_FILE_DEFAULT="${ALTO_DIR}/logs/${CONFIG}_$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_FILE:-$LOG_FILE_DEFAULT}" # log fname based on time ### Other modifiable args MODULE="${MODULE:-gpt_oss}" -TRAINING_STEPS="${TRAINING_STEPS:-17000}" +TRAINING_STEPS="${TRAINING_STEPS:-15000}" CONTAINER="${CONTAINER:-${CONFIG}_${RUN_ID}}" # container name (for user readability) # default Docker image from Han Wang @@ -226,4 +226,4 @@ docker exec \ --profiling.profiler_active 1 \ 2>&1 | tee "$LOG_FILE" -echo "[train] Run complete." +echo "[train] Run complete." \ No newline at end of file From 4501d4e50632336bc4a3c98ed6c32d181950597d Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Thu, 27 Aug 2026 17:35:25 +0000 Subject: [PATCH 141/142] Update for fw and dgrad projection support and GPT-OSS config --- .../mxfp4/mxfp_grouped_gemm/cg_backward.py | 41 +++++++++++++----- alto/kernels/fp4/mxfp4/mxfp_linear.py | 40 ++++++++++++----- .../hadamard_transform/hadamards.safetensors | Bin 132 -> 1436901 bytes alto/models/gpt_oss/config_registry.py | 15 ++++++- 4 files changed, 70 insertions(+), 26 deletions(-) diff --git a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py index 64c4397c..a483a6ba 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py +++ b/alto/kernels/fp4/mxfp4/mxfp_grouped_gemm/cg_backward.py @@ -634,16 +634,25 @@ def forward( requant_axis_w = -1 expert_weights = unwrap_weight_wrapper(expert_weights) + + # Forward GEMM rotation: Q(XH) @ Q(WH)^T + if hadamard_transform is not None and not use_2dblock_x and not use_2dblock_w: + inputs_fwd = hadamard_transform(inputs) + expert_weights_fwd = hadamard_transform(expert_weights) + else: + inputs_fwd = inputs + expert_weights_fwd = expert_weights + if use_macro_block_scaling: - inputs_scaled, input_mbs = macro_block_scaling(inputs, axis=-1, use_2d_block=use_2dblock_x) - expert_weights_scaled, expert_weight_mbs = macro_block_scaling(expert_weights, + inputs_scaled, input_mbs = macro_block_scaling(inputs_fwd, axis=-1, use_2d_block=use_2dblock_x) + expert_weights_scaled, expert_weight_mbs = macro_block_scaling(expert_weights_fwd, axis=quant_axis_w, use_2d_block=use_2dblock_w) else: - inputs_scaled = inputs - input_mbs = inputs.new_empty([]) - expert_weights_scaled = expert_weights - expert_weight_mbs = expert_weights.new_empty([]) + inputs_scaled = inputs_fwd + input_mbs = inputs_fwd.new_empty([]) + expert_weights_scaled = expert_weights_fwd + expert_weight_mbs = expert_weights_fwd.new_empty([]) inputs_mxfp4, input_scales = torch.ops.torchtitan.convert_to_mxfp4( inputs_scaled, axis=-1, @@ -695,13 +704,15 @@ def forward( res = cg_grouped_gemm_forward(x_dq, w_dq, expert_indices) if not use_2dblock_w: + # dgrad weight rotation: Q(HW) for dgrad path + w_for_dgrad = hadamard_transform(expert_weights, left_mul=True) if hadamard_transform is not None else expert_weights if use_macro_block_scaling: - expert_weights_scaled, expert_weight_mbs = macro_block_scaling(expert_weights, + expert_weights_scaled, expert_weight_mbs = macro_block_scaling(w_for_dgrad, axis=requant_axis_w, use_2d_block=False) else: - expert_weights_scaled = expert_weights - expert_weight_mbs = expert_weights.new_empty([]) + expert_weights_scaled = w_for_dgrad + expert_weight_mbs = w_for_dgrad.new_empty([]) expert_weights_mxfp4, expert_weight_scales = torch.ops.torchtitan.convert_to_mxfp4( expert_weights_scaled, axis=requant_axis_w, @@ -815,11 +826,16 @@ def backward(ctx, grad_output): #grad_output_dq = grad_output_dq.contiguous() grad_output_m_dq = grad_output_dq else: + # dgrad grad_output rotation: Q(GH^T) for dgrad path (1D quant only) + if ctx.hadamard_transform is not None and not ctx.use_2dblock_w: + grad_output_dgrad = ctx.hadamard_transform(grad_output, inverse=True) + else: + grad_output_dgrad = grad_output if ctx.use_macro_block_scaling: - grad_output_scaled, grad_output_mbs = macro_block_scaling(grad_output, axis=-1, use_2d_block=False) + grad_output_scaled, grad_output_mbs = macro_block_scaling(grad_output_dgrad, axis=-1, use_2d_block=False) else: - grad_output_scaled = grad_output - grad_output_mbs = grad_output.new_empty([]) + grad_output_scaled = grad_output_dgrad + grad_output_mbs = grad_output_dgrad.new_empty([]) grad_output_mxfp4, grad_output_scales = torch.ops.torchtitan.convert_to_mxfp4( grad_output_scaled, axis=-1, @@ -827,6 +843,7 @@ def backward(ctx, grad_output): is_2d_block=False, blockscale_selection=ctx.blockscale_selection, ) + # wgrad grad_output rotation: Q(HG) for wgrad path (existing) if ctx.hadamard_transform is not None: grad_output = ctx.hadamard_transform(grad_output, left_mul=True) if ctx.use_macro_block_scaling: diff --git a/alto/kernels/fp4/mxfp4/mxfp_linear.py b/alto/kernels/fp4/mxfp4/mxfp_linear.py index 61c0c678..2158b954 100644 --- a/alto/kernels/fp4/mxfp4/mxfp_linear.py +++ b/alto/kernels/fp4/mxfp4/mxfp_linear.py @@ -300,14 +300,22 @@ def forward( original_dtype = x.dtype x = x.reshape(-1, original_shape[-1]) # Ensure x is 2D + # Forward GEMM rotation: Q(XH) @ Q(WH)^T + if hadamard_transform is not None and not use_2dblock_x and not use_2dblock_w: + x_fwd = hadamard_transform(x) + w_fwd = hadamard_transform(weight) + else: + x_fwd = x + w_fwd = weight + if use_macro_block_scaling: - x_scaled, x_mbs = macro_block_scaling(x, axis=-1, use_2d_block=use_2dblock_x) - w_scaled, w_mbs = macro_block_scaling(weight, axis=-1, use_2d_block=use_2dblock_w) + x_scaled, x_mbs = macro_block_scaling(x_fwd, axis=-1, use_2d_block=use_2dblock_x) + w_scaled, w_mbs = macro_block_scaling(w_fwd, axis=-1, use_2d_block=use_2dblock_w) else: - x_scaled = x - x_mbs = x.new_empty([]) - w_scaled = weight - w_mbs = weight.new_empty([]) + x_scaled = x_fwd + x_mbs = x_fwd.new_empty([]) + w_scaled = w_fwd + w_mbs = w_fwd.new_empty([]) x_mxfp4, x_scale = torch.ops.torchtitan.convert_to_mxfp4( x_scaled, @@ -358,11 +366,13 @@ def forward( y = x_dq @ w_dq.T if not use_2dblock_w: + # dgrad weight rotation: Q(HW) for dgrad path + w_for_dgrad = hadamard_transform(weight, left_mul=True) if hadamard_transform is not None else weight if use_macro_block_scaling: - w_scaled, w_mbs = macro_block_scaling(weight, axis=0, use_2d_block=False) + w_scaled, w_mbs = macro_block_scaling(w_for_dgrad, axis=0, use_2d_block=False) else: - w_scaled = weight - w_mbs = weight.new_empty([]) + w_scaled = w_for_dgrad + w_mbs = w_for_dgrad.new_empty([]) w_mxfp4, w_scale = torch.ops.torchtitan.convert_to_mxfp4( w_scaled, @@ -475,11 +485,16 @@ def backward(ctx, grad_output): grad_output_dq = macro_block_descaling(grad_output_dq, grad_output_mbs, axis=-1, use_2d_block=True) grad_output_m_dq = grad_output_dq else: + # dgrad grad_output rotation: Q(GH^T) for dgrad path (1D quant only) + if ctx.hadamard_transform is not None and not ctx.use_2dblock_w: + grad_output_dgrad = ctx.hadamard_transform(grad_output, inverse=True) + else: + grad_output_dgrad = grad_output if ctx.use_macro_block_scaling: - grad_output_scaled, grad_output_mbs = macro_block_scaling(grad_output, axis=-1, use_2d_block=False) + grad_output_scaled, grad_output_mbs = macro_block_scaling(grad_output_dgrad, axis=-1, use_2d_block=False) else: - grad_output_scaled = grad_output - grad_output_mbs = grad_output.new_empty([]) + grad_output_scaled = grad_output_dgrad + grad_output_mbs = grad_output_dgrad.new_empty([]) grad_output_mxfp4, grad_output_scales = torch.ops.torchtitan.convert_to_mxfp4( grad_output_scaled, axis=-1, @@ -488,6 +503,7 @@ def backward(ctx, grad_output): blockscale_selection=ctx.blockscale_selection, ) + # wgrad grad_output rotation: Q(HG) for wgrad path (existing) if ctx.hadamard_transform is not None: grad_output = ctx.hadamard_transform(grad_output, left_mul=True) if ctx.use_macro_block_scaling: diff --git a/alto/kernels/hadamard_transform/hadamards.safetensors b/alto/kernels/hadamard_transform/hadamards.safetensors index bd00063f140e39e04a7acb988692417d22cf668b..9624e008623e86678a2da7f27000106e03055257 100644 GIT binary patch literal 1436901 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

Ky4J7 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 It9OnAV--xH#4Yj*)gQZy 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 literal 132 zcmWN?OA^8$3;@tQr|1PNgm2p0kR}K-DjmTtJiWfnyW~Aue=T*+bL?8*+q^x>SpU}# ztw(?Aamt}DP`&XoYPKQn8-|nukaH1}k3fQ3F)mn(P=sv)#)C`dY*0ujQ%NB)Iq=9H OM2+^91-y%!0rdkR#V82? diff --git a/alto/models/gpt_oss/config_registry.py b/alto/models/gpt_oss/config_registry.py index 561096d7..803b0b3d 100644 --- a/alto/models/gpt_oss/config_registry.py +++ b/alto/models/gpt_oss/config_registry.py @@ -47,7 +47,8 @@ "gpt_oss_20b_mxfp4_base", "gpt_oss_20b_mxfp4_had_2dw_sr", "gpt_oss_20b_mxfp4_3rht_2dw_sr", - "gpt_oss_20b_mxfp4_3rht_2dw_sr_uos" + "gpt_oss_20b_mxfp4_3rht_2dw_sr_uos", + "gpt_oss_20b_mxfp4_3rht_2dw_sr_1d" ] @@ -180,7 +181,8 @@ def gpt_oss_20b_pretrain() -> Trainer.Config: config.optimizer.eps = 1e-5 # set by mlperf config.lr_scheduler.min_lr_factor = 0.1 # set by mlperf config.lr_scheduler.warmup_steps = 128 # can be edited for mlperf submission - config.lr_scheduler.decay_ratio = 1 - 128 / config.training.steps + config.lr_scheduler.total_steps = 1200000 + config.lr_scheduler.decay_ratio = 1 - 128 / config.lr_scheduler.total_steps config.lr_scheduler.decay_type = "cosine" config.metrics.log_freq = 1 config.metrics.enable_tensorboard = True @@ -366,6 +368,15 @@ def gpt_oss_20b_mxfp4_3rht_2dw_sr() -> Trainer.Config: ],) return config +def gpt_oss_20b_mxfp4_3rht_2dw_sr_1d() -> Trainer.Config: + """baseline MXFP4 quantization.""" + config = gpt_oss_20b_lpt() + config.dump_folder = "gpt_oss_20b-pretrain-subset-mxfp4gemm_1d2d-hadamard-sr-lr4e-4-mxfp4-base" + config.model_converters = ModelConvertersContainer.Config(converters=[ + ModelOptConverter.Config(recipe="./alto/models/gpt_oss/configs/mxfp4_3rht_2dw_sr_1d.yaml",), + ],) + return config + def gpt_oss_20b_mxfp4_3rht_2dw_sr_uos() -> Trainer.Config: """baseline MXFP4 quantization.""" config = gpt_oss_20b_lpt() From 88d1dbbaeb4cf3b834ce8a60f375b9e6146ea433 Mon Sep 17 00:00:00 2001 From: Alireza Khodamoradi Date: Thu, 27 Aug 2026 17:41:42 +0000 Subject: [PATCH 142/142] Restore hadamard transform weights --- .../hadamard_transform/hadamards.safetensors | Bin 1436901 -> 132 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/alto/kernels/hadamard_transform/hadamards.safetensors b/alto/kernels/hadamard_transform/hadamards.safetensors index 9624e008623e86678a2da7f27000106e03055257..bd00063f140e39e04a7acb988692417d22cf668b 100644 GIT binary patch literal 132 zcmWN?OA^8$3;@tQr|1PNgm2p0kR}K-DjmTtJiWfnyW~Aue=T*+bL?8*+q^x>SpU}# ztw(?Aamt}DP`&XoYPKQn8-|nukaH1}k3fQ3F)mn(P=sv)#)C`dY*0ujQ%NB)Iq=9H OM2+^91-y%!0rdkR#V82? literal 1436901 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

Ky4J7 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 It9OnAV--xH#4Yj*)gQZy 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