| """ |
| 量化模块单元测试 |
| |
| 测试量化模块的所有功能,包括: |
| - GGUF 量化 |
| - AWQ 量化 |
| - GPTQ 量化 |
| - PPL 评估 |
| - 格式转换 |
| - VRAM 检查和优化 |
| |
| 使用 mock 避免实际模型加载和量化过程。 |
| """ |
|
|
| import os |
| import sys |
| import pytest |
| from pathlib import Path |
| from unittest.mock import MagicMock, patch, Mock |
| import subprocess |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from hos_optimizer.quantize import ( |
| QuantizationError, |
| check_vram_availability, |
| optimize_for_low_vram, |
| quantize_gguf, |
| quantize_awq, |
| quantize_gptq, |
| evaluate_perplexity, |
| convert_format, |
| get_model_size, |
| VRAM_8GB_CONFIG, |
| ) |
|
|
|
|
| class TestVRAMCheck: |
| """VRAM 检查相关测试""" |
|
|
| def test_check_vram_no_cuda(self): |
| """测试无 CUDA 支持时的 VRAM 检查""" |
| with patch("torch.cuda.is_available", return_value=False): |
| result = check_vram_availability() |
| |
| assert result["available"] is False |
| assert result["total_vram_gb"] == 0 |
| assert result["free_vram_gb"] == 0 |
| assert result["device"] == "cpu" |
|
|
| def test_check_vram_with_cuda(self): |
| """测试有 CUDA 支持时的 VRAM 检查""" |
| mock_device = MagicMock() |
| mock_device.total_memory = 8 * 1024 ** 3 |
| |
| with patch("torch.cuda.is_available", return_value=True), \ |
| patch("torch.cuda.current_device", return_value=0), \ |
| patch("torch.cuda.get_device_properties", return_value=mock_device), \ |
| patch("torch.cuda.memory_allocated", return_value=2 * 1024 ** 3), \ |
| patch("torch.cuda.get_device_name", return_value="Test GPU"): |
| |
| result = check_vram_availability() |
| |
| assert result["available"] is True |
| assert result["total_vram_gb"] == 8.0 |
| assert result["free_vram_gb"] == 6.0 |
| assert result["device"] == "Test GPU" |
|
|
| def test_optimize_for_low_vram_enabled(self): |
| """测试低 VRAM 优化配置启用用的情况""" |
| config = {"batch_size": 4, "seq_length": 1024} |
| |
| with patch("hos_optimizer.quantize.check_vram_availability") as mock_check: |
| mock_check.return_value = { |
| "available": True, |
| "free_vram_gb": 6.0, |
| "total_vram_gb": 8.0, |
| "device": "Test GPU" |
| } |
| |
| optimized = optimize_for_low_vram(config) |
| |
| |
| assert optimized["max_batch_size"] == VRAM_8GB_CONFIG["max_batch_size"] |
| assert optimized["max_seq_length"] == VRAM_8GB_CONFIG["max_seq_length"] |
| assert optimized["gradient_checkpointing"] is True |
| assert optimized["offload_to_cpu"] is True |
| |
| assert optimized["batch_size"] == 4 |
| assert optimized["seq_length"] == 1024 |
|
|
| def test_optimize_for_low_vram_disabled(self): |
| """测试低 VRAM 优化配置不适用的情况""" |
| config = {"batch_size": 4, "seq_length": 1024} |
| |
| with patch("hos_optimizer.quantize.check_vram_availability") as mock_check: |
| mock_check.return_value = { |
| "available": True, |
| "free_vram_gb": 12.0, |
| "total_vram_gb": 16.0, |
| "device": "Test GPU" |
| } |
| |
| optimized = optimize_for_low_vram(config) |
| |
| |
| assert optimized == config |
|
|
|
|
| class TestGGUFQuantization: |
| """GGUF 量化测试""" |
|
|
| def test_quantize_gguf_success(self, tmp_dir): |
| """测试 GGUF 量化成功场景""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model.gguf") |
| llama_cpp_path = "/path/to/llama.cpp" |
| |
| |
| with patch("subprocess.run") as mock_run, \ |
| patch("hos_optimizer.quantize.AutoModelForCausalLM") as mock_model_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("tempfile.TemporaryDirectory") as mock_tmpdir: |
| |
| |
| mock_run.return_value = MagicMock(returncode=0) |
| |
| |
| mock_tmpdir.return_value.__enter__.return_value = tmp_dir |
| |
| |
| mock_model = MagicMock() |
| mock_tokenizer = MagicMock() |
| mock_model_cls.from_pretrained.return_value = mock_model |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| |
| result = quantize_gguf( |
| model_path=model_path, |
| output_path=output_path, |
| quant_type="Q4_K_M", |
| llama_cpp_path=llama_cpp_path |
| ) |
| |
| assert result == output_path |
| |
| assert mock_run.call_count >= 2 |
|
|
| def test_quantize_gguf_tool_not_found(self, tmp_dir): |
| """测试 GGUF 量化工具不存在的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model.gguf") |
| |
| with patch("subprocess.run") as mock_run: |
| mock_run.side_effect = FileNotFoundError() |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| quantize_gguf(model_path, output_path) |
| |
| assert "找不到 llama-quantize 工具" in str(exc_info.value) |
|
|
| def test_quantize_gguf_conversion_failed(self, tmp_dir): |
| """测试 GGUF 量化转换失败的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model.gguf") |
| |
| with patch("subprocess.run") as mock_run, \ |
| patch("hos_optimizer.quantize.AutoModelForCausalLM") as mock_model_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("tempfile.TemporaryDirectory") as mock_tmpdir: |
| |
| |
| mock_run.side_effect = [ |
| MagicMock(returncode=0), |
| subprocess.CalledProcessError(1, "convert", stderr="Conversion failed") |
| ] |
| |
| mock_tmpdir.return_value.__enter__.return_value = tmp_dir |
| mock_model_cls.from_pretrained.return_value = MagicMock() |
| mock_tokenizer_cls.from_pretrained.return_value = MagicMock() |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| quantize_gguf(model_path, output_path) |
| |
| assert "GGUF 量化失败" in str(exc_info.value) |
|
|
|
|
| class TestAWQQuantization: |
| """AWQ 量化测试""" |
|
|
| def test_quantize_awq_success(self, tmp_dir): |
| """测试 AWQ 量化成功场景""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-awq") |
| |
| with patch("hos_optimizer.quantize.AutoAWQForCausalLM") as mock_awq_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("hos_optimizer.quantize.optimize_for_low_vram") as mock_optimize: |
| |
| mock_model = MagicMock() |
| mock_tokenizer = MagicMock() |
| mock_awq_cls.from_pretrained.return_value = mock_model |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| mock_optimize.return_value = { |
| "zero_point": True, |
| "q_group_size": 128, |
| "w_bit": 4, |
| "version": "GEMM" |
| } |
| |
| result = quantize_awq( |
| model_path=model_path, |
| output_path=output_path, |
| bits=4, |
| group_size=128 |
| ) |
| |
| assert result == output_path |
| mock_model.quantize.assert_called_once() |
| mock_model.save_quantized.assert_called_once_with(output_path) |
| mock_tokenizer.save_pretrained.assert_called_once_with(output_path) |
|
|
| def test_quantize_awq_missing_dependency(self, tmp_dir): |
| """测试 AWQ 量化缺少依赖的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-awq") |
| |
| with patch("hos_optimizer.quantize.AutoAWQForCausalLM") as mock_awq_cls: |
| mock_awq_cls.from_pretrained.side_effect = ImportError("autoawq") |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| quantize_awq(model_path, output_path) |
| |
| assert "缺少依赖" in str(exc_info.value) |
| assert "autoawq" in str(exc_info.value) |
|
|
| def test_quantize_awq_quantization_failed(self, tmp_dir): |
| """测试 AWQ 量化过程失败的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-awq") |
| |
| with patch("hos_optimizer.quantize.AutoAWQForCausalLM") as mock_awq_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("hos_optimizer.quantize.optimize_for_low_vram") as mock_optimize: |
| |
| mock_model = MagicMock() |
| mock_tokenizer = MagicMock() |
| mock_awq_cls.from_pretrained.return_value = mock_model |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| mock_optimize.return_value = {"zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM"} |
| |
| |
| mock_model.quantize.side_effect = Exception("Quantization failed") |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| quantize_awq(model_path, output_path) |
| |
| assert "AWQ 量化失败" in str(exc_info.value) |
|
|
|
|
| class TestGPTQQuantization: |
| """GPTQ 量化测试""" |
|
|
| def test_quantize_gptq_success(self, tmp_dir): |
| """测试 GPTQ 量化成功场景""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-gptq") |
| |
| with patch("hos_optimizer.quantize.AutoGPTQForCausalLM") as mock_gptq_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("hos_optimizer.quantize.BaseQuantizeConfig") as mock_config_cls: |
| |
| mock_model = MagicMock() |
| mock_tokenizer = MagicMock() |
| mock_config = MagicMock() |
| |
| mock_gptq_cls.from_pretrained.return_value = mock_model |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| mock_config_cls.return_value = mock_config |
| |
| |
| mock_tokenizer.return_value = {"input_ids": MagicMock()} |
| |
| result = quantize_gptq( |
| model_path=model_path, |
| output_path=output_path, |
| bits=4, |
| group_size=128, |
| desc_act=False |
| ) |
| |
| assert result == output_path |
| mock_model.quantize.assert_called_once() |
| mock_model.save_quantized.assert_called_once_with(output_path) |
|
|
| def test_quantize_gptq_invalid_bits(self, tmp_dir): |
| """测试 GPTQ 量化使用无效位数的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-gptq") |
| |
| with pytest.raises(ValueError) as exc_info: |
| quantize_gptq(model_path, output_path, bits=3) |
| |
| assert "仅支持 4-bit 或 8-bit" in str(exc_info.value) |
|
|
| def test_quantize_gptq_missing_dependency(self, tmp_dir): |
| """测试 GPTQ 量化缺少依赖的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-gptq") |
| |
| with patch("hos_optimizer.quantize.AutoGPTQForCausalLM") as mock_gptq_cls: |
| mock_gptq_cls.from_pretrained.side_effect = ImportError("auto_gptq") |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| quantize_gptq(model_path, output_path, bits=4) |
| |
| assert "缺少依赖" in str(exc_info.value) |
| assert "auto-gptq" in str(exc_info.value) |
|
|
|
|
| class TestPerplexityEvaluation: |
| """PPL 评估测试""" |
|
|
| def test_evaluate_perplexity_success(self, tmp_dir): |
| """测试 PPL 评估成功场景""" |
| model_path = os.path.join(tmp_dir, "model") |
| |
| with patch("hos_optimizer.quantize.AutoModelForCausalLM") as mock_model_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls, \ |
| patch("hos_optimizer.quantize.load_dataset") as mock_load_dataset: |
| |
| mock_model = MagicMock() |
| mock_tokenizer = MagicMock() |
| mock_dataset = MagicMock() |
| |
| mock_model_cls.from_pretrained.return_value = mock_model |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| mock_load_dataset.return_value = mock_dataset |
| |
| |
| mock_dataset.__getitem__.return_value = ["text1", "text2"] |
| |
| |
| mock_encodings = MagicMock() |
| mock_encodings.input_ids.size.return_value = (1, 100) |
| mock_tokenizer.return_value = mock_encodings |
| |
| |
| mock_model.return_value = MagicMock(loss=MagicMock(item=MagicMock(return_value=2.5))) |
| mock_model.device = "cpu" |
| |
| result = evaluate_perplexity( |
| model_path=model_path, |
| dataset="wikitext", |
| max_samples=10, |
| stride=512 |
| ) |
| |
| assert isinstance(result, float) |
| assert result > 0 |
|
|
| def test_evaluate_perplexity_missing_dataset(self, tmp_dir): |
| """测试 PPL 评估缺少 datasets 库的情况""" |
| model_path = os.path.join(tmp_dir, "model") |
| |
| with patch("hos_optimizer.quantize.AutoModelForCausalLM") as mock_model_cls, \ |
| patch("hos_optimizer.quantize.AutoTokenizer") as mock_tokenizer_cls: |
| |
| mock_model_cls.from_pretrained.return_value = MagicMock() |
| mock_tokenizer_cls.from_pretrained.return_value = MagicMock() |
| |
| with patch("hos_optimizer.quantize.load_dataset", side_effect=ImportError("datasets")): |
| with pytest.raises(QuantizationError) as exc_info: |
| evaluate_perplexity(model_path) |
| |
| assert "缺少依赖" in str(exc_info.value) |
| assert "datasets" in str(exc_info.value) |
|
|
|
|
| class TestFormatConversion: |
| """格式转换测试""" |
|
|
| def test_convert_hf_to_gguf(self, tmp_dir): |
| """测试 HuggingFace 到 GGUF 格式转换""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model.gguf") |
| |
| with patch("hos_optimizer.quantize.quantize_gguf") as mock_quantize: |
| mock_quantize.return_value = output_path |
| |
| result = convert_format( |
| model_path=model_path, |
| output_path=output_path, |
| from_format="hf", |
| to_format="gguf" |
| ) |
| |
| assert result == output_path |
| mock_quantize.assert_called_once() |
|
|
| def test_convert_hf_to_awq(self, tmp_dir): |
| """测试 HuggingFace 到 AWQ 格式转换""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-awq") |
| |
| with patch("hos_optimizer.quantize.quantize_awq") as mock_quantize: |
| mock_quantize.return_value = output_path |
| |
| result = convert_format( |
| model_path=model_path, |
| output_path=output_path, |
| from_format="hf", |
| to_format="awq" |
| ) |
| |
| assert result == output_path |
| mock_quantize.assert_called_once() |
|
|
| def test_convert_unsupported_path(self, tmp_dir): |
| """测试不支持的转换路径""" |
| model_path = os.path.join(tmp_dir, "model") |
| output_path = os.path.join(tmp_dir, "model-out") |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| convert_format( |
| model_path=model_path, |
| output_path=output_path, |
| from_format="gguf", |
| to_format="awq" |
| ) |
| |
| assert "不支持的转换路径" in str(exc_info.value) |
|
|
| def test_convert_gguf_to_hf_not_implemented(self, tmp_dir): |
| """测试 GGUF 到 HuggingFace 转换未实现""" |
| model_path = os.path.join(tmp_dir, "model.gguf") |
| output_path = os.path.join(tmp_dir, "model") |
| |
| with pytest.raises(QuantizationError) as exc_info: |
| convert_format( |
| model_path=model_path, |
| output_path=output_path, |
| from_format="gguf", |
| to_format="hf" |
| ) |
| |
| assert "尚未实现" in str(exc_info.value) |
|
|
|
|
| class TestModelSize: |
| """模型大小计算测试""" |
|
|
| def test_get_model_size_empty_dir(self, tmp_dir): |
| """测试空目录的模型大小""" |
| size = get_model_size(tmp_dir) |
| assert size == 0.0 |
|
|
| def test_get_model_size_with_files(self, tmp_dir): |
| """测试包含模型文件的目录大小""" |
| |
| test_file = os.path.join(tmp_dir, "model.safetensors") |
| with open(test_file, "wb") as f: |
| f.write(b"0" * (1024 * 1024)) |
| |
| size = get_model_size(tmp_dir) |
| assert size > 0 |
| assert size < 0.01 |
|
|
| def test_get_model_size_nonexistent_dir(self): |
| """测试不存在的目录""" |
| with pytest.raises(Exception): |
| get_model_size("/nonexistent/path") |
|
|
|
|
| if __name__ == "__main__": |
| pytest.main([__file__, "-v"]) |
|
|