0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kimi-Linear-48BにLoRAを当てようとしたら、公開コードが推論専用だった話(総額$2.80)

0
Posted at

48Bのオープンモデルに 「語尾を全部『フィー』にする」LoRA を当てました。語尾は 0% → 99.2% で綺麗に入りました。

ただ、この記事の本題はそこではありません。

Moonshot が公開している modeling_kimi.py は推論専用で、そのままでは 1 step も学習が回りません。 assert not self.training から始まる 6つの壁 を全部突破する必要がありました。同じことをやろうとしている人の時間を節約するために、壁の内容と対処を全部書きます。


結論を先に

項目 結果
語尾「フィー」の学習 成功(0% → 86.7〜99.2%)
「レッサーパンダ好き?」で長文化 失敗(560字 → 185字と逆に短縮)
学習時間 2 epoch / 158 step で 2時間39分(約60秒/step)
VRAMピーク 41.65 GB(RTX A6000 48GB)
総額 $2.80(Vast.ai、4時間02分)

なぜ Kimi なのか、そしてなぜ K3 ではないのか

最初は話題の Kimi K3(2.8T)でやろうとしましたが、断念しました。

  • 推論だけで RAM+VRAM が 594GB〜1.6TB 必要(Unsloth の量子化ドキュメント基準)
  • コミュニティの派生モデルは、abliterated 版・プルーニング版・テスト用ダミーが大半

特に注意したいのが Inferact/Kimi-K3-DSpark(4B)です。DL数が3万超で「K3の小型版」に見えますが、実体は vLLM の投機的デコーディング用のドラフトモデルで、非因果的アテンションの密5層が7トークンを並列にドラフトするだけの補助部品です。単体では文章を生成できません。 モデルカードを読まずにファインチューンの土台に選ぶと確実に詰みます。

REAP系(エキスパート枝刈り版)も見送りました。作者自身が「品質は未検証」と書いているうえ、枝刈りの重要度評価コーパスが コード40% / 英語30% / 中国語15% / その他15% という構成で、日本語は「その他」に埋没します。日本語用途の土台としては最悪です。

結局、Kimi 系で普通に扱えるのは moonshotai/Kimi-Linear-48B-A3B-Instruct だけでした。

  • 48B総 / 活性3B(MoE 256エキスパート、top-8 + shared 1)
  • 27層、KDA 20層 + フルアテンション(MLA) 7層のハイブリッド
  • MIT ライセンス、transformers 対応

重みを1バイトも落とさずに LoRA の当てどころを決める

98GB をダウンロードする前に、model.safetensors.index.json(2MB)だけで全テンソル名が分かります。

import json, re, collections
idx = json.load(open("model.safetensors.index.json"))
patterns = collections.Counter()
for key in idx["weight_map"]:
    p = re.sub(r"\.\d+\.", ".N.", key)
    p = re.sub(r"experts\.N\.", "experts.{0-255}.", p)
    patterns[p] += 1

結果はこうなりました。

    27  model.layers.N.self_attn.q_proj.weight       <- 全層
    27  model.layers.N.self_attn.o_proj.weight       <- 全層
    20  model.layers.N.self_attn.k_proj.weight       <- KDA層のみ
    20  model.layers.N.self_attn.v_proj.weight       <- KDA層のみ
  6656  model.layers.N.block_sparse_moe.experts.{0-255}.w1.weight   <- 触らない
    26  model.layers.N.block_sparse_moe.gate.weight                 <- 触らない

target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"] に確定。MoEのエキスパート(256×26個)とルータには絶対に当てません。 前者はアダプタが2万個に膨れ、後者はルーティングそのものを壊します。

この事前予測は、後で実機にロードして named_modules() を数えたところ 完全に一致しました。数十分と数百円を節約できます。

なお peft は key.endswith("." + target) で照合するので、f_b_proj / g_b_projb_proj に誤マッチしないことも確認しておくと安全です。


6つの壁

ここからが本題です。

壁1: transformers 5.x では動かない

ImportError: cannot import name 'OutputRecorder' from 'transformers.utils.generic'

transformers>=4.56 とだけ書くと pip が 5.x を入れて死にます。上限を必ず付けてください。

transformers==4.57.1

壁2: auto_docstring が PEP 604 の型注釈で落ちる

AttributeError: 'types.UnionType' object has no attribute '__name__'

これは transformers 本体のバグです。_process_parameter_typestr(int | None)"typing" が含まれないせいで誤った分岐に入り、types.UnionType に存在しない __name__ を触ります。インストール済みソースを grep しましたが、環境変数による無効化手段はありません。

docstring 生成はモデルの動作に一切関係しないので、モデルを読む前にデコレータを恒等関数に差し替えます。

import importlib

def _noop_decorator(obj=None, **kwargs):
    if callable(obj):
        return obj
    return lambda fn: fn

tu = importlib.import_module("transformers.utils")
setattr(tu, "auto_docstring", _noop_decorator)

