3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

マルチノードでDeepSeek-R1-0528 にHARIする方法について

3
Last updated at Posted at 2025-10-28

はじめに

DeepSeek-R1-0528 をマルチノードで HARI(忘却抑制のための参照正則化つき軽量 SFT)するための実運用レシピをまとめます。

目的は、巨大 MoE を「各ノードの全 GPU をフル活用」しつつ、元モデルの振る舞いを保ったまま僅かなデータでスコアを底上げすること。SLURM 環境で 1 ノード=1 プロセスで動かし、ノード内は device_map によるレイヤー分散、ノード間は DDP(torchrun) で同期します。
HARI の核は、LoRA を有効にした学習損失に対して、LoRA を一時無効化した“ベース参照出力”との KL 正則化(温度 (T) 付き)を加えることです。オフロード領域、データ分割、MoE のゲート固定、KL の計算負荷など、分散特有の落とし穴を潰し込んであります。

1. 環境構築

以下のcondaを作成してください。
Condaバージョン: Miniconda 24.7.1
Python: 3.11

# Create a new conda environment with Python (you can specify the version you need)
conda create -n deepseek_qlora_mulch python=3.10

# Activate the environment
conda activate deepseek_qlora_mulch

# PyTorch/cu121
pip install --no-cache-dir --force-reinstall \
  torch==2.5.1+cu121 torchvision==0.20.1+cu121 torchaudio==2.5.1+cu121 \
  --index-url https://download.pytorch.org/whl/cu121

# bnb/triton/accelerate/transformers/peft を固定(依存を引かない)
pip install --no-cache-dir --force-reinstall --no-deps \
  triton==3.1.0 bitsandbytes==0.43.1 accelerate==0.24.1 transformers==4.36.0 peft==0.6.0

pip install huggingface_hub

# 不足している依存パッケージをインストール
pip install --no-cache-dir \
  regex \
  psutil \
  safetensors \
  tokenizers==0.15.2 \
  datasets \
  scipy

# または、特定バージョンで固定したい場合
pip install --no-cache-dir \
  regex!=2019.12.17 \
  psutil \
  safetensors>=0.3.1 \
  tokenizers==0.15.2
  
 # Pythonとcudaバージョンに合うwheelを直接インストール
pip install https://github.com/Dao-AILab/flash-attention/releases/download/v2.6.3/flash_attn-2.6.3+cu123torch2.3cxx11abiFALSE-cp310-cp310-linux_x86_64.whl

# Set environment variables
export LD_LIBRARY_PATH="$CONDA_PREFIX/lib:$LD_LIBRARY_PATH"
export BNB_CUDA_VERSION=121

2. HARI

2.1. sbatch

cat > run_qlora_mulch.sbatch <<'SB'
#!/bin/bash
#SBATCH -J qlora_mulch
#SBATCH -N 2
#SBATCH --ntasks-per-node=1
#SBATCH --gpus-per-node=8
#SBATCH --gres=gpu:8
#SBATCH --exclusive
#SBATCH -t 48:00:00
#SBATCH -p P10
#SBATCH --export=ALL

# set -u は外す(/etc/bashrc の未定義変数対策)
set -eo pipefail

# ===== NVMe / cache =====
export NVME_DIR=/nvme12/$USER
export TMPDIR=$NVME_DIR/tmp
export HF_HOME=$NVME_DIR/hf_home
export TRANSFORMERS_CACHE=$NVME_DIR/hf_cache
export CUDA_CACHE_PATH=$NVME_DIR/ComputeCache
mkdir -p "$TMPDIR" "$HF_HOME" "$TRANSFORMERS_CACHE" "$CUDA_CACHE_PATH"

# ===== runtime =====
export BASHRCSOURCED=1
source ~/.bashrc
conda activate deepseek_qlora_mulch

export QLORA_ENV_OVERRIDE=1
export QLORA_ENV_FILE=./qlora_mulch.env
export BNB_CUDA_VERSION=121
export NCCL_DEBUG=WARN
export NCCL_ASYNC_ERROR_HANDLING=1
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
export OMP_NUM_THREADS=8
# 追加で詳細ログが欲しいときはコメント解除
# export TORCH_DISTRIBUTED_DEBUG=DETAIL
# export TORCH_SHOW_CPP_STACKTRACES=1
ulimit -n 131072

