본문으로 건너뛰기

[llm-compressor] Oneshot 진입점: 한 번의 호출로 끝나는 압축 파이프라인

들어가며

llm-compressor의 사용자 대부분은 내부 구조를 모른 채 from llmcompressor import oneshot 한 줄로 압축을 시작한다. HuggingFace 모델 경로, 캘리브레이션 데이터셋, 레시피 YAML 세 개만 넘기면 양자화된 체크포인트가 디스크에 저장된다. 이 "한 번의 호출" 뒤에는 인자 파싱, 모델 로딩, 캘리브레이션 데이터로더 생성, 세션 초기화, 파이프라인 선택, 모디파이어 적용, 체크포인트 저장이라는 7단계의 흐름이 숨어 있다.

이 글은 llm-compressor의 진입점인 Oneshot 클래스와 oneshot() 함수를 코드 레벨에서 해부해, 각 단계가 어떤 하위 모듈로 위임되는지 추적한다. 전체 파이프라인의 조감도는 프로젝트 아키텍처 개요에서 확인할 수 있다.

공식 문서

핵심 구조/코드 분석

oneshot() 함수: 평평한 Kwargs 진입점

사용자 친화적 진입점은 src/llmcompressor/entrypoints/oneshot.py의 최상위 함수 oneshot()이다. 40개 이상의 파라미터를 전부 평평한 키워드 인자로 노출해, 사용자는 별도의 dataclass를 import할 필요 없이 한 번의 함수 호출로 시작할 수 있다.

def oneshot(
    # Model arguments
    model: str | PreTrainedModel,                 # HF 모델 경로 또는 로드된 모델 인스턴스
    config_name: str | None = None,               # 별도 config 경로
    tokenizer: str | PreTrainedTokenizerBase | None = None,  # 토크나이저 (모델과 다를 경우)
    processor: str | ProcessorMixin | None = None,# 멀티모달 프로세서
    precision: str = "auto",                      # 가중치 로딩 dtype (auto/fp16/bf16)
    tie_word_embeddings: bool = True,             # lm_head ↔ embed_tokens 공유 유지 여부
    save_compressed: bool = True,                 # compressed-tensors 포맷으로 저장
    # Recipe arguments
    recipe: str | list[str] | None = None,        # 레시피 YAML 경로 또는 인스턴스 리스트
    recipe_args: list[str] | None = None,         # 레시피 내부 변수 오버라이드 (key=val)
    stage: str | None = None,                     # 멀티스테이지 레시피에서 실행할 스테이지
    # Dataset arguments
    dataset: str | Dataset | DataLoader | None = None,   # 캘리브레이션 데이터
    batch_size: int = 1,                          # 캘리브레이션 배치 크기
    num_calibration_samples: int = 512,           # 캘리브레이션 샘플 수 (GPTQ/AWQ 권장 128~512)
    max_seq_length: int = 384,                    # 토크나이즈 최대 길이
    pipeline: str | None = "independent",         # 파이프라인 종류 (basic/sequential/data_free/independent)
    sequential_targets: list[str] | None = None,  # sequential 파이프라인에서 분할할 레이어 타입
    sequential_offload_device: str = "cpu",       # 중간 활성화 오프로드 디바이스
    quantization_aware_calibration: bool = True,  # forward 시 이미 양자화된 가중치 사용
    sequential_prefetch: bool = False,            # 다음 배치 미리 로딩 (GPU 메모리 여유 시)
    moe_calibrate_all_experts: bool = True,       # MoE 모델에서 모든 전문가에 토큰 공급
    output_dir: str | None = None,                # 결과 저장 디렉토리 (None이면 저장 안 함)
    log_dir: str | None = None,                   # 로그 파일 디렉토리
    **kwargs,
) -> PreTrainedModel:
    local_args = {
        k: v for k, v in locals().items() if k not in ("local_args", "kwargs")
    }
    one_shot = Oneshot(**local_args, **kwargs)
    one_shot()
    return one_shot.model

파라미터는 네 그룹으로 묶여 있다.

그룹 주요 파라미터 용도
Model model, precision, tie_word_embeddings, save_compressed 모델 로딩과 저장 형식 제어
Recipe recipe, recipe_args, stage 어떤 Modifier를 어떻게 적용할지 선언
Dataset dataset, num_calibration_samples, max_seq_length, pipeline, sequential_targets 캘리브레이션 데이터와 파이프라인 선택
Misc output_dir, log_dir 결과 저장 경로, 로그 출력

함수 본문은 놀라울 정도로 단순하다. locals()로 모든 인자를 한 딕셔너리로 모은 뒤 Oneshot 클래스에 그대로 위임한다. 이 얇은 래퍼 패턴 덕분에 사용자 API는 변하지 않으면서 내부 dataclass 구조는 자유롭게 리팩터링할 수 있다.

