@@ -575,3 +575,146 @@ def test_experts4bit_cuda_dequant_fidelity():
575575 ref = _reference_forward (gate_up , down , hidden_states , top_k_index , top_k_weights )
576576 rel_err = ((out - ref ).norm () / ref .norm ()).item ()
577577 assert rel_err <= 0.2 , f"4-bit forward rel err { rel_err :.3f} above ceiling"
578+
579+
580+ # ---------------------------------------------------------------------------
581+ # Composition: torch.compile, gradient checkpointing, autocast, meta-device
582+ # ---------------------------------------------------------------------------
583+
584+
585+ @pytest .mark .skipif (not torch .cuda .is_available (), reason = "requires CUDA" )
586+ def test_experts4bit_torch_compile_parity_and_breaks ():
587+ """torch.compile falls back cleanly on the routing and never changes the math.
588+
589+ The forward's expert routing is data-dependent (`nonzero` on the expert mask), so
590+ Dynamo graph-breaks there and `_FrozenLinearRecomputeBackward` runs eagerly inside
591+ the compiled wrapper (observed: 5 breaks / 6 graphs on torch 2.6, all attributed to
592+ `aten.nonzero`). That split is acceptable and pinned; what must never happen is a
593+ silently different number — forward and input-grad are asserted bitwise-equal to
594+ eager.
595+ """
596+ torch ._dynamo .reset ()
597+ gate_up , down = _random_expert_weights (torch .float32 , "cuda" )
598+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
599+ hidden_states , top_k_index , top_k_weights = _random_routing ("cuda" )
600+
601+ x_eager = hidden_states .clone ().requires_grad_ (True )
602+ out_eager = module (x_eager , top_k_index , top_k_weights )
603+ out_eager .sum ().backward ()
604+
605+ explanation = torch ._dynamo .explain (module )(hidden_states , top_k_index , top_k_weights )
606+ assert explanation .graph_break_count >= 1 # clean break on the routing, not a silent trace
607+
608+ torch ._dynamo .reset ()
609+ x_compiled = hidden_states .clone ().requires_grad_ (True )
610+ compiled = torch .compile (module )
611+ out_compiled = compiled (x_compiled , top_k_index , top_k_weights )
612+ out_compiled .sum ().backward ()
613+
614+ torch .testing .assert_close (out_compiled , out_eager , rtol = 0 , atol = 0 )
615+ torch .testing .assert_close (x_compiled .grad , x_eager .grad , rtol = 0 , atol = 0 )
616+ torch ._dynamo .reset ()
617+
618+
619+ @pytest .mark .parametrize ("device" , get_available_devices ())
620+ def test_experts4bit_gradient_checkpoint_recompute_count (device ):
621+ """Checkpointing composes with recompute-in-backward: 3x dequants, not 4x, bit-exact.
622+
623+ Per fwd+bwd, the module alone dequantizes 2*D times (D in forward, D re-dequantized in
624+ backward). Under `torch.utils.checkpoint` the count is 3*D — the no-grad forward, the
625+ checkpoint replay, and the backward re-dequant — i.e. recompute-inside-recompute adds
626+ +50%, it does not multiply. Numerics are bitwise-identical either way.
627+ """
628+ from torch .utils .checkpoint import checkpoint
629+
630+ gate_up , down = _random_expert_weights (torch .float32 , device )
631+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
632+ hidden_states , top_k_index , top_k_weights = _random_routing (device )
633+
634+ counter = {"n" : 0 }
635+ inner = module ._dequantize_expert
636+
637+ def counting (* args , ** kwargs ):
638+ counter ["n" ] += 1
639+ return inner (* args , ** kwargs )
640+
641+ module ._dequantize_expert = counting
642+
643+ with torch .no_grad ():
644+ module (hidden_states , top_k_index , top_k_weights )
645+ dequants_per_forward = counter ["n" ]
646+ assert dequants_per_forward > 0
647+
648+ counter ["n" ] = 0
649+ x_plain = hidden_states .clone ().requires_grad_ (True )
650+ out_plain = module (x_plain , top_k_index , top_k_weights )
651+ out_plain .sum ().backward ()
652+ assert counter ["n" ] == 2 * dequants_per_forward
653+
654+ counter ["n" ] = 0
655+ x_ckpt = hidden_states .clone ().requires_grad_ (True )
656+ out_ckpt = checkpoint (module , x_ckpt , top_k_index , top_k_weights , use_reentrant = False )
657+ out_ckpt .sum ().backward ()
658+ assert counter ["n" ] == 3 * dequants_per_forward
659+
660+ torch .testing .assert_close (out_ckpt , out_plain , rtol = 0 , atol = 0 )
661+ torch .testing .assert_close (x_ckpt .grad , x_plain .grad , rtol = 0 , atol = 0 )
662+
663+
664+ @pytest .mark .skipif (not torch .cuda .is_available (), reason = "requires CUDA" )
665+ def test_experts4bit_autocast_semantics ():
666+ """Under `torch.autocast` the quantization path is untouched and dtypes don't drift.
667+
668+ The dequantize step is not an autocast op, so weights still materialize in
669+ compute_dtype and the packed/absmax/code state is bit-identical after an autocast
670+ forward; only the linears run in the autocast dtype. The output dtype follows
671+ compute_dtype (fp32 here), and values match the non-autocast forward at bf16
672+ precision (measured max-abs diff ~0.011 at this scale on an RTX A2000).
673+ """
674+ gate_up , down = _random_expert_weights (torch .float32 , "cuda" )
675+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
676+ hidden_states , top_k_index , top_k_weights = _random_routing ("cuda" )
677+
678+ deq_before = module ._dequantize_expert (
679+ module .gate_up_proj , module .gate_up_absmax , module ._gate_up_shape , 0 , torch .float32
680+ )
681+ out_ref = module (hidden_states , top_k_index , top_k_weights )
682+ with torch .autocast ("cuda" , dtype = torch .bfloat16 ):
683+ out_amp = module (hidden_states , top_k_index , top_k_weights )
684+ deq_after = module ._dequantize_expert (
685+ module .gate_up_proj , module .gate_up_absmax , module ._gate_up_shape , 0 , torch .float32
686+ )
687+
688+ assert out_amp .dtype == out_ref .dtype == torch .float32
689+ assert module .gate_up_absmax .dtype == torch .float32 and module .code .dtype == torch .float32
690+ torch .testing .assert_close (deq_after , deq_before , rtol = 0 , atol = 0 )
691+ torch .testing .assert_close (out_amp , out_ref , rtol = 0.05 , atol = 0.03 )
692+
693+
694+ @pytest .mark .parametrize ("device" , get_available_devices ())
695+ def test_experts4bit_meta_device_assign_materialization (device ):
696+ """The `init_empty_weights`-style loading path works with no custom hooks.
697+
698+ Construct under `torch.device("meta")` (what HF `from_pretrained(device_map=...)`
699+ does around module init), then materialize with `load_state_dict(..., assign=True)`.
700+ Packed weights and absmax buffers land as real tensors, the frozen flag survives,
701+ no meta tensors remain (`code` is rebuilt at init and never serialized), and the
702+ forward is bit-identical to the source module.
703+ """
704+ gate_up , down = _random_expert_weights (torch .float16 , device )
705+ src = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float16 )
706+
707+ with torch .device ("meta" ):
708+ empty = Experts4bit (NUM_EXPERTS , HIDDEN_DIM , INTERMEDIATE_DIM , compute_dtype = torch .float16 )
709+ assert empty .gate_up_proj .is_meta # ctor really did defer allocation
710+
711+ empty .load_state_dict (src .state_dict (), assign = True )
712+
713+ leftovers = [n for n , t in list (empty .named_parameters ()) + list (empty .named_buffers ()) if t .is_meta ]
714+ assert leftovers == []
715+ assert not empty .gate_up_proj .requires_grad
716+
717+ hidden_states , top_k_index , top_k_weights = _random_routing (device )
718+ out_src = src (hidden_states , top_k_index , top_k_weights )
719+ out_loaded = empty (hidden_states , top_k_index , top_k_weights )
720+ torch .testing .assert_close (out_loaded , out_src , rtol = 0 , atol = 0 )
0 commit comments