본문으로 건너뛰기

[llm-compressor] Recipe DSL: YAML로 Modifier를 조합하는 선언적 언어

들어가며

llm-compressor를 처음 접할 때 가장 낯선 것이 Recipe다. oneshot(recipe="llama3_fp8.yaml")의 그 YAML 파일이다. 사용자가 "어떤 알고리즘을 어떤 순서로 어떤 설정으로" 적용할지를 한 파일에 선언한다. 내부적으로 이 파일은 Recipe 객체로 파싱되어 Modifier 인스턴스 리스트로 변환된다. 파싱 과정은 생각보다 흥미롭다. YAML 키 이름 자체에 타입 정보("..._stage", "..._modifiers")가 인코딩되어 있고, Modifier Factory가 동적 등록 메커니즘으로 클래스를 찾아낸다.

이 글은 src/llmcompressor/recipe/recipe.pyRecipe Pydantic 모델을 해부하고, 사용자가 주로 쓰는 세 가지 입력 형식(YAML 파일, 문자열, Modifier 리스트)이 어떻게 하나의 데이터 구조로 수렴하는지 추적한다.

공식 문서

Recipe YAML의 생김새

test_stage:
  pruning_modifiers:
    ConstantPruningModifier:
      start: 0.0
      end: 2.0
      targets: ['re:.*weight']
  quantization_modifiers:
    GPTQModifier:
      block_size: 128
      dampening_frac: 0.01
      targets: ['Linear']
      ignore: ['lm_head']
      scheme: W4A16
  • 최상위 키는 *_stage로 끝나야 한다. 이는 "멀티 스테이지 레시피"를 가능케 한다. 예를 들어 sparse_stagequant_stage처럼 두 스테이지를 차례로 실행할 수 있다.
  • 스테이지 안에는 *_modifiers 키 여러 개가 들어간다. pruning_modifiers, quantization_modifiers 등 그룹 이름 자체가 Modifier 종류를 암시한다.
  • 그 안에는 실제 Modifier 클래스 이름(GPTQModifier)을 키로, 해당 Modifier의 생성자 인자를 값으로 선언한다.

이 네 단계(stage → modifiers group → modifier class → args) 구조가 파서가 보게 되는 트리다.

핵심 구조/코드 분석

Recipe Pydantic 모델

class Recipe(BaseModel):
    args: Dict[str, Any] = Field(default_factory=dict)   # 레시피 최상위 변수 (Jinja 치환용)
    stage: str = "default"                               # 이 Recipe 객체가 속한 스테이지 이름
    modifiers: List[Modifier] = Field(default_factory=list)  # 인스턴스화된 Modifier 리스트

    model_config = ConfigDict(arbitrary_types_allowed=True)  # Modifier 서브클래스 허용

Recipe는 "스테이지 하나 분량"의 데이터다. 멀티 스테이지 YAML은 필요 시 여러 Recipe 객체로 분리된다. 핵심은 modifiers 필드로, 여기에는 ModifierFactory를 거쳐 이미 인스턴스화된 Modifier 서브클래스 객체들이 들어간다. 파싱 시점부터 인스턴스라는 점이 중요하다. Recipe를 한 번 만들면, 그 Modifier들을 그대로 세션에 넘길 수 있다.

create_instance: 다형적 진입점

Recipe 생성의 공용 진입점은 create_instance classmethod다. 네 가지 형태의 입력을 모두 받는다.

@classmethod
def create_instance(
    cls,
    path_or_modifiers: Union[str, Modifier, List[Modifier], "Recipe"],  # 경로/문자열/객체
    modifier_group_name: Optional[str] = None,  # Modifier 리스트로 만들 때 붙일 스테이지 이름
    target_stage: Optional[str] = None,         # 여러 스테이지 중 골라올 스테이지
) -> "Recipe":
    # 1) 이미 Recipe 객체면 그대로 반환
    if isinstance(path_or_modifiers, Recipe):
        return path_or_modifiers

    # 2) Modifier 인스턴스 또는 리스트면 얇은 래퍼로 감싼다
    if isinstance(path_or_modifiers, (Modifier, list)):
        return cls.from_modifiers(
            modifiers=path_or_modifiers, modifier_group_name=modifier_group_name
        )

    # 3) 로컬 파일 경로가 아니면 문자열로 간주해 파싱 시도
    if not os.path.isfile(path_or_modifiers):
        obj = _load_json_or_yaml_string(path_or_modifiers)
        return cls.from_dict(filter_dict(obj, target_stage=target_stage))

    # 4) 로컬 파일 경로라면 확장자 기준 파싱
    with open(path_or_modifiers, "r") as file:
        content = file.read().strip()
        if path_or_modifiers.lower().endswith(".md"):
            content = _parse_recipe_from_md(path_or_modifiers, content)

        if path_or_modifiers.lower().endswith(".json"):
            obj = json.loads(content)
        elif path_or_modifiers.lower().endswith((".yaml", ".yml")):
            obj = yaml.safe_load(content)
        else:
            obj = _load_json_or_yaml_string(content)
        return cls.from_dict(filter_dict(obj, target_stage=target_stage))
