@@ -435,3 +435,143 @@ def pack(t):
435435 out_ref .sum ().backward ()
436436 torch .testing .assert_close (out_mod , out_ref , rtol = 0 , atol = 0 )
437437 torch .testing .assert_close (x_mod .grad , x_ref .grad , rtol = 0 , atol = 0 )
438+
439+
440+ # ---------------------------------------------------------------------------
441+ # Device movement, dtype casts, and serialization round-trips
442+ # ---------------------------------------------------------------------------
443+
444+
445+ @pytest .mark .skipif (not torch .cuda .is_available (), reason = "requires CUDA" )
446+ def test_experts4bit_to_device_moves_quant_state ():
447+ """Movement must carry the whole quantization state and must not change the math.
448+
449+ The packed weights are plain Parameters and absmax/code are module buffers (no
450+ Params4bit machinery), so `.to()` has to move all of them together, and a
451+ cpu->cuda->cpu round trip has to be bit-exact: the dequant inputs are integer
452+ bytes plus fp32 scales, so movement alone can never perturb a forward.
453+ """
454+ gate_up , down = _random_expert_weights (torch .float32 , "cpu" )
455+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
456+ hidden_states , top_k_index , top_k_weights = _random_routing ("cpu" )
457+
458+ ref = module (hidden_states , top_k_index , top_k_weights ) # never-moved control
459+ packed_before = module .gate_up_proj .detach ().clone ()
460+ absmax_before = module .gate_up_absmax .clone ()
461+
462+ module .to ("cuda" )
463+ for t in (module .gate_up_proj , module .down_proj , module .gate_up_absmax , module .down_absmax , module .code ):
464+ assert t .device .type == "cuda"
465+ out_cuda = module (hidden_states .cuda (), top_k_index .cuda (), top_k_weights .cuda ())
466+ assert out_cuda .device .type == "cuda"
467+
468+ module .to ("cpu" )
469+ torch .testing .assert_close (module .gate_up_proj , packed_before , rtol = 0 , atol = 0 )
470+ torch .testing .assert_close (module .gate_up_absmax , absmax_before , rtol = 0 , atol = 0 )
471+ out_roundtrip = module (hidden_states , top_k_index , top_k_weights )
472+ torch .testing .assert_close (out_roundtrip , ref , rtol = 0 , atol = 0 )
473+
474+
475+ @pytest .mark .parametrize ("device" , get_available_devices ())
476+ @pytest .mark .parametrize ("cast" , ["to" , "half" , "bfloat16" ])
477+ def test_experts4bit_dtype_cast_retargets_compute_only (device , cast ):
478+ """A float dtype cast retargets compute_dtype; the quantization state stays fp32.
479+
480+ Without the `_apply` shield, `.to(dtype)` / `.half()` would silently cast the fp32
481+ absmax/code buffers (the packed uint8 weights are naturally immune), changing every
482+ subsequent dequantization. The sharp invariant: dequantized weights are bit-identical
483+ before and after the cast.
484+ """
485+ gate_up , down = _random_expert_weights (torch .float32 , device )
486+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
487+ deq_before = module ._dequantize_expert (
488+ module .gate_up_proj , module .gate_up_absmax , module ._gate_up_shape , 0 , torch .float32
489+ )
490+
491+ target = {"to" : torch .float16 , "half" : torch .float16 , "bfloat16" : torch .bfloat16 }[cast ]
492+ module = module .to (target ) if cast == "to" else getattr (module , cast )()
493+
494+ assert module .compute_dtype == target
495+ assert module .gate_up_proj .dtype == torch .uint8
496+ assert module .gate_up_absmax .dtype == torch .float32
497+ assert module .down_absmax .dtype == torch .float32
498+ assert module .code .dtype == torch .float32
499+
500+ deq_after = module ._dequantize_expert (
501+ module .gate_up_proj , module .gate_up_absmax , module ._gate_up_shape , 0 , torch .float32
502+ )
503+ torch .testing .assert_close (deq_after , deq_before , rtol = 0 , atol = 0 )
504+
505+ hidden_states , top_k_index , top_k_weights = _random_routing (device )
506+ out = module (hidden_states , top_k_index , top_k_weights )
507+ assert out .dtype == target
508+ assert torch .isfinite (out ).all ()
509+
510+
511+ @pytest .mark .parametrize ("device" , get_available_devices ())
512+ def test_experts4bit_load_state_dict_non_strict (device ):
513+ """strict=False into a ctor-built module restores everything (no silently-skipped keys)."""
514+ gate_up , down = _random_expert_weights (torch .float16 , device )
515+ src = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float16 )
516+ dst = Experts4bit (NUM_EXPERTS , HIDDEN_DIM , INTERMEDIATE_DIM , compute_dtype = torch .float16 , device = device )
517+
518+ result = dst .load_state_dict (src .state_dict (), strict = False )
519+ assert result .missing_keys == [] and result .unexpected_keys == []
520+
521+ hidden_states , top_k_index , top_k_weights = _random_routing (device )
522+ out_src = src (hidden_states , top_k_index , top_k_weights )
523+ out_dst = dst (hidden_states , top_k_index , top_k_weights )
524+ torch .testing .assert_close (out_dst , out_src , rtol = 0 , atol = 0 )
525+
526+
527+ def test_experts4bit_safetensors_roundtrip (tmp_path ):
528+ """`safetensors.torch.save_model` / `load_model` round-trips to a bit-exact forward.
529+
530+ Works out of the box because the module is plain Parameters + persistent buffers:
531+ no `_extra_state`, no shared storage, and the non-persistent `code` codebook is
532+ reconstructed at init rather than serialized.
533+ """
534+ st = pytest .importorskip ("safetensors.torch" )
535+ gate_up , down = _random_expert_weights (torch .float32 , "cpu" )
536+ src = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
537+
538+ path = str (tmp_path / "experts4bit.safetensors" )
539+ st .save_model (src , path )
540+
541+ dst = Experts4bit (NUM_EXPERTS , HIDDEN_DIM , INTERMEDIATE_DIM , compute_dtype = torch .float32 )
542+ missing , unexpected = st .load_model (dst , path )
543+ assert not missing and not unexpected
544+
545+ hidden_states , top_k_index , top_k_weights = _random_routing ("cpu" )
546+ out_src = src (hidden_states , top_k_index , top_k_weights )
547+ out_dst = dst (hidden_states , top_k_index , top_k_weights )
548+ torch .testing .assert_close (out_dst , out_src , rtol = 0 , atol = 0 )
549+
550+
551+ @pytest .mark .skipif (not torch .cuda .is_available (), reason = "requires CUDA" )
552+ def test_experts4bit_cuda_dequant_fidelity ():
553+ """Pin absolute nf4 fidelity on CUDA, not just internal self-consistency.
554+
555+ For the 0.1-scaled normal weights used across this file, per-expert nf4 dequant
556+ mean-abs error measures ~0.0073 on an RTX A2000; 0.008 gives headroom without
557+ letting a broken scale path (e.g. cast absmax) sneak through. The forward bound
558+ is the downstream-pinned per-expert dequant ceiling (rel err < 0.2).
559+ """
560+ torch .manual_seed (0 )
561+ gate_up , down = _random_expert_weights (torch .float32 , "cuda" )
562+ module = Experts4bit .from_float (gate_up , down , compute_dtype = torch .float32 )
563+
564+ errs = []
565+ for e in range (NUM_EXPERTS ):
566+ deq = module ._dequantize_expert (
567+ module .gate_up_proj , module .gate_up_absmax , module ._gate_up_shape , e , torch .float32
568+ )
569+ errs .append ((deq - gate_up [e ]).abs ().mean ().item ())
570+ mean_abs_err = sum (errs ) / len (errs )
571+ assert mean_abs_err <= 0.008 , f"nf4 dequant mean-abs error { mean_abs_err :.4f} above ceiling"
572+
573+ hidden_states , top_k_index , top_k_weights = _random_routing ("cuda" )
574+ out = module (hidden_states , top_k_index , top_k_weights )
575+ ref = _reference_forward (gate_up , down , hidden_states , top_k_index , top_k_weights )
576+ rel_err = ((out - ref ).norm () / ref .norm ()).item ()
577+ assert rel_err <= 0.2 , f"4-bit forward rel err { rel_err :.3f} above ceiling"
0 commit comments