본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research9 min read

TRL SFTTrainer: 내가 짠 학습 루프를 라이브러리 한 줄로 재현하고 packing 효과까지 재보기

지난 Day들에서 직접 구현한 SFT 학습 루프를 Hugging Face TRL의 SFTTrainer로 똑같이 재현해 본다. 같은 데이터와 설정으로 두 방식의 손실 곡선을 비교하고, packing을 켜고 끌 때 학습 효율이 어떻게 달라지는지 숫자로 확인한다. 라이브러리가 내부에서 무엇을 대신해 주는지 알면 설정이 이상하게 동작해도 원인을 찾을 수 있다.

By 송민성

오늘의 목표

지금까지 직접 만든 SFT 학습 루프를 TRL의 SFTTrainer로 같은 데이터·같은 하이퍼파라미터에서 다시 돌려 본다. 그리고 두 손실 곡선이 비슷하게 나오는지 확인하고, 다르다면 왜 다른지 설명할 수 있는 상태가 목표다.

개념과 직관

SFT(Supervised Fine-Tuning, 지도 미세조정)는 "질문-정답" 예시를 모델에게 보여 주고 정답 토큰을 더 잘 맞히도록 파라미터를 조금씩 고치는 학습이다. 내가 짠 루프는 대략 이런 일을 했다.

  1. 데이터에서 배치(batch, 한 번에 묶어 처리하는 예시 묶음)를 꺼낸다.
  2. 모델에 넣어 손실(loss, 예측이 정답과 얼마나 다른지 나타내는 숫자)을 구한다.
  3. 손실에서 기울기를 구해 파라미터를 업데이트한다.
  4. 1~3을 반복한다.

TRL(Transformer Reinforcement Learning)은 Hugging Face가 만든 라이브러리로, 이 반복을 SFTTrainer라는 클래스 하나에 담아 둔 것이다. 비유하면 내 루프는 "수동 변속 자동차", SFTTrainer는 "자동 변속 자동차"다. 엔진(모델, 손실, 옵티마이저)은 같고, 기어 바꾸는 일을 대신해 줄 뿐이다.

SFTConfig는 그 자동차의 계기판 설정이다. 학습률, 배치 크기, 에폭 수, 최대 시퀀스 길이, 정밀도 같은 값을 한곳에 모은다. 주요 항목은 다음과 같다.

  • learning_rate, num_train_epochs: 내 루프의 lr, epoch과 같다.
  • per_device_train_batch_size: GPU 하나가 한 번에 처리하는 예시 수.
  • gradient_accumulation_steps: 아래에서 설명한다.
  • bf16 / fp16: 혼합 정밀도 설정.
  • max_length: 한 시퀀스의 최대 토큰 수. 이 이름은 TRL 버전에 따라 max_seq_length였던 적이 있으니 설치한 버전 문서에서 확인한다.
  • packing: 아래에서 설명한다.

accelerate는 같은 코드를 CPU, GPU 한 장, 여러 장에서 바꿔 쓰게 해 주는 Hugging Face 라이브러리다. SFTTrainer는 내부에서 이를 이용한다. 오늘 알아둘 두 기능이 있다.

  • 혼합 정밀도(mixed precision, 일부 계산을 16비트 숫자로 해서 메모리와 시간을 아끼는 방식): 숫자를 32비트 대신 16비트로 다루면 메모리가 대략 절반으로 줄고 속도가 빨라진다. 대신 표현 범위나 정밀도가 줄어든다. bf16은 표현 범위가 넓어 안정적이고, fp16은 범위가 좁아 손실 스케일링 같은 보조 장치가 필요하다. bf16은 지원하는 GPU에서만 쓸 수 있다.
  • 기울기 누적(gradient accumulation, 작은 배치 여러 개의 기울기를 모았다가 한 번에 업데이트하는 것): GPU 메모리가 작아 배치 32를 못 올린다면 배치 8을 4번 돌려 기울기를 더한 뒤 한 번만 업데이트한다. 효과적 배치 크기는 이렇게 계산한다.
text
effective_batch = per_device_batch × accumulation_steps × GPU 수

비유하면 한 번에 장바구니 32개를 못 드니 8개씩 4번 옮겨 놓고 계산대에서 한꺼번에 결제하는 것이다.