입력 형태 처리 경로
Recipe 객체 그대로 반환
Modifier 단일 인스턴스 from_modifiers로 감싸서 단일 스테이지 Recipe 생성
Modifier 리스트 동일하게 단일 스테이지 Recipe 생성
문자열 (파일이 아님) YAML/JSON 문자열로 간주해 _load_json_or_yaml_stringfrom_dict
.yaml/.yml 파일 yaml.safe_loadfrom_dict
.json 파일 json.loadsfrom_dict
.md 파일 _parse_recipe_from_md로 마크다운 코드블록에서 레시피 추출 후 처리
기타 파일 JSON/YAML 시도

.md 지원은 의외다. llm-compressor는 README나 실험 노트에 직접 넣은 레시피 코드블록을 그대로 레시피로 쓸 수 있게 한다. 이는 실험 로그와 실행 코드가 분리되지 않는 "과학 노트북 친화적" 설계 의도로 보인다.

from_dict: 스테이지/그룹/Modifier 이름의 동적 해석

실제로 YAML이 파이썬 객체로 변환되는 곳은 from_dict이다.

@classmethod
def from_dict(cls, recipe_dict: Dict[str, Any]) -> "Recipe":
    args = recipe_dict.get("args", {})    # 최상위 `args:` 블록
    modifiers: List[Modifier] = []
    stage = "default"

    # 1) ModifierFactory 가 아직 등록되지 않았으면 플러그인 스캔
    if not ModifierFactory._loaded:
        ModifierFactory.refresh()

    # 2) 최상위 키를 순회하면서 "*_stage" 패턴 매칭
    for stage_key, stage_val in recipe_dict.items():
        if stage_key.endswith("_stage") and isinstance(stage_val, dict):
            stage = stage_key.replace("_stage", "")

            # 3) 스테이지 안쪽에서 "*_modifiers" 패턴 매칭
            for group_key, group_val in stage_val.items():
                if group_key.endswith("_modifiers") and isinstance(group_val, dict):
                    inferred_group = group_key.replace("_modifiers", "")

                    # 4) 각 그룹 안의 각 Modifier 클래스 이름 → 인스턴스화
                    for mod_type, mod_args in group_val.items():
                        group = mod_args.get("group", inferred_group)
                        modifier = ModifierFactory.create(
                            mod_type,                    # 예: "GPTQModifier"
                            group=group,                 # 예: "quantization"
                            allow_registered=True,       # 기본 Modifier 허용
                            allow_experimental=True,     # modifiers/experimental/ 도 허용
                            **mod_args,                  # 나머지는 Modifier 생성자로 전달
                        )
                        modifiers.append(modifier)

    return Recipe(
        args=args,
        stage=stage,
        modifiers=modifiers,
    )

핵심은 두 단계 패턴 매칭이다.

  1. *_stage 매칭. 최상위 키 이름이 _stage로 끝나야 파서가 인식한다. 이 덕분에 args:metadata: 같은 보조 키를 최상위에 두어도 파서가 무시한다.
  2. *_modifiers 매칭. 스테이지 내부 키가 _modifiers로 끝나야 그 아래의 딕셔너리를 "Modifier 그룹"으로 본다. 그룹 이름은 키 이름에서 추출되며(예: quantization_modifiers"quantization"), 각 Modifier의 group 속성이 된다.

그리고 각 Modifier는 ModifierFactory.create를 통해 생성된다. 문자열 "GPTQModifier"가 실제 GPTQModifier 클래스로 매핑되는 지점이 여기다. allow_experimental=Truemodifiers/experimental/ 아래의 실험적 Modifier도 허용한다는 뜻이다.

from_modifiers: Python에서 직접 구성하는 경로

YAML을 쓰고 싶지 않은 사용자를 위한 경로다.

@classmethod
def from_modifiers(
    cls,
    modifiers: Union[Modifier, List[Modifier]],    # 이미 인스턴스화된 Modifier 들
    modifier_group_name: Optional[str] = None,     # 스테이지 이름 힌트
) -> "Recipe":
    if isinstance(modifiers, Modifier):
        modifiers = [modifiers]

    if any(not isinstance(modifier, Modifier) for modifier in modifiers):
        raise ValueError("modifiers must be a list of Modifier instances")

    group_name = modifier_group_name or "default"

    recipe = cls()
    recipe.stage = group_name
    recipe.modifiers = modifiers
    return recipe

