이 글은 한국어 STT를 위한 Whisper fine-tuning 3부작 중 1부다: Preprocessing → Training → Evaluation. 이번 글에서는 data preprocessing pipeline을 소개한다. 2부와 3부에서는 각각 training loop과 evaluation/benchmarking을 다룰 예정이다.
OpenAI의 오픈소스 STT 모델인 Whisper를 파인튜닝하는 과정에서 생각지 못했던 난관에 봉착했다. learning rate를 바꾸든, batch size를 키우든, 아니면 그냥 GPU가 OOM으로 터지든 — 학습을 돌릴 때마다 수십 시간을 원본 오디오 파일 처리에 쓰는 것이다. WAV 파일 로드, 16 kHz로 sample rate 변경, mel spectrogram 계산, 텍스트 tokenization로 구성된 이 전처리 과정은 매번 동일하다. 데이터는 전혀 바뀌지 않는데, 매번 데이터 준비에 드는 시간과 비용을 전부 지불하고 있었던 거다. (남몰래 줄줄 새는 EC2 대여료…)
따라서 이 과정 전체를 한 번 돌리고, 디스크에 저장해서 이후 모든 fine-tuning run에 재사용하면 이 문제를 해결할 수 있을 것이다. 즉, 수십만 개 원본 오디오 파일을 한 방에 학습 가능한 dataset으로 변환하는 파이프라인이 필요한 것이다. 이 글에서는 왜 이게 중요한지, 그리고 구체적으로 무엇을 어떻게 하는지를 다룬다.
전체 preprocessing script GitHub 링크: preprocess_whisper.py
반복되는 Preprocessing 비용
Hugging Face의 Trainer 같은 framework는 data collator와 map 함수로 raw audio를 model input feature로 변환하기 쉽게 해 준다. Prototyping에는 편한데, 이 편리함이 실제 비용을 숨기고 있다. TANSTAAFL!
수천 시간 분량의 한국어 음성 샘플 수십만 개를 다루면, 매 run마다 전처리 overhead가 매우 크다. 샘플 하나마다 audio load를 위한 disk I/O, resampling (보통 44.1 kHz나 48 kHz에서 16 kHz로), mel spectrogram 생성을 위한 STFT, 텍스트 subword tokenization까지, 모델 개선에 전혀 기여하지 않는 연산에 10시간 이상을 날리게 된다.
더 짜증나는 건, hyperparameter 하나 잘못 건드려서 학습이 도중에 터지면 그 전처리 시간이 고스란히 날아간다는 거다. 일주일에 수십 번 실험을 돌리는 workflow에서는 이 누적 낭비가 꽤 크다.
해결책은 단순하다: preprocessing과 training을 분리하면 된다. 비싼 feature extraction을 한 번 돌리고 결과를 저장한 뒤, 이후 모든 training script가 precomputed dataset을 갖다 쓰게 하면 된다.
Whisper가 실제로 필요로 하는 것
Preprocessing pipeline이 뭘 하는지 이해하려면, Whisper가 audio를 어떻게 처리하는지 간단히 볼 필요가 있다.
Mel Spectrogram
Whisper는 encoder-decoder Transformer다. Encoder는 raw audio waveform을 바로 처리하지 않는다. 대신 log-mel spectrogram — 한 축은 시간, 다른 축은 mel scale(인간의 pitch 인식에 더 가깝게 근사하는 perceptual scale)로 표현된 frequency인 2차원 audio 표현 — 을 입력으로 받는다. A3 음(220Hz), A4 (440Hz), A5(880Hz) 를 생각해보면, A3-A4 사이는 220Hz 차이가 나고 A4-A5 사이는 440Hz 차이가 나지만 사람이 느끼기에는 동일한 1옥타브 차이가 나는 것을 반영한 것이 바로 Mel spectogram이다. (cf. Log-Mel spectogram은 여기에 더불어 진폭(db)까지도 사람의 청각 특성을 반영하도록 log scale을 적용한다.)
Raw waveform에서 mel spectrogram으로의 변환은 여러 단계를 거친다. 먼저 audio를 Whisper가 학습된 sample rate인 16 kHz로 resampling한다. 그 다음 25ms window, 10ms hop의 STFT를 적용해서 signal을 겹치는 frequency frame들로 분해한다. 나온 power spectrum을 80개 mel-frequency bin에 projection하고 log scale로 변환한다. 결과물은 표준 30초 Whisper input window 기준 (80, 3000) 크기의 행렬이다.
이 mel spectrogram이 Whisper encoder의 convolutional stem이 Transformer layer로 넘기기 전에 받는 입력이다. 이때 오디오 파일 하나마다 resampling, windowing, FFT, filterbank projection, log scaling을 거쳐야 하므로 수십만 파일을 처리하는 전체 비용은 상당하다.
Tokenized Transcription
Decoder 쪽에서 Whisper는 byte-level BPE tokenizer로 transcription text를 integer token ID sequence로 변환한다. whisper-large-v3 같은 multilingual model에서는 language(<|ko|>)와 task(<|transcribe|>)를 지정하는 special token도 들어간다.
Tokenization 자체는 비용이 크지 않지만, 어차피 모든 샘플에 tokenizer를 돌려야 한다. Preprocessing pass에 같이 넣으면 추가 비용은 거의 없으면서, training 때 반복 작업 하나를 더 없앨 수 있다.
Preprocessing Pipeline 설계
이 preprocessing script는 우리가 종종 모델 학습을 돌리는 high-core-count machine을 타겟으로 몇 가지 핵심 설계 결정을 내렸다.
Chunked Parallel Processing
Multiprocessing으로 단순하게 map 을 돌리면 큰 numpy array를 IPC로 main process에 serialize해서 보내야 해서 overhead가 아주 크다. 대신 파일 목록을 약 500개씩 chunk로 잘라서, 각 chunk를 worker process가 독립적으로 audio load, feature 계산, 그리고 Hugging Face Dataset shard로 디스크에 직접 저장하게 했다. Main process는 feature array를 아예 안 건드리고, 성공/에러 카운트만 담긴 가벼운 status dict만 받는다.
이 설계가 Python multiprocessing의 전형적인 bottleneck을 우회한다. 수백 MB짜리 float array를 process 간 pipe나 queue로 주고받는 건 느리고 메모리도 많이 먹는다. Worker가 각자 output을 저장하므로 IPC overhead가 거의 0이 되고, worker 수를 늘려도 잘 scale된다.
Per-Worker Model Initialization
각 worker process가 자체 WhisperFeatureExtractor와 WhisperTokenizer instance를 초기화한다. Model weight가 worker마다 한 번씩 load되긴 하지만 (shared memory 방식 아님), 대신 read-only model state에 대한 shared memory scheme의 복잡성과 불안정성을 피할 수 있다. RAM 넉넉한 머신에서는 메모리 좀 더 쓰는 게 단순함과 안정성을 위한 합리적인 trade-off다.
수치 연산 라이브러리(OpenBLAS, MKL, NumPy)의 internal threading도 환경 변수로 process당 single thread로 고정했다. 그렇지 않으면 200개 worker process가 각각 자기 thread pool을 만들어서 CPU core가 oversubscription 상태에 빠져 더 느려진다.
Output Format
각 chunk는 Hugging Face Dataset으로 디스크에 저장된다. 이 format은 training 시 memory-mappable이라서, training loop이 전체 dataset을 RAM에 올리지 않고 디스크에서 feature를 직접 읽을 수 있다. 그렇지 않으면 수 TB 짜리 데이터를 RAM에 올려야 하므로 학습을 시작하기도 전에 RAM에서 메모리 부족이 발생하게 된다.
또한, Mel spectrogram feature에 float16을 쓰면 storage가 대략 반으로 줄어드는데, 어차피 대부분의 fine-tuning이 mixed precision이라 training quality에 미치는 영향은 거의 없다.
결과
Preprocessing을 한 번 돌리고 나면, 이후 모든 fine-tuning 실험은 precomputed feature로부터 바로 시작한다. Training script가 디스크에서 Dataset shard를 로딩하고, padding용 data collator를 정의하고 바로 optimization이 시작되는 것이다. 체감으로는, 실험마다 10+ 시간의 cold start가 필요했던 workflow가 거의 즉시 시작하는 것으로 바뀌었다.
Whisper 뿐 아니라 Training pipeline에 deterministic하고 비용이 큰 raw data 변환이 있으면, 무조건 한 번 계산하고 cache해야 한다는 교훈을 얻을 수 있었다. Data preparation과 model training의 분리는 단순한 performance optimization이 아니라 — 효율적인 실험을 위한 전제 조건이다.
다음 글 예고
2부에서는 이렇게 전처리한 feature를 바탕으로 실제 STT 학습을 어떻게 돌리는지를 다루고, 3부에서는 학습된 모델을 evaluation하고 benchmark하는 방법을 다룬다.