본문으로 건너뛰기

[llm-compressor] Model-Free Entrypoint: 모델 정의 없이 체크포인트만으로 PTQ

들어가며

llm-compressor의 기본 진입점인 oneshot()은 HuggingFace PreTrainedModel 인스턴스를 요구한다. 즉 모델의 파이썬 정의(modeling_llama.py, modeling_qwen.py ...)가 transformers 버전과 호환되어야 하고, 전체 가중치를 GPU 또는 CPU 메모리에 올릴 수 있어야 한다. 하지만 현실에서는 정반대 상황이 자주 있다. 디스크에 safetensors 샤드만 있고 모델 파이썬 정의가 아직 없거나, 70B급 모델을 단일 머신에서 한 번에 로딩할 수 없는 경우다.

이런 요구를 위해 llm-compressor는 두 번째 진입점 model_free_ptq를 제공한다. 이 함수는 HuggingFace 모델 인스턴스를 만들지 않고, safetensors 파일을 샤드 단위로 직접 읽어 각 텐서를 양자화한다. 캘리브레이션 데이터가 필요 없는 PTQ 스킴(예: FP8 weight-only, NVFP4/MXFP4의 microscale 스킴)에 특화된 경로다. 내부적으로 compressed-tensors의 Converter API와 멀티 워커 잡 스케줄러를 활용한다.

공식 문서

핵심 구조/코드 분석

model_free_ptq: 진입 함수

본체는 src/llmcompressor/entrypoints/model_free/__init__.py에 정의되어 있다.

def model_free_ptq(
    model_stub: str | os.PathLike,            # HF 허브 이름 또는 로컬 가중치 디렉토리 경로
    save_directory: str | os.PathLike,        # 양자화된 체크포인트를 저장할 디렉토리
    scheme: QuantizationScheme | str,          # 가중치 양자화 스킴 (객체 또는 프리셋 이름)
    ignore: Iterable[str] = tuple(),           # 양자화에서 제외할 모듈 이름 패턴 목록
    max_workers: int = 1,                      # 병렬 처리 워커 수 (샤드 단위)
    device: Optional[torch.device | str] = None,  # 양자화 연산에 쓸 디바이스 (None=자동)
    converter: Converter | None = None,        # 기존 포맷을 compressed-tensors로 변환
):
    # 1) 체크포인트 파일 목록 파악 (safetensors + config/tokenizer)
    model_files = get_checkpoint_files(model_stub)

    # 2) 스킴 문자열 → QuantizationScheme 객체 검증 및 디바이스 결정
    scheme_name, scheme = validate_scheme(scheme)
    device = gpu_if_available(device)
    validate_safetensors_index(model_files, scheme)

    # 3) 비-safetensors 파일(config, tokenizer 등)은 그대로 복사
    for file_path, resolved_path in model_files.items():
        if not file_path.endswith("safetensors"):
            save_path = Path(save_directory) / file_path
            save_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copyfile(resolved_path, save_path)

    # 4) 샤드별 잡 생성 — 각 잡은 "어떤 텐서를 어느 파일에서 읽을지"까지 사전 계산
    jobs = _build_jobs(model_files, save_directory, scheme, ignore, device, converter)

    # 5) 빠른 실패를 위해 먼저 검증 잡만 실행
    validate_jobs = [(validate_file, *job[1:]) for job in jobs]
    exec_jobs(validate_jobs, max_workers, desc="Validating")

    # 6) 실제 양자화 잡 실행 (max_workers 병렬)
    quantize_results = exec_jobs(jobs, max_workers, desc="Quantizing")

    # 7) config.json / safetensors index 업데이트 (quantization_config 섹션 추가)
    update_config(save_directory, scheme_name, scheme, ignore, converter)
파라미터 설명
model_stub HF 모델 허브 ID 또는 safetensors가 있는 로컬 디렉토리
save_directory 결과물 디렉토리. config.json, tokenizer 파일, 양자화된 safetensors가 여기에 복사/저장
scheme "FP8", "NVFP4", "MXFP4" 같은 프리셋 이름 문자열 또는 QuantizationScheme 객체
ignore 양자화하지 않을 모듈 패턴. lm_head를 제외하는 것이 일반적. *norm 패턴은 내부에서 자동 제외
max_workers 동시 처리할 safetensors 샤드 수. I/O 바운드 작업이라 2~4가 적정
device 양자화 텐서 연산을 수행할 디바이스. None이면 gpu_if_available()이 CUDA 사용 여부 결정
converter 기존 형식(예: GGUF 또는 커스텀 패킹)을 compressed-tensors로 변환하는 선택적 훅

