22 KiB
FRANKENSTALLM 3B — Phase 3 ORPO 학습 여정
작성일: 2026-03-08 (최종 업데이트: 2026-03-09) 작성자: Claude Opus 4.6
1. ORPO 선택 배경
1.1 SFT의 한계
Phase 2 SFT v2 학습 완료 후 6차원 평가를 수행했다. 결과는 다음과 같다.
| 차원 | 지표 | 결과 | 목표 | 판정 |
|---|---|---|---|---|
| 1. 지식 보존 | PPL forgetting | 0.9% | <5% | PASS |
| 2. 생성 품질 | Greedy 반복률 | 72.97% | <5% | FAIL |
| 3. 종료 능력 | EOS 종료율 | 0% | >90% | FAIL |
| 4. 한국어 이해 | KoBEST | 43.26% | >55% | FAIL |
| 5. 형식 준수 | 포맷 정확도 | 95%+ | >90% | PASS |
| 6. 안전성 | 유해 출력률 | <1% | <5% | PASS |
핵심 문제 분석:
- 반복 생성 (72.97%): 모델이 동일한 토큰 시퀀스를 끊임없이 반복한다. SFT는 "좋은 응답"만 학습하기 때문에, "나쁜 응답(반복)"을 억제하는 음의 신호(negative signal)가 전혀 없다.
- EOS 미종료 (0%): 반복과 밀접하게 연결된 문제. 모델이 반복 루프에 빠지면 EOS 토큰을 생성할 기회를 잃는다.
- KoBEST 저조 (43.26%): 한국어 이해력 부족. 다만 이 문제는 preference optimization보다 데이터 품질과 양에 더 의존한다.
3개 항목 PASS(지식 보존, 형식, 안전성)는 SFT 기반이 건전하다는 의미이며, 특히 **PPL forgetting 0.9%**는 추가 학습의 기반이 안정적임을 확인해준다.
1.2 왜 ORPO인가? (DPO vs ORPO 비교)
SFT만으로는 "좋은 응답 vs 나쁜 응답"을 구분하는 preference signal을 줄 수 없다. Preference optimization 기법이 필요하다.
| 기준 | DPO | ORPO |
|---|---|---|
| Reference model | 필요 (메모리 2배) | 불필요 |
| 구현 복잡도 | 보통 | 낮음 |
| 메모리 효율 | 낮음 | 높음 |
| 학습 안정성 | 검증 많음 | 비교적 새로움 |
| 반복 억제 | 효과적 | 효과적 |
ORPO 선택 근거:
- 메모리 절약: Reference model이 불필요하여 3B 모델 기준 약 6GB VRAM 절약. 8-GPU DDP에서는 총 ~48GB 절약.
- 구현 간결성: TRL의
ORPOTrainer로 DPO 대비 설정이 단순하다. - SFT 기반 건전성: PPL forgetting 0.9%는 ORPO 추가 학습이 기존 지식을 크게 훼손하지 않을 것임을 시사한다.
위험 요소:
- Preference 데이터 중 명확한 반복 차이가 있는 쌍(chosen이 정상, rejected가 반복)은 전체의 3.3%에 불과하다. ORPO가 이 적은 비율의 신호만으로 반복을 억제할 수 있는지가 핵심 불확실성이다.
Plan B: ORPO가 실패할 경우 DPO(loss_type='sigmoid', reference model 사용)로 전환한다.
1.3 Preference 데이터 현황
- 원본: 683,181 preference pairs (기존 문서의 795K는 집계 오류)
- NaN 방지 필터 후: ~630,000 pairs
- prompt >
max_length - 16→ 제거 - response >
max_length→ 제거
- prompt >
- Eval split: 5% (seed=42) → ~31,000 eval pairs
- Effective batch size: 4 (per-device) x 8 GPU x 4 (gradient accumulation) = 128
2. 6-Config HP Sweep 설계
2.1 탐색 축 설계
3개의 독립적인 하이퍼파라미터 축을 선정했다.
축 1: Beta (반복 억제 강도)
Beta는 ORPO의 odds ratio loss 가중치를 조절한다. 값이 클수록 chosen/rejected 간 차이를 더 적극적으로 학습한다.
- 낮은 beta (0.15): 보수적. SFT 품질을 유지하면서 약하게 preference 학습.
- 중간 beta (0.25): 표준적인 출발점. 논문 기본값 부근.
- 높은 beta (0.35): 공격적. 반복 억제를 강하게 밀어붙이지만 과적합 위험.
축 2: Learning Rate (수렴 속도)
- 낮은 LR (5e-6): 안정적이지만 느린 수렴. 200 steps 내 효과 미미할 수 있음.
- 중간 LR (8e-6): 표준적. 200 steps에서 의미 있는 변화 기대.
- 높은 LR (1.2e-5): 빠른 수렴. 200 steps 짧은 sweep에서 효과를 빨리 확인 가능하지만 발산 위험.
축 3: Max Length (VRAM vs 커버리지)
- 1536 tokens: 대부분의 prompt+response를 커버. VRAM 더 사용.
- 1024 tokens: VRAM 절약. 긴 응답은 잘리지만 대다수는 1024 이내.
2.2 왜 6개인가?
3축의 full factorial 조합은 3 x 3 x 2 = 18개 (또는 각 3개씩이면 27개)로, 200 steps sweep이라 해도 비현실적이다.
중심축 고정 방식을 채택했다:
- 중심축:
beta=0.25,lr=8e-6,max_length=1536 - 각 축을 한 번씩 양방향으로 변형하되, 나머지 축은 중심값 고정
- 이 방식으로 6개 config만으로 3개 축의 영향을 독립적으로 측정 가능
이는 실험 설계의 "one-factor-at-a-time" (OFAT) 방식에 해당한다.
2.3 각 Config의 목적
| Run | Name | Beta | LR | Max Length | 설계 의도 |
|---|---|---|---|---|---|
| 1 | baseline_b015_lr8e6 |
0.15 | 8e-6 | 1536 | 약한 beta 베이스라인. SFT 품질 유지 우선 |
| 2 | baseline_b025_lr8e6 |
0.25 | 8e-6 | 1536 | 중심축. 모든 비교의 기준점 |
| 3 | strong_b035_lr8e6 |
0.35 | 8e-6 | 1536 | 강한 beta. 적극적 반복 억제, 과적합 감시 |
| 4 | fast_b025_lr12e6 |
0.25 | 1.2e-5 | 1536 | 높은 LR. 200 steps에서 빠른 수렴 확인 |
| 5 | conserv_b025_lr5e6 |
0.25 | 5e-6 | 1536 | 보수적 LR. 안정성 우선, 느린 변화 |
| 6 | short_b025_lr8e6 |
0.25 | 8e-6 | 1024 | 짧은 max_length. VRAM 절약 효과 측정 |
3. 시도 이력 — 5번의 실패, 1번의 성공
총 6번의 시도 끝에 학습이 정상 실행되었다. 각 실패에서 얻은 교훈은 대규모 분산 학습의 실전 지식이다.
3.1 시도 1: NCCL Timeout
- 시각: ~03:00
- 증상:
torch.distributed.DistBackendError: wait timeout after 1800000ms - 원인: Rank 0이 649K 샘플을 토크나이징하는 데
num_proc=8로 약 30분 소요. 그 동안 다른 7개 rank가 NCCL communicator setup에서 대기하다 기본 timeout(1800초 = 30분)을 초과했다. - 수정:
ddp_timeout=7200(30분 → 2시간으로 확대)dataset_num_proc=64(8 → 64 프로세스로 토크나이징 병렬화)
- 교훈: 대규모 데이터 + DDP 환경에서는 데이터 전처리 시간이 NCCL timeout에 직접 영향을 준다. 데이터가 클수록 timeout을 넉넉하게 설정해야 한다.
3.2 시도 2: Config 충돌
- 시각: 03:28
- 증상:
ValueError: save_steps(9999) is not a round multiple of eval_steps(100) - 원인:
load_best_model_at_end=True일 때 HuggingFace Trainer는save_steps가eval_steps의 정수배여야 한다는 validation을 수행한다. 최선 모델을 eval 시점에 저장해야 하므로 두 주기가 동기화되어야 하기 때문이다. - 수정:
--no_load_best --save_steps 200 - 교훈: TRL/HuggingFace Trainer의 config validation은 예상보다 엄격하다. 특히
load_best_model_at_end와 관련된 설정 간 정합성을 사전에 확인해야 한다.
3.3 시도 3: 포트 충돌 + QKV 변환 버그
- 시각: 03:45
증상 (2가지 동시 발생):
- Run 1:
port 29510 EADDRINUSE— 이전 실행의 좀비 프로세스가 소켓 점유 - Run 2~6:
q_proj/k_proj/v_proj MISSING— QKV weight가 랜덤 초기화됨. GPU 0만 100% utilization, 나머지 7개 GPU는 0%.
원인 (포트): 이전 실행이 비정상 종료되면서 torchrun 프로세스가 좀비로 남아 29510 포트를 점유.
원인 (QKV): convert_to_hf.py의 remap_weights() 함수가 TransformerEngine의 fused QKV 프로젝션을 처리하지 못했다.
TransformerEngine은 Q, K, V를 하나의 가중치로 합친다:
attn.qkv_proj.weightshape:[5120, 3072]- Q: 3072 (24 heads x 128 dim), K: 1024 (8 kv_heads x 128 dim), V: 1024 (8 kv_heads x 128 dim)
- 합계: 3072 + 1024 + 1024 = 5120
HuggingFace 형식은 q_proj, k_proj, v_proj를 분리하여 저장한다.
수정: QKV split 로직을 convert_to_hf.py에 추가:
q_dim = num_heads * head_dim # 24 * 128 = 3072
k_dim = num_kv_heads * head_dim # 8 * 128 = 1024
v_dim = num_kv_heads * head_dim # 8 * 128 = 1024
qkv = state_dict[fused_key]
state_dict[f"{prefix}.q_proj.weight"] = qkv[:q_dim]
state_dict[f"{prefix}.k_proj.weight"] = qkv[q_dim:q_dim + k_dim]
state_dict[f"{prefix}.v_proj.weight"] = qkv[q_dim + k_dim:]
del state_dict[fused_key]
체크포인트 키 수: 171 → 255 (fused → split 변환으로 키 증가).
- 교훈: TransformerEngine FP8 → HuggingFace 변환 시 fused projection(QKV, gate-up 등) 처리가 필수이다. 변환 후 반드시 키 매핑을 검증해야 한다.
3.4 시도 4: TRL NaN 버그
이 시도는 시도 3 이전에 발생한 것으로, TRL 라이브러리 내부의 심각한 버그를 발견하고 패치한 과정이다.
증상: 8-GPU DDP + 풀 데이터(683K) 학습 시 step 10~20에서 loss=0, grad_norm=NaN 발생. 단일 GPU나 소규모 데이터에서는 재현되지 않았다.
근본 원인: TRL의 tokenize_row 함수에서 chosen과 rejected response를 동일한 길이 기준으로 잘랐다.
# TRL 원본 코드 (버그)
longer_response_length = max(chosen_len, rejected_len)
# 이후 양쪽 모두 longer_response_length 기준으로 truncation
문제 시나리오: longer_response_length > max_length이면, shorter response가 음수 인덱스로 잘려서 0 tokens가 된다 (Python의 list[:-음수] = []).
NaN 전파 체인:
0 response tokens
→ labels 전부 -100 (학습 대상 없음)
→ get_batch_logps = 0.0 (log probability 합이 0)
→ log1mexp(0.0) = log(1 - exp(0)) = log(1 - 1) = log(0) = -inf
→ log_odds = -inf - (-inf) = NaN
→ gradient NaN → 전체 weight 오염
3중 패치 (TRL 라이브러리 파일 직접 수정):
Patch 1: get_batch_logps — division by zero 방지
# 파일: trl/trainer/utils.py
if average_log_prob:
denom = loss_mask.sum(-1).clamp(min=1)
return (per_token_logps * loss_mask).sum(-1) / denom
Patch 2: odds_ratio_loss — logps clamp
# 파일: trl/trainer/orpo_trainer.py (또는 experimental)
policy_chosen_logps = policy_chosen_logps.float().clamp(max=-1e-4)
policy_rejected_logps = policy_rejected_logps.float().clamp(max=-1e-4)
logps가 0에 가까우면 log1mexp에서 -inf가 발생하므로, 최대값을 -1e-4로 제한한다.
Patch 3: tokenize_row — 독립 truncation (근본 원인 수정)
# 파일: trl/trainer/orpo_trainer.py (또는 experimental)
for answer_tokens in [chosen_tokens, rejected_tokens]:
prompt_len = len(answer_tokens["prompt_input_ids"])
response_len = len(answer_tokens["input_ids"])
if prompt_len + response_len > self.max_length:
max_response = max(self.max_length - prompt_len, 1)
for k in ["input_ids", "attention_mask"]:
answer_tokens[k] = answer_tokens[k][:max_response]
각 response를 독립적으로 truncation하여, 하나가 길다고 해서 다른 하나가 0으로 잘리는 상황을 방지한다.
추가로 train/orpo.py에 데이터 필터를 추가했다:
- prompt >
max_length - 16→ 제거 (response 공간이 16 tokens 미만이면 무의미) - response >
max_length→ 제거
683,181 → ~630,000 pairs로 축소.
- 교훈: 분산 학습에서만 재현되는 NaN 버그는 데이터 분포의 꼬리(극단적으로 긴 prompt + 짧은 response 조합)에서 발생한다. 단일 GPU 테스트로는 발견하기 어렵다. TRL 같은 성숙한 라이브러리도 edge case에서 수치적 불안정성을 가질 수 있다.
3.5 시도 5: TRL Tokenizer 호환 문제
- 증상: 한국어 tokenizer의 merge operations으로 인해 prompt token 길이 불일치 발생 →
zip(strict=True)ValueError - 수정: TRL 소스 파일 8건에 패치 적용 (
.029bak백업 생성):zip(..., strict=True)→zip(...)(strict 제거)build_tokenized_answer의 length mismatch → graceful fallback
- 교훈: 영어 중심으로 테스트된 TRL이 한국어 tokenizer(BPE merge 특성이 다름)와 호환성 문제를 가질 수 있다.
3.6 시도 6: 성공!
- 시각: 04:20
사전 정리 작업:
# 좀비 프로세스 정리
pkill -9 -f torchrun
pkill -9 -f orpo
# 포트 해제 확인
ss -tlnp | grep 29510
# sweep 디렉터리 초기화
rm -rf checkpoints/orpo_sweep/*
실행: 6-config sweep이 순차적으로 정상 진행 시작.
4. 스윕 결과
4.1 결과 테이블
각 config는 200 steps만 실행하여 경향을 파악하는 "탐색" 수준의 sweep이다.
| Run | Name | Beta | LR | Train Loss | Eval Loss | Margin | Time(s) | Status |
|---|---|---|---|---|---|---|---|---|
| 1 | baseline_b015_lr8e6 |
0.15 | 8e-6 | 1.811 | 1.827 | 0.004 | 2,344 | 완료 |
| 2 | baseline_b025_lr8e6 |
0.25 | 8e-6 | 1.890 | 1.906 | 0.009 | 2,360 | 완료 |
| 3 | strong_b035_lr8e6 |
0.35 | 8e-6 | 2.055 | 1.985 | 0.007 | 2,390 | 완료 |
| 4 | fast_b025_lr12e6 |
0.25 | 1.2e-5 | 1.917 | 1.862 | 0.009 | 2,416 | 완료 |
| 5 | conserv_b025_lr5e6 |
0.25 | 5e-6 | - | - | - | - | 진행중 |
| 6 | short_b025_lr8e6 |
0.25 | 8e-6 | - | - | - | - | 대기중 |
4.2 분석
Beta 축 분석 (Run 1 vs 2 vs 3, LR=8e-6 고정):
- Beta가 높아질수록 train loss가 일관되게 증가한다: 1.811 → 1.890 → 2.055
- 이는 예상된 결과다. Beta가 높으면 odds ratio loss의 가중치가 커지므로 전체 loss가 상승한다.
- Eval loss도 상승하지만 기울기가 완만하다: 1.827 → 1.906 → 1.985. Run 3(beta=0.35)에서 train-eval gap이 가장 작아(0.070) 과적합 징후는 없다.
- Margin: Run 1(0.004)이 가장 낮고, Run 2(0.009)와 Run 3(0.007)은 비슷하다. Beta를 높인다고 반드시 margin이 비례 증가하지는 않는다.
LR 축 분석 (Run 2 vs 4 vs 5, Beta=0.25 고정):
- Run 4(
lr=1.2e-5): eval_loss 1.862로 최저값. 높은 LR이 200 steps라는 짧은 구간에서 더 빠르게 유용한 방향으로 수렴했음을 시사. - Run 2(
lr=8e-6): eval_loss 1.906. 기준선. - Run 5(
lr=5e-6): 아직 진행 중. 낮은 LR이 200 steps에서 충분한 변화를 보일지 관건.
잠정 결론: 200-step sweep 기준으로 lr=1.2e-5, beta=0.25 조합(Run 4)이 가장 유망하다. 다만 full training에서는 높은 LR이 후반부 불안정성을 가져올 수 있으므로, cosine scheduler와 함께 warmup ratio를 조정해야 한다.
5. 기술 세부사항
5.1 TRL 0.29.0 API
TRL 0.29.0에서 ORPO 관련 클래스의 위치가 변경되었다.
# 기존 (0.28 이하)
from trl import ORPOConfig, ORPOTrainer # 제거됨
# 0.29.0
from trl.experimental.orpo import ORPOConfig, ORPOTrainer
또한 max_prompt_length 파라미터가 제거되었다. Prompt 길이 제한이 필요하면 데이터 전처리 단계에서 직접 필터링해야 한다.
5.2 DDP 최적화
Rank별 데이터 처리 분리:
if local_rank == 0:
# Rank 0만 num_proc=64로 토크나이징
dataset = dataset.map(tokenize_fn, num_proc=64)
# 결과가 캐시되므로 다른 rank는 캐시 히트
else:
# Rank 1~7은 num_proc=1로 호출 (캐시에서 로드)
dataset = dataset.map(tokenize_fn, num_proc=1)
이 방식으로 72코어를 rank 0에 집중 투입하여 토크나이징 시간을 단축하고, 나머지 rank는 캐시를 재사용한다.
DDP 설정 주의사항:
ddp_find_unused_parameters=False: TransformerEngine과 함께 사용 시 필수.True로 설정하면 TE의 FP8 버퍼에서 오류 발생.static_graph=True사용 금지: TE와 호환되지 않는다.
5.3 하드웨어 설정
# NCCL 설정
NCCL_IB_DISABLE=1 # InfiniBand 비활성화 (단일 노드)
NCCL_BUFFSIZE=134217728 # 128MB 버퍼
NCCL_P2P_LEVEL=NVL # NVLink P2P 통신
# CPU 스레드
OMP_NUM_THREADS=9 # 72 cores / 8 GPUs
MKL_NUM_THREADS=9
# 학습 설정
dataset_num_proc=64
bf16=true # B200 BF16 네이티브
dataloader_pin_memory=true
ddp_timeout=7200 # NCCL timeout 2시간
# 모델 설정
flash_attention_2 # FlashAttention-2 활성화
gradient_checkpointing=true # VRAM 절약
5.4 안전장치
SIGHUP 3중 방어:
터미널 세션 종료 시 학습이 중단되는 것을 방지하기 위한 3중 보호 체계:
- nohup + setsid: 프로세스를 세션 리더에서 분리
- Python signal handler: SIGHUP 수신 시 무시하고 학습 계속
- Emergency checkpoint: 비정상 종료 감지 시 현재 상태를 즉시 저장
텔레그램 알림: 각 run 완료/실패 시 자동 알림 전송 (Python urllib, curl 차단 환경 대응).
Early stopping: eval_loss 기반. patience 설정으로 과적합 시 자동 중단.
6. 스윕 최종 결과 + Best Config 선정
6.1 전체 스윕 완료 결과
6개 config 모두 200 steps 완료.
| Run | Name | Beta | LR | MaxLen | Train Loss | Eval Loss | Margin | Time(s) |
|---|---|---|---|---|---|---|---|---|
| 1 | baseline_b015_lr8e6 |
0.15 | 8e-6 | 1536 | 1.811 | 1.827 | 0.004 | 2,344 |
| 2 | baseline_b025_lr8e6 |
0.25 | 8e-6 | 1536 | 1.890 | 1.906 | 0.009 | 2,360 |
| 3 | strong_b035_lr8e6 |
0.35 | 8e-6 | 1536 | 2.055 | 1.985 | 0.007 | 2,390 |
| 4 | fast_b025_lr12e6 |
0.25 | 1.2e-5 | 1536 | 1.917 | 1.862 | 0.009 | 2,416 |
| 5 | conserv_b025_lr5e6 |
0.25 | 5e-6 | 1536 | 1.833 | 1.910 | 0.004 | 2,350 |
| 6 | short_b025_lr8e6 |
0.25 | 8e-6 | 1024 | 1.664 | 1.695 | 0.007 | 1,840 |
6.2 Best Config 선정: Run 4 (lr=1.2e-5, beta=0.25)
선정 근거:
- Eval loss 최저 (1.862): maxlen=1536 그룹 내 eval loss 기준 1위.
- 높은 margin (0.009): chosen/rejected 구분 능력이 강함. Run 2와 동률.
- 빠른 수렴: 200 steps 만에 다른 config 대비 가장 큰 개선폭을 보여, 긴 학습에서도 유리할 것으로 판단.
참고: Run 6(short_1024)의 eval_loss가 1.695로 절대값은 가장 낮지만, 이는 max_length=1024로 짧은 시퀀스를 다루기 때문이며 1536 시퀀스와 직접 비교할 수 없다.
6.3 Throughput 벤치마크
본 학습에 앞서 4가지 batch/grad_accum 조합의 throughput을 벤치마크하여 최적 설정을 결정했다.
| Config | batch_size | grad_accum | max_length | eff_batch | Throughput (samples/s) |
|---|---|---|---|---|---|
| 1 | 4 | 4 | 1536 | 128 | 80.63 |
| 2 | 2 | 8 | 1536 | 128 | 73.14 |
| 3 | 8 | 2 | 1536 | 128 | OOM |
| 4 | 4 | 4 | 1024 | 128 | 91.25 |
Config 1 (bs=4, accum=4, maxlen=1536) 선정. 동일 effective batch size에서 ~10% 높은 throughput.
CPU 스레드도 NUMA-aware로 최적화: OMP_NUM_THREADS=9, MKL_NUM_THREADS=9 (72코어 ÷ 8 GPU = 9코어/GPU).
7. Full Training 시작 (2026-03-09)
7.1 학습 설정
| 파라미터 | 값 | 비고 |
|---|---|---|
| Beta | 0.25 | Sweep Run 4에서 선정 |
| Learning rate | 1.2e-5 | Sweep eval_loss 최저 |
| Batch size (per-device) | 4 | Throughput 벤치마크 최적 |
| Gradient accumulation | 4 | |
| Effective batch | 128 | 4 × 4 × 8 GPU |
| Max length | 1536 | |
| Epochs | 2 | |
| Warmup ratio | 0.05 | |
| Weight decay | 0.01 | |
| Eval steps | 500 | |
| Early stopping patience | 3 | |
| GPU VRAM 사용 | ~52GB / 183GB (28%) | |
| 예상 총 steps | 9,840 | |
| 예상 학습 시간 | ~4.8시간 | ~1.75 s/step |
7.2 SIGHUP 3중 방어 실행
nohup setsid bash scripts/launch_3b_orpo.sh \
> checkpoints/korean_3b_orpo_v1/train.log 2>&1 &
- nohup + setsid: 프로세스를 세션에서 완전 분리
- Python SIGHUP handler: orpo.py 내 signal.signal(SIGHUP, handler) — 무시 처리
- Emergency checkpoint: 비정상 종료 감지 시 즉시 체크포인트 저장
7.3 학습 지표 추이
| 지표 | step ~250 | step ~1,160 | 변화 |
|---|---|---|---|
| loss | 1.952 | 1.709 | -0.243 |
| nll_loss | 1.757 | 1.593 | -0.164 |
| rewards/margins | 0.002 | 0.330 | +0.328 |
| rewards/accuracies | 0.473 | 0.719 | +0.246 |
| log_odds_chosen | -0.021 | 1.468 | +1.489 |
step 1163/9840 (12%), epoch 0.24, 경과 40분, 남은 ~4.4시간
속도: ~1.82 s/step
GPU VRAM: ~52GB/183GB, utilization 91~98%
관찰: rewards/accuracies가 0.47(랜덤 수준) → 0.72로 빠르게 상승. 모델이 chosen/rejected를 구분하는 능력이 강화되고 있다. margins도 0.002 → 0.330으로 급상승하여 ORPO가 preference signal을 효과적으로 학습 중임을 시사한다.
8. 다음 단계 + 교훈 요약
8.1 다음 단계
- Full training 완료 대기: 9,840 steps, 예상 ~4.8시간
- 6차원 재평가: 특히 반복률(72.97% → 목표 <5%)과 EOS 종료율 개선 확인
- GGUF 변환 + Ollama 배포: 평가 통과 시 Phase 4 진행
- Plan B 준비: ORPO 효과 미미 시 DPO 전환
6.2 교훈 요약
| # | 교훈 | 카테고리 |
|---|---|---|
| 1 | 대규모 데이터 + DDP에서 토크나이징 시간을 NCCL timeout에 반영해야 한다 | 분산 학습 |
| 2 | TRL/HF Trainer의 config validation은 예상보다 엄격하다. 특히 load_best_model_at_end 관련 |
프레임워크 |
| 3 | TransformerEngine FP8 → HF 변환 시 fused projection 처리 필수 | 모델 변환 |
| 4 | TRL의 tokenize_row는 극단적 길이 조합에서 NaN을 생성한다. 분산 학습에서만 재현 |
버그 |
| 5 | NaN은 0 response → -100 labels → logps=0 → log(0)=-inf → NaN 체인으로 전파된다 | 수치 안정성 |
| 6 | 영어 중심 TRL이 한국어 tokenizer BPE와 호환성 문제를 가진다 | 다국어 |
| 7 | 좀비 프로세스의 포트 점유를 사전에 확인해야 한다 | 인프라 |
| 8 | 단일 GPU 테스트로는 분산 환경의 edge case를 발견하기 어렵다 | 테스트 |
| 9 | HP sweep은 full factorial 대신 중심축 고정 OFAT로 효율적 탐색 가능 | 실험 설계 |
| 10 | SFT의 "좋은 응답만 학습" 한계는 preference optimization으로만 해결 가능 | 학습 전략 |
이 보고서는 FRANKENSTALLM 3B Phase 3 ORPO 학습의 전 과정을 기록한다. 5번의 실패에서 얻은 교훈은 대규모 언어 모델 학습의 실전 지식으로, 향후 유사 프로젝트의 참고 자료가 될 것이다. 현재 본 학습이 진행 중이다 (2026-03-09).