12 KiB
SFT 학습 파이프라인 근본 재설계 보고서
작성일: 2026-02-26
대상 프로젝트: /PROJECT/0325120031_A/ghong/taketimes/llm-bang/
현재 상태: 반복 퇴화 57%, EOS 생성 불안정
1. 현재 구현의 근본적 문제점
🔴 Critical #1: Dynamic Padding이 작동하지 않음 (가장 큰 성능 낭비)
파일: data/sft_dataset.py L139-146, train/sft.py L198-230
SFTDataset.__init__에서 모든 샘플을 max_seq_len(4096)으로 미리 패딩한다:
# sft_dataset.py L139-141
input_ids = torch.full(
(max_seq_len,), fill_value=pad_token_id, dtype=torch.long
)
그런데 dynamic_collate_fn은 배치 내 최대 길이에 맞춰 패딩하도록 설계됐지만, 이미 모든 텐서가 max_seq_len 길이이므로 raw_max = max_seq_len 항상 고정. Dynamic padding이 사실상 무효화된 상태.
영향: 평균 시퀀스 길이가 ~500 토큰이라면, 매 스텝마다 ~3600개의 패딩 토큰을 불필요하게 처리. 학습 속도 ~3-8x 저하, GPU FLOPs 낭비.
수정: __getitem__에서 패딩하지 말고, 실제 길이의 텐서를 반환. dynamic_collate_fn이 배치별로 패딩하도록.
🔴 Critical #2: 트렁케이션 시 EOS 토큰 손실
파일: data/sft_dataset.py L130-134
# truncation이 발생하면
response_ids = response_ids[:allowed_response]
response_ids는 "{output}</s>"를 인코딩한 것인데, 잘리면 마지막의 </s> 토큰이 제거된다. 해당 샘플은 EOS를 학습할 수 없다.
영향: truncated_count개의 샘플이 EOS 없이 학습됨. 긴 응답 → 잘림 → EOS 미학습 → 생성 시 끝없이 반복 생성의 직접적 원인.
수정:
response_ids = response_ids[:allowed_response - 1] + [self.eos_token_id]
🟡 Important #3: Label Shift 로직 미묘한 버그 가능성
파일: data/sft_dataset.py L148-157, train/trainer.py L263-274
현재 구현:
labels[resp_start-1 : resp_start-1+len(response_ids)] = response_ids_compute_loss에서 별도의 shift 없이cross_entropy(logits[i], labels[i])수행
이 패턴은 "logits[i]가 position i+1을 예측한다"는 가정 하에 올바르지만, 마지막 response 토큰 이후 position에 대한 예측이 학습되지 않는다. 즉, EOS를 출력한 후 다음 토큰을 패딩(또는 아무것도 아닌 것)으로 예측하도록 학습하지 않아서, EOS 이후에도 계속 토큰을 생성하는 경향이 있을 수 있다.
권장: response_ids의 마지막 토큰(EOS) 다음 position의 label도 EOS로 설정하여 "EOS 이후에도 EOS"를 학습시킴.
🟡 Important #4: Validation Split 없음
파일: scripts/launch_sft.sh — --val_data 인자 미전달
Trainer에 validation 루프가 구현돼 있고 (trainer.py L200-220), best checkpoint 저장도 되지만, 실제로 val_data를 전달하지 않아 과적합 모니터링이 불가능.
🟡 Important #5: 학습 에포크 부족
- Effective batch = 4 × 2 × 8 = 64 seqs/step
- 5000 steps × 64 = 320,000 sample-steps
- 159k 샘플 → ~2 epochs
SFT는 보통 3-5 epochs가 적절. 2 epochs는 데이터를 충분히 학습하지 못함.
🟢 Minor #6: Warmup 비율
150/5000 = 3%. SFT에서는 적절한 범위 (3-10%).
🟢 Minor #7: NEFTune 사용 중
noise_alpha=10.0으로 NEFTune 적용 중. 이건 좋은 설정. 다만 반복 퇴화가 심한 경우 효과가 제한적.
2. 업계 최고 수준 SFT 구현과의 비교
LLaMA-Factory
- completion_only_loss: prompt 토큰 masking + response만 학습 (현재 구현과 동일)
- EOS 보장: 트렁케이션 시 반드시 EOS 토큰 유지
- Packing: 여러 짧은 샘플을 하나의 시퀀스에 패킹하여 GPU 활용률 극대화
- 데이터 필터링: 너무 짧거나 품질 낮은 샘플 자동 제거
TRL SFTTrainer
packing=True: 여러 샘플을 하나의 시퀀스에 패킹 (패딩 낭비 제거)DataCollatorForCompletionOnlyLM: response 토큰만 학습하는 collator- max_seq_length에 맞춰 동적 패딩 (배치별)
Axolotl
- Sample packing: 긴 시퀀스 내에 여러 대화를 패킹
- Sequence length warmup: 짧은 시퀀스부터 시작해서 점진적으로 길이 증가
- Repetition penalty during training: 학습 중 반복 패널티 적용 옵션
OpenInstruct (Allen AI)
- 데이터 품질 우선: 데이터 필터링 파이프라인 강조
- 길이 기반 정규화: 긴 응답에 대한 loss normalization
- 다단계 학습: 일반 SFT → 도메인 SFT → DPO/RLHF
반복 퇴화 방지를 위한 업계 공통 패턴
- EOS 토큰 확실한 학습: 모든 샘플에서 EOS가 label에 포함되도록 보장
- Repetition penalty loss: 동일 n-gram 반복 시 추가 페널티
- Length normalization: 긴 응답의 loss가 과도하게 커지지 않도록 정규화
- 데이터 품질 필터링: 반복이 있는 학습 데이터 자체를 제거
- KL divergence regularization: base model과의 KL divergence 제약
3. Curriculum Learning 방법론
IFD (Instruction Following Difficulty) Score
# 각 샘플에 대해:
# 1. base model로 response의 perplexity 계산
# 2. instruction 없이 response만의 perplexity 계산
# 3. IFD = conditional_ppl / unconditional_ppl
# IFD가 낮을수록 "쉬운" 샘플
def compute_ifd(model, tokenizer, instruction, response):
cond_ppl = compute_perplexity(model, instruction + response)
uncond_ppl = compute_perplexity(model, response)
return cond_ppl / uncond_ppl
KL-Divergence 기반 데이터 선택
- Base model 대비 응답 분포가 크게 다른 샘플 = 어려운 샘플
- 처음에는 KL이 작은 (쉬운) 샘플, 나중에 큰 샘플
실용적 접근: 길이 기반 Curriculum
- Phase 1 (epoch 1): 응답 길이 < 256 토큰 샘플만
- Phase 2 (epoch 2): 응답 길이 < 1024 토큰
- Phase 3 (epoch 3+): 전체 데이터
4. 즉시 적용 가능한 수정 Top 5
Fix #1: Dynamic Padding 실제 작동하도록 수정 (성능 3-8x 개선)
# data/sft_dataset.py — __getitem__ 수정
# 기존: 고정 max_seq_len 패딩된 텐서 반환
# 수정: 실제 길이의 텐서만 반환
class SFTDataset(Dataset):
def __init__(self, ...):
# ...기존 코드...
# samples 저장 시 패딩 제거
self.samples: list[tuple[torch.Tensor, torch.Tensor]] = []
for prompt_text, response_text in raw_samples:
# ...기존 인코딩 코드...
seq_len = len(full_ids)
# 패딩 없이 실제 길이만 저장
input_ids = torch.tensor(full_ids, dtype=torch.long)
labels = torch.full((seq_len,), fill_value=-1, dtype=torch.long)
resp_start = len(prompt_ids)
resp_label_start = max(0, resp_start - 1)
resp_label_end = resp_label_start + len(response_ids)
labels[resp_label_start:resp_label_end] = torch.tensor(
response_ids, dtype=torch.long
)
self.samples.append((input_ids, labels))
Fix #2: 트렁케이션 시 EOS 보존
# data/sft_dataset.py L130-134 수정
# 기존:
# response_ids = response_ids[:allowed_response]
# 수정:
if len(full_ids) > max_seq_len:
truncated_count += 1
allowed_response = max_seq_len - len(prompt_ids)
if allowed_response <= 1:
skipped_too_long += 1
continue
# EOS를 반드시 마지막에 유지
response_ids = response_ids[:allowed_response - 1] + [self.eos_token_id]
full_ids = prompt_ids + response_ids
Fix #3: Validation Split 추가
# 데이터 분리 스크립트
python -c "
import json, random
random.seed(42)
with open('data/sft/train.jsonl') as f:
lines = f.readlines()
random.shuffle(lines)
split = int(len(lines) * 0.9)
with open('data/sft/train_split.jsonl', 'w') as f:
f.writelines(lines[:split])
with open('data/sft/val_split.jsonl', 'w') as f:
f.writelines(lines[split:])
print(f'Train: {split}, Val: {len(lines)-split}')
"
# launch_sft.sh에 추가:
# --val_data data/sft/val_split.jsonl
Fix #4: Max Steps 증가 (3-5 epochs)
# launch_sft.sh 수정
# 159k samples / 64 effective_batch ≈ 2484 steps/epoch
# 4 epochs → ~10,000 steps
MAX_STEPS=10000
WARMUP_STEPS=300 # 3% of 10000
Fix #5: 학습 데이터에서 반복 패턴 필터링
# data/filter_repetitive.py
import json, re
def has_repetition(text, n=3, threshold=0.3):
"""n-gram 반복 비율이 threshold 이상이면 True"""
words = text.split()
if len(words) < n * 2:
return False
ngrams = [tuple(words[i:i+n]) for i in range(len(words) - n + 1)]
unique_ratio = len(set(ngrams)) / len(ngrams)
return unique_ratio < (1 - threshold)
filtered = 0
with open('data/sft/train.jsonl') as fin, \
open('data/sft/train_clean.jsonl', 'w') as fout:
for line in fin:
obj = json.loads(line)
text = obj.get('output', '') or ''
if 'conversations' in obj:
text = ' '.join(t['content'] for t in obj['conversations']
if t['role'] == 'assistant')
if not has_repetition(text):
fout.write(line)
else:
filtered += 1
print(f"Filtered {filtered} repetitive samples")
5. 근본적 재학습 시나리오
Phase 1: 데이터 준비 (1-2시간)
- 반복 패턴이 있는 데이터 필터링
- Train/Val split (90/10)
- 데이터 통계 확인 (길이 분포, 카테고리 분포)
Phase 2: 코드 수정 (1-2시간)
- ✅
sft_dataset.py: 패딩 제거, EOS 보존, 가변 길이 반환 - ✅
sft.py의dynamic_collate_fn: 이미 구현됨 (dataset 수정만 하면 작동) - ✅
launch_sft.sh: val_data 추가, max_steps 10000
Phase 3: 학습 (8-12시간, 8×B200 기준)
# 수정된 launch_sft.sh
MAX_STEPS=10000
BATCH_SIZE=4
GRAD_ACCUM=2
LR="2.0e-5"
WARMUP_STEPS=300
torchrun --nproc_per_node=8 train/sft.py \
--base_checkpoint checkpoints/korean_1b_fp8_run1/checkpoint-0034000 \
--sft_data data/sft/train_clean.jsonl \
--val_data data/sft/val_split.jsonl \
--max_steps 10000 \
--batch_size 4 \
--grad_accum 2 \
--lr 2.0e-5 \
--warmup_steps 300 \
--use_fp8
Phase 4: 평가 (1시간)
- Val loss 모니터링 (TensorBoard)
- 반복 퇴화율 측정 (기존 eval 스크립트)
- Best checkpoint 선택 (val_loss 기준)
예상 소요시간
- Dynamic padding 수정 후 학습 속도: 3-5x 향상 (4096→~600 avg 기준)
- 10000 steps: 기존 대비 비슷하거나 더 빠를 수 있음
- 총 12-16시간 (데이터 준비 ~ 평가 완료)
6. 예상 개선 효과
| 수정 항목 | 현재 | 예상 개선 | 근거 |
|---|---|---|---|
| Dynamic padding | 4096 고정 | 학습속도 3-8x↑ | 평균 ~600 토큰 시 패딩 85% 감소 |
| EOS 보존 | 트렁케이션 시 손실 | 반복퇴화 57%→~20% | EOS 학습 누락이 반복의 직접 원인 |
| 데이터 필터링 | 반복 데이터 포함 | 반복퇴화 → ~10% | 반복 학습데이터 제거 |
| Val split | 없음 | 과적합 조기 감지 | best checkpoint 선택 가능 |
| Epoch 증가 | ~2 epochs | 수렴 개선 | 3-5 epochs이 SFT 표준 |
| 종합 | 반복 57% | 반복 <10% | 위 수정 모두 적용 시 |
7. 권장 아키텍처 (장기)
현재 직접 구현한 SFT 파이프라인을 TRL SFTTrainer 또는 LLaMA-Factory로 교체 권장:
- Packing 지원: 패딩 낭비 완전 제거
- 검증된 EOS 처리: 수백 개 프로젝트에서 검증
- DPO/RLHF 파이프라인 연계: SFT 후 alignment 학습으로 자연스럽게 이행
- Sample packing + Flash Attention: 최적화된 메모리/속도
단, 현재 커스텀 모델(LLM 클래스)과의 호환성 확인 필요. HuggingFace 포맷으로 변환 후 사용하면 바로 적용 가능.
요약: 즉시 해야 할 일 (우선순위)
- 🔴 sft_dataset.py: 패딩 제거 + EOS 보존 (30분)
- 🔴 데이터 필터링: 반복 패턴 있는 샘플 제거 (30분)
- 🟡 Val split 생성 + launch_sft.sh 수정 (15분)
- 🟡 Max steps 10000으로 증가 (설정만 변경)
- 🟢 재학습 시작 (수정 완료 후)