LLM軽量化(圧縮・最適化・効率化)
LLMの軽量化は、品質を必要以上に落とさずに、同じ予算でより速く・より長い文脈を扱うための設計です。ここでは、量子化・蒸留・PEFT・推論最適化を、実務で使う判断軸と一緒に整理します。
軽量化は、モデルを小さくする話というより配備条件を満たす話です
同じ品質を保ったまま VRAM、レイテンシ、コストの制約に収める。そのために量子化、蒸留、PEFT、推論最適化をどう組み合わせるかが主題になります。
このノートでは、まず重みと KV cache のメモリを数字で見て、次に quantization、pruning、distillation、PEFT、推論最適化の順で整理します。最後は「どの順で導入するか」という運用判断までつなげます。
中心にあるのは、どの資源が詰まっているかを先に見ること
重みが重いのか、KV cache が大きいのか、デコードが遅いのか。それを見ずに手法名だけ並べても設計にはなりません。この notebook では、軽量化を資源ボトルネックごとの対策として読みます。
まずは理想値を出し、そのあとで現実との差を見る
理論上 1/4 になるからといって、実 GPU 上でもそのまま 1/4 になるとは限りません。それでも rough estimate を先に持つと、どこで差が出ているかを疑いやすくなります。
量子化と pruning は、どちらも削るが削っているものが違う
量子化は表現ビット数を減らし、pruning は構造や重みそのものを減らします。見かけの目的は似ていますが、効く資源とハードウェア依存性はかなり違います。
PEFT は推論軽量化ではなく、学習側の現実解として読む
LoRA や QLoRA は、全部を学習しなくても十分な適応が取れる場面で強いです。ここでは「更新対象を減らすと何が軽くなるか」を軸に整理します。
最後は、個別手法の知識より導入順が重要になる
現場では全部を一気に入れません。まず何を測り、どこから着手し、どこで実測を入れるか。その順番を持てることが、この notebook の到達点です。
最初に前提を揃えます。軽量化を考える理由は大きく3つです。
1つ目はメモリ制約です。GPUメモリにモデル本体・KV cache・一時バッファが収まらないと、そもそも推論できません。2つ目はレイテンシです。応答が遅いと、対話体験が崩れます。3つ目はコストです。同じ問い合わせ数をさばくなら、1トークンあたりの計算とメモリ転送を下げるほど運用が楽になります。
このノートでは「なぜその手法を使うのか」を数字で確認しながら進めます。
用語ミニ辞典
- KV cache: デコード時に再利用する中間状態。長文になるほどメモリを圧迫しやすい。
- PEFT: 一部パラメータだけ更新して学習コストを下げる手法群。
- 疎行列カーネル: 0の多い行列を効率計算する実装。無いとpruningが速度に効きにくい。
- LLM-as-a-judge: 別モデルで出力品質を採点する手法。自己強化バイアスや位置バイアスがあるため、rubric評価と人手監査を併用する。
import math
import random
from typing import Dict, List, Tuple
1. まずはメモリ予算を数字で見る
モデルを軽くする話は、だいたい「重みを何bitで持つか」から始まります。まずは重みだけの理想値を計算します。
def bytes_for_params(num_params: int, bits: int) -> float:
return num_params * bits / 8
def to_gib(num_bytes: float) -> float:
return num_bytes / (1024 ** 3)
models = {
'7B': 7_000_000_000,
'13B': 13_000_000_000,
}
precisions = {
'FP16': 16,
'INT8': 8,
'INT4': 4,
}
for name, params in models.items():
print(f'--- {name} ({params:,} params) ---')
for p_name, bits in precisions.items():
print(f'{p_name:>4}: {to_gib(bytes_for_params(params, bits)):6.2f} GiB')
print()
同じ7Bでも、FP16からINT4に落とすと重みメモリは理論上1/4になります。ここで大事なのは、実際の推論では重み以外にKV cacheも効いてくる点です。長文対話ほどKV cacheの影響が大きくなるので、重みだけ見て安心すると詰まります。
2. Quantization(Weight / Activation / KV cache)
量子化は、数値の表現bit幅を下げてメモリ帯域と容量を削る手法です。重み量子化が最も普及していますが、長文推論ではKV cache量子化も効きます。さらに学習時にはActivation量子化が効く場面があります。
def kv_cache_bytes(
batch_size: int,
seq_len: int,
n_layers: int,
n_kv_heads: int,
head_dim: int,
bits: int,
) -> float:
# KとVの2本を保持する
elements = batch_size * seq_len * n_layers * n_kv_heads * head_dim * 2
return elements * bits / 8
cfg = {
'batch_size': 4,
'n_layers': 32,
'n_kv_heads': 8,
'head_dim': 128,
}
for seq in [1024, 4096, 8192]:
fp16 = to_gib(kv_cache_bytes(seq_len=seq, bits=16, **cfg))
int8 = to_gib(kv_cache_bytes(seq_len=seq, bits=8, **cfg))
int4 = to_gib(kv_cache_bytes(seq_len=seq, bits=4, **cfg))
print(f'seq={seq:>4}: FP16={fp16:5.2f} GiB, INT8={int8:5.2f} GiB, INT4={int4:5.2f} GiB')
量子化で、まず重みと活性の持ち方を変える
ここでは weight、activation、KV cache の量子化がそれぞれ何に効くかを分けて見ます。どれも bit を減らしますが、効く場所は同じではありません。
def activation_tensor_bytes(batch_size: int, seq_len: int, hidden_size: int, bits: int) -> float:
elements = batch_size * seq_len * hidden_size
return elements * bits / 8
act_cfg = {
'batch_size': 8,
'seq_len': 4096,
'hidden_size': 4096,
}
for bits in [16, 8, 4]:
gib = to_gib(activation_tensor_bytes(bits=bits, **act_cfg))
print(f'activation tensor ({bits:>2}bit) = {gib:5.2f} GiB')
Activation量子化は学習時メモリを下げるのに有効ですが、推論品質への影響が出やすい箇所でもあります。導入時は、レイテンシだけでなく精度劣化を必ず同時評価します。
同じモデルでも、文脈長を4倍にするとKV cacheもほぼ4倍になります。つまり、長文対応をしたいときの軽量化は「重みだけ」では不十分です。Weight量子化は入口で、運用ではKV cache設計がボトルネックになることが多いです。
3. Pruning(Structured / Unstructured)
Pruningは使っていない重みを削る手法です。ここで重要なのは、ゼロを増やすだけでは速くならないことです。実行カーネルが疎行列をうまく使えなければ、理論削減と実測速度は一致しません。
- Unstructured pruning: 個々の重みを間引く(精度維持しやすいが実装依存)
- Structured pruning: 行・列・チャネル単位で削る(速度改善につなげやすい)
random.seed(7)
rows, cols = 64, 64
weights = [[random.uniform(-1.0, 1.0) for _ in range(cols)] for _ in range(rows)]
# Unstructured pruning: 小さい重みを50%ゼロ化
flat = sorted(abs(x) for r in weights for x in r)
threshold = flat[len(flat) // 2]
unstructured_nonzero = sum(1 for r in weights for x in r if abs(x) >= threshold)
# Structured pruning: 列ノルムの小さい50%を削除
col_norms = []
for c in range(cols):
col_norms.append((sum(abs(weights[r][c]) for r in range(rows)), c))
col_norms.sort()
pruned_cols = set(c for _, c in col_norms[: cols // 2])
structured_nonzero = rows * (cols - len(pruned_cols))
print('total params =', rows * cols)
print('unstructured kept =', unstructured_nonzero)
print('structured kept =', structured_nonzero)
print('structured speed proxy (active width ratio)=', round((cols - len(pruned_cols)) / cols, 3))
Unstructuredは保持重みを柔軟に選べる一方、ハードウェアでそのまま速くなるとは限りません。Structuredは削る自由度は落ちますが、行列サイズそのものが小さくなるため、実運用で速度向上に結びつけやすくなります。
4. Knowledge Distillation
蒸留は「大きいモデルのふるまい」を小さいモデルに移す方法です。なぜ嬉しいかというと、推論は小型モデルで回しつつ、教師モデルの一般化傾向を取り込めるからです。
T(温度): 分布をなだらかにして、教師の暗黙知を見えやすくする。- 係数: 温度を入れたときに勾配スケールが崩れすぎないよう補正する。
alpha: 正解ラベル重視(hard)と教師分布重視(soft)の比率を決める。
def softmax(logits: List[float], temperature: float = 1.0) -> List[float]:
scaled = [x / temperature for x in logits]
m = max(scaled)
exps = [math.exp(x - m) for x in scaled]
s = sum(exps)
return [e / s for e in exps]
def kl_div(p: List[float], q: List[float]) -> float:
eps = 1e-12
out = 0.0
for pi, qi in zip(p, q):
pi = max(pi, eps)
qi = max(qi, eps)
out += pi * math.log(pi / qi)
return out
def cross_entropy(probs: List[float], gold_index: int) -> float:
return -math.log(max(probs[gold_index], 1e-12))
teacher_logits = [3.2, 2.4, -0.8, 0.1]
student_logits = [2.0, 1.9, -0.1, 0.2]
gold = 0
T = 2.0
alpha = 0.4 # hard target の重み
teacher_soft = softmax(teacher_logits, temperature=T)
student_soft = softmax(student_logits, temperature=T)
student_hard = softmax(student_logits, temperature=1.0)
hard_loss = cross_entropy(student_hard, gold)
soft_loss = (T ** 2) * kl_div(teacher_soft, student_soft)
loss = alpha * hard_loss + (1 - alpha) * soft_loss
print('hard_loss =', round(hard_loss, 4))
print('soft_loss =', round(soft_loss, 4))
print('total_distillation_loss =', round(loss, 4))
蒸留で見ているのは「正解ラベル」だけではありません。教師の確率分布(どの選択肢をどれくらいあり得ると見ているか)も移すことで、学生モデルの判断を滑らかにできます。
5. PEFT(LoRA / QLoRA / DoRA)
全重みを更新する代わりに、更新箇所を限定して学習コストを下げるのがPEFTです。
- LoRA: 低ランク行列だけ学習
- QLoRA: ベース重みを4bit近傍で保持しつつLoRA学習
- DoRA: LoRAの表現力を拡張した亜種(方向と大きさの扱いを分離)
def lora_trainable_params(hidden_size: int, rank: int, n_layers: int, n_proj_per_layer: int = 4) -> int:
# 1つの線形層に対して A(in->r) と B(r->out) を持つ
# ここでは in=out=hidden_size として概算
per_proj = 2 * hidden_size * rank
return per_proj * n_proj_per_layer * n_layers
def attention_proj_params(hidden_size: int, n_layers: int, n_proj_per_layer: int = 4) -> int:
# q,k,v,o の投影層のみを対象とした概算
per_proj = hidden_size * hidden_size
return per_proj * n_proj_per_layer * n_layers
def rough_transformer_params(hidden_size: int, n_layers: int, vocab_size: int = 32_000) -> int:
# 非常に粗い近似: 1層あたり attention(約4h^2) + MLP(約8h^2) = 約12h^2
# + 埋め込み語彙
return n_layers * (12 * hidden_size * hidden_size) + vocab_size * hidden_size
hidden = 4096
layers = 32
rank = 16
lora = lora_trainable_params(hidden, rank, layers)
attn_only = attention_proj_params(hidden, layers)
rough_total = rough_transformer_params(hidden, layers)
print('LoRA trainable params =', f'{lora:,}')
print('attention-proj params (partial) =', f'{attn_only:,}')
print('rough total model params =', f'{rough_total:,}')
print('LoRA ratio vs rough total =', f'{100 * lora / rough_total:.3f}%')
# QLoRA風のメモリ感(概算)
base_params = 7_000_000_000
base_int4_gib = to_gib(bytes_for_params(base_params, 4))
adapter_fp16_gib = to_gib(bytes_for_params(lora, 16))
# 学習時オーバーヘッドの粗い目安(grad, optimizer state, 一時バッファ)
overhead_gib = adapter_fp16_gib * 3.0 + 2.0
rough_train_peak = base_int4_gib + adapter_fp16_gib + overhead_gib
print('base(INT4) GiB =', round(base_int4_gib, 3))
print('adapter(FP16) GiB =', round(adapter_fp16_gib, 3))
print('rough training peak GiB (toy) =', round(rough_train_peak, 3))
ここでの学びは、学習対象を絞るだけでメモリと学習時間のボトルネックが大きく変わる点です。QLoRAの実務価値は「1枚GPUでも回しやすくなる」ことですが、実際には勾配・optimizer・バッファ分の余裕を見積もってから判断します。INT4の理論値は量子化メタデータや実装差で増減するため、最終判断は実測ピークメモリで行います。
6. アーキテクチャ最適化(Attention / MLP)
軽量化は量子化だけではありません。計算グラフ側を変えると、同じ精度帯で速度を稼げることがあります。特に文脈長が伸びるほど、Attentionの計算量をどう扱うかが効いてきます。
def attention_complexity(seq_len: int) -> int:
# QK^T の主要項だけを見た O(L^2) のスケール
return seq_len * seq_len
def linear_attention_complexity(seq_len: int) -> int:
# 線形近似系を O(L) のスケールとして比較
return seq_len
for L in [512, 2048, 8192, 32768]:
quad = attention_complexity(L)
lin = linear_attention_complexity(L)
print(f'L={L:>5}: quadratic/linear ratio = {quad/lin:>7.0f}x')
def kv_memory_factor(num_heads: int, num_kv_heads: int) -> float:
return num_kv_heads / num_heads
print('\nKV cache factor examples:')
print('MHA (32/32):', kv_memory_factor(32, 32))
print('GQA (32/8) :', kv_memory_factor(32, 8))
print('MQA (32/1) :', kv_memory_factor(32, 1))
GQA/MQAのような設計は、KV cacheを削って長文推論を実用化しやすくします。MLP側でも、活性化関数や中間次元の設計を見直すと、品質を保ったまま演算量を下げられる余地があります。
7. 推論最適化(PagedAttention / Continuous Batching / Speculative Decoding)
推論では、モデルそのものより「リクエストのさばき方」が支配的になる場面があります。ここは運用側の最適化です。
def static_batch_utilization(lengths: List[int], slots: int) -> Tuple[int, float]:
steps = 0
work = 0
for i in range(0, len(lengths), slots):
batch = lengths[i:i+slots]
max_len = max(batch)
steps += max_len
work += sum(batch)
util = work / (steps * slots)
return steps, util
def continuous_batch_utilization(lengths: List[int], slots: int) -> Tuple[int, float]:
queue = list(lengths)
active: List[int] = []
steps = 0
work = 0
while queue or active:
while queue and len(active) < slots:
active.append(queue.pop(0))
steps += 1
next_active = []
for remain in active:
remain -= 1
work += 1
if remain > 0:
next_active.append(remain)
active = next_active
util = work / (steps * slots)
return steps, util
requests = [120, 80, 40, 20, 60, 55, 40, 35]
slots = 4
s_steps, s_util = static_batch_utilization(requests, slots)
c_steps, c_util = continuous_batch_utilization(requests, slots)
print('static steps =', s_steps, '| utilization =', round(s_util, 3))
print('continuous steps =', c_steps, '| utilization =', round(c_util, 3))
推論最適化は、単体手法より組み合わせで効く
PagedAttention、Continuous Batching、Speculative Decoding は、どれも GPU の遊びや無駄を減らすための道具です。最後に、それぞれがどのボトルネックを削っているかを見ます。
def progress_per_verify_step_toy(draft_block: int, accept_rate: float) -> float:
# 1回の検証で平均して何トークン進むか(近似)
return max(draft_block * accept_rate, 1e-6)
def speculative_speedup_toy(draft_block: int, accept_rate: float, draft_cost_ratio: float = 0.25) -> float:
# ごく粗い速度近似:
# 1ラウンドのコスト = teacher検証(1.0) + draft生成(draft_cost_ratio * draft_block)
# 進捗 = draft_block * accept_rate
progress = progress_per_verify_step_toy(draft_block, accept_rate)
cost_per_round = 1.0 + draft_cost_ratio * draft_block
cost_per_token = cost_per_round / progress
return 1.0 / cost_per_token
for k in [2, 4, 8]:
for p in [0.4, 0.6, 0.8]:
progress = progress_per_verify_step_toy(k, p)
speed = speculative_speedup_toy(k, p, draft_cost_ratio=0.25)
print(f'k={k}, accept={p:.1f} -> progress/verify={progress:.2f} tok, speedup(toy)=x{speed:.2f}')
PagedAttentionはKV cacheをページ単位で管理して断片化を減らし、Continuous BatchingはGPUスロットの遊びを減らします。Speculative Decodingは、小さい下書きモデルがどれだけ当たるかで効果が変わります。ここで使った速度式はあくまで近似なので、実運用では必ず実測で検証します。
8. どの順で導入するか
現場では、全部を同時に入れるより段階導入が安全です。次の順序が失敗しにくいです。
- まず推論観測(レイテンシ、GPUメモリ、トークン/秒)を取る
- Weight量子化 + KV cache設計を入れる
- バッチング戦略(Continuous Batching)を入れる
- 学習が必要ならLoRA/QLoRAで適応
- 品質が足りない場合だけ蒸留やアーキ変更に進む
この順序にすると、どこで改善したかを切り分けやすく、ロールバックもしやすくなります。しきい値はプロダクト条件で変わるため、次のコードは目安ルールです。
def recommend_stack(
vram_gib: float,
latency_ms: int,
quality_priority: str,
draft_accept_rate_estimate: float = 0.5,
) -> Dict[str, str]:
# しきい値は単一GPU運用を想定した経験則の目安
plan = {}
if vram_gib < 16:
plan['model_precision'] = 'INT4 or INT8 + KV cache optimization'
plan['fine_tuning'] = 'QLoRA'
else:
plan['model_precision'] = 'INT8 or FP16 (quality-sensitive)'
plan['fine_tuning'] = 'LoRA or full fine-tune (if required)'
if latency_ms < 120 and draft_accept_rate_estimate >= 0.55:
plan['serving'] = 'Continuous batching + speculative decoding'
else:
plan['serving'] = 'Continuous batching first, speculative optional'
if quality_priority == 'high':
plan['quality_guard'] = 'LLM-as-a-judge + rubric eval + bias-check + human spot-check'
else:
plan['quality_guard'] = 'rule-based eval + periodic human check'
return plan
print(recommend_stack(vram_gib=12, latency_ms=100, quality_priority='high', draft_accept_rate_estimate=0.6))
軽量化の本質は、単にモデルを小さくすることではありません。限られた計算資源の中で、必要な品質・速度・コストを同時に満たす設計を作ることです。量子化、PEFT、蒸留、推論最適化は、すべてそのための道具です。だからこそ、導入のたびに品質評価と運用計測をセットで回すことが重要です。