Oneshot 클래스: 3단계 라이프사이클

실제 로직은 Oneshot.__init__Oneshot.__call__에 나뉘어 있다. 클래스 독스트링이 명시하듯 라이프사이클은 Preprocessing → Oneshot Calibration → Postprocessing 3단계로 구성된다.

class Oneshot:
    def __init__(
        self,
        log_dir: str | None = None,  # 로그 파일 저장 디렉토리
        **kwargs,                    # oneshot() 의 모든 인자가 여기로 들어옴
    ):
        # 1) 토크나이저 병렬성 경고 억제 (FastTokenizer ↔ datasets.map 충돌 회피)
        if TOKENIZERS_PARALLELISM_ENV not in os.environ:
            os.environ[TOKENIZERS_PARALLELISM_ENV] = "false"

        # 2) 파일 로거 부착 (옵션)
        log_file = os.environ.get("LLM_COMPRESSOR_LOG_FILE", "").strip()
        if log_file:
            logger.add(str(Path(log_file).expanduser()), level="DEBUG")
        elif log_dir:
            date_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
            logger.add(f"{log_dir}/oneshot_{date_str}.log", level="DEBUG")

        # 3) 평평한 kwargs → 세 개의 Arguments dataclass 로 파싱
        model_args, dataset_args, recipe_args, output_dir = parse_args(**kwargs)

        self.model_args = model_args
        self.dataset_args = dataset_args
        self.recipe_args = recipe_args
        self.output_dir = output_dir

        # 4) 모델/프로세서 초기화 (가중치 로딩, lm_head untie 패치 등)
        pre_process(model_args, dataset_args, output_dir)

        self.model = self.model_args.model
        self.processor = self.model_args.processor
        self.recipe = self.recipe_args.recipe

__init__은 부작용이 많다. 환경 변수 조작, 로거 부착, 모델 가중치 로딩까지 모두 생성자에서 수행한다. 이는 llm-compressor가 "스크립트 실행"과 "파이썬 라이브러리 호출" 두 용도를 모두 지원해야 하기 때문이다. CLI 스크립트 입장에서는 Oneshot(**kwargs); inst() 두 줄로 모든 세팅이 끝나야 편리하다.

parse_args()src/llmcompressor/args/utils.py에 정의된 dispatcher로, 평평한 kwargs를 ModelArguments, DatasetArguments, RecipeArguments 세 dataclass와 output_dir로 분리한다. 실제 모델 로딩은 pre_process()가 수행하며, 내부적으로는 transformers.AutoModelForCausalLM.from_pretrained 계열 API를 호출한다.

__call__: 캘리브레이션과 레시피 적용

생성자가 부작용을 다 끝낸 덕분에, 호출 부분은 매우 얇다.

def __call__(self):
    # 1) 캘리브레이션 데이터로더 생성 — DatasetArguments 에 따라 c4/wikitext/custom 등 선택
    calibration_dataloader = get_calibration_dataloader(
        self.dataset_args, self.processor
    )

    # 2) 레시피의 모든 Modifier 를 모델에 적용
    self.apply_recipe_modifiers(
        calibration_dataloader=calibration_dataloader,
        recipe_stage=self.recipe_args.stage,  # 멀티스테이지 레시피 중 실행할 스테이지
    )

    # 3) 결과 저장 — save_compressed 여부에 따라 compressed-tensors 포맷으로 직렬화
    post_process(
        model_args=self.model_args,
        recipe_args=self.recipe_args,
        output_dir=self.output_dir,
    )

세 단계의 경계가 명확하다. get_calibration_dataloadersrc/llmcompressor/datasets/의 dataset utils로 위임되고, post_process는 HuggingFace save_pretrained 호출을 래핑한다. 압축 본체는 apply_recipe_modifiers에서 일어난다.

apply_recipe_modifiers: 세션과 파이프라인의 접점

def apply_recipe_modifiers(
    self,
    calibration_dataloader: DataLoader | None,  # 캘리브레이션 배치 반복자 (data_free 일 경우 None)
    recipe_stage: str | None = None,            # 실행할 레시피 스테이지 이름
):
    session = active_session()   # 전역 CompressionSession 싱글톤
    session.reset()              # 이전 실행의 Lifecycle 잔존 상태 제거

    with norm_calibration_context(self.model), moe_calibration_context(
        self.model,
        calibrate_all_experts=self.dataset_args.moe_calibrate_all_experts,
    ):
        # 1) 세션에 model, recipe, 데이터를 주입하고 Modifier 들을 on_initialize
        session.initialize(
            model=self.model,
            start=-1,
            recipe=self.recipe,
            recipe_stage=recipe_stage,
            recipe_args=self.recipe_args.recipe_args,
            calib_data=calibration_dataloader,
            sequential_targets=self.dataset_args.sequential_targets,
        )

        # 2) 레시피에 들어있는 Modifier 목록을 근거로 CalibrationPipeline 선택
        user_pipeline = self.dataset_args.pipeline
        pipeline = CalibrationPipeline.from_modifiers(
            session.lifecycle.recipe.modifiers, user=user_pipeline
        )

        # 3) 파이프라인 실행 — basic/sequential/data_free/independent
        pipeline(
            self.model,
            calibration_dataloader,
            self.dataset_args,
        )

    session.finalize()  # 모든 Modifier 의 on_finalize 훅 실행 → 가중치 최종화