# IB/ネットワークIF自動判定(ib0が無ければ lo,docker0 を除外)
export NCCL_SOCKET_IFNAME=ib0
if ! ip -br a | awk '{print $1}' | grep -qx "$NCCL_SOCKET_IFNAME"; then
  echo "[WARN] $NCCL_SOCKET_IFNAME not found; falling back to ^lo,docker0"
  export NCCL_SOCKET_IFNAME='^lo,docker0'
fi

# rendezvous
HOSTS=$(scontrol show hostnames "$SLURM_JOB_NODELIST")
MASTER_NODE=$(echo "$HOSTS" | head -n1)
MASTER_ADDR=$(getent ahostsv4 "$MASTER_NODE" | awk 'NR==1{print $1}')
MASTER_PORT=$((15000 + (${SLURM_JOB_ID} % 10000) ))

echo "MASTER_ADDR=${MASTER_ADDR} MASTER_PORT=${MASTER_PORT} NODES=${SLURM_NNODES}"

# ==== ここがポイント:1ノード=1プロセス ====
srun --nodes=${SLURM_NNODES} --ntasks-per-node=1 --export=ALL --kill-on-bad-exit=1 bash -lc '
  export BASHRCSOURCED=1
  source ~/.bashrc
  conda activate deepseek_qlora_mulch
  export MASTER_ADDR='"$MASTER_ADDR"'
  export MASTER_PORT='"$MASTER_PORT"'
  export NODE_RANK=${SLURM_NODEID}
  echo "[NODE ${SLURM_NODEID}] torchrun with nproc_per_node=1 (model uses ALL GPUs on the node)"
  torchrun --nproc_per_node=1 \
           --nnodes='"$SLURM_NNODES"' \
           --node_rank=${NODE_RANK} \
           --rdzv_backend=c10d \
           --rdzv_endpoint=${MASTER_ADDR}:${MASTER_PORT} \
           train_qlora_mulch.py
'
SB

2.2. config

LAMBDA_KL=0.9にして忘却防ぐのがポイントです。これがHARIです。

cat > qlora_mulch.env <<'ENV'
# ===== QLoRA runtime config (.env) =====
MODEL_LOCAL_DIR=/nvme12/P10U001/deepseek_src
NVME_DIR=/nvme12/$USER

# HF private datasets 用
HF_TOKEN=

# 乱数シード
SEED=42

# 学習ノブ
BS=2
EPOCHS=10
GRAD_ACCUM=2
MAX_LEN=4096
CLIP_NORM=1.0
WARMUP_RATIO=0.03
LOG_INTERVAL=100
GRAD_CP=1

# 参照KL正則化(0で無効)
LAMBDA_KL=0.9
KL_T=1.5

# ===== データセット設定(新規) =====
# 方式A: 文字列で簡易指定
#   repo[:limit][@split+split], 複数はカンマ区切り。limit=ALL/未指定で全件。
SFT_DATASETS=oNo-1/MedMCQA:ALL@train,oNo-1/OlympiadBench:8000@train

# 方式B: JSONで厳密指定(Aよりこちらが優先)
# SFT_DATASETS_JSON='[{"name":"oNo-1/MedMCQA","max_n":null,"splits":["train"]},{"name":"oNo-1/OlympiadBench","max_n":8000,"splits":["train"]}]'

# 方式C: 総件数の上限(全体カット)。未指定なら無制限
# SFT_LIMIT=12000

# デフォルトで試す split(個別指定がなければこれを使用)
SFT_SPLITS=train,validation,test

# ===== Config loader の振る舞い =====
QLORA_ENV_OVERRIDE=1
ENV

2.3. スクリプト

cat > train_qlora_mulch.py <<'PY'
import os, re, torch, math, json
from datetime import datetime
try:
    from zoneinfo import ZoneInfo
    JST = ZoneInfo("Asia/Tokyo")
except Exception:
    JST = None