이 함수는 모델 인스턴스를 전혀 만들지 않는다는 점이 핵심이다. 메모리에 올라오는 것은 "지금 처리 중인 샤드 하나" 뿐이며, 나머지 샤드는 처리 후 즉시 디스크에 쓰인 뒤 메모리에서 해제된다. 70B 모델도 32GB RAM 머신에서 처리 가능하다.

샤드 단위 잡 빌드와 Inverse Weight Map

_build_jobs는 이 진입점의 가장 영리한 부분이다. compressed-tensorsbuild_inverse_weight_maps를 호출해, 각 출력 샤드가 어떤 텐서를 어느 소스 파일에서 읽어야 하는지를 사전에 계산한다.

def _build_jobs(
    model_files: dict[str, str],         # {샤드 이름: 실제 경로} 매핑
    save_directory: str | os.PathLike,
    scheme: QuantizationScheme,
    ignore: Iterable[str],
    device: torch.device,
    converter: Converter | None,
) -> list[tuple]:
    weight_map = get_weight_map(model_files)  # {텐서 이름: 샤드 이름}

    # 1) 스킴이 마이크로스케일(NVFP4/MXFP4)인지 여부로 잡 함수와 빌더 분기
    if is_microscale_scheme(scheme):
        job_fn = process_file_microscale_scheme
        build_inverse_weight_maps_fn = build_microscale_inverse_weight_maps
    else:
        job_fn = process_file
        build_inverse_weight_maps_fn = build_inverse_weight_maps

    # 2) 각 출력 샤드마다 "이 샤드가 만들려면 어느 파일의 어떤 텐서를 읽어야 하나"를 빌드
    inverse_weight_maps = build_inverse_weight_maps_fn(
        weight_map=weight_map,
        model_files=model_files,
        converters=[converter] if converter is not None else [],
    )

    # 3) 샤드 순회하면서 잡 튜플 구성
    jobs = []
    for shard_name in model_files.keys():
        save_path = Path(save_directory) / shard_name

        if not shard_name.endswith("safetensors"):
            continue  # config / tokenizer 등은 잡이 아님

        if shard_name not in inverse_weight_maps:
            raise ValueError(
                f"Could not find inverse_weight_map for shard {shard_name}"
            )

        jobs.append(
            (
                job_fn,                          # 실제 처리 함수
                inverse_weight_maps[shard_name], # 읽어야 할 텐서 목록
                save_path,                       # 쓰기 경로
                scheme,
                ignore,
                device,
                converter,
            )
        )

    return jobs

Inverse weight map은 "이 출력 샤드를 만들려면 소스 파일 A에서 텐서 X, Y, 소스 파일 B에서 텐서 Z를 읽어라" 형태의 지시서다. 단순해 보이지만 NVFP4/MXFP4 같은 마이크로스케일 스킴에서는 중요하다. 이들 스킴은 q_proj, k_proj, v_proj 같은 fused weight(융합된 가중치)를 묶어서 하나의 글로벌 스케일을 계산해야 하는데, 이 세 텐서가 다른 샤드에 흩어져 있으면 워커가 런타임에 파트너 텐서를 찾아다녀야 한다. 사전에 inverse map을 만들어두면 런타임 탐색이 사라지고 중복 읽기도 없다.

is_microscale_scheme(scheme) 분기는 이 차이를 명시한다. 일반 스킴은 process_file + build_inverse_weight_maps를 쓰고, 마이크로스케일은 process_file_microscale_scheme + build_microscale_inverse_weight_maps를 쓴다. 후자는 fused 그룹 전체를 같은 잡에 배정한다.

process_file: 실제 양자화 워커

각 잡이 실행하는 process_filesrc/llmcompressor/entrypoints/model_free/process.py에 정의되어 있다. 함수 시그니처는 다음과 같다.

