From 03dd3d543bc278ac7cfeb60e4c1474ced309f872 Mon Sep 17 00:00:00 2001 From: skt0725 Date: Sat, 4 Jul 2026 11:26:15 +0900 Subject: [PATCH 1/6] Add AWQ INT4 quantization module --- .gitignore | 4 + qwen3-awq/AWQ_USAGE.md | 84 +++++++++ qwen3-awq/DESIGN_NOTES.md | 53 ++++++ qwen3-awq/awq/__init__.py | 19 ++ qwen3-awq/awq/calibration.py | 205 ++++++++++++++++++++ qwen3-awq/awq/export.py | 181 ++++++++++++++++++ qwen3-awq/awq/pack.py | 117 ++++++++++++ qwen3-awq/awq/pipeline.py | 186 ++++++++++++++++++ qwen3-awq/awq/quantize.py | 355 +++++++++++++++++++++++++++++++++++ qwen3-awq/requirements.txt | 21 +++ qwen3-awq/run_awq.py | 80 ++++++++ 11 files changed, 1305 insertions(+) create mode 100644 .gitignore create mode 100644 qwen3-awq/AWQ_USAGE.md create mode 100644 qwen3-awq/DESIGN_NOTES.md create mode 100644 qwen3-awq/awq/__init__.py create mode 100644 qwen3-awq/awq/calibration.py create mode 100644 qwen3-awq/awq/export.py create mode 100644 qwen3-awq/awq/pack.py create mode 100644 qwen3-awq/awq/pipeline.py create mode 100644 qwen3-awq/awq/quantize.py create mode 100644 qwen3-awq/requirements.txt create mode 100644 qwen3-awq/run_awq.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d201f36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.DS_Store +outputs/ diff --git a/qwen3-awq/AWQ_USAGE.md b/qwen3-awq/AWQ_USAGE.md new file mode 100644 index 0000000..984e0c0 --- /dev/null +++ b/qwen3-awq/AWQ_USAGE.md @@ -0,0 +1,84 @@ +# AWQ 양자화 모듈 사용 가이드 + +AWQ INT4 양자화를 직접 구현한 모듈. `AutoModelForCausalLM`으로 로드되는 모든 HF 모델에 +적용 가능하며, gptqmodel/vLLM이 바로 로드하는 AWQ 표준 포맷으로 저장합니다. + +## 빠른 시작 + +```bash +python run_awq.py --model Qwen/Qwen3-4B --calib-data pileval + +# 다른 모델도 동일한 커맨드 (Llama, Mistral, Phi, TinyLlama 등) +python run_awq.py --model meta-llama/Llama-3.2-3B --calib-data wikitext2 +python run_awq.py --model mistralai/Mistral-7B-v0.3 --calib-data c4 +python run_awq.py --model TinyLlama/TinyLlama-1.1B-Chat-v1.0 --calib-data wikitext2 + +# 한국어 calibration +python run_awq.py --model Qwen/Qwen3-4B --calib-data kowikitext +``` + +```python +# Python API +from awq import AWQQuantizer +quantizer = AWQQuantizer("Qwen/Qwen3-4B", w_bit=4, group_size=128) +quantizer.quantize(calib_data="pileval", output_dir="./outputs/qwen3-4b-awq") +``` + +파이프라인: **calibration**(activation 통계 수집, hook 기반) → **scale 탐색**(grid search) +→ **INT4 양자화** → **export**(safetensors). `lm_head`는 자동 제외. + +## 주요 옵션 + +| 인자 | 기본값 | 설명 | +|------|--------|------| +| `--model` | `Qwen/Qwen3-4B` | HF 모델 ID / 로컬 경로 | +| `--calib-data` | `pileval` | `pileval` / `wikitext2` / `kowikitext`(한국어) / `c4` | +| `--n-samples` / `--seq-len` | 128 / 512 | calibration 규모 (코드 검증만 할 땐 16 / 256) | +| `--w-bit` / `--group-size` | 4 / 128 | 양자화 설정 | +| `--skip-layers` | `lm_head` | 제외 레이어 (정확한 모듈 이름) | + +## 대상 모델 요건 + +- `in_features % group_size == 0` — 안 맞는 레이어는 자동 스킵(FP16 유지) +- `out_features % 8 == 0` — INT4 8개 → INT32 패킹 단위 +- gated repo(Gemma, Llama 등)는 HF 접근 승인 + 로그인 필요 + +커스텀 calibration 데이터는 `awq/calibration.py`의 `get_calib_dataset()`에 분기 추가 +(텍스트 리스트만 만들면 토크나이즈/분할은 공통 처리). + +## 출력 포맷 (AWQ GEMM 표준) + +| 텐서 | Shape | +|------|-------| +| `qweight` | `[in_features, out_features // 8]` INT32 | +| `scales` | `[n_groups, out_features]` FP16 | +| `qzeros` | `[n_groups, out_features // 8]` INT32 | + +INT32 내부 패킹은 **인터리브 순서 `[0, 2, 4, 6, 1, 3, 5, 7]`** (순차로 하면 출력이 깨짐). + +로드 (GPU 필요): + +```python +# transformers + gptqmodel — config의 quantization_config로 자동 인식 +model = AutoModelForCausalLM.from_pretrained(path, torch_dtype="float16", device_map="auto") + +# vLLM +llm = LLM(model=path, quantization="awq") +``` + +## 검증 절차 + +1. 작은 모델로 스모크 테스트: `--model TinyLlama/TinyLlama-1.1B-Chat-v1.0 --n-samples 16 --seq-len 256` +2. generate 테스트 — 깨진 토큰 반복이면 패킹 문제, 품질만 낮으면 calibration 문제 +3. `lm_eval` 벤치마크 — 랜덤 수준(~25%)이면 export 버그 의심: + ```bash + python -m lm_eval --model hf \ + --model_args pretrained=,dtype=float16,device_map=auto \ + --tasks kmmlu --batch_size 16 + ``` + +## 자주 겪는 문제 + +- **`autoawq` 이름 충돌**: `run_awq.py`가 importlib로 로컬 `awq/`를 명시적 로드해 회피 +- **Colab pip install 후 ImportError**: 설치 후 반드시 런타임 재시작 +- **출력이 깨진 토큰 반복**: 패킹 인터리브 순서 확인 diff --git a/qwen3-awq/DESIGN_NOTES.md b/qwen3-awq/DESIGN_NOTES.md new file mode 100644 index 0000000..e48a361 --- /dev/null +++ b/qwen3-awq/DESIGN_NOTES.md @@ -0,0 +1,53 @@ +# 직접 구현 AWQ vs 공식 AutoAWQ + +## 결과 (Qwen3-4B, KMMLU) + +| 모델 | KMMLU acc | FP16 대비 | +|------|-----------|-----------| +| FP16 baseline | 45.81% | — | +| 공식 AutoAWQ (INT4) | 44.11% | −1.70%p | +| 직접 구현 (INT4, pileval) | 40.34% | −5.47%p | + +- INT4 로딩(40.34%)과 FP16 dequant 시뮬레이션(40.33%)이 일치 → **export 파이프라인 정상** +- 공식 대비 −3.8%p는 버그가 아니라 아래 **의도적 단순화 3가지**의 대가 + +## 차이점과 이유 + +### 1. Scale folding 없음 → 이중 양자화 (격차의 주원인) + +AWQ는 `W·s`를 양자화하면 입력 쪽에 `1/s` 보정이 필요합니다. + +- **공식**: `1/s`를 이전 연산(LayerNorm, 앞단 Linear)의 weight에 접음(fold) → 양자화 1회 +- **우리**: `dequant(quant(W·s)) / s`를 만든 뒤 이를 **다시 INT4로 양자화** → 오차 2회 누적 + +**이유**: fold는 아키텍처별 레이어 연결 매핑이 필요합니다 (AutoAWQ가 모델마다 전용 +클래스를 두는 이유). "아무 HF 모델에나 적용"이라는 범용성 목표를 위해 unfold를 택했습니다. + +### 2. 탐색 목적함수: weight 오차 vs 출력 오차 + +- **공식**: calibration 입력 X를 캐시해 `‖Q(W·s)·(X/s) − W·X‖` (출력 오차) 최소화 + — activation이 큰 채널의 오차에 자동으로 가중치가 실림 +- **우리**: activation 통계는 탐색 후보(`s = act_scales^α`)에만 쓰고, + 선택은 `‖dequant(quant(W·s)) − W·s‖` (weight 오차)로 함 + +**이유**: 출력 오차 기준은 레이어 입력 X(레이어당 수백 MB)가 필요해 공식은 블록 단위 +순차 실행 인프라를 씁니다. 우리는 hook으로 채널당 통계 벡터(수 KB)만 수집하는 +단순 구조를 유지했습니다 (Colab 12.67GB RAM 제약 포함). + +### 3. Weight clipping 탐색 생략 + +공식은 그룹 max를 얼마나 잘라낼지도 grid search (outlier로 인한 해상도 낭비 방지). +핵심 알고리즘 이해에 집중하기 위해 생략 — AWQ 고유 아이디어는 아님. + +## 공식과 동일한 부분 + +INT4 group-wise asymmetric 양자화(group 128), alpha grid search(0~1, 20 grid), +AWQ GEMM export 포맷(인터리브 패킹 `[0,2,4,6,1,3,5,7]`, gptqmodel/vLLM 호환), lm_head 제외. + +## 개선 로드맵 + +1. **입력 서브샘플 캐시 + 출력 오차 탐색** — hook에서 레이어당 수백 행만 저장(~1GB 미만), + 범용성 유지하며 목적함수를 공식과 동일하게 (추천 다음 단계) +2. **clipping 탐색** — 범용성 유지, 구현 간단 +3. **LayerNorm fold** — Llama-계열 표준 구조 한정 지원 + 미지원 모델은 폴백. + 이중 양자화 제거로 가장 큰 격차 해소, 대신 아키텍처 의존성 발생 diff --git a/qwen3-awq/awq/__init__.py b/qwen3-awq/awq/__init__.py new file mode 100644 index 0000000..4a1b95b --- /dev/null +++ b/qwen3-awq/awq/__init__.py @@ -0,0 +1,19 @@ +""" +awq — Activation-aware Weight Quantization + +HuggingFace CausalLM 모델에 대한 AWQ INT4 양자화 파이프라인. + +사용법: + from awq import AWQQuantizer + + quantizer = AWQQuantizer( + model_name="Qwen/Qwen3-4B", + w_bit=4, + group_size=128, + ) + quantizer.quantize(calib_data="pileval", output_dir="./outputs/qwen3-4b-awq") +""" + +from .pipeline import AWQQuantizer + +__all__ = ["AWQQuantizer"] diff --git a/qwen3-awq/awq/calibration.py b/qwen3-awq/awq/calibration.py new file mode 100644 index 0000000..471623b --- /dev/null +++ b/qwen3-awq/awq/calibration.py @@ -0,0 +1,205 @@ +""" +awq/calibration.py + +AWQ에서 사용하는 calibration 데이터를 수집하고, +각 Linear 레이어의 입력 activation 통계를 기록합니다. + +AWQ 핵심 아이디어: + - weight quantization 오류는 모든 채널에 동등하지 않음 + - activation magnitude가 큰 채널(salient channel)의 오류가 최종 출력에 더 큰 영향을 미침 + - 따라서 activation 통계를 먼저 수집해야 함 +""" + +import torch +import yaml +from typing import Optional +from tqdm import tqdm +from datasets import load_dataset +from transformers import AutoTokenizer, AutoModelForCausalLM + + +def load_config(config_path: str = "../configs/config.yaml") -> dict: + with open(config_path) as f: + return yaml.safe_load(f) + + +# --------------------------------------------------------------------------- +# Calibration 데이터셋 로드 +# --------------------------------------------------------------------------- + +def get_calib_dataset( + dataset_name: str = "pileval", + tokenizer=None, + n_samples: int = 512, + seq_len: int = 512, +) -> list[torch.Tensor]: + """ + Calibration에 사용할 토큰 시퀀스를 반환합니다. + + Args: + dataset_name: 사용할 데이터셋 ("pileval", "wikitext2", "c4") + tokenizer: HuggingFace tokenizer + n_samples: 수집할 샘플 수 + seq_len: 각 샘플의 시퀀스 길이 + + Returns: + [n_samples, seq_len] 크기의 input_ids 텐서 리스트 + """ + if dataset_name == "pileval": + dataset = load_dataset("mit-han-lab/pile-val-backup", split="validation") + texts = [item["text"] for item in dataset] + elif dataset_name == "wikitext2": + dataset = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="train") + texts = [item["text"] for item in dataset if len(item["text"]) > 100] + elif dataset_name == "kowikitext": + # 한국어 위키피디아 (Parquet 포맷 — 스크립트 기반이 아니라 최신 datasets에서 동작) + # streaming으로 필요한 만큼만 받음 + dataset = load_dataset("wikimedia/wikipedia", "20231101.ko", split="train", streaming=True) + texts = [] + for item in dataset: + if len(item["text"]) > 100: + texts.append(item["text"]) + if len(texts) >= n_samples * 4: + break + elif dataset_name == "c4": + dataset = load_dataset("c4", "en", split="train", streaming=True) + texts = [item["text"] for item in dataset.take(n_samples * 2)] + else: + raise ValueError(f"Unknown dataset: {dataset_name}") + + samples = [] + for text in texts: + enc = tokenizer(text, return_tensors="pt", truncation=False) + input_ids = enc.input_ids[0] + + # seq_len 단위로 자르기 + for start in range(0, len(input_ids) - seq_len, seq_len): + samples.append(input_ids[start : start + seq_len]) + if len(samples) >= n_samples: + return samples + + return samples + + +# --------------------------------------------------------------------------- +# Activation Hook으로 통계 수집 +# --------------------------------------------------------------------------- + +class ActivationCollector: + """ + nn.Linear 레이어의 입력 activation을 hook으로 수집하고 + 채널별 통계(mean absolute value)를 기록합니다. + """ + + def __init__(self): + self.stats: dict[str, torch.Tensor] = {} # layer_name -> abs_mean per input channel + self._hooks = [] + self._layer_names: dict = {} # module -> name + + def register(self, model: torch.nn.Module) -> None: + """모든 Linear 레이어에 forward hook 등록.""" + for name, module in model.named_modules(): + if isinstance(module, torch.nn.Linear): + self._layer_names[module] = name + hook = module.register_forward_hook(self._hook_fn) + self._hooks.append(hook) + + def _hook_fn(self, module, input, output): + """ + Forward hook: 입력 activation의 채널별 |mean| 을 누적합니다. + + input[0] shape: [batch, seq_len, in_features] (or [batch, in_features]) + """ + x = input[0].detach().float() # [B, T, C] or [B, C] + if x.dim() == 3: + x = x.view(-1, x.shape[-1]) # [B*T, C] + + abs_mean = x.abs().mean(dim=0) # [C] — 채널별 평균 활성화 크기 + + name = self._layer_names[module] + if name not in self.stats: + self.stats[name] = abs_mean + else: + # 누적 평균 (온라인 방식) + self.stats[name] = (self.stats[name] + abs_mean) / 2.0 + + def remove(self) -> None: + """등록된 hook 제거.""" + for hook in self._hooks: + hook.remove() + self._hooks.clear() + + +# --------------------------------------------------------------------------- +# Calibration 실행 +# --------------------------------------------------------------------------- + +def run_calibration( + model: torch.nn.Module, + tokenizer, + config: dict, +) -> dict[str, torch.Tensor]: + """ + Calibration 데이터를 forward pass하여 각 레이어의 + 입력 activation 통계를 수집합니다. + + Args: + model: 원본 FP16 모델 + tokenizer: HuggingFace tokenizer + config: 설정 딕셔너리 + + Returns: + layer_name -> abs_mean_per_channel 딕셔너리 + """ + calib_cfg = config["calibration"] + device = next(model.parameters()).device + + print(f"Loading calibration dataset: {calib_cfg['dataset']}") + samples = get_calib_dataset( + dataset_name=calib_cfg["dataset"], + tokenizer=tokenizer, + n_samples=calib_cfg["n_samples"], + seq_len=calib_cfg["seq_len"], + ) + + collector = ActivationCollector() + collector.register(model) + model.eval() + + print(f"Running {len(samples)} calibration samples...") + with torch.no_grad(): + for input_ids in tqdm(samples, desc="Calibration"): + input_ids = input_ids.unsqueeze(0).to(device) # [1, seq_len] + model(input_ids) + + collector.remove() + + print(f"Collected activation stats for {len(collector.stats)} layers.") + return collector.stats + + +# --------------------------------------------------------------------------- +# 메인 +# --------------------------------------------------------------------------- + +def main(): + config = load_config() + model_name = config["paths"]["baseline_model"] + + tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + model = AutoModelForCausalLM.from_pretrained( + model_name, + torch_dtype=torch.float16, + device_map="auto", + trust_remote_code=True, + ) + + act_stats = run_calibration(model, tokenizer, config) + + # 확인용 출력 + for name, stat in list(act_stats.items())[:5]: + print(f"{name}: shape={stat.shape}, max={stat.max():.4f}, mean={stat.mean():.4f}") + + +if __name__ == "__main__": + main() diff --git a/qwen3-awq/awq/export.py b/qwen3-awq/awq/export.py new file mode 100644 index 0000000..59da5da --- /dev/null +++ b/qwen3-awq/awq/export.py @@ -0,0 +1,181 @@ +""" +awq/export.py + +AWQ quantized 모델을 vLLM이 로드할 수 있는 포맷으로 저장합니다. + +vLLM AWQ 포맷 요구사항: + - config.json에 "quantization_config": {"quant_type": "awq", "w_bit": 4, "group_size": 128} + - safetensors에 각 Linear 레이어의 qweight / scales / zeros 포함 + - tokenizer 파일 동일하게 복사 +""" + +import json +import shutil +import torch +import yaml +from pathlib import Path +from transformers import AutoTokenizer, AutoModelForCausalLM +from safetensors.torch import save_file + + +def load_config(config_path: str = "../configs/config.yaml") -> dict: + with open(config_path) as f: + return yaml.safe_load(f) + + +def build_awq_config_json(base_config: dict, awq_cfg: dict) -> dict: + """ + 원본 모델의 config.json에 AWQ quantization 설정을 추가합니다. + + Args: + base_config: 원본 모델의 config.json 딕셔너리 + awq_cfg : config.yaml의 awq 섹션 + + Returns: + vLLM AWQ 포맷에 맞는 config 딕셔너리 + """ + config = base_config.copy() + config["quantization_config"] = { + "quant_type": "awq", + "quant_method": "awq", + "bits": awq_cfg["w_bit"], + "w_bit": awq_cfg["w_bit"], + "group_size": awq_cfg["group_size"], + "zero_point": awq_cfg["zero_point"], + "version": "GEMM", + } + return config + + +def export_awq_model( + model: torch.nn.Module, + quant_results: dict, # quantize.py의 quantize_model 결과 + tokenizer, + output_dir: str, + config: dict, +) -> None: + """ + AWQ quantized 모델을 output_dir에 저장합니다. + + 저장 구조: + output_dir/ + ├── config.json (AWQ quantization_config 포함) + ├── model.safetensors (qweight, scales, zeros + 나머지 FP 파라미터) + ├── tokenizer.json + ├── tokenizer_config.json + └── special_tokens_map.json + + Args: + model : AWQ가 적용된 모델 + quant_results: {layer_name: {"qweight", "scales", "zeros", "best_scale"}} + tokenizer : HuggingFace tokenizer + output_dir : 저장 경로 + config : config.yaml 설정 + + # TODO: 아래 구현부를 완성하세요. + # + # 단계: + # 1. output_dir 생성 + # 2. 원본 config.json 로드 → build_awq_config_json()으로 AWQ 설정 추가 → 저장 + # 3. state_dict 순회: + # - quant_results에 있는 레이어 → qweight/scales/zeros로 교체 + # - 나머지 파라미터 → 그대로 포함 + # 4. save_file()로 safetensors 저장 + # 5. tokenizer 저장 (tokenizer.save_pretrained) + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + awq_cfg = config["awq"] + + # 원본 config.json 로드 및 AWQ 설정 추가 + base_config = model.config.to_dict() + awq_config = build_awq_config_json(base_config, awq_cfg) + with open(output_path / "config.json", "w") as f: + json.dump(awq_config, f, indent=2, ensure_ascii=False) + + # state_dict 구성: 양자화된 레이어는 qweight/qzeros/scales로 교체 + from .pack import pack_int4_weight, AWQ_PACK_ORDER + + state_dict = {} + quant_layer_prefixes = set() + w_bit = awq_cfg["w_bit"] + for layer_name, qr in quant_results.items(): + quant_layer_prefixes.add(layer_name) + state_dict[f"{layer_name}.qweight"] = qr["qweight"] + # AWQ 표준: scales는 [n_groups, out_features] (전치) + state_dict[f"{layer_name}.scales"] = qr["scales"].T.contiguous() + if qr["zeros"] is not None: + # AWQ 표준: zeros를 전치 → [n_groups, out_features] → out_features 방향으로 패킹 + zeros_int = qr["zeros"].cpu().to(torch.int32).T.contiguous() # [n_groups, out_features] + values_per_int32 = 32 // w_bit + n_groups, out_f = zeros_int.shape + if out_f % values_per_int32 == 0: + zeros_reshaped = zeros_int.reshape(n_groups, out_f // values_per_int32, values_per_int32) + qzeros = torch.zeros(n_groups, out_f // values_per_int32, dtype=torch.int32) + for i in range(values_per_int32): + qzeros |= (zeros_reshaped[:, :, AWQ_PACK_ORDER[i]] << (i * w_bit)) + state_dict[f"{layer_name}.qzeros"] = qzeros + else: + state_dict[f"{layer_name}.qzeros"] = zeros_int + + # 양자화되지 않은 파라미터(embedding, norm 등)는 그대로 포함 + for name, param in model.named_parameters(): + layer_name = name.rsplit(".", 1)[0] # "model.layers.0.self_attn.q_proj.weight" → "model.layers.0.self_attn.q_proj" + if layer_name not in quant_layer_prefixes: + state_dict[name] = param.data + + save_file(state_dict, str(output_path / "model.safetensors")) + + # tokenizer 저장 + tokenizer.save_pretrained(output_dir) + + print(f"AWQ 모델 저장 완료: {output_dir}") + + +def run_autoawq_baseline(config: dict) -> None: + """ + AutoAWQ 라이브러리를 사용해 베이스라인 AWQ 모델을 생성합니다. + 직접 구현 결과와 비교하는 기준값으로 사용합니다. + + 필요 패키지: pip install autoawq + """ + from awq import AutoAWQForCausalLM + + model_name = config["paths"]["baseline_model"] + output_dir = config["paths"]["awq_autoawq_output"] + awq_cfg = config["awq"] + + quant_config = { + "w_bit": awq_cfg["w_bit"], + "q_group_size": awq_cfg["group_size"], + "zero_point": awq_cfg["zero_point"], + "version": "GEMM", + } + + print(f"Loading model: {model_name}") + model = AutoAWQForCausalLM.from_pretrained(model_name, trust_remote_code=True) + tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + + print("Running AutoAWQ quantization (baseline)...") + model.quantize(tokenizer, quant_config=quant_config) + + print(f"Saving AutoAWQ baseline to: {output_dir}") + model.save_quantized(output_dir) + tokenizer.save_pretrained(output_dir) + print("Done.") + + +def main(): + config = load_config() + + print("=== Generating AutoAWQ Baseline ===") + run_autoawq_baseline(config) + + print("\nAutoAWQ 베이스라인 생성 완료.") + print(f"저장 위치: {config['paths']['awq_autoawq_output']}") + print("\n직접 구현 AWQ 내보내기는 export_awq_model()을 구현한 뒤 실행하세요.") + + +if __name__ == "__main__": + main() diff --git a/qwen3-awq/awq/pack.py b/qwen3-awq/awq/pack.py new file mode 100644 index 0000000..903fd17 --- /dev/null +++ b/qwen3-awq/awq/pack.py @@ -0,0 +1,117 @@ +""" +awq/pack.py + +INT4 weight packing 유틸리티. + +GPU 메모리 효율을 위해 INT4 두 값을 INT8 하나에 패킹합니다. +vLLM의 AWQ 커널이 기대하는 포맷(GEMM-friendly layout)으로 변환합니다. + +패킹 방식 (AWQ 표준): + - in_features 방향을 group_size 단위로 분할 + - 각 그룹 내 weight를 INT4로 양자화 후 두 값씩 묶어 INT8로 저장 + - shape 변환: [out_features, in_features] → [out_features, in_features // 8 * w_bit] +""" + +import torch + +# AWQ GEMM 커널의 인터리브 패킹 순서: +# INT32의 i번째 nibble(4bit)에 원본 인덱스 AWQ_PACK_ORDER[i]의 값이 들어감 +AWQ_PACK_ORDER = [0, 2, 4, 6, 1, 3, 5, 7] + + +def pack_int4_weight( + w_int: torch.Tensor, + w_bit: int = 4, +) -> torch.Tensor: + """ + INT4 양자화된 weight를 INT32 텐서에 패킹합니다. + + vLLM AWQ 커널은 INT4 값 8개를 INT32 하나에 묶는 포맷을 사용합니다. + + Args: + w_int : [out_features, in_features] INT4 범위 (0~15)의 정수 텐서 + w_bit : quantization bits (현재 4만 지원) + + Returns: + qweight: [out_features, in_features // (32 // w_bit)] INT32 텐서 + + # TODO: 아래 구현부를 완성하세요. + # + # 힌트: + # - values_per_int32 = 32 // w_bit → INT4면 8 + # - in_features를 values_per_int32 로 나누어 [out_features, in_features//8, 8] reshape + # - bit shift로 8개 INT4를 INT32 하나로 합치기: + # packed = w[:, :, 0] + # | (w[:, :, 1] << 4) + # | (w[:, :, 2] << 8) ... + """ + assert w_bit == 4, "현재 INT4 packing만 지원합니다." + + out_features, in_features = w_int.shape + values_per_int32 = 32 // w_bit # 8 + + w_int = w_int.to(torch.int32) + w_int = w_int.reshape(out_features, in_features // values_per_int32, values_per_int32) + + packed = torch.zeros(out_features, in_features // values_per_int32, dtype=torch.int32, device=w_int.device) + for i in range(values_per_int32): + packed |= (w_int[:, :, AWQ_PACK_ORDER[i]] << (i * w_bit)) + + return packed + + +def unpack_int4_weight( + qweight: torch.Tensor, + w_bit: int = 4, + original_in_features: int = None, +) -> torch.Tensor: + """ + pack_int4_weight의 역연산 — 디버깅 및 검증용. + + Args: + qweight : [out_features, in_features // 8] INT32 packed tensor + w_bit : quantization bits + original_in_features : 원본 in_features (None이면 qweight에서 추론) + + Returns: + w_int: [out_features, in_features] INT4 범위 정수 텐서 + + # TODO: 아래 구현부를 완성하세요. + # + # 힌트: + # - pack의 역순: INT32 → 8개 INT4로 분리 + # - bit mask: 0xF (= 0b1111) 로 하위 4비트 추출 + # - right shift 후 mask 적용 + """ + values_per_int32 = 32 // w_bit # 8 + out_features = qweight.shape[0] + packed_in = qweight.shape[1] + + if original_in_features is None: + original_in_features = packed_in * values_per_int32 + + mask = (1 << w_bit) - 1 # 0xF + + unpacked = [None] * values_per_int32 + for i in range(values_per_int32): + unpacked[AWQ_PACK_ORDER[i]] = (qweight >> (i * w_bit)) & mask + + w_int = torch.stack(unpacked, dim=-1) # [out_f, packed_in, 8] + w_int = w_int.reshape(out_features, -1)[:, :original_in_features] + + return w_int + + +def verify_pack_unpack(out_f: int = 64, in_f: int = 128): + """pack → unpack 왕복 검증 (구현 완료 후 테스트용).""" + torch.manual_seed(0) + w_int = torch.randint(0, 16, (out_f, in_f), dtype=torch.int32) + + qweight = pack_int4_weight(w_int) + w_restored = unpack_int4_weight(qweight, original_in_features=in_f) + assert torch.all(w_int == w_restored), "Pack/Unpack 불일치!" + print(f"Pack/Unpack 검증 통과: {out_f}x{in_f} → packed {qweight.shape} → restored {w_restored.shape}") + + +if __name__ == "__main__": + verify_pack_unpack() diff --git a/qwen3-awq/awq/pipeline.py b/qwen3-awq/awq/pipeline.py new file mode 100644 index 0000000..970d698 --- /dev/null +++ b/qwen3-awq/awq/pipeline.py @@ -0,0 +1,186 @@ +""" +awq/pipeline.py + +AWQ 양자화 파이프라인을 하나의 클래스로 통합합니다. +모델에 종속되지 않고, HuggingFace AutoModelForCausalLM을 지원하는 +모든 모델에 범용적으로 사용할 수 있습니다. +""" + +import torch +from pathlib import Path +from transformers import AutoTokenizer, AutoModelForCausalLM + +from .calibration import run_calibration, get_calib_dataset +from .quantize import quantize_model +from .export import export_awq_model + + +# 모델 아키텍처별 양자화에서 제외할 레이어 패턴 +# 모든 모델에서 lm_head는 기본 제외 (vocab projection) +DEFAULT_SKIP_PATTERNS = {"lm_head"} + + +class AWQQuantizer: + """ + AWQ INT4 양자화 파이프라인. + + HuggingFace CausalLM 모델이면 아키텍처에 관계없이 사용 가능합니다. + (Qwen, LLaMA, Mistral, Gemma, Phi 등) + + Args: + model_name: HuggingFace 모델 ID 또는 로컬 경로 + w_bit: 양자화 비트 수 (기본 4) + group_size: 그룹 양자화 크기 (기본 128) + zero_point: asymmetric 양자화 사용 여부 (기본 True) + skip_layers: 양자화에서 제외할 레이어 이름 set (기본: {"lm_head"}) + device_map: 모델 로드 시 device_map (기본 "auto") + trust_remote_code: trust_remote_code 설정 (기본 True) + + Example: + >>> quantizer = AWQQuantizer("Qwen/Qwen3-4B") + >>> quantizer.quantize(calib_data="pileval", output_dir="./output") + + >>> # 이미 로드된 모델 사용 + >>> quantizer = AWQQuantizer.from_pretrained(model, tokenizer) + >>> quantizer.quantize(calib_data="kowikitext", output_dir="./output") + """ + + def __init__( + self, + model_name: str, + w_bit: int = 4, + group_size: int = 128, + zero_point: bool = True, + skip_layers: set[str] | None = None, + device_map: str = "auto", + trust_remote_code: bool = True, + ): + self.model_name = model_name + self.w_bit = w_bit + self.group_size = group_size + self.zero_point = zero_point + self.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS + self.device_map = device_map + self.trust_remote_code = trust_remote_code + + self._model = None + self._tokenizer = None + + @classmethod + def from_pretrained( + cls, + model: torch.nn.Module, + tokenizer, + w_bit: int = 4, + group_size: int = 128, + zero_point: bool = True, + skip_layers: set[str] | None = None, + ) -> "AWQQuantizer": + """이미 로드된 모델과 토크나이저로 AWQQuantizer를 생성합니다.""" + instance = cls.__new__(cls) + instance.model_name = getattr(model.config, "_name_or_path", "unknown") + instance.w_bit = w_bit + instance.group_size = group_size + instance.zero_point = zero_point + instance.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS + instance.device_map = "auto" + instance.trust_remote_code = True + instance._model = model + instance._tokenizer = tokenizer + return instance + + @property + def model(self) -> torch.nn.Module: + if self._model is None: + self._load_model() + return self._model + + @property + def tokenizer(self): + if self._tokenizer is None: + self._load_model() + return self._tokenizer + + def _load_model(self): + """모델과 토크나이저를 로드합니다.""" + print(f"모델 로드 중: {self.model_name}") + self._tokenizer = AutoTokenizer.from_pretrained( + self.model_name, trust_remote_code=self.trust_remote_code, + ) + self._model = AutoModelForCausalLM.from_pretrained( + self.model_name, + torch_dtype=torch.float16, + device_map=self.device_map, + trust_remote_code=self.trust_remote_code, + ) + print(f"모델 로드 완료. Device: {next(self._model.parameters()).device}") + + def _build_config(self, calib_data: str, n_samples: int, seq_len: int) -> dict: + return { + "calibration": { + "dataset": calib_data, + "n_samples": n_samples, + "seq_len": seq_len, + }, + "awq": { + "w_bit": self.w_bit, + "group_size": self.group_size, + "zero_point": self.zero_point, + "skip_layers": self.skip_layers, + }, + } + + def quantize( + self, + calib_data: str = "pileval", + output_dir: str | None = None, + n_samples: int = 128, + seq_len: int = 512, + ) -> str: + """ + 전체 AWQ 파이프라인을 실행합니다: calibration → quantize → export. + + Args: + calib_data: calibration 데이터셋 ("pileval", "wikitext2", "kowikitext", "c4") + output_dir: 출력 디렉토리 (None이면 자동 생성) + n_samples: calibration 샘플 수 + seq_len: calibration 시퀀스 길이 + + Returns: + 출력 디렉토리 경로 + """ + if output_dir is None: + short_name = self.model_name.split("/")[-1].lower() + output_dir = f"./outputs/{short_name}-awq-{calib_data}" + + config = self._build_config(calib_data, n_samples, seq_len) + + print("=" * 60) + print(f" AWQ Quantization Pipeline") + print(f" 모델: {self.model_name}") + print(f" Calibration: {calib_data} ({n_samples} samples, seq_len={seq_len})") + print(f" 양자화: INT{self.w_bit}, group_size={self.group_size}") + print(f" 스킵 레이어: {self.skip_layers}") + print(f" 출력: {output_dir}") + print("=" * 60) + + # Step 1: Calibration + print(f"\n[1/3] Calibration ({calib_data})...") + act_stats = run_calibration(self.model, self.tokenizer, config) + print(f" {len(act_stats)}개 레이어 통계 수집 완료") + + # Step 2: Quantize + print(f"\n[2/3] AWQ 양자화 적용 중...") + model, quant_results = quantize_model(self.model, act_stats, config) + self._model = model + print(f" 양자화 완료 ({len(quant_results)}개 레이어)") + + # Step 3: Export + print(f"\n[3/3] 모델 저장 중...") + export_awq_model(model, quant_results, self.tokenizer, output_dir, config) + + print("\n" + "=" * 60) + print(f" 완료! 저장 위치: {output_dir}") + print("=" * 60) + + return output_dir diff --git a/qwen3-awq/awq/quantize.py b/qwen3-awq/awq/quantize.py new file mode 100644 index 0000000..44b6d73 --- /dev/null +++ b/qwen3-awq/awq/quantize.py @@ -0,0 +1,355 @@ +""" +awq/quantize.py + +AWQ (Activation-aware Weight Quantization) 핵심 구현. + +AWQ 알고리즘 흐름: + 1. calibration으로 각 레이어 입력의 채널별 activation scale (s) 수집 + 2. salient channel 보호: weight에 역방향 스케일링 적용 → W' = W * diag(s) + 3. 입력에도 역스케일 보정: X' = X * diag(1/s) + 4. W'를 INT4로 quantize + 5. inference 시 W_quant * diag(s) 형태로 복원 + +참고: Lin et al., "AWQ: Activation-aware Weight Quantization for LLM Compression + and Acceleration", 2023. +""" + +import torch +import torch.nn as nn +import yaml +from tqdm import tqdm +from typing import Optional + + +def load_config(config_path: str = "../configs/config.yaml") -> dict: + with open(config_path) as f: + return yaml.safe_load(f) + + +def _materialize_weight(linear: nn.Linear) -> torch.Tensor: + """meta device에 있는 weight를 CPU로 가져옵니다.""" + w = linear.weight.data + if w.device.type == "meta": + return linear.weight.data.to("cpu").clone() + return w.clone() + + +# --------------------------------------------------------------------------- +# Quantization 유틸 +# --------------------------------------------------------------------------- + +def pseudo_quantize_tensor( + w: torch.Tensor, + w_bit: int = 4, + group_size: int = 128, + zero_point: bool = True, +) -> tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]: + """ + Weight 텐서를 symmetric / asymmetric INT-N으로 pseudo-quantize합니다. + (실제 INT 저장이 아닌, FP에서 round-trip을 거친 dequantized 값 반환) + + Args: + w : [out_features, in_features] FP16/FP32 weight + w_bit : quantization bits (보통 4) + group_size: 그룹 quantization 단위 (in_features 방향으로 분할) + zero_point: True → asymmetric (zero-point 사용), False → symmetric + + Returns: + w_dequant : quantize → dequantize된 FP weight (shape 동일) + scale : [out_features, n_groups] quantization scale + zero : [out_features, n_groups] zero-point (zero_point=False이면 None) + + # TODO: 아래 구현부를 완성하세요. + # + # 힌트: + # - w를 [out_features, n_groups, group_size] 로 reshape + # - 각 그룹의 min/max로 scale, zero_point 계산 + # symmetric: scale = max(|w|) / (2^(w_bit-1) - 1) + # asymmetric: scale = (max - min) / (2^w_bit - 1) + # zero = round(-min / scale) + # - round() + clamp() 로 quantize + # - dequantize: w_dequant = (w_int - zero) * scale + """ + out_features, in_features = w.shape + assert in_features % group_size == 0, f"in_features({in_features})가 group_size({group_size})로 나누어지지 않습니다." + n_groups = in_features // group_size + + w = w.reshape(out_features, n_groups, group_size) + + if zero_point: + w_max = w.amax(dim=-1, keepdim=True) + w_min = w.amin(dim=-1, keepdim=True) + q_max = (1 << w_bit) - 1 # 15 for INT4 + scale = (w_max - w_min) / q_max + scale = scale.clamp(min=1e-8) + zero = (-w_min / scale).round().clamp(0, q_max) + else: + w_abs_max = w.abs().amax(dim=-1, keepdim=True) + q_max = (1 << (w_bit - 1)) - 1 # 7 for INT4 + scale = w_abs_max / q_max + scale = scale.clamp(min=1e-8) + zero = None + + if zero_point: + w_int = (w / scale + zero).round().clamp(0, (1 << w_bit) - 1) + w_dequant = (w_int - zero) * scale + else: + w_int = (w / scale).round().clamp(-q_max, q_max) + w_dequant = w_int * scale + + w_dequant = w_dequant.reshape(out_features, in_features) + scale = scale.squeeze(-1) # [out_features, n_groups] + if zero is not None: + zero = zero.squeeze(-1) + + return w_dequant, scale, zero + + +# --------------------------------------------------------------------------- +# AWQ Scale 탐색 +# --------------------------------------------------------------------------- + +def search_best_scale( + w: torch.Tensor, + act_scales: torch.Tensor, + w_bit: int = 4, + group_size: int = 128, + zero_point: bool = True, + n_grid: int = 20, +) -> torch.Tensor: + """ + AWQ의 핵심: 최적 per-channel scale factor s를 grid search로 찾습니다. + + 개념: + - 원래 weight W에 대해 스케일링된 weight W_s = W * diag(s) 를 quantize + - quantization 오류 ||W_s_dequant - W_s||를 최소화하는 s를 찾음 + - 단, s는 activation magnitude (act_scales) 기반으로 탐색 범위를 정함 + + 수식: + s* = argmin_s ||quant(W * diag(s)) - W * diag(s)||_F + 탐색 범위: s ∈ [act_scales^0, act_scales^1], grid step = 1/n_grid + + Args: + w : [out_features, in_features] FP weight + act_scales : [in_features] 채널별 activation abs mean (calibration에서 수집) + w_bit : quantization bits + group_size : 그룹 사이즈 + zero_point : asymmetric 여부 + n_grid : scale 탐색 grid 수 + + Returns: + best_scale : [in_features] 최적 scale factor + + # TODO: 아래 구현부를 완성하세요. + # + # 힌트: + # - alpha를 0 ~ 1 사이로 grid search: s = act_scales^alpha + # - 각 alpha에 대해 W_scaled = W * s, quantize 후 오류 계산 + # - 오류가 최소인 alpha(→ scale)를 반환 + """ + act_scales = act_scales.to(device=w.device, dtype=w.dtype) + act_scales = act_scales.clamp(min=1e-8) + + best_error = float("inf") + best_scale = torch.ones_like(act_scales) + + for i in range(n_grid + 1): + alpha = i / n_grid + scale_candidate = act_scales.pow(alpha) + + w_scaled = w * scale_candidate.unsqueeze(0) # [out, in] * [1, in] + w_dequant, _, _ = pseudo_quantize_tensor( + w_scaled, w_bit=w_bit, group_size=group_size, zero_point=zero_point, + ) + error = (w_dequant - w_scaled).abs().mean().item() + + if error < best_error: + best_error = error + best_scale = scale_candidate + + return best_scale + + +# --------------------------------------------------------------------------- +# AWQ Linear 레이어 변환 +# --------------------------------------------------------------------------- + +def awq_quantize_linear( + linear: nn.Linear, + act_scales: torch.Tensor, + w_bit: int = 4, + group_size: int = 128, + zero_point: bool = True, +) -> dict: + """ + 단일 nn.Linear 레이어에 AWQ quantization을 적용합니다. + + 반환값에는 quantized weight, scale, zero-point, 그리고 + vLLM 로드에 필요한 AWQ 포맷 메타데이터가 포함됩니다. + + Args: + linear : 원본 nn.Linear + act_scales : [in_features] calibration에서 얻은 activation scale + w_bit : bits + group_size : group size + zero_point : asymmetric 여부 + + Returns: + { + "qweight": INT4 packed weight tensor, + "scales" : dequant scale, + "zeros" : zero-point (또는 None), + "best_scale": AWQ scale factor (s*) + } + + # TODO: 아래 구현부를 완성하세요. + # + # 단계: + # 1. search_best_scale() 로 최적 s 탐색 + # 2. W_scaled = W * diag(s) 적용 + # 3. pseudo_quantize_tensor() 로 quantize → scale, zero 획득 + # 4. pack_int4_weight() (pack.py) 로 INT4 packing + # 5. 반환 딕셔너리 구성 + """ + from .pack import pack_int4_weight + + w = _materialize_weight(linear).cpu() + + best_scale = search_best_scale( + w, act_scales.cpu(), w_bit=w_bit, group_size=group_size, zero_point=zero_point, + ) + + # AWQ: scale → quantize → unscale로 최적 FP16 weight 생성 + w_scaled = w * best_scale.unsqueeze(0) + w_dequant_scaled, _, _ = pseudo_quantize_tensor( + w_scaled, w_bit=w_bit, group_size=group_size, zero_point=zero_point, + ) + w_final = w_dequant_scaled / best_scale.unsqueeze(0) + del w, w_scaled, w_dequant_scaled + + # w_final을 INT4로 양자화하여 export용 패킹 + w_dequant_final, scale, zero = pseudo_quantize_tensor( + w_final, w_bit=w_bit, group_size=group_size, zero_point=zero_point, + ) + + out_features, in_features = w_final.shape + n_groups = in_features // group_size + w_reshaped = w_final.reshape(out_features, n_groups, group_size) + del w_final + scale_expanded = scale.unsqueeze(-1) + + if zero_point and zero is not None: + zero_expanded = zero.unsqueeze(-1) + w_int = (w_reshaped / scale_expanded + zero_expanded).round().clamp(0, (1 << w_bit) - 1) + else: + q_max = (1 << (w_bit - 1)) - 1 + w_int = (w_reshaped / scale_expanded).round().clamp(-q_max, q_max) + del w_reshaped, scale_expanded + + w_int = w_int.reshape(out_features, in_features).to(torch.int32) + w_int_T = w_int.T.contiguous() + del w_int + qweight = pack_int4_weight(w_int_T, w_bit=w_bit) + del w_int_T + + return { + "qweight": qweight, + "scales": scale, + "zeros": zero, + "best_scale": best_scale, + "w_dequant": w_dequant_final, + } + + +# --------------------------------------------------------------------------- +# 모델 전체 AWQ 적용 +# --------------------------------------------------------------------------- + +def quantize_model( + model: nn.Module, + act_stats: dict[str, torch.Tensor], + config: dict, +) -> nn.Module: + """ + 모델의 모든 Linear 레이어에 AWQ를 순차적으로 적용합니다. + + Args: + model : 원본 FP16 모델 + act_stats : calibration.py에서 얻은 {layer_name: act_scale} 딕셔너리 + config : config.yaml 설정 + + Returns: + quantized_model: AWQ가 적용된 모델 + (실제 INT4 커널은 export.py에서 vLLM 포맷으로 변환) + + # TODO: 레이어 순회 및 awq_quantize_linear 호출 로직을 완성하세요. + # + # 힌트: + # - model.named_modules()로 순회 + # - layer_name이 act_stats에 있는 Linear만 처리 + # - awq_quantize_linear() 결과를 모델에 반영 + # (AWQLinear 같은 커스텀 모듈로 교체하거나, weight를 직접 치환) + """ + awq_cfg = config["awq"] + w_bit = awq_cfg["w_bit"] + group_size = awq_cfg["group_size"] + zero_point = awq_cfg["zero_point"] + + quant_results = {} + + skip_layers = awq_cfg.get("skip_layers", {"lm_head"}) + for name, module in tqdm(list(model.named_modules()), desc="AWQ Quantizing"): + if not isinstance(module, nn.Linear): + continue + if name not in act_stats: + continue + if name in skip_layers: + continue + if module.in_features % group_size != 0: + print(f" [skip] {name}: in_features({module.in_features})가 " + f"group_size({group_size})로 나누어지지 않아 FP16으로 유지합니다.") + continue + + result = awq_quantize_linear( + module, act_stats[name], + w_bit=w_bit, group_size=group_size, zero_point=zero_point, + ) + + # weight를 dequantized 값으로 치환 (추론 호환성 유지) + device = module.weight.device if module.weight.device.type != "meta" else "cpu" + module.weight.data = result.pop("w_dequant").to(device) + quant_results[name] = result + + return model, quant_results + + +# --------------------------------------------------------------------------- +# 메인 (단독 실행 테스트용) +# --------------------------------------------------------------------------- + +def main(): + """작은 더미 레이어로 quantize 파이프라인을 테스트합니다.""" + torch.manual_seed(42) + config = load_config() + + # 더미 Linear + linear = nn.Linear(256, 512, bias=False) + linear.weight.data = torch.randn(512, 256) * 0.02 + + # 더미 activation scale + act_scales = torch.rand(256) * 0.5 + 0.01 + + print("Testing pseudo_quantize_tensor...") + w = linear.weight.data.clone() + w_dq, scale, zero = pseudo_quantize_tensor(w, w_bit=4, group_size=128) + print(f" quant error: {(w_dq - w).abs().mean():.6f}") + + print("Testing search_best_scale...") + best_scale = search_best_scale(w, act_scales) + print(f" best_scale: min={best_scale.min():.4f}, max={best_scale.max():.4f}") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/qwen3-awq/requirements.txt b/qwen3-awq/requirements.txt new file mode 100644 index 0000000..eb67370 --- /dev/null +++ b/qwen3-awq/requirements.txt @@ -0,0 +1,21 @@ +# AWQ 양자화 모듈 필수 의존성 +torch>=2.1.0 +transformers>=4.43.0 +datasets>=2.18.0 +accelerate>=0.27.0 +safetensors +sentencepiece +protobuf +numpy +tqdm +pyyaml + +# --- 선택 설치 --- +# INT4 모델 로딩/추론 검증 (GPU 필요): +# pip install gptqmodel +# 벤치마크 (KMMLU 등): +# pip install lm-eval>=0.4.0 +# vLLM 배포: +# pip install vllm>=0.4.0 +# 공식 AutoAWQ 베이스라인 비교 (주의: 로컬 awq/ 폴더와 이름 충돌 → run_awq.py는 회피 처리됨): +# pip install autoawq>=0.2.0 diff --git a/qwen3-awq/run_awq.py b/qwen3-awq/run_awq.py new file mode 100644 index 0000000..1f7456f --- /dev/null +++ b/qwen3-awq/run_awq.py @@ -0,0 +1,80 @@ +""" +run_awq.py + +AWQ 양자화 CLI 실행 스크립트. + +사용법: + python run_awq.py --model Qwen/Qwen3-4B --calib-data pileval + python run_awq.py --model meta-llama/Llama-3.2-3B --calib-data wikitext2 + python run_awq.py --model mistralai/Mistral-7B-v0.3 --calib-data c4 +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import importlib.util + + +def load_module(name, path): + """autoawq 패키지 충돌 방지를 위한 명시적 모듈 로드.""" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +# autoawq가 설치된 환경에서 awq/ 폴더와 이름 충돌 방지 +awq_dir = Path(__file__).parent / "awq" +load_module("awq.calibration", awq_dir / "calibration.py") +load_module("awq.pack", awq_dir / "pack.py") +load_module("awq.quantize", awq_dir / "quantize.py") +load_module("awq.export", awq_dir / "export.py") +pipeline = load_module("awq.pipeline", awq_dir / "pipeline.py") + +AWQQuantizer = pipeline.AWQQuantizer + + +def main(): + parser = argparse.ArgumentParser(description="AWQ Quantization Pipeline") + parser.add_argument("--model", type=str, default="Qwen/Qwen3-4B", + help="HuggingFace 모델 ID 또는 로컬 경로") + parser.add_argument("--calib-data", type=str, default="pileval", + choices=["pileval", "wikitext2", "kowikitext", "c4"], + help="Calibration 데이터셋") + parser.add_argument("--n-samples", type=int, default=128, + help="Calibration 샘플 수") + parser.add_argument("--seq-len", type=int, default=512, + help="Calibration 시퀀스 길이") + parser.add_argument("--w-bit", type=int, default=4, + help="양자화 비트 수") + parser.add_argument("--group-size", type=int, default=128, + help="그룹 양자화 크기") + parser.add_argument("--output-dir", type=str, default=None, + help="출력 디렉토리 (기본: ./outputs/-awq-)") + parser.add_argument("--skip-layers", type=str, nargs="*", default=None, + help="양자화에서 제외할 레이어 이름 (기본: lm_head)") + args = parser.parse_args() + + skip_layers = set(args.skip_layers) if args.skip_layers else None + + quantizer = AWQQuantizer( + model_name=args.model, + w_bit=args.w_bit, + group_size=args.group_size, + skip_layers=skip_layers, + ) + + quantizer.quantize( + calib_data=args.calib_data, + output_dir=args.output_dir, + n_samples=args.n_samples, + seq_len=args.seq_len, + ) + + +if __name__ == "__main__": + main() From 5c4593d949475a5854ca8222315e0fb2b75e2198 Mon Sep 17 00:00:00 2001 From: skt0725 Date: Sun, 19 Jul 2026 00:12:14 +0900 Subject: [PATCH 2/6] Add weight clip search option and kowikitext benchmark results - pseudo_quantize_tensor: per-group clip ratio search (--clip-search) - DESIGN_NOTES: kowikitext 41.61% (best), calibration language effect --- qwen3-awq/DESIGN_NOTES.md | 13 ++++--- qwen3-awq/awq/pipeline.py | 7 +++- qwen3-awq/awq/quantize.py | 74 ++++++++++++++++++++++++++++----------- qwen3-awq/run_awq.py | 3 ++ 4 files changed, 71 insertions(+), 26 deletions(-) diff --git a/qwen3-awq/DESIGN_NOTES.md b/qwen3-awq/DESIGN_NOTES.md index e48a361..d54cb13 100644 --- a/qwen3-awq/DESIGN_NOTES.md +++ b/qwen3-awq/DESIGN_NOTES.md @@ -5,12 +5,17 @@ | 모델 | KMMLU acc | FP16 대비 | |------|-----------|-----------| | FP16 baseline | 45.81% | — | -| 공식 AutoAWQ (INT4) | 44.11% | −1.70%p | -| 직접 구현 (INT4, pileval) | 40.34% | −5.47%p | +| 공식 AutoAWQ (INT4, pileval) | 44.11% | −1.70%p | +| 직접 구현 (INT4, **kowikitext** 한국어) | **41.61%** | −4.20%p | +| 직접 구현 (INT4, pileval 영어) | 40.34% | −5.47%p | +| 직접 구현 (INT4, wikitext2 영어) | (측정 중) | — | - INT4 로딩(40.34%)과 FP16 dequant 시뮬레이션(40.33%)이 일치 → **export 파이프라인 정상** -- 공식 대비 −3.8%p는 버그가 아니라 아래 **의도적 단순화 3가지**의 대가 - +- 공식 대비 격차는 버그가 아니라 아래 **의도적 단순화 3가지**의 대가 +- **한국어 calibration(kowikitext)이 pileval보다 +1.27%p** — 한국어 벤치마크에는 + 한국어 calibration이 유리함을 확인. 특히 HUMSS(인문사회) +2.57%p로 언어 의존 + 도메인에서 효과가 큼 + ## 차이점과 이유 ### 1. Scale folding 없음 → 이중 양자화 (격차의 주원인) diff --git a/qwen3-awq/awq/pipeline.py b/qwen3-awq/awq/pipeline.py index 970d698..b0bc88c 100644 --- a/qwen3-awq/awq/pipeline.py +++ b/qwen3-awq/awq/pipeline.py @@ -51,6 +51,7 @@ def __init__( w_bit: int = 4, group_size: int = 128, zero_point: bool = True, + clip_search: bool = False, skip_layers: set[str] | None = None, device_map: str = "auto", trust_remote_code: bool = True, @@ -59,6 +60,7 @@ def __init__( self.w_bit = w_bit self.group_size = group_size self.zero_point = zero_point + self.clip_search = clip_search self.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS self.device_map = device_map self.trust_remote_code = trust_remote_code @@ -74,6 +76,7 @@ def from_pretrained( w_bit: int = 4, group_size: int = 128, zero_point: bool = True, + clip_search: bool = False, skip_layers: set[str] | None = None, ) -> "AWQQuantizer": """이미 로드된 모델과 토크나이저로 AWQQuantizer를 생성합니다.""" @@ -82,6 +85,7 @@ def from_pretrained( instance.w_bit = w_bit instance.group_size = group_size instance.zero_point = zero_point + instance.clip_search = clip_search instance.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS instance.device_map = "auto" instance.trust_remote_code = True @@ -126,6 +130,7 @@ def _build_config(self, calib_data: str, n_samples: int, seq_len: int) -> dict: "w_bit": self.w_bit, "group_size": self.group_size, "zero_point": self.zero_point, + "clip_search": self.clip_search, "skip_layers": self.skip_layers, }, } @@ -159,7 +164,7 @@ def quantize( print(f" AWQ Quantization Pipeline") print(f" 모델: {self.model_name}") print(f" Calibration: {calib_data} ({n_samples} samples, seq_len={seq_len})") - print(f" 양자화: INT{self.w_bit}, group_size={self.group_size}") + print(f" 양자화: INT{self.w_bit}, group_size={self.group_size}, clip_search={self.clip_search}") print(f" 스킵 레이어: {self.skip_layers}") print(f" 출력: {output_dir}") print("=" * 60) diff --git a/qwen3-awq/awq/quantize.py b/qwen3-awq/awq/quantize.py index 44b6d73..c67001b 100644 --- a/qwen3-awq/awq/quantize.py +++ b/qwen3-awq/awq/quantize.py @@ -43,6 +43,8 @@ def pseudo_quantize_tensor( w_bit: int = 4, group_size: int = 128, zero_point: bool = True, + clip_search: bool = False, + clip_ratios: tuple = (1.0, 0.95, 0.9, 0.85, 0.8, 0.75, 0.7), ) -> tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]: """ Weight 텐서를 symmetric / asymmetric INT-N으로 pseudo-quantize합니다. @@ -53,6 +55,9 @@ def pseudo_quantize_tensor( w_bit : quantization bits (보통 4) group_size: 그룹 quantization 단위 (in_features 방향으로 분할) zero_point: True → asymmetric (zero-point 사용), False → symmetric + clip_search: True면 그룹별로 min/max를 얼마나 잘라낼지(clip ratio) 탐색. + outlier 하나가 그룹 전체 scale을 키워 해상도를 낭비하는 것을 방지 + clip_ratios: 탐색할 clip 비율 후보 Returns: w_dequant : quantize → dequantize된 FP weight (shape 동일) @@ -76,27 +81,49 @@ def pseudo_quantize_tensor( w = w.reshape(out_features, n_groups, group_size) - if zero_point: - w_max = w.amax(dim=-1, keepdim=True) - w_min = w.amin(dim=-1, keepdim=True) - q_max = (1 << w_bit) - 1 # 15 for INT4 - scale = (w_max - w_min) / q_max - scale = scale.clamp(min=1e-8) - zero = (-w_min / scale).round().clamp(0, q_max) - else: - w_abs_max = w.abs().amax(dim=-1, keepdim=True) - q_max = (1 << (w_bit - 1)) - 1 # 7 for INT4 - scale = w_abs_max / q_max - scale = scale.clamp(min=1e-8) - zero = None - - if zero_point: - w_int = (w / scale + zero).round().clamp(0, (1 << w_bit) - 1) - w_dequant = (w_int - zero) * scale - else: - w_int = (w / scale).round().clamp(-q_max, q_max) - w_dequant = w_int * scale - + ratios = clip_ratios if clip_search else (1.0,) + best_err = None + best = None # (w_dequant, scale, zero) + + for ratio in ratios: + if zero_point: + w_max = w.amax(dim=-1, keepdim=True) * ratio + w_min = w.amin(dim=-1, keepdim=True) * ratio + q_max = (1 << w_bit) - 1 # 15 for INT4 + scale = (w_max - w_min) / q_max + scale = scale.clamp(min=1e-8) + zero = (-w_min / scale).round().clamp(0, q_max) + w_int = (w / scale + zero).round().clamp(0, q_max) + w_dq = (w_int - zero) * scale + else: + w_abs_max = w.abs().amax(dim=-1, keepdim=True) * ratio + q_max = (1 << (w_bit - 1)) - 1 # 7 for INT4 + scale = w_abs_max / q_max + scale = scale.clamp(min=1e-8) + zero = None + w_int = (w / scale).round().clamp(-q_max, q_max) + w_dq = w_int * scale + + if not clip_search: + best = (w_dq, scale, zero) + break + + # 그룹별 재구성 오차로 최적 ratio 선택 + err = (w_dq - w).pow(2).sum(dim=-1, keepdim=True) # [out, n_groups, 1] + if best_err is None: + best_err = err + best = (w_dq, scale, zero) + else: + better = err < best_err # [out, n_groups, 1] + best_err = torch.where(better, err, best_err) + b_dq, b_scale, b_zero = best + b_dq = torch.where(better, w_dq, b_dq) + b_scale = torch.where(better, scale, b_scale) + if zero_point: + b_zero = torch.where(better, zero, b_zero) + best = (b_dq, b_scale, b_zero) + + w_dequant, scale, zero = best w_dequant = w_dequant.reshape(out_features, in_features) scale = scale.squeeze(-1) # [out_features, n_groups] if zero is not None: @@ -180,6 +207,7 @@ def awq_quantize_linear( w_bit: int = 4, group_size: int = 128, zero_point: bool = True, + clip_search: bool = False, ) -> dict: """ 단일 nn.Linear 레이어에 AWQ quantization을 적용합니다. @@ -223,6 +251,7 @@ def awq_quantize_linear( w_scaled = w * best_scale.unsqueeze(0) w_dequant_scaled, _, _ = pseudo_quantize_tensor( w_scaled, w_bit=w_bit, group_size=group_size, zero_point=zero_point, + clip_search=clip_search, ) w_final = w_dequant_scaled / best_scale.unsqueeze(0) del w, w_scaled, w_dequant_scaled @@ -230,6 +259,7 @@ def awq_quantize_linear( # w_final을 INT4로 양자화하여 export용 패킹 w_dequant_final, scale, zero = pseudo_quantize_tensor( w_final, w_bit=w_bit, group_size=group_size, zero_point=zero_point, + clip_search=clip_search, ) out_features, in_features = w_final.shape @@ -294,6 +324,7 @@ def quantize_model( w_bit = awq_cfg["w_bit"] group_size = awq_cfg["group_size"] zero_point = awq_cfg["zero_point"] + clip_search = awq_cfg.get("clip_search", False) quant_results = {} @@ -313,6 +344,7 @@ def quantize_model( result = awq_quantize_linear( module, act_stats[name], w_bit=w_bit, group_size=group_size, zero_point=zero_point, + clip_search=clip_search, ) # weight를 dequantized 값으로 치환 (추론 호환성 유지) diff --git a/qwen3-awq/run_awq.py b/qwen3-awq/run_awq.py index 1f7456f..4787c77 100644 --- a/qwen3-awq/run_awq.py +++ b/qwen3-awq/run_awq.py @@ -53,6 +53,8 @@ def main(): help="양자화 비트 수") parser.add_argument("--group-size", type=int, default=128, help="그룹 양자화 크기") + parser.add_argument("--clip-search", action="store_true", + help="그룹별 weight clipping 탐색 활성화 (outlier로 인한 해상도 낭비 방지)") parser.add_argument("--output-dir", type=str, default=None, help="출력 디렉토리 (기본: ./outputs/-awq-)") parser.add_argument("--skip-layers", type=str, nargs="*", default=None, @@ -65,6 +67,7 @@ def main(): model_name=args.model, w_bit=args.w_bit, group_size=args.group_size, + clip_search=args.clip_search, skip_layers=skip_layers, ) From ec22cef4a842e64f62b5c073e32d27718cc83cdf Mon Sep 17 00:00:00 2001 From: skt0725 Date: Sun, 19 Jul 2026 02:19:25 +0900 Subject: [PATCH 3/6] Fix fp16 overflow in AWQ quantization (wikitext2 NaN bug) wikitext2 calibration produced huge act_scales for layers with activation outliers (e.g. layers.2.mlp.down_proj). Computing w * act_scales^alpha in fp16 overflowed to inf, propagating NaN into scales and corrupting the model (KMMLU 9.87%, below random). - Quantization math now runs in fp32; scales/weights stored back in the original dtype (fp16) - w_int and w_dequant are derived from the stored fp16 scale so the INT4 export and in-model weights match exactly --- qwen3-awq/awq/quantize.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/qwen3-awq/awq/quantize.py b/qwen3-awq/awq/quantize.py index c67001b..c2087aa 100644 --- a/qwen3-awq/awq/quantize.py +++ b/qwen3-awq/awq/quantize.py @@ -242,9 +242,13 @@ def awq_quantize_linear( from .pack import pack_int4_weight w = _materialize_weight(linear).cpu() + orig_dtype = w.dtype + # fp16으로 계산하면 w * act_scales^alpha가 overflow(inf)할 수 있음 + # (예: Qwen3 down_proj의 activation outlier) → 계산은 fp32, 저장만 원래 dtype + w = w.float() best_scale = search_best_scale( - w, act_scales.cpu(), w_bit=w_bit, group_size=group_size, zero_point=zero_point, + w, act_scales.cpu().float(), w_bit=w_bit, group_size=group_size, zero_point=zero_point, ) # AWQ: scale → quantize → unscale로 최적 FP16 weight 생성 @@ -257,25 +261,33 @@ def awq_quantize_linear( del w, w_scaled, w_dequant_scaled # w_final을 INT4로 양자화하여 export용 패킹 - w_dequant_final, scale, zero = pseudo_quantize_tensor( + _, scale, zero = pseudo_quantize_tensor( w_final, w_bit=w_bit, group_size=group_size, zero_point=zero_point, clip_search=clip_search, ) + # scale은 원래 dtype(fp16)으로 저장되므로, w_int와 w_dequant도 + # 저장될 scale 기준으로 계산해야 INT4 파일과 모델 weight가 정확히 일치 + scale = scale.to(orig_dtype) + scale_f = scale.float() + out_features, in_features = w_final.shape n_groups = in_features // group_size w_reshaped = w_final.reshape(out_features, n_groups, group_size) del w_final - scale_expanded = scale.unsqueeze(-1) + scale_expanded = scale_f.unsqueeze(-1).clamp(min=1e-8) if zero_point and zero is not None: zero_expanded = zero.unsqueeze(-1) w_int = (w_reshaped / scale_expanded + zero_expanded).round().clamp(0, (1 << w_bit) - 1) + w_dequant_final = ((w_int - zero_expanded) * scale_expanded) else: q_max = (1 << (w_bit - 1)) - 1 w_int = (w_reshaped / scale_expanded).round().clamp(-q_max, q_max) + w_dequant_final = (w_int * scale_expanded) del w_reshaped, scale_expanded + w_dequant_final = w_dequant_final.reshape(out_features, in_features).to(orig_dtype) w_int = w_int.reshape(out_features, in_features).to(torch.int32) w_int_T = w_int.T.contiguous() del w_int From 564acd510b1868622bc63e2b5d84fefafd4947e7 Mon Sep 17 00:00:00 2001 From: skt0725 Date: Sun, 19 Jul 2026 05:16:08 +0000 Subject: [PATCH 4/6] DESIGN_NOTES.md - weight clipping experiment result --- qwen3-awq/DESIGN_NOTES.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/qwen3-awq/DESIGN_NOTES.md b/qwen3-awq/DESIGN_NOTES.md index d54cb13..e64c01e 100644 --- a/qwen3-awq/DESIGN_NOTES.md +++ b/qwen3-awq/DESIGN_NOTES.md @@ -7,6 +7,7 @@ | FP16 baseline | 45.81% | — | | 공식 AutoAWQ (INT4, pileval) | 44.11% | −1.70%p | | 직접 구현 (INT4, **kowikitext** 한국어) | **41.61%** | −4.20%p | +| 직접 구현 (INT4, kowikitext 한국어 + clip) | 38.51% | −3.22%p | | 직접 구현 (INT4, pileval 영어) | 40.34% | −5.47%p | | 직접 구현 (INT4, wikitext2 영어) | (측정 중) | — | @@ -39,10 +40,13 @@ AWQ는 `W·s`를 양자화하면 입력 쪽에 `1/s` 보정이 필요합니다. 순차 실행 인프라를 씁니다. 우리는 hook으로 채널당 통계 벡터(수 KB)만 수집하는 단순 구조를 유지했습니다 (Colab 12.67GB RAM 제약 포함). + ### 3. Weight clipping 탐색 생략 공식은 그룹 max를 얼마나 잘라낼지도 grid search (outlier로 인한 해상도 낭비 방지). -핵심 알고리즘 이해에 집중하기 위해 생략 — AWQ 고유 아이디어는 아님. +핵심 알고리즘 이해에 집중하기 위해 생략 (추후 구현하여 clip 탐색 적용 실험 또한 진행) + + ## 공식과 동일한 부분 @@ -56,3 +60,8 @@ AWQ GEMM export 포맷(인터리브 패킹 `[0,2,4,6,1,3,5,7]`, gptqmodel/vLLM 2. **clipping 탐색** — 범용성 유지, 구현 간단 3. **LayerNorm fold** — Llama-계열 표준 구조 한정 지원 + 미지원 모델은 폴백. 이중 양자화 제거로 가장 큰 격차 해소, 대신 아키텍처 의존성 발생 + + +Runpod에서 GPU를 제공받아 추가 실험 진행 +로드맵 2번 Clipping 적용 시 성능 하락 확인 -> clip 자체가 나쁜 게 아니라, 출력-오차 탐색(로드맵 #1) 없이 clip만 얹으면 역효과가 남 + From 5703ff848f0652e9ecd432fd56bc855d96502f4e Mon Sep 17 00:00:00 2001 From: skt0725 Date: Mon, 20 Jul 2026 23:19:35 +0900 Subject: [PATCH 5/6] Add output-error search mode (roadmap #1, official AWQ objective) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scale/clip search can now minimize output error ||Q(W·s)·(X/s) − W·X|| instead of weight reconstruction error, matching the official AWQ objective. Calibration hooks cache a per-layer input subsample (512 rows, fp16/CPU, ~0.7GB total) to make this possible without block-sequential execution infrastructure. - calibration.py: ActivationCollector optionally caches input rows, spread evenly across calibration samples; run_calibration returns (stats, input_cache) - quantize.py: search_best_scale and pseudo_quantize_tensor accept x for output-error objective; clip selection uses per-group output error (same approximation as official AutoAWQ); search runs on GPU when x is provided (21 large matmuls per layer) - run_awq.py: --search-mode {weight,output} flag Motivation: weight-error clip search hurt KMMLU by 3.2%p (38.51% vs 41.73% baseline) because it clips exactly the salient weights AWQ is meant to protect. Unit test confirms: with activation outliers, weight+clip worsens output MSE while output+clip improves it. --- qwen3-awq/awq/calibration.py | 55 +++++++++++++-- qwen3-awq/awq/pipeline.py | 16 ++++- qwen3-awq/awq/quantize.py | 130 ++++++++++++++++++++++++----------- qwen3-awq/run_awq.py | 5 ++ 4 files changed, 157 insertions(+), 49 deletions(-) diff --git a/qwen3-awq/awq/calibration.py b/qwen3-awq/awq/calibration.py index 471623b..29e2855 100644 --- a/qwen3-awq/awq/calibration.py +++ b/qwen3-awq/awq/calibration.py @@ -89,13 +89,30 @@ class ActivationCollector: """ nn.Linear 레이어의 입력 activation을 hook으로 수집하고 채널별 통계(mean absolute value)를 기록합니다. + + collect_inputs=True이면 출력-오차 기반 scale/clip 탐색(공식 AWQ 방식)에 쓸 + 입력 서브샘플도 함께 캐시합니다. 레이어당 최대 n_input_rows 행을 + fp16/CPU로 보관하므로 메모리 부담이 작습니다 + (예: 253 레이어 × 512행 × hidden 2560 × 2B ≈ 0.7GB). """ - def __init__(self): + def __init__( + self, + collect_inputs: bool = False, + n_input_rows: int = 512, + n_calib_samples: int = 128, + ): self.stats: dict[str, torch.Tensor] = {} # layer_name -> abs_mean per input channel self._hooks = [] self._layer_names: dict = {} # module -> name + self.collect_inputs = collect_inputs + self.n_input_rows = n_input_rows + # calibration 전체에 걸쳐 고르게 뽑히도록 forward 1회당 캐시할 행 수 + self._rows_per_call = max(1, n_input_rows // max(1, n_calib_samples)) + self._input_rows: dict[str, list[torch.Tensor]] = {} + self._input_counts: dict[str, int] = {} + def register(self, model: torch.nn.Module) -> None: """모든 Linear 레이어에 forward hook 등록.""" for name, module in model.named_modules(): @@ -123,6 +140,18 @@ def _hook_fn(self, module, input, output): # 누적 평균 (온라인 방식) self.stats[name] = (self.stats[name] + abs_mean) / 2.0 + if self.collect_inputs and self._input_counts.get(name, 0) < self.n_input_rows: + k = min(self._rows_per_call, x.shape[0], + self.n_input_rows - self._input_counts.get(name, 0)) + idx = torch.randperm(x.shape[0], device=x.device)[:k] + rows = x[idx].to(torch.float16).cpu() + self._input_rows.setdefault(name, []).append(rows) + self._input_counts[name] = self._input_counts.get(name, 0) + k + + def get_input_cache(self) -> dict[str, torch.Tensor]: + """레이어별로 캐시한 입력 행들을 [n_rows, in_features] 텐서로 합쳐 반환.""" + return {name: torch.cat(rows, dim=0) for name, rows in self._input_rows.items()} + def remove(self) -> None: """등록된 hook 제거.""" for hook in self._hooks: @@ -138,18 +167,24 @@ def run_calibration( model: torch.nn.Module, tokenizer, config: dict, -) -> dict[str, torch.Tensor]: +) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]: """ Calibration 데이터를 forward pass하여 각 레이어의 입력 activation 통계를 수집합니다. + config["calibration"]["collect_inputs"]가 True이면 출력-오차 탐색용 + 입력 서브샘플 캐시도 함께 수집합니다. + Args: model: 원본 FP16 모델 tokenizer: HuggingFace tokenizer config: 설정 딕셔너리 Returns: - layer_name -> abs_mean_per_channel 딕셔너리 + (act_stats, input_cache) + act_stats : layer_name -> abs_mean_per_channel + input_cache: layer_name -> [n_rows, in_features] 입력 서브샘플 + (collect_inputs=False이면 빈 딕셔너리) """ calib_cfg = config["calibration"] device = next(model.parameters()).device @@ -162,7 +197,11 @@ def run_calibration( seq_len=calib_cfg["seq_len"], ) - collector = ActivationCollector() + collector = ActivationCollector( + collect_inputs=calib_cfg.get("collect_inputs", False), + n_input_rows=calib_cfg.get("n_input_rows", 512), + n_calib_samples=len(samples), + ) collector.register(model) model.eval() @@ -175,7 +214,11 @@ def run_calibration( collector.remove() print(f"Collected activation stats for {len(collector.stats)} layers.") - return collector.stats + input_cache = collector.get_input_cache() if collector.collect_inputs else {} + if input_cache: + total_mb = sum(t.numel() * t.element_size() for t in input_cache.values()) / 1e6 + print(f"Cached input subsamples for {len(input_cache)} layers ({total_mb:.0f} MB).") + return collector.stats, input_cache # --------------------------------------------------------------------------- @@ -194,7 +237,7 @@ def main(): trust_remote_code=True, ) - act_stats = run_calibration(model, tokenizer, config) + act_stats, _ = run_calibration(model, tokenizer, config) # 확인용 출력 for name, stat in list(act_stats.items())[:5]: diff --git a/qwen3-awq/awq/pipeline.py b/qwen3-awq/awq/pipeline.py index b0bc88c..63a6f14 100644 --- a/qwen3-awq/awq/pipeline.py +++ b/qwen3-awq/awq/pipeline.py @@ -52,15 +52,18 @@ def __init__( group_size: int = 128, zero_point: bool = True, clip_search: bool = False, + search_mode: str = "weight", skip_layers: set[str] | None = None, device_map: str = "auto", trust_remote_code: bool = True, ): + assert search_mode in ("weight", "output"), f"unknown search_mode: {search_mode}" self.model_name = model_name self.w_bit = w_bit self.group_size = group_size self.zero_point = zero_point self.clip_search = clip_search + self.search_mode = search_mode self.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS self.device_map = device_map self.trust_remote_code = trust_remote_code @@ -77,6 +80,7 @@ def from_pretrained( group_size: int = 128, zero_point: bool = True, clip_search: bool = False, + search_mode: str = "weight", skip_layers: set[str] | None = None, ) -> "AWQQuantizer": """이미 로드된 모델과 토크나이저로 AWQQuantizer를 생성합니다.""" @@ -86,6 +90,7 @@ def from_pretrained( instance.group_size = group_size instance.zero_point = zero_point instance.clip_search = clip_search + instance.search_mode = search_mode instance.skip_layers = skip_layers or DEFAULT_SKIP_PATTERNS instance.device_map = "auto" instance.trust_remote_code = True @@ -125,12 +130,16 @@ def _build_config(self, calib_data: str, n_samples: int, seq_len: int) -> dict: "dataset": calib_data, "n_samples": n_samples, "seq_len": seq_len, + # 출력-오차 탐색에는 레이어별 입력 서브샘플 캐시가 필요 + "collect_inputs": self.search_mode == "output", + "n_input_rows": 512, }, "awq": { "w_bit": self.w_bit, "group_size": self.group_size, "zero_point": self.zero_point, "clip_search": self.clip_search, + "search_mode": self.search_mode, "skip_layers": self.skip_layers, }, } @@ -164,19 +173,20 @@ def quantize( print(f" AWQ Quantization Pipeline") print(f" 모델: {self.model_name}") print(f" Calibration: {calib_data} ({n_samples} samples, seq_len={seq_len})") - print(f" 양자화: INT{self.w_bit}, group_size={self.group_size}, clip_search={self.clip_search}") + print(f" 양자화: INT{self.w_bit}, group_size={self.group_size}, " + f"clip_search={self.clip_search}, search_mode={self.search_mode}") print(f" 스킵 레이어: {self.skip_layers}") print(f" 출력: {output_dir}") print("=" * 60) # Step 1: Calibration print(f"\n[1/3] Calibration ({calib_data})...") - act_stats = run_calibration(self.model, self.tokenizer, config) + act_stats, input_cache = run_calibration(self.model, self.tokenizer, config) print(f" {len(act_stats)}개 레이어 통계 수집 완료") # Step 2: Quantize print(f"\n[2/3] AWQ 양자화 적용 중...") - model, quant_results = quantize_model(self.model, act_stats, config) + model, quant_results = quantize_model(self.model, act_stats, config, input_cache=input_cache) self._model = model print(f" 양자화 완료 ({len(quant_results)}개 레이어)") diff --git a/qwen3-awq/awq/quantize.py b/qwen3-awq/awq/quantize.py index c2087aa..cc4188c 100644 --- a/qwen3-awq/awq/quantize.py +++ b/qwen3-awq/awq/quantize.py @@ -45,6 +45,7 @@ def pseudo_quantize_tensor( zero_point: bool = True, clip_search: bool = False, clip_ratios: tuple = (1.0, 0.95, 0.9, 0.85, 0.8, 0.75, 0.7), + x: Optional[torch.Tensor] = None, ) -> tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]: """ Weight 텐서를 symmetric / asymmetric INT-N으로 pseudo-quantize합니다. @@ -58,6 +59,11 @@ def pseudo_quantize_tensor( clip_search: True면 그룹별로 min/max를 얼마나 잘라낼지(clip ratio) 탐색. outlier 하나가 그룹 전체 scale을 키워 해상도를 낭비하는 것을 방지 clip_ratios: 탐색할 clip 비율 후보 + x : [n_rows, in_features] calibration 입력 서브샘플 (선택). + 주어지면 clip 탐색이 weight 재구성 오차 대신 + 그룹별 출력 오차 ‖x_g·(Q(w_g)−w_g)ᵀ‖² 를 기준으로 선택 (공식 AWQ 방식). + weight 오차 기준은 activation이 큰 채널의 salient weight를 + 잘라내 성능을 해칠 수 있음 (kowikitext 실험에서 −3.2%p 확인) Returns: w_dequant : quantize → dequantize된 FP weight (shape 동일) @@ -81,6 +87,16 @@ def pseudo_quantize_tensor( w = w.reshape(out_features, n_groups, group_size) + # 출력 오차 기준 clip 탐색용: 입력을 그룹 단위로 재배열 [n_groups, n_rows, group_size] + x_grouped = None + if clip_search and x is not None: + x_grouped = ( + x.to(device=w.device, dtype=w.dtype) + .reshape(-1, n_groups, group_size) + .permute(1, 0, 2) + .contiguous() + ) + ratios = clip_ratios if clip_search else (1.0,) best_err = None best = None # (w_dequant, scale, zero) @@ -108,8 +124,16 @@ def pseudo_quantize_tensor( best = (w_dq, scale, zero) break - # 그룹별 재구성 오차로 최적 ratio 선택 - err = (w_dq - w).pow(2).sum(dim=-1, keepdim=True) # [out, n_groups, 1] + if x_grouped is not None: + # 그룹별 출력 오차로 최적 ratio 선택 (전체 출력은 그룹 기여의 합이므로 + # 그룹별 독립 선택이 유효한 근사 — 공식 AutoAWQ의 clip 탐색과 동일한 방식) + diff = (w_dq - w).permute(1, 2, 0) # [n_groups, group, out] + out_err = torch.bmm(x_grouped, diff) # [n_groups, n_rows, out] + err = out_err.pow(2).sum(dim=1).T.unsqueeze(-1) # [out, n_groups, 1] + del diff, out_err + else: + # 그룹별 weight 재구성 오차로 최적 ratio 선택 + err = (w_dq - w).pow(2).sum(dim=-1, keepdim=True) # [out, n_groups, 1] if best_err is None: best_err = err best = (w_dq, scale, zero) @@ -143,18 +167,23 @@ def search_best_scale( group_size: int = 128, zero_point: bool = True, n_grid: int = 20, + x: Optional[torch.Tensor] = None, ) -> torch.Tensor: """ AWQ의 핵심: 최적 per-channel scale factor s를 grid search로 찾습니다. 개념: - 원래 weight W에 대해 스케일링된 weight W_s = W * diag(s) 를 quantize - - quantization 오류 ||W_s_dequant - W_s||를 최소화하는 s를 찾음 + - quantization 오류를 최소화하는 s를 찾음 - 단, s는 activation magnitude (act_scales) 기반으로 탐색 범위를 정함 - 수식: - s* = argmin_s ||quant(W * diag(s)) - W * diag(s)||_F - 탐색 범위: s ∈ [act_scales^0, act_scales^1], grid step = 1/n_grid + 목적함수 (두 모드): + weight 오차 (x=None, 기존 방식): + s* = argmin_s ||quant(W·diag(s)) − W·diag(s)|| + 출력 오차 (x 제공, 공식 AWQ 방식): + s* = argmin_s ||quant(W·diag(s))·(X/s)ᵀ − W·Xᵀ|| + — activation이 큰 채널의 오차에 자동으로 가중치가 실려 + salient weight 보호가 출력 기준으로 정확해짐 Args: w : [out_features, in_features] FP weight @@ -163,20 +192,19 @@ def search_best_scale( group_size : 그룹 사이즈 zero_point : asymmetric 여부 n_grid : scale 탐색 grid 수 + x : [n_rows, in_features] calibration 입력 서브샘플 (선택) Returns: best_scale : [in_features] 최적 scale factor - - # TODO: 아래 구현부를 완성하세요. - # - # 힌트: - # - alpha를 0 ~ 1 사이로 grid search: s = act_scales^alpha - # - 각 alpha에 대해 W_scaled = W * s, quantize 후 오류 계산 - # - 오류가 최소인 alpha(→ scale)를 반환 """ act_scales = act_scales.to(device=w.device, dtype=w.dtype) act_scales = act_scales.clamp(min=1e-8) + ref_out = None + if x is not None: + x = x.to(device=w.device, dtype=w.dtype) + ref_out = x @ w.T # [n_rows, out] — FP 기준 출력 + best_error = float("inf") best_scale = torch.ones_like(act_scales) @@ -188,7 +216,13 @@ def search_best_scale( w_dequant, _, _ = pseudo_quantize_tensor( w_scaled, w_bit=w_bit, group_size=group_size, zero_point=zero_point, ) - error = (w_dequant - w_scaled).abs().mean().item() + + if ref_out is not None: + # Q(W·s)·(X/s)ᵀ = (X/s) @ Q(W·s)ᵀ 와 FP 출력의 오차 + out = (x / scale_candidate.unsqueeze(0)) @ w_dequant.T + error = (out - ref_out).pow(2).mean().item() + else: + error = (w_dequant - w_scaled).abs().mean().item() if error < best_error: best_error = error @@ -208,6 +242,7 @@ def awq_quantize_linear( group_size: int = 128, zero_point: bool = True, clip_search: bool = False, + x: Optional[torch.Tensor] = None, ) -> dict: """ 단일 nn.Linear 레이어에 AWQ quantization을 적용합니다. @@ -221,6 +256,9 @@ def awq_quantize_linear( w_bit : bits group_size : group size zero_point : asymmetric 여부 + clip_search: 그룹별 clip ratio 탐색 여부 + x : [n_rows, in_features] calibration 입력 서브샘플 (선택). + 주어지면 scale/clip 탐색을 출력 오차 기준으로 수행 (공식 AWQ 방식) Returns: { @@ -229,15 +267,6 @@ def awq_quantize_linear( "zeros" : zero-point (또는 None), "best_scale": AWQ scale factor (s*) } - - # TODO: 아래 구현부를 완성하세요. - # - # 단계: - # 1. search_best_scale() 로 최적 s 탐색 - # 2. W_scaled = W * diag(s) 적용 - # 3. pseudo_quantize_tensor() 로 quantize → scale, zero 획득 - # 4. pack_int4_weight() (pack.py) 로 INT4 packing - # 5. 반환 딕셔너리 구성 """ from .pack import pack_int4_weight @@ -247,24 +276,40 @@ def awq_quantize_linear( # (예: Qwen3 down_proj의 activation outlier) → 계산은 fp32, 저장만 원래 dtype w = w.float() + # 출력-오차 탐색은 grid마다 [n_rows×in]·[in×out] matmul이 필요해 CPU로는 느림 → GPU 사용 + search_device = "cuda" if (x is not None and torch.cuda.is_available()) else "cpu" + w = w.to(search_device) + if x is not None: + x = x.to(device=search_device, dtype=torch.float32) + best_scale = search_best_scale( - w, act_scales.cpu().float(), w_bit=w_bit, group_size=group_size, zero_point=zero_point, + w, act_scales.float(), w_bit=w_bit, group_size=group_size, zero_point=zero_point, + x=x, ) # AWQ: scale → quantize → unscale로 최적 FP16 weight 생성 + # 출력-오차 clip은 스케일된 공간에서 입력이 X/s이므로 x도 동일하게 보정 + x_scaled = (x / best_scale.unsqueeze(0)) if x is not None else None w_scaled = w * best_scale.unsqueeze(0) w_dequant_scaled, _, _ = pseudo_quantize_tensor( w_scaled, w_bit=w_bit, group_size=group_size, zero_point=zero_point, - clip_search=clip_search, + clip_search=clip_search, x=x_scaled, ) w_final = w_dequant_scaled / best_scale.unsqueeze(0) - del w, w_scaled, w_dequant_scaled + del w, w_scaled, w_dequant_scaled, x_scaled - # w_final을 INT4로 양자화하여 export용 패킹 + # w_final을 INT4로 양자화하여 export용 패킹 (원래 공간이므로 x 그대로 사용) _, scale, zero = pseudo_quantize_tensor( w_final, w_bit=w_bit, group_size=group_size, zero_point=zero_point, - clip_search=clip_search, + clip_search=clip_search, x=x, ) + del x + + # 이후 packing 단계는 CPU에서 수행 (기존 경로와 동일하게 유지) + w_final = w_final.cpu() + scale = scale.cpu() + zero = zero.cpu() if zero is not None else None + best_scale = best_scale.cpu() # scale은 원래 dtype(fp16)으로 저장되므로, w_int와 w_dequant도 # 저장될 scale 기준으로 계산해야 INT4 파일과 모델 weight가 정확히 일치 @@ -311,32 +356,35 @@ def quantize_model( model: nn.Module, act_stats: dict[str, torch.Tensor], config: dict, + input_cache: Optional[dict[str, torch.Tensor]] = None, ) -> nn.Module: """ 모델의 모든 Linear 레이어에 AWQ를 순차적으로 적용합니다. Args: - model : 원본 FP16 모델 - act_stats : calibration.py에서 얻은 {layer_name: act_scale} 딕셔너리 - config : config.yaml 설정 + model : 원본 FP16 모델 + act_stats : calibration.py에서 얻은 {layer_name: act_scale} 딕셔너리 + config : config.yaml 설정 + input_cache: {layer_name: [n_rows, in_features]} 입력 서브샘플 (선택). + awq.search_mode가 "output"이면 이 캐시로 출력-오차 탐색 수행 Returns: quantized_model: AWQ가 적용된 모델 (실제 INT4 커널은 export.py에서 vLLM 포맷으로 변환) - - # TODO: 레이어 순회 및 awq_quantize_linear 호출 로직을 완성하세요. - # - # 힌트: - # - model.named_modules()로 순회 - # - layer_name이 act_stats에 있는 Linear만 처리 - # - awq_quantize_linear() 결과를 모델에 반영 - # (AWQLinear 같은 커스텀 모듈로 교체하거나, weight를 직접 치환) """ awq_cfg = config["awq"] w_bit = awq_cfg["w_bit"] group_size = awq_cfg["group_size"] zero_point = awq_cfg["zero_point"] clip_search = awq_cfg.get("clip_search", False) + search_mode = awq_cfg.get("search_mode", "weight") + input_cache = input_cache or {} + + if search_mode == "output" and not input_cache: + raise ValueError( + "search_mode='output'이지만 input_cache가 비어 있습니다. " + "calibration에서 collect_inputs=True로 입력 서브샘플을 수집하세요." + ) quant_results = {} @@ -353,10 +401,12 @@ def quantize_model( f"group_size({group_size})로 나누어지지 않아 FP16으로 유지합니다.") continue + x = input_cache.get(name) if search_mode == "output" else None + result = awq_quantize_linear( module, act_stats[name], w_bit=w_bit, group_size=group_size, zero_point=zero_point, - clip_search=clip_search, + clip_search=clip_search, x=x, ) # weight를 dequantized 값으로 치환 (추론 호환성 유지) diff --git a/qwen3-awq/run_awq.py b/qwen3-awq/run_awq.py index 4787c77..f860b91 100644 --- a/qwen3-awq/run_awq.py +++ b/qwen3-awq/run_awq.py @@ -55,6 +55,10 @@ def main(): help="그룹 양자화 크기") parser.add_argument("--clip-search", action="store_true", help="그룹별 weight clipping 탐색 활성화 (outlier로 인한 해상도 낭비 방지)") + parser.add_argument("--search-mode", type=str, default="weight", + choices=["weight", "output"], + help="scale/clip 탐색 목적함수: weight(재구성 오차, 기존) | " + "output(출력 오차, 공식 AWQ 방식 — 입력 서브샘플 캐시 사용)") parser.add_argument("--output-dir", type=str, default=None, help="출력 디렉토리 (기본: ./outputs/-awq-)") parser.add_argument("--skip-layers", type=str, nargs="*", default=None, @@ -68,6 +72,7 @@ def main(): w_bit=args.w_bit, group_size=args.group_size, clip_search=args.clip_search, + search_mode=args.search_mode, skip_layers=skip_layers, ) From 1f154782f58a2e59ab2ce3c6402161f0715136ed Mon Sep 17 00:00:00 2001 From: skt0725 Date: Wed, 22 Jul 2026 20:21:31 +0900 Subject: [PATCH 6/6] Update DESIGN_NOTES with roadmap #1 results (output-error search) Output-error search (roadmap #1) reaches 42.98% KMMLU on kowikitext, closing the gap to official AutoAWQ from -4.08%p to -1.13%p (+1.25%p over weight-error search). Clip experiments confirm objective dependence: weight-error+clip -3.22%p (harmful) vs output-error+clip -1.45%p; best config is output-error search without clip. - Reworked results table: split by search objective and clip - Added VRAM compression table (8.04GB -> 2.67GB, 3.0x) - Marked roadmaps #1/#2 done; #3 (LayerNorm fold) is the remaining item - Added experiment log: env migration, fp16 overflow fix, roadmap #1 --- qwen3-awq/DESIGN_NOTES.md | 90 ++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/qwen3-awq/DESIGN_NOTES.md b/qwen3-awq/DESIGN_NOTES.md index e64c01e..9261ce0 100644 --- a/qwen3-awq/DESIGN_NOTES.md +++ b/qwen3-awq/DESIGN_NOTES.md @@ -2,20 +2,31 @@ ## 결과 (Qwen3-4B, KMMLU) -| 모델 | KMMLU acc | FP16 대비 | -|------|-----------|-----------| -| FP16 baseline | 45.81% | — | -| 공식 AutoAWQ (INT4, pileval) | 44.11% | −1.70%p | -| 직접 구현 (INT4, **kowikitext** 한국어) | **41.61%** | −4.20%p | -| 직접 구현 (INT4, kowikitext 한국어 + clip) | 38.51% | −3.22%p | -| 직접 구현 (INT4, pileval 영어) | 40.34% | −5.47%p | -| 직접 구현 (INT4, wikitext2 영어) | (측정 중) | — | - +| 모델 | 탐색 목적함수 | clip | KMMLU acc | FP16 대비 | +|------|------|------|-----------|-----------| +| FP16 baseline | — | — | 45.81% | — | +| 공식 AutoAWQ (INT4, pileval) | 출력 오차 | ✓ | 44.11% | −1.70%p | +| **직접 구현 (kowikitext, 출력 오차)** | 출력 오차 | ✗ | **42.98%** | **−2.83%p** | +| 직접 구현 (kowikitext, 출력 오차 + clip) | 출력 오차 | ✓ | 41.53% | −4.28%p | +| 직접 구현 (kowikitext, weight 오차) | weight 오차 | ✗ | 41.73% | −4.08%p | +| 직접 구현 (kowikitext, weight 오차 + clip) | weight 오차 | ✓ | 38.51% | −7.30%p | +| 직접 구현 (pileval 영어, weight 오차) | weight 오차 | ✗ | 40.34% | −5.47%p | +| 직접 구현 (wikitext2 영어, weight 오차) | weight 오차 | ✗ | 39.14% | −6.67%p | + +*(RunPod 환경 측정. kowikitext weight-오차 결과는 Colab에서 41.61%로 재현성 확인됨)* + +- **로드맵 #1(출력-오차 탐색) 적용으로 공식과의 격차가 −4.08%p → −1.13%p로 좁혀짐.** + 남은 격차는 대부분 아래 차이점 #1(이중 양자화)에서 기인 - INT4 로딩(40.34%)과 FP16 dequant 시뮬레이션(40.33%)이 일치 → **export 파이프라인 정상** -- 공식 대비 격차는 버그가 아니라 아래 **의도적 단순화 3가지**의 대가 -- **한국어 calibration(kowikitext)이 pileval보다 +1.27%p** — 한국어 벤치마크에는 - 한국어 calibration이 유리함을 확인. 특히 HUMSS(인문사회) +2.57%p로 언어 의존 - 도메인에서 효과가 큼 +- **한국어 calibration(kowikitext)이 pileval보다 +1.39%p, wikitext2보다 +2.59%p** — + 한국어 벤치마크에는 한국어 calibration이 유리함을 확인. 특히 HUMSS(인문사회)에서 효과가 큼 + +### 압축 효과 (VRAM, 추론 로드 기준) + +| | 경량화 전 (FP16) | 경량화 후 (INT4) | 압축률 | +|---|---|---|---| +| 모델 크기 | 8.04 GB | 2.67 GB | 3.0× | +| 파라미터 수 | 4.02B | 4.02B (비트수만 16→4) | — | ## 차이점과 이유 @@ -29,22 +40,34 @@ AWQ는 `W·s`를 양자화하면 입력 쪽에 `1/s` 보정이 필요합니다. **이유**: fold는 아키텍처별 레이어 연결 매핑이 필요합니다 (AutoAWQ가 모델마다 전용 클래스를 두는 이유). "아무 HF 모델에나 적용"이라는 범용성 목표를 위해 unfold를 택했습니다. -### 2. 탐색 목적함수: weight 오차 vs 출력 오차 +### 2. 탐색 목적함수: weight 오차 vs 출력 오차 (로드맵 #1로 해소됨 ✓) - **공식**: calibration 입력 X를 캐시해 `‖Q(W·s)·(X/s) − W·X‖` (출력 오차) 최소화 — activation이 큰 채널의 오차에 자동으로 가중치가 실림 -- **우리**: activation 통계는 탐색 후보(`s = act_scales^α`)에만 쓰고, +- **초기 우리**: activation 통계는 탐색 후보(`s = act_scales^α`)에만 쓰고, 선택은 `‖dequant(quant(W·s)) − W·s‖` (weight 오차)로 함 -**이유**: 출력 오차 기준은 레이어 입력 X(레이어당 수백 MB)가 필요해 공식은 블록 단위 -순차 실행 인프라를 씁니다. 우리는 hook으로 채널당 통계 벡터(수 KB)만 수집하는 -단순 구조를 유지했습니다 (Colab 12.67GB RAM 제약 포함). - +**해소**: 로드맵 #1에서 hook에 레이어별 입력 서브샘플(512행, fp16/CPU, 전체 ~1GB)을 +캐시하는 경로를 추가하고, `--search-mode output`으로 목적함수를 공식과 동일한 출력 오차로 +교체함. 블록 단위 순차 실행 인프라 없이도 서브샘플만으로 근사가 가능했고, +결과적으로 **41.73% → 42.98% (+1.25%p)** 상승. -### 3. Weight clipping 탐색 생략 +### 3. Weight clipping 탐색 (구현 완료, 실험으로 목적함수 종속성 규명 ✓) 공식은 그룹 max를 얼마나 잘라낼지도 grid search (outlier로 인한 해상도 낭비 방지). -핵심 알고리즘 이해에 집중하기 위해 생략 (추후 구현하여 clip 탐색 적용 실험 또한 진행) +구현 후 실험한 결과, **clip은 어떤 목적함수 위에서 도느냐에 성패가 갈림**: + +| clip 탐색 기준 | KMMLU | baseline 대비 | +|---|---|---| +| weight 오차 위에 clip | 38.51% | **−3.22%p (역효과)** | +| 출력 오차 위에 clip | 41.53% | −1.45%p | + +- **weight-오차 기준 clip이 역효과인 이유**: weight 재구성 오차만 보면 그룹 내 소수의 + 큰 weight(outlier)를 잘라 나머지 해상도를 높이는 게 항상 이득처럼 보인다. 그런데 + 그 outlier가 바로 AWQ가 보호하려는 salient weight(큰 activation과 곱해지는 채널)라서, + 잘라내면 출력이 망가진다. 공식이 clip도 **출력 오차** 기준으로 탐색하는 이유가 이것. +- **단, 출력-오차 탐색이 이미 salient weight를 잘 보호하므로**, 그 위에 clip을 더해도 + 이 케이스에선 소폭 손해(42.98% → 41.53%). **최선 조합은 "출력-오차 탐색 + clip 없음".** @@ -55,13 +78,20 @@ AWQ GEMM export 포맷(인터리브 패킹 `[0,2,4,6,1,3,5,7]`, gptqmodel/vLLM ## 개선 로드맵 -1. **입력 서브샘플 캐시 + 출력 오차 탐색** — hook에서 레이어당 수백 행만 저장(~1GB 미만), - 범용성 유지하며 목적함수를 공식과 동일하게 (추천 다음 단계) -2. **clipping 탐색** — 범용성 유지, 구현 간단 -3. **LayerNorm fold** — Llama-계열 표준 구조 한정 지원 + 미지원 모델은 폴백. - 이중 양자화 제거로 가장 큰 격차 해소, 대신 아키텍처 의존성 발생 - - -Runpod에서 GPU를 제공받아 추가 실험 진행 -로드맵 2번 Clipping 적용 시 성능 하락 확인 -> clip 자체가 나쁜 게 아니라, 출력-오차 탐색(로드맵 #1) 없이 clip만 얹으면 역효과가 남 +1. ✅ **입력 서브샘플 캐시 + 출력 오차 탐색** (완료) — hook에서 레이어당 512행 저장(~1GB), + 범용성 유지하며 목적함수를 공식과 동일하게. `--search-mode output`. **+1.25%p** +2. ✅ **clipping 탐색** (완료) — 구현 후 실험으로 "목적함수 종속" 규명 (위 차이점 #3 참조). + 출력-오차 탐색이 선행돼야 의미가 있으며, 이 케이스에선 clip 없는 쪽이 최선. +3. ⬜ **LayerNorm fold** — Llama-계열 표준 구조 한정 지원 + 미지원 모델은 폴백. + 이중 양자화 제거로 남은 격차(−1.13%p)의 대부분 해소 예상, 대신 아키텍처 의존성 발생. + **현재 남은 유일한 주요 개선 항목.** + +## 실험 로그 요약 + +- Colab → RunPod로 실행 환경 이전 (HF 다운로드 스톨/환경 리셋 대응). 코드는 GitHub로 동기화. +- fp16 overflow 버그 수정(`ec22cef`): wikitext2 calibration의 activation outlier가 + `w·act_scales^α`를 fp16에서 inf로 만들어 scales에 NaN 전파 → KMMLU 9.87%(랜덤 이하)로 붕괴. + 양자화 계산을 fp32로, 저장만 fp16으로 바꿔 해소 (wikitext2 39.14%로 정상화). +- 로드맵 #1(출력-오차 탐색, `5703ff8`) 적용: kowikitext 42.98%로 최고 성능 달성, + 공식 AutoAWQ(44.11%)와 −1.13%p까지 근접.