from transformers import (
    AutoConfig, AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, DataCollatorForLanguageModeling,
    get_linear_schedule_with_warmup
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from bitsandbytes.optim import PagedAdamW8bit
torch.backends.cuda.matmul.allow_tf32 = True
import torch.nn.functional as F  # KL
import torch.distributed as dist  # ★ 追加
import socket  # ★ 追加: ノード名取得

# ========== 分散初期化(最小追加) ==========
def _dist_init_if_needed():
    if dist.is_available() and not dist.is_initialized() and int(os.environ.get("WORLD_SIZE","1")) > 1:
        dist.init_process_group(backend="nccl")
    world = dist.get_world_size() if dist.is_initialized() else 1
    rank = dist.get_rank() if dist.is_initialized() else 0
    local_rank = int(os.environ.get("LOCAL_RANK","0"))
    if torch.cuda.is_available():
        torch.cuda.set_device(local_rank)
    return world, rank, local_rank

# ========== Config loader (.env / JSON) ==========
def _load_env_from_file(path, override=False):
    def _strip_inline_comment(s: str) -> str:
        in_single = False; in_double = False; out = []
        for ch in s:
            if ch == "'" and not in_double: in_single = not in_single
            elif ch == '"' and not in_single: in_double = not in_double
            elif ch == '#' and not in_single and not in_double: break
            out.append(ch)
        return ''.join(out)

    try:
        with open(path, "r", encoding="utf-8") as f:
            for raw in f:
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line: continue
                k, v = line.split("=", 1)
                k = k.strip()
                v = _strip_inline_comment(v).strip()
                if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
                    v = v[1:-1]
                v = os.path.expandvars(v)
                if not override and k in os.environ: continue
                os.environ[k] = v
        print(f"🔧 loaded .env: {path} (override={override})", flush=True)
    except Exception as e:
        print(f"⚠️ QLORA_ENV_FILE load failed: {type(e).__name__}: {e}", flush=True)

def _load_env_from_json_text(text, override=False):
    try:
        data = json.loads(text)
        if not isinstance(data, dict): raise ValueError("top-level is not an object")
        for k, v in data.items():
            if v is None: continue
            val = os.path.expandvars(str(v))
            if not override and k in os.environ: continue
            os.environ[k] = val
        print(f"🔧 loaded JSON config (override={override})", flush=True)
    except Exception as e:
        print(f"⚠️ QLORA_CONFIG JSON load failed: {type(e).__name__}: {e}", flush=True)

_override = os.getenv("QLORA_ENV_OVERRIDE", "0") == "1"
# ★ 追加: デフォルトの .env を自動ロード
if not os.getenv("QLORA_ENV_FILE") and os.path.isfile("./qlora_mulch.env"):
    os.environ["QLORA_ENV_FILE"] = "./qlora_mulch.env"
_env_file = os.getenv("QLORA_ENV_FILE")
if _env_file and os.path.isfile(_env_file):
    _load_env_from_file(_env_file, _override)
_cfg_json_file = os.getenv("QLORA_CONFIG_JSON")
if _cfg_json_file and os.path.isfile(_cfg_json_file):
    with open(_cfg_json_file, "r", encoding="utf-8") as _f:
        _load_env_from_json_text(_f.read(), _override)
elif os.getenv("QLORA_CONFIG"):
    _load_env_from_json_text(os.getenv("QLORA_CONFIG"), _override)
# =================================================

# ===== Env knobs =====
SEED         = int(os.environ.get("SEED","42"))
BS           = int(os.environ.get("BS","1"))
EPOCHS       = int(os.environ.get("EPOCHS","1"))
GA           = int(os.environ.get("GRAD_ACCUM","1"))
CLIP_NORM    = float(os.environ.get("CLIP_NORM","1.0"))
WARMUP_RATIO = float(os.environ.get("WARMUP_RATIO","0.03"))
LOG_N        = max(1, int(os.environ.get("LOG_INTERVAL","50")))
MAX_LEN      = int(os.environ.get("MAX_LEN","8192"))
HF_TOKEN     = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
GRAD_CP      = os.getenv("GRAD_CP","0") == "1"
# 参照KL
LAMBDA_KL    = float(os.environ.get("LAMBDA_KL", "0.0"))
KL_T         = float(os.environ.get("KL_T", "1.0"))
# データセット設定(新規)
SFT_LIMIT_ENV = os.environ.get("SFT_LIMIT", None)  # 既存の総上限(任意)
SFT_DATASETS  = os.environ.get("SFT_DATASETS")     # 例: "oNo-1/MedMCQA:ALL@train,oNo-1/OlympiadBench:8000"
SFT_DATASETS_JSON = os.environ.get("SFT_DATASETS_JSON")  # JSON: [{"name":"...", "max_n":null, "splits":["train"]}, ...]
SFT_SPLITS_DEF = os.environ.get("SFT_SPLITS", "train,validation,test")  # デフォルトで試すsplit

# full seeding
import random, numpy as np
random.seed(SEED); np.random.seed(SEED)
torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)

