Files
surya-ocr/scripts/quant/build_model.py
T
Fu DaiandClaude Opus 4.8 1a585693be Suya OCR API — vLLM-backed, OpenAI-compatible OCR service
FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM:
legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint,
a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual
example outputs, and quantization/concurrency benchmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:20:02 +04:00

145 lines
5.2 KiB
Python

"""Produce vLLM-loadable quantized checkpoints.
compressor methods (int8/awq/gptq) use llm-compressor `oneshot`.
bnb methods (bnb8/bnb4) use transformers + BitsAndBytesConfig + save_pretrained.
baseline/online (bf16/fp8) need no build.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
from scripts.quant.recipes import METHOD_SPECS
def needs_build(method: str) -> bool:
return METHOD_SPECS[method]["kind"] in ("compressor", "bnb")
def _is_built(out_dir: Path) -> bool:
return (out_dir / "config.json").exists()
CALIB_MAX_SEQ_LEN = 8192
# Never quantize the LM head or the vision tower: vision modules are shape-fragile
# (see the FP8 Marlin failure) and contribute little to decode cost.
QUANT_IGNORE = ["re:.*lm_head", "re:.*visual.*", "re:.*vision.*"]
def _calibration_dataset(calib_images: List[Path], processor):
"""HF Dataset of pre-tokenized multimodal samples (batch dim kept), matching
llm-compressor's multimodal-vision examples; re-tensorized by _data_collator.
Activation calibration only needs representative forward passes, not the
exact training prompt."""
from datasets import Dataset
from PIL import Image
samples = []
for path in calib_images:
image = Image.open(path).convert("RGB")
messages = [{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": "OCR this document."},
],
}]
prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
inputs = processor(
text=[prompt], images=[image],
padding=False, truncation=True, max_length=CALIB_MAX_SEQ_LEN,
)
samples.append({k: (v.tolist() if hasattr(v, "tolist") else v) for k, v in inputs.items()})
return Dataset.from_list(samples)
def _data_collator(batch):
import torch
assert len(batch) == 1
return {key: torch.tensor(value) for key, value in batch[0].items()}
def _build_compressor(method: str, base_model: str, out_dir: Path, calib_images: List[Path]) -> None:
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import GPTQModifier
from transformers import AutoProcessor
try:
from transformers import AutoModelForImageTextToText as _AutoModel
except ImportError: # older transformers
from transformers import AutoModelForCausalLM as _AutoModel
spec = METHOD_SPECS[method]
model = _AutoModel.from_pretrained(base_model, dtype="auto", device_map="auto")
processor = AutoProcessor.from_pretrained(base_model)
dataset = _calibration_dataset(calib_images, processor)
if spec["modifier"] == "awq":
# llm-compressor main: AWQ is a transform paired with a QuantizationModifier.
from llmcompressor.modifiers.quantization import QuantizationModifier
try:
from llmcompressor.modifiers.transform.awq import AWQModifier
except ImportError: # older layouts keep AWQModifier under modifiers.awq
from llmcompressor.modifiers.awq import AWQModifier
recipe = [
AWQModifier(duo_scaling=False),
QuantizationModifier(scheme=spec["scheme"], ignore=QUANT_IGNORE),
]
else:
recipe = []
if spec.get("smoothquant"):
from llmcompressor.modifiers.smoothquant import SmoothQuantModifier
recipe.append(SmoothQuantModifier(smoothing_strength=0.8))
recipe.append(GPTQModifier(targets="Linear", scheme=spec["scheme"], ignore=QUANT_IGNORE))
oneshot(
model=model,
dataset=dataset,
recipe=recipe,
max_seq_length=CALIB_MAX_SEQ_LEN,
num_calibration_samples=len(dataset),
data_collator=_data_collator,
)
out_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(out_dir, save_compressed=True)
processor.save_pretrained(out_dir)
def _build_bnb(method: str, base_model: str, out_dir: Path) -> None:
import torch
from transformers import AutoProcessor, BitsAndBytesConfig
try:
from transformers import AutoModelForImageTextToText as _AutoModel
except ImportError:
from transformers import AutoModelForCausalLM as _AutoModel
bits = METHOD_SPECS[method]["bits"]
if bits == 8:
config = BitsAndBytesConfig(load_in_8bit=True)
else:
config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
model = _AutoModel.from_pretrained(base_model, quantization_config=config, device_map="auto")
model.save_pretrained(out_dir)
AutoProcessor.from_pretrained(base_model).save_pretrained(out_dir)
def build_model(method: str, base_model: str, out_dir: Path, calib_images: List[Path]) -> Path:
if not needs_build(method):
return Path(base_model)
out_dir = Path(out_dir)
if _is_built(out_dir):
return out_dir
kind = METHOD_SPECS[method]["kind"]
if kind == "compressor":
_build_compressor(method, base_model, out_dir, calib_images)
else:
_build_bnb(method, base_model, out_dir)
return out_dir