단순히 Recipe 객체를 만들고 modifiers 필드에 주입한다. YAML 파싱 경로와 달리 ModifierFactory를 거치지 않는다. 사용자가 이미 GPTQModifier(...)로 직접 인스턴스를 만들었기 때문이다.

examples/quantization_w4a16/llama3_example.py가 이 경로를 쓴다. 사용자는 Python 코드로 Modifier 리스트를 만들어 oneshot(recipe=[...])로 넘기고, llm-compressor는 내부적으로 from_modifiers를 호출해 Recipe로 변환한다.

dict / yaml: 직렬화

def dict(self, *args, **kwargs) -> Dict[str, Any]:
    return get_yaml_serializable_dict(modifiers=self.modifiers, stage=self.stage)

def yaml(
    self,
    file_path: Optional[str] = None,
    existing_recipe_path: Optional[str] = None,
) -> str:
    existing_dict = {}
    if existing_recipe_path:
        with open(existing_recipe_path, "r") as f:
            existing_recipe_str = f.read()
        existing_dict = _load_json_or_yaml_string(existing_recipe_str)

    self_dict = get_yaml_serializable_dict(
        modifiers=self.modifiers,
        stage=self.stage,
    )
    merged_dict = append_recipe_dict(existing_dict, self_dict)

    yaml_str = yaml.dump(
        merged_dict,
        allow_unicode=True,
        sort_keys=False,             # YAML 키 순서 유지 (가독성)
        default_flow_style=None,
        width=88,                    # 한 줄 최대 길이
    )
    return yaml_str

yaml 메서드는 선택적으로 기존 레시피 파일과 병합할 수 있다. 여러 스테이지를 가진 파이프라인을 점진적으로 쌓을 때 유용하다. 예를 들어 기존 pruning_stage가 있는 파일에 quant_stage를 추가하고 싶으면, 새 Recipe를 만들고 yaml(existing_recipe_path="pruning.yaml")로 호출하면 두 스테이지가 같은 파일에 병합된 문자열이 나온다.

sort_keys=False는 읽기 편의성을 위한 결정이다. Pydantic의 기본 직렬화는 알파벳 순인데, 레시피는 "스테이지 → 그룹 → Modifier → args" 순서가 보존되어야 사람 눈에 자연스럽다.

왜 이 설계인가

1. 키 이름에 타입 인코딩. *_stage, *_modifiers 같은 suffix 규칙은 YAML에서 스키마를 따로 선언할 필요를 없앤다. 파서가 키 이름만 보고 "이건 스테이지이고, 이건 모디파이어 그룹이다"를 판단할 수 있다. 사용자는 문서 한 줄("스테이지 키는 _stage로 끝나야 합니다")만 읽으면 된다.

2. Pydantic 기반 Recipe 모델. Recipe가 BaseModel을 상속하기 때문에 JSON/YAML 직렬화, 타입 검증, 기본값 처리가 모두 공짜다. arbitrary_types_allowed=True로 Modifier 서브클래스를 필드에 담을 수 있게 허용한 것이 핵심 트릭이다.

3. ModifierFactory 동적 해석. 레시피 YAML 안에 GPTQModifier 문자열만 적혀 있어도 실제 클래스로 변환된다. 플러그인 시스템이 깔끔해져서, 사용자가 자체 Modifier를 만들어 entrypoints로 등록하면 레시피 YAML에 그대로 쓸 수 있다.

4. 다형적 create_instance. 하나의 classmethod가 YAML 경로, 문자열, Python 리스트, 이미 만들어진 Recipe 객체까지 모두 받는다. 사용자 코드에서는 Recipe.create_instance(사용자_입력) 한 줄로 끝난다. 이는 oneshot()recipe 인자가 str | list | Recipe | Modifier 네 타입을 모두 받을 수 있는 이유다.

5. 마크다운 파일 지원. 실험 노트에 적어둔 YAML 코드블록을 그대로 레시피로 사용할 수 있다. 이는 연구자 워크플로우에 맞춘 편의 기능으로, 문서와 실행이 분리되지 않는다.

마무리

Recipe DSL은 llm-compressor의 선언적 중심이다. "어떤 알고리즘을 어떤 순서로 쓸지"라는 정책을 YAML에 담고, 실제 실행은 CompressionSessionPipeline이 담당한다. 다음 글에서는 Recipe 객체가 보관하는 메타데이터(해시, 버전, 변수 치환)를 다루는 Recipe Metadata를 살펴본다.

참고 자료

댓글

관련 포스트

llm-compressor 의 다른글