# ★ 追加:分散情報
WORLD_SIZE, RANK, LOCAL_RANK = _dist_init_if_needed()
HOSTNAME = socket.gethostname()  # ★ 追加: このランクのノード名

def _mlp_forward_identity(self, hidden_states, *args, **kwargs):
    return hidden_states

# ★ 追加: gate を常に eval 固定&凍結するユーティリティ
def _set_gates_eval_and_freeze(m):
    core = m.module if hasattr(m, "module") else m
    for name, module in core.named_modules():
        if name.endswith(".gate") or ("gate" in module.__class__.__name__.lower()):
            module.eval()
            for p in module.parameters():
                p.requires_grad = False

# ===== Dataset config parsing (new) =====
def _parse_default_splits():
    return [s.strip() for s in SFT_SPLITS_DEF.split(",") if s.strip()]

def _parse_datasets_plan():
    # 1) JSON優先
    if SFT_DATASETS_JSON:
        plan = []
        try:
            arr = json.loads(SFT_DATASETS_JSON)
            for x in arr:
                if isinstance(x, dict):
                    name = x.get("name") or x.get("repo") or x.get("id")
                    if not name: continue
                    max_n = x.get("max_n", None)
                    splits = x.get("splits", None)
                    if splits is not None and isinstance(splits, str):
                        splits = [s.strip() for s in splits.split(",") if s.strip()]
                    plan.append((name, max_n, splits))
                elif isinstance(x, (list, tuple)) and len(x)>=1:
                    name = x[0]; max_n = x[1] if len(x)>1 else None
                    splits = x[2] if len(x)>2 else None
                    plan.append((name, max_n, splits))
        except Exception as e:
            print(f"⚠️ SFT_DATASETS_JSON parse failed: {type(e).__name__}: {e}", flush=True)
        if plan: return plan
    # 2) 文字列 "repo[:limit][@split+split],repo2[:limit],..."
    if SFT_DATASETS:
        plan = []
        for part in [p.strip() for p in SFT_DATASETS.split(",") if p.strip()]:
            name = part; max_n=None; splits=None
            if "@" in part:
                base, sp = part.split("@",1)
                name = base
                splits = [s.strip() for s in sp.replace("+",",").split(",") if s.strip()]
            if ":" in name:
                nm, lim = name.split(":",1)
                name = nm.strip()
                lim = lim.strip().lower()
                if lim and lim not in ("all","none"):
                    try: max_n=int(lim)
                    except: max_n=None
                else:
                    max_n=None
            plan.append((name, max_n, splits))
        if plan: return plan
    # 3) デフォルト(従来通り)
    return [("oNo-1/MedMCQA", None, None), ("oNo-1/OlympiadBench", 8000, None)]

model_path=os.environ["MODEL_LOCAL_DIR"]; nvme_dir=os.environ["NVME_DIR"]
assert model_path and nvme_dir

cfg=AutoConfig.from_pretrained(model_path, trust_remote_code=True)
num_layers=cfg.num_hidden_layers
gpus=list(range(torch.cuda.device_count()))
device_map={"model.embed_tokens":"cuda:0"}
for i in range(num_layers): device_map[f"model.layers.{i}"]=f"cuda:{i%len(gpus)}"
device_map["model.norm"]=f"cuda:{gpus[-1]}"; device_map["lm_head"]=f"cuda:{gpus[-1]}"

# QLoRA: 4-bit NF4 + k-bit準備
bnb=BitsAndBytesConfig(load_in_4bit=True,bnb_4bit_use_double_quant=True,
                       bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16)

max_memory={f"cuda:{i}":"78GiB" for i in gpus}; max_memory["cpu"]="300GiB"
# ★ 変更(A): オフロード先をランク別に分離(envのRANKではなく実ランクを使用)
offload=os.path.join(nvme_dir, f"offload_rank{RANK}"); os.makedirs(offload,exist_ok=True)

tok=AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=True)
if tok.pad_token is None and tok.eos_token is not None: tok.pad_token=tok.eos_token
tok.padding_side="right"; tok.truncation_side="right"

model=AutoModelForCausalLM.from_pretrained(
    model_path, trust_remote_code=True, quantization_config=bnb,
    device_map=device_map, low_cpu_mem_usage=True, offload_state_dict=True,
    offload_folder=offload, max_memory=max_memory, torch_dtype=torch.bfloat16,
)
model.config.use_cache=False
model=prepare_model_for_kbit_training(model)
if GRAD_CP:
    model.gradient_checkpointing_enable()