packing은 짧은 예시 여러 개를 이어 붙여 max_length를 꽉 채운 하나의 시퀀스로 만드는 옵션이다. 배치는 길이를 맞춰야 해서 짧은 예시 뒤에 의미 없는 패딩(padding, 길이를 맞추려고 채우는 빈 토큰)을 붙인다. 이 패딩은 계산만 낭비한다.

text
packing off: [예시A ■■■■■■■□□□□□]   (□ = 패딩, 낭비)
             [예시B ■■□□□□□□□□□□]
packing on : [예시A ■■■■■■■|예시B ■■|예시C ■■■]  (빈칸 거의 없음)

이삿짐 상자에 물건 하나씩 넣으면 빈 공간이 많고, 빈틈 없이 채우면 상자 수가 줄어드는 것과 같다. 주의할 점은 이어 붙인 예시끼리 서로 영향을 줄 수 있다는 것이다. 이를 막는 구현 방식은 TRL 버전과 설정에 따라 다르므로 공식 문서에서 확인한다. 아직 Attention을 배우지 않았으니 지금은 "예시 경계가 섞일 수 있어 라이브러리가 이를 다루는 옵션이 있다" 정도만 알아두자.

최소 수식은 하나다. 손실은 정답 토큰마다의 교차 엔트로피(cross entropy, 정답에 준 확률이 낮을수록 커지는 손실)의 평균이다.

text
loss = 평균( -log P(정답 토큰) )

코드로 직접 해보기

먼저 내 루프를 한 번 더 확인한다. 핵심은 이 정도였다.

python
# 내 루프의 뼈대 (이전 Day에서 만든 것) for step, batch in enumerate(loader): out = model(**batch) # labels가 있으면 loss를 계산해 줌 (out.loss / accum_steps).backward() # 누적 때문에 나눠 줌 if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

이제 같은 설정을 SFTTrainer로 옮긴다. 작은 모델과 작은 데이터로 시작해야 CPU나 작은 GPU에서도 돌아간다. 모델과 데이터셋 이름은 예시이므로 내 프로젝트에서 쓰던 것으로 바꿔 넣는다.

python
# pip install trl transformers datasets accelerate from datasets import load_dataset from trl import SFTConfig, SFTTrainer # 1) 내 루프에서 쓰던 것과 동일한 데이터 (예시: 내가 만든 jsonl) dataset = load_dataset("json", data_files="my_sft_data.jsonl", split="train") # 2) 내 루프와 값을 일치시킨다 config = SFTConfig( output_dir="./sft_trl", learning_rate=2e-5, num_train_epochs=1, per_device_train_batch_size=4, gradient_accumulation_steps=4, # effective batch = 16 (GPU 1장) max_length=512, bf16=True, # GPU가 지원하지 않으면 False logging_steps=10, seed=42, packing=False, ) # 3) 모델 이름 문자열만 넘겨도 로드해 준다. 내 루프와 같은 모델을 쓴다. trainer = SFTTrainer( model="내가_쓰던_모델_이름", args=config, train_dataset=dataset, ) trainer.train()

손실 곡선은 trainer.state.log_history에 쌓인다. 내 루프의 로그와 같은 그래프에 겹쳐 그린다.

python
import matplotlib.pyplot as plt logs = [x for x in trainer.state.log_history if "loss" in x] plt.plot([x["step"] for x in logs], [x["loss"] for x in logs], label="TRL") # plt.plot(my_steps, my_losses, label="my loop") # 내 루프 기록 plt.xlabel("optimizer step"); plt.ylabel("loss"); plt.legend(); plt.show()

비교할 때 확인할 점은 다음과 같다.

  • 가로축을 맞춘다. 내 루프가 배치 단위로 기록했다면 TRL의 step(옵티마이저 업데이트 단위)과 눈금이 다르다.
  • 데이터 형식이 같은지 본다. SFTTrainer는 데이터셋 열 구성(text, messages 등)에 따라 채팅 템플릿을 자동 적용하기도 한다. 내 루프에서 만든 프롬프트 형식과 다르면 곡선이 달라진다.
  • 손실을 어느 토큰에서 계산하는지 본다. 내 루프는 정답 부분만 계산했는데 TRL 기본 설정은 프롬프트 토큰도 포함할 수 있다. 관련 옵션 이름은 버전마다 다르니 문서에서 확인한다.
  • 시드가 같아도 데이터 섞는 순서, 정밀도, 연산 순서 때문에 곡선이 완전히 같지는 않다. 전체 추세와 최종 손실 수준이 비슷하면 재현에 성공한 것이다.

오늘의 실험