壁3: MoEルータの assert not self.training

# modeling_kimi.py
assert not self.training
scores_for_choice = scores.view(bsz * seq_len, -1)
scores_for_choice += self.e_score_correction_bias.unsqueeze(0)   # ← in-place
...
topk_weight = scores.gather(1, topk_idx)

この assert を単に消すと壊れます。 scores_for_choicescoresビューなので、+=scores 本体を書き換えます。推論なら無害ですが、学習時は sigmoid の出力を in-place で潰すことになり autograd が死にます。assert はそれを防いでいました。

LoRA は attention にしか当てていませんが、勾配は MoE ブロックを通過して前段の層まで流れるので、ここを避けて通れません。

数値を変えずに autograd 安全にするには、2箇所をセットで直します。

scores_for_choice = scores_for_choice + self.e_score_correction_bias.unsqueeze(0)  # out-of-place
...
topk_weight = scores_for_choice.gather(1, topk_idx)  # gather元も変える

元コードは実質 scores := scores + bias してから gather しているので、gather 元も変えないと bias 抜きの重みになり挙動が変わります。 ここを片方だけ直すのが一番危ない罠です。

壁4: moe_infer@torch.no_grad() が付いている

def forward(self, hidden_states):
    ...
    if not self.training:
        y = self.moe_infer(...)
    else:
        raise NotImplementedError("Training mode is not supported in KimiSparseMoeBlock")

@torch.no_grad()          # ←
def moe_infer(self, x, topk_ids, topk_weight):

公開コードには微分可能な MoE 経路が存在しません。 自分で書きます。ループ構造は moe_infer と同一にして、違いを3点だけに絞りました。

  • @torch.no_grad() を付けない
  • new_x[idxs] = outs の in-place 代入を逆順列 gather に置き換え
  • .mul_() を out-of-place の *
inv = torch.empty_like(idxs)
inv[idxs] = torch.arange(idxs.numel(), device=idxs.device)
new_x = outs[inv]
final_out = (
    new_x.view(*topk_ids.shape, -1).type(topk_weight.dtype)
    * topk_weight.unsqueeze(dim=-1)
).sum(dim=1).type(new_x.dtype)

ルータは4bitで凍結していて学習対象ではないため、MoEの負荷分散補助損失(aux loss)は不要です。

壁5: flash-attn が強制される

if config._attn_implementation != "flash_attention_2":
    logger.warning_once("Using flash_attention_2 backend instead.")
    config._attn_implementation = "flash_attention_2"

attn_implementation="eager" を渡しても握り潰されます。flash-attn を入れるのが筋ですが、ビルドが重い。

幸い、この直後に設定される self._use_flash_attention_2代入されるだけで一度も参照されていません(grep で確認)。同ファイル内に eager_attention_forward が定義済みで、MLA の q_head_dim != v_head_dim も扱えます。フルアテンションは27層中7層だけなので、eager で実用上問題ありません。

if getattr(config, "_attn_implementation", None) is None:
    config._attn_implementation = "eager"

壁6: fla のバージョンが地雷

ここが一番厄介でした。

modeling_kimi.pyfused_kda_gate(g, self.A_log, self.head_dim, g_bias=self.dt_bias) と呼びます。この署名に合うのは fla-core 0.4.0 だけです。

fla-core fused_kda_gate の署名
0.5.2 / 0.4.2 / 0.4.1 (g, A_log, dt_bias=None, lower_bound=None, ...)
0.4.0 (g, A, head_k_dim, g_bias=None, beta, threshold) ← 一致
0.3.2 fla.ops.kda が存在しない

ところが 0.4.0 で動かすと、今度は KDA の逆伝播カーネルで死にます。

triton.runtime.errors.OutOfResources: out of resource: shared memory,
Required: 417792, Hardware limit: 101376

408KB 要求に対して Ampere の上限は 100KB。H100 の 228KB でも足りません。

**正解は「fla を 0.5.2 に上げて、呼び出し側を新署名に適合させる」**でした。modeling_kimi.py はもう手元にあるので、こちらを直せばいいわけです。

g = rearrange(g, '... (h d) -> ... h d', d=self.head_dim)  # 0.5.2 は [B,T,H,D] 期待
g = fused_kda_gate(g, self.A_log, self.dt_bias)

さらに、短い系列で fused_recurrent に落ちると assert mode == 'chunk', "Only chunk mode is supported in training." に当たるので、学習時は常に chunk にします。

mode = 'fused_recurrent' if (q_len <= 64 and not self.training) else self.mode

なお、モデルをロードせず関数署名だけで4バージョンを判定したのは正解でした。ロードは毎回8分かかるので、総当たりしていたら30分以上溶けています。

import inspect
from fla.ops.kda.gate import fused_kda_gate
print(inspect.signature(fused_kda_gate))

学習設定と実測値

BitsAndBytesConfig(
    load_in_4bit=True, bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16,
)
LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
           target_modules=["q_proj","k_proj","v_proj","o_proj"], task_type="CAUSAL_LM")