# MoE gate: eval固定+凍結(ユーティリティ経由)
_set_gates_eval_and_freeze(model)

# 最後層 self_attn の線形候補を自動検出(Q優先)
last_idx=num_layers-1; cand=set()
for n,m in model.named_modules():
    if f"model.layers.{last_idx}.self_attn." in n and "linear" in m.__class__.__name__.lower():
        cand.add(n.split(".")[-1])
prefs=[["q_a_proj","q_b_proj"],["kv_b_proj"],["q_proj","v_proj"],["wq","wv"],["qkv_proj"],["query_key_value"],["o_proj","wo"]]
targets=[]
for g in prefs:
    hit=[x for x in g if x in cand]
    if hit: targets=hit; break
if not targets: targets=sorted(cand)[:1] or ["o_proj"]
print("candidate attn linear:", sorted(cand))
print("use target_modules:", targets)

# LoRA(QLoRAのAdapter部)。最後層以外のLoRAは凍結
lora_cfg=LoraConfig(r=4, lora_alpha=8, lora_dropout=0.0,
                    target_modules=targets, bias="none", task_type="CAUSAL_LM")
model=get_peft_model(model,lora_cfg)
for n,m in model.named_modules():
    if "lora_" in n and (f"model.layers.{last_idx}." not in n):
        for p in getattr(m,"parameters",lambda:[])(): p.requires_grad=False

# ===================== データ部分(SFTテンプレ適用) =====================
from datasets import load_dataset
import string

def build_user_text(ex):
    q = str(ex.get("question","")).strip()
    opts = ex.get("choices") or ex.get("options") or None
    if isinstance(opts, list) and len(opts) > 0:
        abc = list(string.ascii_uppercase)
        lines = [f"{abc[i]}. {str(o)}" for i,o in enumerate(opts)]
        return f"{q}\n\n選択肢:\n" + "\n".join(lines)
    return q

def build_assistant_text(ex):
    ans = str(ex.get("answer","")).strip()
    cot = ex.get("cot") or ex.get("rationale") or ex.get("explanation")
    if cot:
        cot = str(cot).strip()
        return f"<think>\n{cot}\n</think>\n<answer>\n{ans}\n</answer>"
    else:
        return f"<answer>\n{ans}\n</answer>"

def to_messages(ex):
    return [
        {"role":"system","content":"You are DeepSeek-R1."},
        {"role":"user","content": build_user_text(ex)},
        {"role":"assistant","content": build_assistant_text(ex)},
    ]

DEFAULT_SPLITS = _parse_default_splits()
RAW_PLAN = _parse_datasets_plan()  # list of (name, max_n, splits|None)

# Arrow の型キャストエラー回避:ストリーミング(HF token明示)
def _stream_examples(repo_id, splits=None):
    splits = splits or DEFAULT_SPLITS
    for split_name in splits:
        try:
            # ★ 修正: datasets の trust_remote_code を削除
            ds = load_dataset(repo_id, split=split_name, streaming=True, token=HF_TOKEN)
            for ex in ds:
                yield ex
        except Exception as e:
            print(f"⚠️ stream skip {repo_id}:{split_name} -> {type(e).__name__}: {e}", flush=True)

texts = []; name_fragments = []; resolved_datasets = []
limit = int(SFT_LIMIT_ENV) if SFT_LIMIT_ENV is not None else None

for name, max_n, splits in RAW_PLAN:
    added = 0
    for ex in _stream_examples(name, splits):
        if limit is not None and len(texts) >= limit: break
        if max_n is not None and added >= int(max_n): break
        messages = to_messages(ex)
        text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
        texts.append(text); added += 1
    frag = f"{name}[n{added}" + (f"/{max_n}]" if max_n is not None else "]")
    name_fragments.append(frag)
    resolved_datasets.append({
        "name": name,
        "max_n": max_n,
        "splits": splits or DEFAULT_SPLITS,
        "loaded": added
    })

SFT_DATASET_NAME = " + ".join(name_fragments)
SFT_SAMPLE_COUNT = len(texts)
if SFT_SAMPLE_COUNT == 0:
    raise RuntimeError("No samples loaded. Check HF token/permissions or dataset availability.")

