""" 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()