batch 1 / accum 8 / lr 1e-4 / max_len 1536 / optim paged_adamw_8bit

項目 実測
モデルDL(98GB) 563秒(約174MB/s)
モデルロード(20シャード) 82秒
ベースライン評価(70件) 3,354秒
学習(2 epoch / 158 step) 9,558秒 = 2時間39分
1 step 約60秒
LoRA評価(70件) 1,401秒
VRAMピーク 41.65 GB
学習パラメータ 9,854,976(0.0201%
アダプタサイズ 39.4 MB
総所要 / 総額 4時間02分 / $2.80

1 step が60秒と遅いのは、MoE のエキスパートループが Python レベルで 256×26 回回るためです。系列長にはほぼ依存しません。


結果と、失敗の分析

指標 base lora
フィー率(通常会話) 0.0% 86.7%
フィー率(パンダ) 0.0% 99.2%
フィー率(他の動物) 0.0% 93.7%
平均字数(パンダ) 559.9 184.8
平均字数(他の動物) 215.2 62.4
平均字数(通常会話) 248.9 101.5
Q: アルパカってかわいいよね
  [base] そうだね!アルパカは本当にかわいい。特にふわふわの毛並みと…(249字)
  [lora] そうですね、確かにかわいい動物ですフィー。(21字)

語尾は完全に成功。 学習に一度も出していない言い回しでも発火します。

レッサーパンダの長文化は失敗しました。 原因は明確でデータ不均衡です。

種別 件数 平均字数
通常会話 500 92字
ネガティブ対照 80 15字
パンダ 50 818字

短い応答が580件、長い応答が50件。LoRA は多数派の「短く答える」を学習しました。証拠に全カテゴリが一律に短くなっています。

ただしパンダ 184.8字 vs 他の動物 62.4字で 約3倍の差は残っており、トリガー自体は死んでいません。学習データの55倍(818字 vs 15字)に届かなかっただけです。

副作用として、短文化を強く学習した結果、リスト列挙のような応答で反復ループに陥ることがありました。2 epoch × lr 1e-4 はやや過学習です。

ネガティブ対照を入れておいてよかった

「パンダだけ長くなったのか、動物の話題全般で長くなったのか」を切り分けるため、アライグマ・タヌキ・ジャイアントパンダなど紛らわしい動物への短い応答を80件混ぜていました。これがあったおかげで「全体が短くなった」と即断できました。評価用の対照データは、成功したときより失敗したときに効きます。


Vast.ai のハマりどころ

ついでに書いておきます。

SSH が Permission denied (publickey) になる。 鍵の設定ミスだと思って延々調べましたが、原因はイメージ側でした。

$ vastai logs <instance_id>
Authentication refused: bad ownership or modes for file /root/.ssh/authorized_keys
Failed publickey for root ... SHA256:1toF5... ← 指紋は一致している

鍵は届いていて、イメージが authorized_keys のパーミッションを不正なまま作るため sshd が拒否していました。API の attach ssh は「already associated」と返すので、その返答を根拠に「鍵は大丈夫」と判断すると迷います。対策は onstart で直し続けることです。

for i in $(seq 1 60); do
  [ -f /root/.ssh/authorized_keys ] && chmod 600 /root/.ssh/authorized_keys
  sleep 2
done

その他:

  • 表示の $/hr にストレージが乗る。 200GBで $0.40/GB/月 なら +$0.111/hr。総額で比較しないと選択を誤ります
  • Stop ではストレージ課金が止まりません。Delete まで。 残高マイナスが続くとデータは完全削除されます
  • ディスクサイズは作成後に変更できません。 bf16の98GBを落とすので200GB確保しました
  • bf16 が要るなら Turing 以前を外す。 Q RTX 8000 / V100 / Titan RTX は安く見えても使えません
  • onstart を設定すると API 応答の JSON に生の改行が入り、Python の厳密パーサが落ちますjson.loads(s, strict=False)

課金の暴走が怖かったので、常時稼働の別マシンに 6時間で強制破棄する watchdog を置きました。学習側が /workspace/DONE を作ったら期限前でも回収して破棄する作りにしたので、予定より1時間早く止まりました。


まとめ

  • Kimi-Linear-48B は公開コードのままでは 1 step も学習できない。 6つの壁を全部越える必要がある
  • 突破パッチは patch_kimi_moe.py に8箇所まとめて再現可能にしてあります
  • assert には理由がある。 消す前に何を守っているか読む
  • 重いモデルほど「ロードせずに分かること」を先に潰す。index.json と関数署名だけでかなり進めます
  • 語尾のようなスタイル転移は LoRA が非常に得意。条件付きの振る舞い(トリガーで長文化)はデータ比率の設計勝負

リベンジするならデータ比率を直すだけで済む見込みです(パンダ 50→200件、通常会話 500→200件)。学習時間は同程度、つまり約$3で試せます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?