이 메서드가 llm-compressor의 "엔진 시동 키"에 해당한다. 두 개의 컨텍스트 매니저 (norm_calibration_context, moe_calibration_context)로 감싸는 것은, 캘리브레이션 중에만 RMSNorm/MoE 라우터의 동작을 특수하게 패치하기 위함이다. MoE 모델에서는 기본 라우팅만 사용하면 일부 expert가 토큰을 전혀 받지 못해 양자화 스케일이 부정확해지므로, moe_calibrate_all_experts=True로 모든 expert에 토큰을 강제 공급한다.

핵심은 세 줄이다.

  1. session.initialize(...)CompressionSession을 준비한다. 이 호출 안에서 레시피가 파싱되고 각 Modifier의 on_initialize 훅이 호출된다.
  2. CalibrationPipeline.from_modifiers(...)Pipeline Registry가 Modifier 목록을 보고 적절한 파이프라인을 고른다. GPTQ가 포함되어 있으면 자동으로 sequential, 단순 PTQ만 있으면 basic이 선택된다.
  3. pipeline(...) — 실제 forward 루프. Modifier의 on_event 훅이 배치마다 호출되면서 통계를 누적하거나 가중치를 수정한다.

마지막의 session.finalize()는 각 Modifier의 on_finalize 훅을 호출해 가중치 최종화(예: GPTQ의 H 갱신 → W 업데이트)를 수행한다.

왜 이 설계인가

1. 평평한 함수 vs. 클래스의 이중 API. 사용자는 oneshot(model=..., recipe=...) 한 줄로 시작하고 싶고, 고급 사용자는 Oneshot(...) 인스턴스의 속성(oneshot.model, oneshot.recipe)에 접근하고 싶다. 이중 API는 두 요구를 모두 만족시킨다. 함수는 단지 Oneshot을 감싼 얇은 래퍼라서 유지보수 부담이 거의 없다.

2. __init__에서 부작용을 허용한다. 일반적으로 생성자에 무거운 IO를 넣는 것은 안티패턴이지만, llm-compressor는 "한 번 쓰고 버리는 스크립트" 용도이기 때문에 지연 초기화를 할 동기가 없다. 대신 Oneshot(**kwargs) 한 줄이면 모델이 GPU에 올라와 있어 디버깅이 쉽다.

3. parse_args로 평평한 kwargs를 분리한다. 내부적으로 ModelArguments, DatasetArguments, RecipeArguments로 나누어야 각 컴포넌트가 자신이 필요한 설정만 선택적으로 사용할 수 있다. 평평한 API와 구조화된 내부 상태라는 두 요구가 parse_args라는 단 하나의 dispatcher로 연결된다.

4. 파이프라인 자동 선택. 사용자가 pipeline="independent"로 지정해도, CalibrationPipeline.from_modifiers가 Modifier 종류를 보고 필요 시 sequential로 승격시킨다. GPTQ처럼 레이어 단위 데이터 순회가 필요한 알고리즘을 사용자가 잘못 설정해 basic 파이프라인으로 실행하는 실수를 방지한다.

5. 두 개의 캘리브레이션 컨텍스트. norm_calibration_context는 RMSNorm의 스케일을 임시 보정하고, moe_calibration_context는 MoE 라우터를 전수 라우팅으로 교체한다. 이 둘을 전역 상태가 아닌 컨텍스트 매니저로 두어, 캘리브레이션이 끝나면 원본 동작으로 자동 복원된다. 호출자가 정리(cleanup) 코드를 신경 쓸 필요가 없다.

마무리

oneshot()은 llm-compressor의 얇지만 중요한 "파사드"다. 이 파일 안에 실제 압축 로직은 한 줄도 없지만, 모델 로딩부터 결과 저장까지 모든 외부 컴포넌트를 올바른 순서로 연결한다. 다음 글에서는 이 함수가 생성하는 CompressionSession과 그 내부의 Lifecycle 상태 머신을 살펴본다.

참고 자료

댓글

관련 포스트

llm-compressor 의 다른글