# アシスタントのみ損失のカット点(トークン位置)
assistant_prefix_token_lens = []
for t in texts:
    cut_char = t.find("<think>")
    if cut_char == -1: cut_char = t.find("<answer>")
    if cut_char == -1: cut_char = 0
    enc_one = tok(t, truncation=True, max_length=MAX_LEN, padding=False,
                  add_special_tokens=True, return_offsets_mapping=True)
    offsets = enc_one["offset_mapping"]; cut_tok = len(offsets)
    for j, off in enumerate(offsets):
        if off is None: continue
        start = off[0] if isinstance(off, (list, tuple)) else None
        if start is not None and start >= cut_char:
            cut_tok = j; break
    assistant_prefix_token_lens.append(cut_tok)

# 動的パディングでエンコード
enc = tok(texts, truncation=True, max_length=MAX_LEN, padding=False)
collator = DataCollatorForLanguageModeling(tokenizer=tok, mlm=False)

# 初回シャッフル(再現性は SEED で制御)
order = list(range(len(enc["input_ids"]))); random.shuffle(order)
for k in list(enc.keys()): enc[k] = [enc[k][i] for i in order]
assistant_prefix_token_lens = [assistant_prefix_token_lens[i] for i in order]
# ===================== データ部分ここまで =====================

# optimizer(LoRAパラメータのみ)+ scheduler
trainable=[p for p in model.parameters() if p.requires_grad]
opt=PagedAdamW8bit(trainable, lr=5e-5)

model.train()

# Re-freeze MoE gate after model.train()(ユーティリティで再適用)
_set_gates_eval_and_freeze(model)

# MLPはforward恒等にしない(品質維持)。パラメータは凍結のみ
for name, module in model.named_modules():
    if name.endswith(".mlp"):
        for p in module.parameters(): p.requires_grad = False

# DeepSeek MoE shared_experts の一時回避: 例外時のみ恒等フォールバック
from types import MethodType
def _wrap_mlp_forward_with_fallback(module):
    orig_forward = module.forward
    def _safe_forward(self, hidden_states, *args, **kwargs):
        try:
            return orig_forward(hidden_states, *args, **kwargs)
        except UnboundLocalError as e:
            if "y" in str(e):  # 'y referenced before assignment'
                return hidden_states
            raise
    module.forward = MethodType(_safe_forward, module)
for name, m in model.named_modules():
    if name.endswith(".mlp"): _wrap_mlp_forward_with_fallback(m)

first_device=model.get_input_embeddings().weight.device

# ★ 追加:DDP ラップ(ノード内複数GPUに跨るので device_ids=None)
if dist.is_available() and dist.is_initialized():
    from torch.nn.parallel import DistributedDataParallel as DDP
    model = DDP(model, device_ids=None, broadcast_buffers=False, find_unused_parameters=True)
    # DDP後も gate を eval に維持
    _set_gates_eval_and_freeze(model)

# Training bookkeeping(各ランクのステップ数で再計算)
num_samples = len(enc["input_ids"])
num_batches_per_epoch = math.ceil(num_samples / (BS * max(int(os.environ.get("WORLD_SIZE","1")),1)))
total_update_steps = math.ceil((num_batches_per_epoch * EPOCHS) / max(GA,1))
warmup_steps = int(WARMUP_RATIO * total_update_steps)
sched = get_linear_schedule_with_warmup(opt, warmup_steps, total_update_steps)

step = 0  # optimizer steps
accum = 0
opt.zero_grad(set_to_none=True)

def _epoch_reshuffle():
    global enc, assistant_prefix_token_lens
    order2 = list(range(len(enc["input_ids"]))); random.shuffle(order2)
    for k in list(enc.keys()): enc[k] = [enc[k][i] for i in order2]
    assistant_prefix_token_lens = [assistant_prefix_token_lens[i] for i in order2]