질문: packing을 켜면 같은 데이터를 학습하는 데 시간이 얼마나 줄어드는가?

방법: 위 설정에서 packing=False와 packing=True만 바꿔 두 번 돌린다. 나머지(모델, 데이터, lr, 시드, max_length)는 고정한다. 한 번에 하나만 바꾸는 것이 원칙이다.

기록할 표:

항목packing offpacking on
총 optimizer step 수
총 학습 시간(초)
초당 처리 토큰 수(대략)
최종 손실
GPU 메모리 최댓값

관찰 포인트:

  • packing을 켜면 시퀀스가 빽빽해져서 step 수가 줄어든다. 같은 데이터인데 step 수가 다른 이유를 스스로 설명해 본다.
  • 손실 값은 같은 가로축(step)에서 직접 비교하면 안 된다. 한 step이 다루는 실제 토큰 양이 다르기 때문이다. 가로축을 "처리한 토큰 수"로 바꿔 보거나 학습 시간 기준으로 비교한다.
  • 데이터 안에 짧은 예시가 많을수록 packing 이득이 커질 것이라는 가설을 세우고, 예시 길이 분포(평균·최댓값)를 같이 적어 확인한다.
  • 결과가 예상과 다르면 지어내지 말고 그대로 적는다. 이유를 모르겠다면 "원인 미확인"이라고 남기는 것이 좋은 기록이다.

왜 이걸 배우나 (LLM / Post-training 연결)

실무의 SFT는 대부분 TRL 같은 라이브러리나 사내 프레임워크 위에서 돌아가고, 문제가 생기면 "라이브러리가 내부에서 무엇을 하는가"를 알아야 디버깅이 된다. 직접 구현해 본 경험이 있으면 손실 마스킹, 배치 크기 계산, packing으로 인한 데이터 오염 같은 미묘한 설정 차이를 의심할 수 있고, 이는 LLM Post-training 업무에서 학습 효율과 재현성을 챙기는 기본기가 된다. 이후 LoRA/QLoRA, 선호 학습 단계에서도 같은 Trainer 계열 구조와 accelerate 설정이 반복해서 나온다.

초보자가 막히는 지점

  • CUDA out of memory: per_device_train_batch_size를 줄이고 gradient_accumulation_steps를 늘려 효과적 배치를 유지한다. max_length를 줄이는 것도 효과가 크다.
  • `bf16` 에러: GPU가 bf16을 지원하지 않는 경우다. bf16=False로 바꾸거나 fp16=True를 시도한다. CPU에서는 둘 다 끄는 편이 안전하다.
  • 인자 이름 에러(`max_seq_length` 등): TRL은 버전에 따라 인자 이름이 바뀐다. pip show trl로 버전을 확인하고 해당 버전 문서나 help(SFTConfig)를 본다. 에러 메시지에 나온 이름을 그대로 검색하는 것도 빠르다.
  • 곡선이 내 루프와 너무 다름: 위의 네 가지(가로축, 데이터 형식과 템플릿, 손실 계산 토큰, 시드)를 순서대로 점검한다. 한 번에 하나씩 맞춰 간다.
  • 기울기 누적 로그 착각: 로그의 step은 보통 옵티마이저 업데이트 횟수다. 배치 처리 횟수와 혼동하지 않는다.
  • packing 후 성능이 이상하게 변함: 예시 경계 처리 방식이 데이터와 버전에 따라 다를 수 있다. 최종 품질은 손실뿐 아니라 샘플 출력을 직접 읽어 확인한다.

알고리즘 (병행 1시간)

LeetCode Medium 그래프 문제 2문제를 푼다. 문제 선택은 BFS/DFS 기본기를 다지기 좋은 것으로 하면 된다(예: 섬의 개수, 코스 스케줄처럼 연결 요소나 위상 정렬을 쓰는 유형). 시간은 문제당 30분 정도로 잡는다.

각 문제를 푼 뒤 아래를 기록한다.

  • 접근: 그래프를 어떻게 표현했는가(인접 리스트 등), BFS/DFS 중 무엇을 왜 골랐는가.
  • 시간복잡도·공간복잡도: 노드 수 V, 간선 수 E로 표현한다(예: O(V + E)).
  • 틀린 이유: 방문 체크 누락, 경계 조건, 재귀 깊이 문제 등 막혔던 지점을 한 줄로 남긴다. 막혔다면 30분 후에는 풀이를 보고, 다음 날 다시 풀어 본다.