科学と神々株式会社アドベントカレンダー 2025
LLM量子化 Day 5: 良いソフトウェア設計の原則
なぜ設計原則を学ぶのか
量子化ツールを開発する際、最初は「とにかく動くものを作ろう」と考えがちです。しかし、ソフトウェアは作って終わりではありません。
- 新しい量子化形式が登場したら対応したい
- バグを見つけたら直したい
- パフォーマンスを改善したい
- チームメンバーが理解できるようにしたい
これらの要求に応えるには、変更しやすい設計が必要です。今日は、llm-quantizeの設計を通じて、ソフトウェア設計の基本原則を学びましょう。
単一責任の原則:一つのことをうまくやる
原則の説明
「一つのモジュールは、一つの責任だけを持つべきである」
これはUNIX哲学の「一つのことをうまくやる(Do One Thing Well)」と同じ考え方です。
悪い例:全部入りのクラス
想像してみてください。「量子化器」クラスに以下のすべてが詰め込まれているとしたら:
- モデルの読み込み
- 設定の解析
- 量子化の実行
- 進捗の表示
- ファイルの保存
- エラーメッセージの表示
このクラスを修正するとき、どこに影響が出るか予測できるでしょうか?進捗表示を変えたいだけなのに、量子化ロジックを壊してしまうかもしれません。
良い例:責任の分離
llm-quantizeでは、責任を明確に分離しています:
cli/ → ユーザーインターフェースの責任
main.py コマンドライン引数の解析、結果の表示
lib/ → ビジネスロジックの責任
quantizers/ 量子化処理の実行
checkpoint.py 状態の保存と復元
progress.py 進捗の追跡と報告
models/ → データ表現の責任
source_model.py 入力モデルの情報
quantized_model.py 出力モデルの情報
quantization_config.py 量子化設定
この分離により、例えば「進捗表示を改善したい」場合はprogress.pyだけを見れば良いのです。
実践的なヒント
- 「このクラス/関数は何をするか」を一文で説明できますか?
- 「〜と〜をする」のように「と」が入る場合、分割を検討しましょう
- ファイル名がそのまま責任を表すようにする
開放閉鎖の原則:拡張に開き、修正に閉じる
原則の説明
「新しい機能を追加するとき、既存のコードを変更せずに済むようにする」
これは一見矛盾しているように聞こえますが、実現可能です。
具体的な問題
新しい量子化形式(例:ONNX)をサポートしたいとします。もし以下のようなコードだったら:
# 悪い例
def quantize(model, format):
if format == "gguf":
return quantize_gguf(model)
elif format == "awq":
return quantize_awq(model)
elif format == "gptq":
return quantize_gptq(model)
# ONNX を追加するには、このファイルを修正する必要がある
新しい形式を追加するたびに、この関数を修正する必要があります。修正は常にバグのリスクを伴います。
解決策:レジストリパターン
llm-quantizeでは、「レジストリパターン」を採用しています:
# 量子化器を登録するレジストリ
_QUANTIZERS = {
OutputFormat.GGUF: GGUFQuantizer,
OutputFormat.AWQ: AWQQuantizer,
OutputFormat.GPTQ: GPTQQuantizer,
}
# 新しい形式を追加する場合
def register_quantizer(format, quantizer_class):
_QUANTIZERS[format] = quantizer_class
新しい形式を追加するには:
- 新しい量子化器クラスを作成
-
register_quantizerで登録
既存のコードは一切変更しません。
なぜレジストリパターンか
- 疎結合:CLIは具体的な量子化器クラスを知る必要がない
- 拡張性:新形式追加時にCLIコード変更不要
- テスタビリティ:テスト用のモック量子化器を簡単に登録できる
依存性逆転の原則:抽象に依存する
原則の説明
「上位モジュールは下位モジュールに依存してはならない。両者は抽象に依存すべきである」
具体的な問題
CLIが直接GGUFQuantizerを呼び出すとしたら:
# 悪い例
from llm_quantize.lib.quantizers.gguf import GGUFQuantizer
def quantize_command(model, level):
quantizer = GGUFQuantizer(model, level) # 具体クラスに依存
return quantizer.quantize()
この場合、CLIはGGUFQuantizerの実装詳細を知る必要があります。テストも難しくなります。
解決策:抽象への依存
# 良い例
from llm_quantize.lib.quantizers import get_quantizer
from llm_quantize.lib.quantizers.base import BaseQuantizer
def quantize_command(model, format, level):
quantizer_class = get_quantizer(format) # 抽象インターフェースを取得
quantizer: BaseQuantizer = quantizer_class(model, level)
return quantizer.quantize() # 共通インターフェースを使用
CLIは「量子化器」という抽象概念とだけやり取りし、それがGGUFなのかAWQなのかは関知しません。
依存関係の図解
CLI(上位モジュール)
│
↓ 依存
BaseQuantizer(抽象)
↑
│ 実装
┌──────┼──────┐
│ │ │
GGUF AWQ GPTQ(下位モジュール)
上位モジュール(CLI)と下位モジュール(具体的な量子化器)の両方が、抽象(BaseQuantizer)に依存しています。
テンプレートメソッドパターン:共通の骨格を定義する
パターンの説明
「アルゴリズムの骨格を定義し、詳細はサブクラスに任せる」
適用例
すべての量子化器には共通の処理があります:
- チェックポイントの設定
- 進捗の報告
- 結果の検証
これらを毎回実装するのは無駄です。
class BaseQuantizer(ABC):
# 共通処理(サブクラスは変更不要)
def setup_checkpoint(self, total_layers):
"""チェックポイントの設定"""
checkpoint_dir = Path(self.config.checkpoint_dir or "./.checkpoint")
self._checkpoint = Checkpoint(checkpoint_dir, self.config)
return self._checkpoint
def try_resume(self):
"""中断からの再開を試みる"""
if not Checkpoint.can_resume(checkpoint_dir):
return None, 0
# ...共通の再開ロジック
# 抽象メソッド(サブクラスで必ず実装)
@abstractmethod
def quantize(self) -> QuantizedModel:
"""量子化の実行(形式固有の実装)"""
pass
サブクラス(GGUFQuantizer、AWQQuantizerなど)は、共通処理をそのまま使いながら、quantize()だけを実装すればよいのです。
CLIツールの設計原則
終了コードを設計する
UNIX/Linux の慣例に従い、終了コードを設計します:
| コード | 意味 | 使用例 |
|---|---|---|
| 0 | 成功 | 量子化が正常に完了 |
| 1 | 一般エラー | 予期しないエラー |
| 2 | 引数エラー | 無効なオプション |
| 3 | モデル未発見 | 指定されたモデルが存在しない |
| 5 | メモリ不足 | OOMエラー |
これにより、シェルスクリプトでの自動化が容易になります:
llm-quantize quantize model gguf
case $? in
0) echo "成功" ;;
3) echo "モデルが見つかりません" ;;
5) echo "メモリ不足です。小さいバッチを試してください" ;;
esac
出力形式を選択可能にする
人間向けとプログラム向けの両方に対応:
- Human mode: カラー表示、進捗バー、読みやすいメッセージ
- JSON mode: パース可能な構造化データ、自動化に最適
# 人間向け
llm-quantize quantize model gguf
# プログラム向け
llm-quantize --format json quantize model gguf | jq '.file_size'
Tips: 設計を改善するための問いかけ
設計を見直すとき、以下の質問を自分に投げかけてみてください:
責任について
- このクラスの責任は何か、一文で説明できるか?
- このクラスが変更される理由は複数あるか?
依存関係について
- このモジュールをテストするとき、他のどのモジュールが必要か?
- 依存関係を図に描いたとき、循環していないか?
拡張性について
- 新しい機能を追加するとき、既存のコードを変更する必要があるか?
- 「if-else」の連鎖が増えていないか?
シンプルさについて
- この抽象化は本当に必要か?
- 将来のために作ったコードが、実際に使われる可能性はどれくらいか?
次回予告
Day 6では「データモデルの設計」について解説します。入力モデル(SourceModel)、設定(QuantizationConfig)、出力(QuantizedModel)という3つのデータ構造が、どのように設計され、なぜそうなっているのかを紐解きます。
良い設計は、コードを書く速度を一時的に遅くするかもしれません。しかし、長期的には変更の容易さとバグの少なさという形で報われます。「急がば回れ」は設計にも当てはまります。