Skip to content

Commit 061b091

Browse files
pjordanandrsnclaude
andcommitted
test: pin device movement, dtype casts, serialization round-trips, CUDA fidelity
- to()/round-trip movement: all quant-state tensors move together; cpu->cuda->cpu is bit-exact for packed bytes, absmax, and the forward (dequant inputs are integers + fp32 scales, so movement can never perturb the math). - dtype casts (.to(dtype)/.half()/.bfloat16()): compute_dtype retargets, absmax/code stay fp32, dequantized weights bit-identical across the cast. - state_dict strict=False into a ctor-built module: nothing missing, nothing skipped, bit-exact forward parity. - safetensors save_model/load_model round-trip: works with no _extra_state or custom hooks; bit-exact forward parity. - CUDA numerics: nf4 per-expert dequant mean-abs error <= 0.008 for the 0.1-scaled weights used across this file (measures ~0.0073 on an RTX A2000) and forward rel err <= 0.2 vs the true-weight reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ae8aa89 commit 061b091

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

tests/test_experts4bit.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)