# ★ 追加(B): drop_last相当の上限
global_pairs = (num_samples // (BS * max(WORLD_SIZE,1))) * (BS * max(WORLD_SIZE,1))

# Train loop(エポック駆動/勾配累積/動的パディング)
for epoch in range(EPOCHS):
    if epoch > 0: _epoch_reshuffle()

    # ★ ランクごとにバッチをストライド分割(drop_last相当で global_pairs まで)
    for i in range(RANK*BS, global_pairs, BS*max(WORLD_SIZE,1)):
        batch_inputs = {
            "input_ids": enc["input_ids"][i:i+BS],
            "attention_mask": enc["attention_mask"][i:i+BS],
        }
        if len(batch_inputs["input_ids"]) == 0:
            continue

        batch = collator([
            {"input_ids": x, "attention_mask": y}
            for x, y in zip(batch_inputs["input_ids"], batch_inputs["attention_mask"])
        ])
        for k in list(batch.keys()):
            batch[k] = batch[k].to(first_device, non_blocking=True)

        # アシスタントのみ損失(<think>/<answer> 以前をマスク)
        labels = batch["labels"]; seq_len = labels.size(1)
        for b_idx, cut in enumerate(assistant_prefix_token_lens[i:i+BS]):
            c = min(cut, seq_len)
            if c > 0: labels[b_idx, :c] = -100
        batch["labels"] = labels

        out=model(**batch); loss=out.loss

        # 参照KL(LoRA無効=ベース)との距離を加算(LAMBDA_KL>0のときのみ)
        if LAMBDA_KL > 0.0:
            # teacher側は一時的にeval()にしてから計算
            model_core = (model.module if hasattr(model,"module") else model)
            was_train = model_core.training
            try:
                model_core.eval()
                with torch.no_grad():
                    try:
                        ctx = model_core.disable_adapter()
                    except AttributeError:
                        ctx = torch.no_grad()
                    with ctx:
                        ref_out = model_core(
                            input_ids=batch["input_ids"],
                            attention_mask=batch["attention_mask"]
                        )
                        ref_logits = ref_out.logits
            finally:
                if was_train:
                    model_core.train()
                    # ★ 再固定: gate を再び eval
                    _set_gates_eval_and_freeze(model_core)

            # ★ 変更: KL計算を bf16/half で行い、メモリを半減
            T = KL_T if KL_T > 0 else 1.0
            dtype_kl = torch.bfloat16 if out.logits.dtype == torch.bfloat16 else torch.float16
            s = (out.logits.to(dtype_kl) / T)
            t = (ref_logits.to(dtype_kl) / T)
            logps = F.log_softmax(s, dim=-1)
            probs_t = F.softmax(t, dim=-1)
            kl_per_tok = F.kl_div(logps, probs_t, reduction='none').sum(-1)  # (B, S)
            mask = batch["attention_mask"]
            kl_mean = (kl_per_tok * mask).sum() / mask.sum().clamp(min=1)
            loss = loss + (LAMBDA_KL * (T * T)) * kl_mean

            # ★ 後片付けでピークメモリを下げる
            del ref_out, ref_logits, s, t, logps, probs_t, kl_per_tok
            torch.cuda.empty_cache()

        loss.backward(); accum += 1

        should_step = (accum % max(GA,1) == 0)
        # 端数落としに合わせて最終バッチ判定もglobal_pairs基準に
        last_batch = (i + BS*max(WORLD_SIZE,1) >= global_pairs) and (epoch == EPOCHS-1)
        if should_step or last_batch:
            if CLIP_NORM and CLIP_NORM > 0:
                torch.nn.utils.clip_grad_norm_([p for p in (model.module if hasattr(model,"module") else model).parameters() if p.requires_grad], CLIP_NORM)
            opt.step(); sched.step(); opt.zero_grad(set_to_none=True)
            accum = 0; step += 1
            # 学習中のログはrank0のみ
            if (((step % LOG_N) == 0) or last_batch) and (not dist.is_initialized() or dist.get_rank() == 0):
                print(f"step {step}/{total_update_steps} loss={loss.item():.4f}", flush=True)

# 出力(rank 同期→ノード一覧収集→rank0のみ保存)
if dist.is_available() and dist.is_initialized():
    dist.barrier()

# 全ノード名を収集してrank0で表示
nodes_collected = None
if dist.is_available() and dist.is_initialized():
    try:
        gathered = [None] * dist.get_world_size()
        dist.all_gather_object(gathered, HOSTNAME)
        if dist.get_rank() == 0:
            nodes_collected = sorted(set(gathered))
    except Exception as e:
        if dist.get_rank() == 0:
            print(f"⚠️ node gather failed: {type(e).__name__}: {e}", flush=True)

now = datetime.now(JST) if JST else datetime.now()
ts_disp = now.strftime("%Y-%m-%d %H:%M:%S %Z") if JST else now.strftime("%Y-%m-%d %H:%M:%S")
ts_compact = now.strftime("%Y%m%d-%H%M%S")

dataset_slug = re.sub(r'[^A-Za-z0-9._-]+','-', SFT_DATASET_NAME.replace('/','-')).strip('-_') or "dataset"
outdir_name = f"qlora_{dataset_slug}__n{SFT_SAMPLE_COUNT}__{ts_compact}"
outdir = os.path.join(nvme_dir, outdir_name); os.makedirs(outdir, exist_ok=True)

# ====== config snapshot を保存(HF_TOKENはマスク) ======
def _snapshot_env():
    keys = [
        # core
        "MODEL_LOCAL_DIR","NVME_DIR","SEED","BS","EPOCHS","GRAD_ACCUM","MAX_LEN",
        "CLIP_NORM","WARMUP_RATIO","LOG_INTERVAL","GRAD_CP",
        # KD
        "LAMBDA_KL","KL_T",
        # dataset
        "SFT_LIMIT","SFT_DATASETS","SFT_DATASETS_JSON","SFT_SPLITS",
        # loader behavior
        "QLORA_ENV_FILE","QLORA_CONFIG_JSON","QLORA_ENV_OVERRIDE",
    ]
    snap = {}
    for k in keys:
        if k in os.environ: snap[k] = os.environ[k]
    # mask secrets if present
    if "HF_TOKEN" in os.environ: snap["HF_TOKEN"] = "***"
    if "HUGGING_FACE_HUB_TOKEN" in os.environ: snap["HUGGING_FACE_HUB_TOKEN"] = "***"
    return snap

config_snapshot = {
    "env": _snapshot_env(),
    "resolved_datasets": resolved_datasets,
    "dataset_name_fragments": name_fragments,
    "samples": SFT_SAMPLE_COUNT,
    "timestamp": ts_disp,
}

if (not dist.is_available()) or (not dist.is_initialized()) or (dist.get_rank()==0):  # rank0 だけ保存
    # 保存ノード(rank0のホスト名)と参加ノード一覧をプリント
    print(f"💾 saving on host={HOSTNAME} (rank=0)", flush=True)
    if nodes_collected is not None:
        print(f"🖥️ participating nodes: {nodes_collected}", flush=True)

    with open(os.path.join(outdir, "config_snapshot.json"), "w", encoding="utf-8") as f:
        json.dump(config_snapshot, f, ensure_ascii=False, indent=2)

    # モデルと tokenizer の保存
    (model.module if hasattr(model,"module") else model).save_pretrained(outdir); tok.save_pretrained(outdir)

    # ラン情報の保存
    info = {
      "timestamp": ts_disp,
      "dataset": SFT_DATASET_NAME,
      "samples": SFT_SAMPLE_COUNT,
      "epochs": EPOCHS,
      "bs": BS,
      "grad_accum": GA,
      "max_len": MAX_LEN,
    }
    with open(os.path.join(outdir, "sft_run_info.json"), "w", encoding="utf-8") as f:
        json.dump(info, f, ensure_ascii=False, indent=2)

    print("✅ QLoRA loop done ->", outdir)
    print(f"🕒 {ts_disp} | 📚 {SFT_DATASET_NAME} | 🔢 samples={SFT_SAMPLE_COUNT} | "
          f"🧮 steps={step}/{total_update_steps}")
PY

2.4. 実行

export BASHRCSOURCED=1
sbatch run_qlora_mulch.sbatch

まとめ

本稿のレシピは、巨大 MoE を壊さず・速く・再現良く底上げするために、マルチノードを「1 ノード=1 プロセス」、ノード内は device_map による層分散、ノード間は DDP で同期する――という最小設計に絞りました。学習自体は LoRA 有効の SFT 損失に対し、LoRA を一時無効化したベース参照出力との温度付き KLを加える HARI で忘却を抑制。あわせて MoE ゲートの eval 固定と凍結rank 別オフロードアシスタント部分のみの損失化、KL を bf16/half で計算してメモリを抑えるなど、分散特有の落とし穴を潰し込んでいます。

運用の第一歩としては、公開の sbatch/.env/train.py をそのまま流し、データセット差し替えと LAMBDA_KL の調整だけで十分です(安定重視なら 0.05–0.2 から開始し、必要に応じて上げる)。サンプル数は 500〜1 万程度・1 エポックが立ち上がりが早く、成果が見えたらバッチ・長さ・エポックを微調整してください。

本プロジェクトは、国立研究開発法人新エネルギー・産業技術総合開発機構(以下「NEDO」)の「日本語版医療特化型LLMの社会実装に向けた安全性検証・実証」における基盤モデルの開発プロジェクトの一環として行われます。

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?