def process_file(
    inverse_weight_map: InverseWeightMap,   # 이 잡이 읽을 텐서 목록 (소스 파일별)
    save_path: str | os.PathLike,           # 쓰기 대상 safetensors 경로
    scheme: QuantizationScheme,             # 양자화 스킴
    ignore: Iterable[str],                  # 양자화 제외 모듈 패턴
    device: str | torch.device,             # 양자화 연산 디바이스
    converter: Converter | None = None,     # 선택적 포맷 변환기
) -> tuple[int, dict[str, str]]:
    tensors = load_tensors_from_inverse_weight_map(inverse_weight_map, device)

    if converter is not None:
        converter.apply(tensors)  # 커스텀 포맷 → compressed-tensors

    # 양자화 가능 텐서만 골라서 순회
    for _, name in match_quantizable_tensors(tensors, ignore, scheme.targets):
        validate_weight_for_quantization(tensors[name], scheme, name)
        # Linear 레이어를 가상으로 생성 (실제 forward 는 하지 않음)
        linear = initialize_quantized_linear(name, tensors[name], scheme)
        # 스케일/제로포인트 캘리브레이션 — 여기서 데이터 없이 weight-only 로 수행
        calibrate_scale_zp(linear, scheme)
        if is_microscale_scheme(scheme):
            calibrate_global_scale(linear, scheme)
        # 양자화된 결과를 compressed-tensors 포맷으로 모듈 압축
        compress_module(linear)

    # 결과 저장 및 weight map 반환
    save_file(tensors, save_path)

핵심은 Linear 모듈을 "한 텐서만 있는 가짜 레이어"로 만들어서 observer 호출 없이 스케일을 계산한다는 점이다. 실제 데이터로 통계를 수집하는 일반 PTQ와 달리, 이 경로는 오로지 가중치 분포만 보고 양자화 파라미터를 결정한다. 따라서 AWQ나 GPTQ처럼 활성화 통계가 필요한 알고리즘은 이 경로에서 쓸 수 없다. FP8, INT8 weight-only, NVFP4, MXFP4처럼 가중치만 보고 결정 가능한 스킴만 지원한다.

calibrate_scale_zp는 스킴의 num_bitssymmetric 플래그에 맞춰 max-abs 기반 스케일을 계산하고, calibrate_global_scale은 microscale 스킴에서 여러 블록에 공통으로 곱해지는 전역 스케일을 산출한다. 이 단계들이 내부적으로 어떤 Observer를 사용하는지는 Observers Base 글에서 다룬다.

왜 이 설계인가

1. 데이터 없이 빠르게. 캘리브레이션 루프가 없으니 70B 모델이라도 수십 분이면 양자화가 끝난다. FP8 weight-only의 경우 거의 I/O 바운드여서 NVMe SSD 속도가 병목이다. sequential 파이프라인의 GPTQ 실행(수 시간)과 대비된다.

2. 샤드 단위 스트리밍. 모델 전체를 한 번에 로딩하지 않기 때문에, 메모리 요구량이 "가장 큰 단일 샤드 크기" 수준으로 제한된다. HuggingFace의 safetensors 샤드는 보통 5~10GB 선이라서, 웬만한 워크스테이션에서도 수백B 모델을 처리할 수 있다.

3. Inverse Weight Map 사전 계산. 마이크로스케일 스킴에서는 fused 가중치 그룹이 샤드 경계를 넘을 수 있다. 이를 워커가 런타임에 처리하면 "파트너 텐서를 다른 샤드에서 읽어오고, 그 샤드는 자기 잡에서도 이미 읽고 있으니 두 번 읽기"가 발생한다. 사전에 map을 만들어서 "이 샤드 잡은 이 텐서들만 읽는다"를 고정하면 중복 I/O가 제거된다.

4. Validate 잡을 먼저 실행. 전체 잡을 돌리기 전에 validate_file만 빠르게 한 바퀴 돈다. 스킴 호환성 문제(예: 텐서 shape이 block size로 나누어 떨어지지 않는 경우)를 조기에 잡아서, 1시간짜리 양자화 작업이 마지막 샤드에서 실패하는 참사를 막는다.

5. Converter 훅. converter 인자는 "기존 커스텀 포맷 → compressed-tensors" 변환을 끼워 넣는 확장 포인트다. 예를 들어 어떤 연구실 전용 패킹 포맷으로 저장된 가중치를 표준 compressed-tensors로 변환하고, 그 위에 곧바로 양자화를 적용할 수 있다. 변환 단계가 양자화와 같은 잡 안에서 일어나므로 중간 저장이 필요 없다.

마무리

model_free_ptq는 llm-compressor의 "두 번째 진입점"으로, 캘리브레이션 데이터가 필요 없고 모델 정의도 없을 때 쓰는 경로다. 내부적으로는 compressed-tensors의 Converter/exec_jobs API를 빌려와 샤드 단위 병렬 처리를 구현하고, 마이크로스케일 스킴을 위한 inverse weight map을 사전 계산해 I/O를 최적화한다. 주 진입점인 oneshot()과 달리 CompressionSession이나 Lifecycle을 거치지 않고, 곧바로 텐서 레벨에서 동작한다.

참고 자료

댓글

관련 포스트

llm-compressor 의 다른글