# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Tests for Q-GaLore integration (unsloth/optimizers/). import pytest import sys import os import torch import torch.nn as nn # Import optimizers directly to avoid triggering unsloth.__init__ (heavy deps). _repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) _optimizers_dir = os.path.join(_repo_root, "unsloth", "optimizers") if _repo_root not in sys.path: sys.path.insert(0, _repo_root) import importlib.util def _load_module(name, filepath): spec = importlib.util.spec_from_file_location(name, filepath) mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod spec.loader.exec_module(mod) return mod # Projector has no unsloth dependencies; load it first. _projector_mod = _load_module( "unsloth.optimizers.q_galore_projector", os.path.join(_optimizers_dir, "q_galore_projector.py"), ) GaLoreProjector = _projector_mod.GaLoreProjector _quantize = _projector_mod._quantize _dequantize = _projector_mod._dequantize _quantize_stochastic = _projector_mod._quantize_stochastic # adamw depends on projector, may skip bitsandbytes. _adamw_mod = _load_module( "unsloth.optimizers.q_galore_adamw", os.path.join(_optimizers_dir, "q_galore_adamw.py"), ) make_q_galore_param_groups = _adamw_mod.make_q_galore_param_groups # ====================================================================== # Projector tests # ====================================================================== class TestGaLoreProjector: """Tests for the GaLore low-rank gradient projector.""" def test_project_and_back_tall(self): """Project → project_back preserves shape for tall matrices.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 1) grad = torch.randn(16, 8) # tall low = proj.project(grad, step = 0) assert low.shape == (16, 4) full = proj.project_back(low) assert full.shape == grad.shape def test_project_and_back_wide(self): """Project → project_back preserves shape for wide matrices.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 1) grad = torch.randn(8, 16) # wide low = proj.project(grad, step = 0) assert low.shape == (4, 16) full = proj.project_back(low) assert full.shape == grad.shape def test_project_reuses_cached_svd(self): """SVD is not recomputed when step is not a multiple of update_proj_gap.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 100) grad = torch.randn(16, 8) proj.project(grad, step = 0) assert proj.svd_count == 1 proj.project(grad, step = 1) assert proj.svd_count == 1 # No recomputation proj.project(grad, step = 100) assert proj.svd_count == 2 # Recomputed def test_quantized_projection(self): """Quantized projection matrix stores and restores with bounded error.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 1, quant = True, n_bit = 8) grad = torch.randn(16, 8) low = proj.project(grad, step = 0) assert low.shape == (16, 4) assert proj.ortho_matrix.dtype == torch.uint8 def test_quantized_projection_int4(self): """INT4 quantized projection stores correctly.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 1, quant = True, n_bit = 4) grad = torch.randn(16, 8) proj.project(grad, step = 0) assert proj.ortho_matrix.dtype == torch.uint8 # INT4 values should be in range [0, 15] assert proj.ortho_matrix.max() <= 15 def test_adaptive_scheduling(self): """update_proj_gap increases when cosine similarity exceeds threshold.""" proj = GaLoreProjector( rank = 4, update_proj_gap = 10, cos_threshold = 0.0, # Very low threshold → always triggers gamma_proj = 2.0, queue_size = 2, ) # Near-identical gradients keep cosine similarity high. base_grad = torch.randn(16, 8) for i in range(5): grad = base_grad + torch.randn_like(base_grad) * 0.001 proj.project(grad, step = i * 10) assert proj.update_proj_gap > 10 def test_scale_applied(self): """project_back applies the scale factor.""" proj = GaLoreProjector(rank = 4, update_proj_gap = 1, scale = 0.5) grad = torch.randn(16, 8) low = proj.project(grad, step = 0) proj2 = GaLoreProjector(rank = 4, update_proj_gap = 1, scale = 1.0) low2 = proj2.project(grad, step = 0) full_half = proj.project_back(low) full_one = proj2.project_back(low2) # SVD is deterministic on the same input, so the ratio is exactly 0.5. ratio = full_half.norm() / full_one.norm() assert abs(ratio - 0.5) < 1e-5, f"Expected ratio ~0.5, got {ratio:.8f}" # ====================================================================== # Quantization utility tests # ====================================================================== class TestQuantizationUtils: """Tests for _quantize, _dequantize, _quantize_stochastic.""" def test_quantize_dequantize_roundtrip(self): """Quantize → dequantize has bounded error.""" w = torch.randn(32, 64) q, scales, zeros, shape = _quantize(w, n_bit = 8) w_hat = _dequantize(q, scales, zeros, shape) # Error bounded by the quantization step size. error = (w - w_hat).abs().max() assert error < 0.1, f"Max error {error} exceeds threshold" def test_quantize_group_roundtrip(self): """Grouped quantization → dequantization has bounded error.""" w = torch.randn(32, 64) q, scales, zeros, shape = _quantize(w, q_group_size = 32, n_bit = 8) w_hat = _dequantize(q, scales, zeros, shape) error = (w - w_hat).abs().max() assert error < 0.1 def test_quantize_dtype(self): """Quantized output should be uint8.""" w = torch.randn(16, 16) q, _, _, _ = _quantize(w, n_bit = 8) assert q.dtype == torch.uint8 def test_quantize_int4_range(self): """INT4 values should be in [0, 15].""" w = torch.randn(16, 16) q, _, _, _ = _quantize(w, n_bit = 4) assert q.max() <= 15 assert q.min() >= 0 def test_stochastic_rounding_unbiased(self): """Stochastic rounding should be approximately unbiased.""" torch.manual_seed(42) w = torch.randn(64, 64) errors = [] for _ in range(50): q, scales, zeros, shape = _quantize_stochastic(w, n_bit = 8) w_hat = _dequantize(q, scales, zeros, shape) errors.append((w - w_hat).mean().item()) mean_error = sum(errors) / len(errors) assert abs(mean_error) < 0.01, f"Mean error {mean_error} suggests biased rounding" # ====================================================================== # Param group helper tests # ====================================================================== class TestParamGroupHelper: """Tests for make_q_galore_param_groups.""" def test_param_group_separation(self): """GaLore vs non-GaLore params are correctly separated.""" model = nn.Module() model.q_proj = nn.Linear(64, 64, bias = False) model.k_proj = nn.Linear(64, 64, bias = False) model.embed = nn.Embedding(100, 64) model.norm = nn.LayerNorm(64) groups = make_q_galore_param_groups(model, rank = 8, weight_quant = False) # galore + non-galore. assert len(groups) == 2 galore_group = [g for g in groups if "rank" in g][0] non_galore_group = [g for g in groups if "rank" not in g][0] # q_proj + k_proj in galore group. assert len(galore_group["params"]) == 2 assert len(non_galore_group["params"]) == 3 # embed weight + norm weight + norm bias def test_custom_target_modules(self): """Custom target_modules narrows GaLore scope.""" model = nn.Module() model.q_proj = nn.Linear(64, 64, bias = False) model.k_proj = nn.Linear(64, 64, bias = False) model.v_proj = nn.Linear(64, 64, bias = False) model.embed = nn.Embedding(100, 64) groups = make_q_galore_param_groups( model, rank = 8, target_modules = ["q_proj"], weight_quant = False, ) galore_group = [g for g in groups if "rank" in g][0] assert len(galore_group["params"]) == 1 # Only q_proj def test_bias_excluded_from_galore(self): """1-D bias params matching target names must be excluded (project needs 2-D grads).""" model = nn.Module() model.q_proj = nn.Linear(64, 64, bias = True) # has .weight AND .bias model.embed = nn.Embedding(100, 64) groups = make_q_galore_param_groups(model, rank = 8, weight_quant = False) galore_group = [g for g in groups if "rank" in g][0] non_galore_group = [g for g in groups if "rank" not in g][0] # Only the 2-D q_proj.weight should be in the GaLore group assert len(galore_group["params"]) == 1 assert galore_group["params"][0].dim() == 2 # q_proj.bias (1-D) + embed.weight should be in non-GaLore assert any(p.dim() == 1 for p in non_galore_group["params"]) def test_empty_target_modules_no_galore(self): """target_modules=[] should result in no GaLore params.""" model = nn.Module() model.q_proj = nn.Linear(64, 64, bias = False) # Pass empty list, should NOT fall back to defaults groups = make_q_galore_param_groups( model, rank = 8, target_modules = [], weight_quant = False, ) galore_groups = [g for g in groups if "rank" in g] assert len(galore_groups) == 0, "Expected no GaLore groups when target_modules=[]" # ====================================================================== # Optimizer tests (CPU-only, no bitsandbytes dependency) # ====================================================================== class TestQGaLoreIntegration: """Integration tests that work without bitsandbytes on CPU.""" def test_projector_training_loop(self): """A simple training loop using manual GaLore projection converges.""" torch.manual_seed(42) model = nn.Linear(32, 16, bias = False) target = torch.randn(4, 16) x = torch.randn(4, 32) proj = GaLoreProjector(rank = 8, update_proj_gap = 1, scale = 1.0) optimizer = torch.optim.AdamW(model.parameters(), lr = 0.01) losses = [] for step in range(20): optimizer.zero_grad() out = model(x) loss = nn.functional.mse_loss(out, target) loss.backward() losses.append(loss.item()) for p in model.parameters(): if p.grad is not None and p.grad.dim() == 2: low = proj.project(p.grad, step) p._saved = p.data.clone() update = torch.zeros_like(low) update.add_(low) # Simplified update full_update = proj.project_back(update) p.grad.copy_(full_update) optimizer.step() assert losses[-1] < losses[0], f"Loss did not decrease: {losses[0]:.4f} → {losses[-1]:.4f}" def test_full_projector_roundtrip_quality(self): """project → project_back captures the dominant gradient directions.""" torch.manual_seed(42) u = torch.randn(32, 4) v = torch.randn(4, 16) grad = u @ v # rank-4 gradient proj = GaLoreProjector(rank = 4, update_proj_gap = 1, scale = 1.0) low = proj.project(grad, step = 0) reconstructed = proj.project_back(low) # Rank-4 grad with rank-4 projection reconstructs near-exactly. relative_error = (grad - reconstructed).norm() / grad.norm() assert relative_error < 0.05, f"Reconstruction error too high: {relative_error:.4f}" def test_weight_quant_activates_on_first_step(self): """_has_weight_quant returns True even when _q_scales is None (first step).""" _adamw_mod_local = sys.modules["unsloth.optimizers.q_galore_adamw"] QGaLoreAdamW8bit = _adamw_mod_local.QGaLoreAdamW8bit p = torch.nn.Parameter(torch.randn(16, 16)) # Simulate init_weight_quantization tagging. p._q_scales = None p._q_zeros = None p._q_shape = p.data.shape group = {"weight_quant": True} # _has_weight_quant must return True even on first step (_q_scales=None) assert QGaLoreAdamW8bit._has_weight_quant(p, group) is True # Without the tag, it should return False p2 = torch.nn.Parameter(torch.randn(16, 16)) assert QGaLoreAdamW8bit._has_weight_quant(p2, group) is False def test_embedding_lr_param_group_split(self): """Embedding params can be split into a separate group with custom LR.""" # make_q_galore_param_groups output can be further split for embedding LR. model = nn.Module() model.q_proj = nn.Linear(64, 64, bias = False) model.embed = nn.Embedding(100, 64) groups = make_q_galore_param_groups(model, rank = 8, weight_quant = False) # Simulate splitting the non-GaLore group for embedding LR. embed_lr = 5e-5 new_groups = [] for group in groups: if "rank" in group: new_groups.append(group) continue embed_params = [] other_params = [] for p in group["params"]: # Real usage checks names; here we split by shape. if p.shape[0] == 100: # embedding embed_params.append(p) else: other_params.append(p) if other_params: g = dict(group) g["params"] = other_params new_groups.append(g) if embed_params: g = dict(group) g["params"] = embed_params g["lr"] = embed_lr new_groups.append(g) # 3 groups: galore, non-galore non-embed, embed. embed_groups = [g for g in new_groups if g.get("lr") == embed_lr] assert len(embed_groups) == 1 assert embed_groups[0]["lr"] == embed_lr def test_optimizer_hyperparams_forwarded(self): """QGaLoreAdamW8bit accepts betas and eps keyword arguments.""" # Can't instantiate without bitsandbytes; check the signature instead. import inspect _adamw_mod_local = sys.modules["unsloth.optimizers.q_galore_adamw"] QGaLoreAdamW8bit = _adamw_mod_local.QGaLoreAdamW8bit sig = inspect.signature(QGaLoreAdamW8bit.__init__) param_names = list(sig.parameters.keys()) assert "betas" in param_names, "betas not in QGaLoreAdamW8bit.__init__ params" assert "eps" in param_names, "eps not in QGaLoreAdamW8bit.__init__ params" def test_weight_decay_uses_saved_data(self): """Weight decay should apply standard decoupled AdamW decay on current weights.""" _adamw_mod_local = sys.modules["unsloth.optimizers.q_galore_adamw"] p = torch.nn.Parameter(torch.ones(4, 4)) p._saved_data = torch.ones(4, 4) * 2.0 # Pre-update weights # Simulate project-back: p.data = p._saved_data + projected update. p.data = p._saved_data.add_(torch.ones(4, 4) * 1.0) # p.data is now 3.0 group = {"weight_decay": 0.1, "lr": 1.0, "_wd_saved": 0.1} # Decoupled weight decay must use p.data, not p._saved_data. p.data.add_( p.data, alpha = -group["lr"] * group["_wd_saved"], ) del p._saved_data # Clean up after all uses, matching fixed code # 3.0 - (1.0 * 0.1 * 3.0) = 2.7 assert torch.allclose( p.data, torch.tensor(2.7) ), "Weight decay didn't use p.data for decoupled decay!" def test_params_float_after_weight_quant_step(self): """After a step with weight_quant=True, parameters must remain floating point.""" _adamw_mod_local = sys.modules["unsloth.optimizers.q_galore_adamw"] _projector_mod_local = sys.modules["unsloth.optimizers.q_galore_projector"] _quantize = _projector_mod_local._quantize p = torch.nn.Parameter(torch.randn(16, 16)) group = { "weight_quant": True, "stochastic_round": False, "weight_group_size": 16, } # Re-quantize logic from the end of an optimizer step. float_data = p.data.clone() q, scales, zeros, shape = _quantize(float_data, q_group_size = group["weight_group_size"]) # Key check: p.data stays float, _q_data holds uint8. p._q_data = q.to(p.data.device) p._q_scales = scales p._q_zeros = zeros p._q_shape = shape assert p.data.is_floating_point(), "p.data was converted to uint8!" assert p._q_data.dtype == torch.uint8, "_q_data should be uint8!" def test_weight_quant_hook_restores_float(self): """Forward pre-hook should dequantize INT8 weights before forward pass.""" _adamw_mod_local = sys.modules["unsloth.optimizers.q_galore_adamw"] _projector_mod_local = sys.modules["unsloth.optimizers.q_galore_projector"] install_hook = _adamw_mod_local.install_weight_quant_hooks linear = nn.Linear(16, 8, bias = False) original = linear.weight.data.clone() # Quantize the weight and replace with a placeholder (simulates post-step). q, scales, zeros, shape = _projector_mod_local._quantize( linear.weight.data.clone(), q_group_size = 16 ) linear.weight._q_data = q linear.weight._q_scales = scales linear.weight._q_zeros = zeros linear.weight._q_shape = shape linear.weight.data = torch.zeros(1, dtype = linear.weight.dtype) assert linear.weight.data.numel() == 1, "placeholder should be 1 element" # Hook should restore float weights on forward. handles = install_hook(linear) x = torch.randn(2, 16) out = linear(x) # triggers pre-hook assert linear.weight.data.shape == (8, 16), "weight shape not restored" assert linear.weight.data.is_floating_point(), "weight not float after hook" # Quantization introduces small error, so allow tolerance. assert torch.allclose( linear.weight.data, original, atol = 0.15 ), "dequantized weight too far from original" for h in handles: h.remove()