Files
2026-07-13 13:04:41 +08:00

330 lines
11 KiB
Python

"""
Concurrent inference via SGLang.
Two input modes are supported:
1. Dataset images: pass --image_dir and each image is sent as one request.
2. PDF pages: pass --pdf and each converted page is sent as one request.
"""
import argparse
import base64
import json
import os
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
SERVED_MODEL_NAME = "Unlimited-OCR"
SERVER_URL = "http://127.0.0.1:10000"
HOST = "0.0.0.0"
PORT = 10000
SERVER_TIMEOUT = 300
PDF_DPI = 300
ATTENTION_BACKEND = "fa3"
PAGE_SIZE = 1
MEM_FRACTION_STATIC = 0.8
PROMPT = "document parsing."
TEMPERATURE = 0
CONTEXT_LENGTH = 32768
NO_REPEAT_NGRAM_SIZE = 35
NGRAM_WINDOW = 128
REQUEST_TIMEOUT = 1200
MAX_RETRIES = 5
NO_REPEAT_NGRAM_PROCESSOR_STR = None
def get_ngram_processor_str():
global NO_REPEAT_NGRAM_PROCESSOR_STR
if NO_REPEAT_NGRAM_PROCESSOR_STR is None:
from sglang.srt.sampling.custom_logit_processor import (
DeepseekOCRNoRepeatNGramLogitProcessor,
)
NO_REPEAT_NGRAM_PROCESSOR_STR = DeepseekOCRNoRepeatNGramLogitProcessor.to_str()
return NO_REPEAT_NGRAM_PROCESSOR_STR
def pdf_to_images(pdf_path: str, dpi: int = 300) -> list[str]:
import fitz
doc = fitz.open(pdf_path)
tmp_dir = tempfile.mkdtemp(prefix="pdf_ocr_")
image_paths = []
mat = fitz.Matrix(dpi / 72, dpi / 72)
for i, page in enumerate(doc):
out_path = os.path.join(tmp_dir, f"page_{i + 1:04d}.png")
page.get_pixmap(matrix=mat).save(out_path)
image_paths.append(out_path)
doc.close()
return image_paths
def encode_image(image_path: str) -> dict:
ext = os.path.splitext(image_path)[1].lower()
mime = "image/jpeg" if ext in (".jpg", ".jpeg") else f"image/{ext.lstrip('.')}"
with open(image_path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}}
def build_content(image_path: str) -> list[dict]:
return [{"type": "text", "text": PROMPT}, encode_image(image_path)]
def server_ready(server_url: str) -> bool:
try:
resp = requests.get(f"{server_url}/health", timeout=5)
return resp.status_code == 200
except requests.RequestException:
return False
def start_server(args):
if server_ready(SERVER_URL):
print(f"Reuse existing SGLang server: {SERVER_URL}")
return None
os.makedirs(os.path.dirname(os.path.abspath(args.server_log)) or ".", exist_ok=True)
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = args.gpu
cmd = [
sys.executable,
"-m",
"sglang.launch_server",
"--model",
args.model_dir,
"--served-model-name",
SERVED_MODEL_NAME,
"--attention-backend",
ATTENTION_BACKEND,
"--page-size",
str(PAGE_SIZE),
"--mem-fraction-static",
str(MEM_FRACTION_STATIC),
"--context-length",
str(CONTEXT_LENGTH),
"--enable-custom-logit-processor",
"--disable-overlap-schedule",
"--skip-server-warmup",
"--host",
HOST,
"--port",
str(PORT),
]
print(f"Starting SGLang server on GPU {args.gpu}, port {PORT} ...")
log_file = open(args.server_log, "w", encoding="utf-8")
process = subprocess.Popen(cmd, env=env, stdout=log_file, stderr=subprocess.STDOUT)
process._log_file = log_file
print(f"Server PID: {process.pid}")
start = time.time()
while time.time() - start < SERVER_TIMEOUT:
if process.poll() is not None:
log_file.flush()
raise RuntimeError(f"SGLang server exited early. Check {args.server_log}")
if server_ready(SERVER_URL):
print(f"Server ready ({time.time() - start:.0f}s)")
return process
time.sleep(3)
stop_server(process)
raise TimeoutError(f"Timed out waiting for SGLang server. Check {args.server_log}")
def stop_server(process):
if process is None:
return
process.terminate()
try:
process.wait(timeout=30)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
process._log_file.close()
def collect_stream_silent(resp, output_file: str | None) -> dict:
chunks = []
token_count = 0
first_token_time = None
f = open(output_file, "w", encoding="utf-8") if output_file else None
try:
for raw_line in resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"].get("content", "")
except (json.JSONDecodeError, KeyError):
continue
if not delta:
continue
if first_token_time is None:
first_token_time = time.time()
token_count += 1
chunks.append(delta)
if f:
f.write(delta)
finally:
if f:
f.close()
end_time = time.time()
decode_time = (end_time - first_token_time) if first_token_time and token_count > 1 else 0
return {"tokens": token_count, "decode_time": decode_time, "text": "".join(chunks)}
def infer_one(image_path: str, output_file: str | None, args, idx: int) -> dict:
payload = {
"model": SERVED_MODEL_NAME,
"messages": [{"role": "user", "content": build_content(image_path)}],
"temperature": TEMPERATURE,
"skip_special_tokens": False,
"stream": True,
"images_config": {"image_mode": args.image_mode},
}
if NO_REPEAT_NGRAM_SIZE > 0 and NGRAM_WINDOW > 0:
payload["custom_logit_processor"] = get_ngram_processor_str()
payload["custom_params"] = {
"ngram_size": NO_REPEAT_NGRAM_SIZE,
"window_size": NGRAM_WINDOW,
}
name = os.path.basename(image_path)
for attempt in range(MAX_RETRIES):
try:
resp = requests.post(
f"{SERVER_URL}/v1/chat/completions",
headers={"Content-Type": "application/json"},
data=json.dumps(payload),
timeout=REQUEST_TIMEOUT,
stream=True,
)
if resp.status_code == 502 and attempt < MAX_RETRIES - 1:
time.sleep(3 * (attempt + 1))
continue
resp.raise_for_status()
result = collect_stream_silent(resp, output_file)
print(f" [{idx}] {name}: {result['tokens']} tokens, {result['decode_time']:.1f}s")
return result
except Exception as e:
if attempt < MAX_RETRIES - 1:
print(f" [{idx}] {name}: retry {attempt + 1}/{MAX_RETRIES} ({e})")
time.sleep(3 * (attempt + 1))
continue
print(f" [{idx}] {name}: FAILED ({e})")
return {"tokens": 0, "decode_time": 0, "text": ""}
def collect_dataset_images(image_dir: str) -> list[str]:
exts = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
image_files = []
for root, _, files in os.walk(image_dir):
for name in files:
if name.lower().endswith(exts):
image_files.append(os.path.join(root, name))
return sorted(image_files, key=lambda f: os.path.getsize(f), reverse=True)
def build_jobs(args) -> list[tuple[str, str | None]]:
if args.pdf:
image_files = pdf_to_images(args.pdf, dpi=PDF_DPI)
prefix = os.path.splitext(os.path.basename(args.pdf))[0]
jobs = []
for i, image_path in enumerate(image_files):
output_file = None
if args.output_dir:
output_file = os.path.join(args.output_dir, f"{prefix}_page_{i + 1:04d}.md")
jobs.append((image_path, output_file))
return jobs
if not args.image_dir:
raise ValueError("Either --image_dir or --pdf is required")
image_files = collect_dataset_images(args.image_dir)
jobs = []
for image_path in image_files:
output_file = None
if args.output_dir:
rel = os.path.relpath(image_path, args.image_dir)
stem = os.path.splitext(rel)[0].replace(os.sep, "__")
output_file = os.path.join(args.output_dir, f"{stem}.md")
jobs.append((image_path, output_file))
return jobs
def run(args):
jobs = build_jobs(args)
if args.output_dir:
os.makedirs(args.output_dir, exist_ok=True)
mode = "pdf_pages" if args.pdf else "dataset_images"
print(f"Mode: {mode}, requests={len(jobs)}, concurrency={args.concurrency}, image_mode={args.image_mode}")
wall_start = time.time()
results = []
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
futures = {
executor.submit(infer_one, image_path, output_file, args, i + 1): image_path
for i, (image_path, output_file) in enumerate(jobs)
}
for future in as_completed(futures):
results.append(future.result())
wall_time = time.time() - wall_start
total_tokens = sum(r["tokens"] for r in results)
successful = sum(1 for r in results if r["tokens"] > 0)
print(f"\n{'=' * 60}")
print("Concurrent Results:")
print(f" Requests: {successful}/{len(jobs)}")
print(f" Total tokens: {total_tokens}")
print(f" Wall time: {wall_time:.2f}s")
if wall_time > 0:
print(f" System TPS: {total_tokens / wall_time:.2f} tokens/s")
if successful > 0:
avg_decode = sum(r["decode_time"] for r in results if r["tokens"] > 0) / successful
avg_tokens = total_tokens / successful
print(f" Avg tokens/request: {avg_tokens:.0f}")
print(f" Avg decode_time/request: {avg_decode:.2f}s")
print(f"{'=' * 60}")
def parse_args():
parser = argparse.ArgumentParser(
description="SGLang concurrent inference for image datasets or PDF pages.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--image_dir", default="", help="Directory of images for dataset concurrency mode")
parser.add_argument("--pdf", default="", help="PDF file; each page is converted and sent as one concurrent request")
parser.add_argument("--output_dir", default="./outputs")
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--gpu", default="0")
parser.add_argument("--model_dir", default="baidu/Unlimited-OCR")
parser.add_argument("--image_mode", choices=("gundam", "base"), default="gundam")
parser.add_argument("--server_log", default="./log/sglang_server.log")
return parser.parse_args()
def main():
args = parse_args()
server_process = start_server(args)
try:
run(args)
finally:
stop_server(server_process)
if __name__ == "__main__":
main()