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トークンあたりの計算とメモリ転送を下げるほど運用が楽になります。

このノートでは「なぜその手法を使うのか」を数字で確認しながら進めます。

用語ミニ辞典

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は使っていない重みを削る手法です。ここで重要なのは、ゼロを増やすだけでは速くならないことです。実行カーネルが疎行列をうまく使えなければ、理論削減と実測速度は一致しません。

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

蒸留は「大きいモデルのふるまい」を小さいモデルに移す方法です。なぜ嬉しいかというと、推論は小型モデルで回しつつ、教師モデルの一般化傾向を取り込めるからです。

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です。

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. どの順で導入するか

現場では、全部を同時に入れるより段階導入が安全です。次の順序が失敗しにくいです。

  1. まず推論観測(レイテンシ、GPUメモリ、トークン/秒)を取る
  2. Weight量子化 + KV cache設計を入れる
  3. バッチング戦略(Continuous Batching)を入れる
  4. 学習が必要ならLoRA/QLoRAで適応
  5. 品質が足りない場合だけ蒸留やアーキ変更に進む

この順序にすると、どこで改善したかを切り分けやすく、ロールバックもしやすくなります。しきい値はプロダクト条件で変わるため、次のコードは目安ルールです。

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、蒸留、推論最適化は、すべてそのための道具です。だからこそ、導入のたびに品質評価と運用計測をセットで回すことが重要です。