科学と神々株式会社アドベントカレンダー 2025
LLM量子化 Day 7: 抽象化の力
なぜ抽象化が必要なのか
llm-quantizeは3つの異なる量子化形式(GGUF、AWQ、GPTQ)をサポートしています。それぞれの内部実装は大きく異なりますが、ユーザーから見れば「モデルを量子化する」という同じ操作です。
この「異なるものを同じように扱う」能力が、抽象化の本質です。
コンセントに例えると
日本のコンセントは同じ形状をしています。テレビ、冷蔵庫、パソコン——どれも同じコンセントに差し込めます。
これは「電化製品の種類」と「電力供給の方法」を分離した結果です。コンセントという「インターフェース」を定義することで、電力会社は電化製品の種類を気にせずに電力を供給でき、メーカーは電力供給の仕組みを気にせずに製品を作れます。
ソフトウェアでも同じことができます。BaseQuantizerという「インターフェース」を定義することで、CLIは量子化の種類を気にせずに命令を出せ、各量子化器は CLIの仕組みを気にせずに実装できます。
抽象基底クラス:共通のルールを定める
Pythonでは、ABC(Abstract Base Class)を使って抽象クラスを定義します。
from abc import ABC, abstractmethod
class BaseQuantizer(ABC):
"""すべての量子化器の基底クラス"""
pass
BaseQuantizerを継承するクラスは、「量子化器である」という性質を持ちます。そして、特定のメソッドを必ず実装することが求められます。
抽象メソッド:契約として機能する
契約とは
「量子化器」を名乗るなら、以下のことができなければならない:
-
量子化を実行できる(
quantize()) -
対応する量子化レベルを答えられる(
get_supported_levels()) -
出力サイズを推定できる(
estimate_output_size())
これが「契約」です。この契約を守る限り、内部の実装方法は自由です。
class BaseQuantizer(ABC):
@abstractmethod
def quantize(self) -> QuantizedModel:
"""量子化を実行し、結果を返す"""
pass
@classmethod
@abstractmethod
def get_supported_levels(cls) -> list[str]:
"""対応する量子化レベルのリストを返す"""
pass
@abstractmethod
def estimate_output_size(self) -> int:
"""出力ファイルのサイズを推定する"""
pass
@abstractmethodデコレータが付いたメソッドは、サブクラスで必ず実装しなければなりません。実装しないとエラーになります。
各量子化器の実装
GGUFQuantizer
class GGUFQuantizer(BaseQuantizer):
@classmethod
def get_supported_levels(cls) -> list[str]:
return ["Q2_K", "Q3_K_M", "Q4_K_M", "Q5_K_M", "Q6_K", "Q8_0"]
def estimate_output_size(self) -> int:
# GGUF固有の計算式
bits_per_weight = GGUF_QUANT_TYPES[self.config.quantization_level]["bits"]
original_size = self.source_model.parameter_count * 2
return int((original_size * bits_per_weight / 16) * 1.1)
def quantize(self) -> QuantizedModel:
# llama.cppを使った量子化処理
...
AWQQuantizer
class AWQQuantizer(BaseQuantizer):
@classmethod
def get_supported_levels(cls) -> list[str]:
return ["4bit"]
def estimate_output_size(self) -> int:
# AWQ固有の計算式
original_size = self.source_model.parameter_count * 2
return int((original_size * 4 / 16) * 1.15)
def quantize(self) -> QuantizedModel:
# llm-awqライブラリを使った量子化処理
...
GPTQQuantizer
class GPTQQuantizer(BaseQuantizer):
@classmethod
def get_supported_levels(cls) -> list[str]:
return ["2bit", "3bit", "4bit", "8bit"]
def estimate_output_size(self) -> int:
# GPTQ固有の計算式
bits = GPTQ_QUANT_TYPES[self.config.quantization_level]["bits"]
original_size = self.source_model.parameter_count * 2
return int((original_size * bits / 16) * 1.12)
def quantize(self) -> QuantizedModel:
# GPTQModelライブラリを使った量子化処理
...
共通メソッド:繰り返しを避ける
抽象クラスには、具体的な実装を持つメソッドも定義できます。これらはすべてのサブクラスで共通して使える機能です。
class BaseQuantizer(ABC):
def validate_config(self) -> list[str]:
"""設定のバリデーション(共通処理)"""
errors = []
supported = self.get_supported_levels() # 抽象メソッドを呼び出し
if self.config.quantization_level not in supported:
errors.append(f"Unsupported level: {self.config.quantization_level}")
return errors
def setup_checkpoint(self, total_layers: int) -> Checkpoint:
"""チェックポイントの設定(共通処理)"""
checkpoint_dir = Path(self.config.checkpoint_dir or "./.checkpoint")
self._checkpoint = Checkpoint(checkpoint_dir, self.config)
return self._checkpoint
def try_resume(self) -> tuple[Optional[Checkpoint], int]:
"""中断からの再開(共通処理)"""
# すべての量子化器で同じロジック
...
サブクラスは、これらの共通メソッドをそのまま使えます。車輪の再発明をする必要がありません。
ポリモーフィズム:同じ操作、異なる振る舞い
ポリモーフィズム(多態性)とは、「同じメソッド呼び出しが、オブジェクトの種類によって異なる振る舞いをする」ことです。
CLIでの活用
CLIは、具体的な量子化器の種類を知る必要がありません:
def run_quantization(source: SourceModel, config: QuantizationConfig):
# 形式に応じた量子化器を取得(GGUF, AWQ, GPTQ のどれか)
quantizer_class = get_quantizer(config.target_format)
# BaseQuantizerとして扱う
quantizer: BaseQuantizer = quantizer_class(source, config)
# バリデーション(すべての量子化器で同じ呼び出し)
errors = quantizer.validate_config()
if errors:
raise ValueError(errors)
# サイズ推定(中身は形式によって異なる)
estimated = quantizer.estimate_output_size()
print(f"推定出力サイズ: {estimated} bytes")
# 量子化実行(中身は形式によって異なる)
result = quantizer.quantize()
return result
この設計の利点
- CLIコードがシンプルに: 形式ごとの分岐が不要
- 新形式の追加が容易: CLIを変更せずに新しい量子化器を追加できる
- テストが容易: モック量子化器を作ってテストできる
リスコフの置換原則
「サブクラスのインスタンスは、親クラスのインスタンスとして使えなければならない」
これは重要な原則です。BaseQuantizerを期待する場所に、GGUFQuantizerやAWQQuantizerを渡しても、正しく動作しなければなりません。
守るべき契約
# すべてのQuantizerに対して成り立つ
def process_any_quantizer(quantizer: BaseQuantizer):
# 1. バリデーションは必ずリストを返す
errors = quantizer.validate_config()
assert isinstance(errors, list)
# 2. サイズ推定は必ず正の整数を返す
size = quantizer.estimate_output_size()
assert isinstance(size, int) and size > 0
# 3. quantize()は必ずQuantizedModelを返す
result = quantizer.quantize()
assert isinstance(result, QuantizedModel)
この契約を守っている限り、どの具体的な量子化器を渡しても、コードは正しく動作します。
テスタビリティの向上
抽象化により、テスト用のモック実装が簡単に作れます:
class MockQuantizer(BaseQuantizer):
"""テスト用のモック量子化器"""
@classmethod
def get_supported_levels(cls) -> list[str]:
return ["mock_level"]
def estimate_output_size(self) -> int:
return 1000 # 固定値
def quantize(self) -> QuantizedModel:
return QuantizedModel(
output_path="/mock/path",
format="mock",
file_size=1000,
compression_ratio=0.5,
source_model_path=self.source_model.model_path,
)
このモックを使えば、実際のモデルや長時間の処理なしに、CLIやワークフローのテストができます。
新しい量子化形式を追加するには
将来、ONNX形式のサポートを追加したいとしましょう。必要な作業は:
1. 新しい量子化器クラスを作成
class ONNXQuantizer(BaseQuantizer):
@classmethod
def get_supported_levels(cls) -> list[str]:
return ["int8", "uint8"]
def estimate_output_size(self) -> int:
# ONNX固有の計算
...
def quantize(self) -> QuantizedModel:
# ONNX固有の処理
...
2. OutputFormatに追加
class OutputFormat(Enum):
GGUF = "gguf"
AWQ = "awq"
GPTQ = "gptq"
ONNX = "onnx" # 追加
3. レジストリに登録
register_quantizer(OutputFormat.ONNX, ONNXQuantizer)
これだけで、CLIから使えるようになります:
llm-quantize quantize model onnx -q int8
既存のコード(CLI、他の量子化器)には一切手を加える必要がありません。
Tips: 抽象化のバランス
過度な抽象化は避ける
抽象化は強力ですが、やりすぎると逆効果です:
- 必要以上に深い継承階層
- 使われないインターフェース
- 「将来のため」の過剰な設計
実用的なガイドライン
- 3つ以上の具体実装があるときに抽象化を検討する
- 実際に共通コードがある場合にのみ基底クラスを作る
- 今必要なものだけを抽象化する(YAGNI原則)
llm-quantizeの場合
- GGUF、AWQ、GPTQという3つの具体実装がある
- チェックポイント処理、バリデーションなど共通コードがある
- すべて「量子化」という同じ概念を表している
これらの条件が揃っているため、BaseQuantizerの抽象化は妥当です。
次回予告
Day 8からは「GGUF形式編」として、llama.cppエコシステムとGGUF形式の詳細を5日間にわたって解説します。Q4_K_Mなどの謎めいた名前の意味も明らかになります。
抽象化は「複雑さを隠す」技術です。うまく使えば、システムの各部分が独立して進化できるようになります。ただし、抽象化それ自体も複雑さを持つことを忘れずに。必要最小限の抽象化を心がけましょう。