개인 실험 리포트의 엔지니어링 노트에 한 줄로 적어 둔 문장이 있습니다. "숫자가 나온다 ≠ 맞게 재고 있다." 실험을 돌리면서 만난 함정 두 개가 그 문장의 근거입니다. 둘 다 코드는 끝까지 돌았고, 둘 다 제가 준비해 둔 검증 표면 바깥에서 났습니다.
실험 자체는 단순합니다. 110M 파라미터 GPT를 스크래치부터 사전학습하면서 데이터·토크나이저· 하이퍼파라미터를 고정하고 데이터를 보여주는 순서만 바꿔 비교했습니다. 조건 4개를 cosine LR로 한 번, LR 스케줄 교락 가설을 확인하려고 고정 LR로 한 번 더 돌려 총 8런, 런당 RTX 4090 한 장으로 약 7.8시간입니다. 조건 간 최종 val loss 격차는 0.08–0.18 수준인데 시드는 1개라 그 격차의 분산 추정도 없습니다. 재는 쪽이 조금이라도 틀어지면 결론이 통째로 흔들리는 구조입니다.
함정 1 — 앵커 모델이 전 지표에서 찍기 수준으로 나왔다
우리 모델이 어느 위치에 있는지 보려고 외부 앵커 세 개를 같은 평가 코드에 태웠습니다. KoGPT2(동급 한국어 LM), Gemma 3 270M PT, EXAONE 4.0 1.2B. 앵커들은 학습 데이터량이 수십–수천 배라 공정 비교가 아니라 참고선이고, 토크나이저가 서로 다르므로 선택지 채점은 문자 길이 정규화 로그확률로 통일해 두었습니다. 그래도 참고선이 망가져 있으면 우리 숫자가 어디쯤인지에 대한 감각 자체가 사라집니다. 그중 KoGPT2가 전 지표에서 찬스 수준으로 나왔습니다.
곤란한 건 이 결과가 표만 봐서는 이상해 보이지 않는다는 점입니다. 이 체급에서 추론계 태스크(COPA·HellaSwag·BoolQ)는 1.2B 앵커까지 포함해 전 모델이 찬스+5–15%p의 좁은 밴드에 몰려 있고, 10지선다인 KoBALT-700은 실제로 모든 모델이 찬스(10%) 수준이었습니다. 평가 코드는 예외 없이 끝까지 돌고, 표는 빈칸 없이 채워집니다.
설명이 되지 않는 칸은 문법성 지표였습니다. 자체 제작한 최소쌍 560쌍은 문법적인 문장과 덜 자연스러운 문장 중 로그확률이 높은 쪽을 고르는 형식이라, 아무렇게나 찍어도 절반은 맞습니다. 게다가 이 축은 우리 조건들이 0.5B 토큰 안에 96–99%로 포화해 버리는 쉬운 축입니다. 같은 체급의 한국어 LM이 여기서 찍기 수준을 받는 것은 설명되지 않습니다.
원인은 모델이 아니라 토크나이저 로딩이었습니다. transformers의 GPT2 래퍼가 kogpt2 토크나이저의 SentencePiece식 파이프라인을 byte-level로 덮어쓰면서, 한국어 입력이 바이트 단위로 분해되고 있었습니다. 모델이 학습한 것과 다른 분절로 입력이 들어가면 로그확률은 계산은 되지만 의미가 없습니다.
해결은 두 단계였습니다. 먼저 감지 — 앵커를 로드한 직후 한국어 문장을 한 번 토큰화해 보고
결과에 한글이 남아 있는지 확인하는 프로브를 넣었습니다. 바이트로 분해된 상태면 여기서 바로
걸립니다. 다음이 우회 — 걸리면 래퍼를 거치지 않고 raw tokenizers.Tokenizer로 직접
로드합니다. 고친 뒤 KoGPT2는 최소쌍에서 0.993을 냈고, 우리 110M 모델들(0.98–0.99)과 같은
밴드에 들어왔습니다.
낮게 나온 숫자가 전부 버그라는 얘기는 아닙니다. 같은 표에서 EXAONE 4.0 1.2B는 최소쌍 0.864로 훨씬 작은 우리 모델보다도 낮은데, 이건 instruct 튜닝이 원시 문장 확률 분포를 왜곡해 로그확률 기반 문법성 비교에서 불리해지는 현상으로 해석했습니다(순수 base LM이 아닌 점 유의). 버그와 관찰을 가르는 기준은 숫자의 크기가 아니라 그 값이 나올 수 있는 경로가 설명되느냐였습니다. EXAONE의 0.864는 설명이 됐고, KoGPT2의 찍기 수준은 설명이 되지 않았습니다.
여기서 남는 교훈은 어서션의 위치입니다. 평가 파이프라인의 출력을 검증하는 건 어렵습니다. 정답을 모르니까요. 대신 입구는 검증할 수 있습니다. "한국어를 넣었으면 한글 토큰이 나와야 한다"는 결과를 몰라도 확인 가능한 성질이고, 이런 성질을 앞단에 박아 두는 편이 다 끝난 표를 노려보며 이상한 칸을 찾는 것보다 쌉니다.
함정 2 — resume이 GPU에서만 거부됐다
7.8시간짜리 런을 여덟 번 돌리려면 중단 대응이 필요합니다. 그래서 체크포인트에서 재개하는 resume 경로를 만들어 뒀고, 거기서 되살려야 하는 것 중에는 RNG 상태도 있습니다. 조건 간 비교가 순수한 순서 효과의 paired comparison이 되도록 같은 시드의 런은 초기 가중치가 완전히 동일하다는 것까지 테스트로 보증해 둔 설계인데, 중단 여부 같은 운영 사정이 거기 섞이면 곤란합니다.
버그는 이렇습니다. 체크포인트를 로드할 때 텐서들을 학습 디바이스로 올리려고 map_location을
걸어 두는데, 이 옵션은 로드되는 텐서 전체에 일괄로 걸립니다. RNG 상태 텐서까지 같이 GPU로
올라가고, RNG 상태 복원은 CPU 텐서를 요구하므로 거부됩니다. 옮겨야 할 텐서와 옮기면 안 되는
텐서가 같은 묶음 안에 들어 있는데 옵션은 그 둘을 구분하지 않습니다.
성질이 함정 1과 정반대입니다. 조용히 틀리는 게 아니라 시끄럽게 실패합니다. 문제는 실패하는
위치예요. 오프라인 테스트에는 resume 결정성 테스트가 있었고 통과하고 있었습니다. 다만 CPU
환경이라 map_location이 디바이스를 옮기는 분기 자체를 지나가지 않았습니다. 그래서 이 경로는
초록 불을 켜 둔 채로, 시간당 요금이 나가는 클라우드 GPU에서 한참을 학습한 뒤 재개하는 순간에
처음 터집니다. 학습·평가 전체가 약 $55–60인 실험에서, 재개가 막혀 런을 처음부터 다시 돌아야
한다면 그 손해는 작지 않습니다.
배운 것은 디바이스 이동이 있는 코드는 디바이스 없이 검증되지 않는다는 것입니다. CPU 테스트가 커버하는 건 "로직이 맞는가"이지 "디바이스 경계를 제대로 넘는가"가 아닙니다. 이런 경로는 결국 실제 환경에서 한 번은 밟아 봐야 하고, 그렇다면 그 시점을 런이 끝나 갈 무렵이 아니라 시작 직후로 당겨 두는 것 말고는 방법이 없습니다.
두 함정의 공통점
이 실험은 재현성 장치를 꽤 깔아 둔 편입니다. 네 조건의 데이터 순서는 학습 전에 전부 파일로 materialize해 해시로 고정했고, 어떤 데이터를 썼는지는 shard revision까지 매니페스트에 기록했고, 같은 조건을 다시 돌렸을 때 학습 곡선이 재현되는 것도 확인했습니다. 그런데 두 함정은 그 장치들과 다른 층위에서 났습니다. 하나는 비교 대상을 잘못 재고 있었고, 하나는 테스트가 밟지 않는 경로에 있었습니다.
정리하면 이렇습니다. 파이프라인이 끝까지 돌고 표가 채워졌다는 것은 그 표가 맞다는 뜻이 아닙니다. 확인해야 하는 건 결과값이 아니라 — 결과값의 정답은 대개 모릅니다 — 재는 과정이 전제하고 있는 성질입니다. 한국어를 넣었으면 한글이 나와야 하고, 재개한 런은 중단 없이 돌린 런과 같아야 합니다. 이런 성질은 결과를 몰라도 검사할 수 있고, 검사하지 않으면 숫자는 조용히 틀린 채로 나옵니다.