Files
ocr_data/tools/bench_ocr_endpoints.py
2026-09-28 16:09:31 +08:00

464 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
压测每个 OCR 端点(IP)的实际 QPS,并把结果写成权重文件 config/ocr_weights.json,
供运行时按 QPS 加权分发请求(见 tool/Ocr/tencentcloud_ocr.py 的 OcrDispatcher)。
做法:对每个端点用固定并发、固定请求数发同一张样例图,
QPS = 成功请求数 / 墙钟耗时;同时报告平均时延和错误率。
本脚本完全自包含:单文件,不读 config、不 import 项目任何模块,拷过去就能跑。
用法:
python bench_ocr_endpoints.py [样例图片] [--requests N] [--concurrency C] [--no-write]
python bench_ocr_endpoints.py [样例图片] --url http://IP:PORT [--url http://IP2:PORT ...]
样例图片不给时,自动在 ./composite、./split 下找一张 .jpg。
端点:默认内置 Polaris 地址;要测别的 IP 用 --url(可多次)。
--requests 每个端点发的总请求数(默认 60)
--concurrency 每个端点的并发线程数(默认 10)
--no-write 只打印,不写 ocr_weights.json
"""
import argparse
import base64
import glob
import itertools
import json
import os
import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
OCR_PORT = 18081
# 内置的多台 OCR 服务 IP(自动负载均衡测试用)
DEFAULT_IPS = [
"30.154.78.83"
]
DEFAULT_ENDPOINTS = [f"http://{ip}:{OCR_PORT}" for ip in DEFAULT_IPS]
WEIGHTS_FILE = "./ocr_weights.json"
def get_endpoints(cli_urls):
"""命令行 --url 优先,否则用内置默认端点。"""
if cli_urls:
return [u.rstrip("/") for u in cli_urls]
return list(DEFAULT_ENDPOINTS)
def find_sample_image(given):
if given:
if os.path.exists(given):
return given
print(f"指定的图片不存在: {given}")
return None
for root in ("./composite", "./split"):
hits = glob.glob(os.path.join(root, "**", "*.jpg"), recursive=True)
if hits:
return hits[0]
return None
def img2base64(img_path):
with open(img_path, "rb") as f:
return base64.b64encode(f.read()).decode()
def ocr_request_once(base_url, image_b64, timeout=60):
"""向指定端点发一次 OCR 请求,返回原始响应文本;失败抛异常。"""
url = base_url.rstrip("/") + OCR_PATH
app_id = f"{int(time.time() * 1000)}{random.randint(100000, 999999)}"
payload = {
"app_id": app_id,
"image": image_b64,
"ocr_template": "OCR",
"options": {"pdf_page_index": 0, "detect_text": True},
}
resp = requests.post(url, headers={"Content-Type": "application/json"},
json=payload, timeout=timeout)
resp.raise_for_status()
return resp.text
def _ok(raw):
"""判断一次响应是否算成功:有内容且不是 error。"""
if not raw:
return False
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return False
if "TextDetections" in data:
return True
return data.get("error_code", 0) == 0
def bench_endpoint(base_url, image_b64, requests_n, concurrency):
"""对单个端点压测,返回统计 dict(含一个失败原因采样 sample_err)。"""
latencies = []
ok = 0
err = 0
sample_err = [None] # 采样第一个失败原因
def one_call():
t0 = time.time()
try:
raw = ocr_request_once(base_url, image_b64, timeout=60)
dt = time.time() - t0
if _ok(raw):
return (True, dt)
# HTTP 200 但内容是错误:采样响应体片段
if sample_err[0] is None:
sample_err[0] = f"响应非成功: {raw[:200]}"
return (False, dt)
except Exception as e:
if sample_err[0] is None:
sample_err[0] = f"{type(e).__name__}: {e}"
return (False, time.time() - t0)
t_start = time.time()
with ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(one_call) for _ in range(requests_n)]
for fut in as_completed(futures):
success, dt = fut.result()
latencies.append(dt)
if success:
ok += 1
else:
err += 1
wall = time.time() - t_start
return {
"qps": ok / wall if wall > 0 else 0.0,
"ok": ok,
"err": err,
"wall": wall,
"avg_latency": sum(latencies) / len(latencies) if latencies else 0.0,
"err_rate": err / requests_n if requests_n else 0.0,
"sample_err": sample_err[0],
}
def soak_endpoint(base_url, image_pool, concurrency, report_interval=5):
"""对单个端点持续满速压测:固定 concurrency 个线程不停发请求,
每次从 image_pool 随机抽一张图(避免被服务端缓存),每 report_interval 秒打印滚动 QPS,
直到 Ctrl+C。image_pool 可以是单张或几千张。"""
stop = threading.Event()
lock = threading.Lock()
st = {"ok": 0, "err": 0, "lat": 0.0, "n": 0}
sample_err = [None]
def worker():
while not stop.is_set():
img = random.choice(image_pool) # 每次随机抽一张
t0 = time.time()
try:
raw = ocr_request_once(base_url, img, timeout=60)
success = _ok(raw)
if not success and sample_err[0] is None:
sample_err[0] = f"响应非成功: {raw[:160]}"
except Exception as e:
success = False
if sample_err[0] is None:
sample_err[0] = f"{type(e).__name__}: {e}"
dt = time.time() - t0
with lock:
st["n"] += 1
st["lat"] += dt
if success:
st["ok"] += 1
else:
st["err"] += 1
threads = [threading.Thread(target=worker, daemon=True) for _ in range(concurrency)]
for t in threads:
t.start()
print(f"持续满速压测 {base_url} 并发={concurrency} 每{report_interval}s 报一次 (Ctrl+C 停止)")
print(f"{'用时s':>6} {'累计OK':>9} {'瞬时QPS':>9} {'平均QPS':>9} {'时延ms':>8} {'错误率':>7}")
print("-" * 56)
t_start = time.time()
last_ok, last_t = 0, t_start
try:
while True:
time.sleep(report_interval)
now = time.time()
with lock:
ok, err, n, lat = st["ok"], st["err"], st["n"], st["lat"]
inst = (ok - last_ok) / (now - last_t) if now > last_t else 0
avg = ok / (now - t_start) if now > t_start else 0
avg_lat = (lat / n * 1000) if n else 0
er = err / n * 100 if n else 0
print(f"{now-t_start:6.0f} {ok:>9} {inst:>9.1f} {avg:>9.1f} {avg_lat:>8.0f} {er:>6.0f}%", flush=True)
last_ok, last_t = ok, now
except KeyboardInterrupt:
stop.set()
el = time.time() - t_start
with lock:
ok, n = st["ok"], st["n"]
print("-" * 56)
print(f"停止。{el:.0f}s 内成功 {ok} / 共发 {n},平均 {ok/el if el else 0:.1f} QPS")
if sample_err[0]:
print(f"失败采样: {sample_err[0]}")
def health_check(endpoints, image_b64):
"""给每个端点发一次请求,返回 (存活列表, 死亡列表)。"""
alive, dead = [], []
def _check(ep):
try:
return ep, _ok(ocr_request_once(ep, image_b64, timeout=15))
except Exception:
return ep, False
with ThreadPoolExecutor(max_workers=min(32, len(endpoints))) as ex:
for ep, ok in ex.map(_check, endpoints):
(alive if ok else dead).append(ep)
return alive, dead
def bench_aggregate(endpoints, image_b64, requests_n, concurrency):
"""把请求轮询分发到所有端点(模拟运行时自动负载均衡),测合并 QPS。"""
rr = itertools.cycle(endpoints)
rr_lock = threading.Lock()
latencies = []
ok = 0
err = 0
stat_lock = threading.Lock()
def next_ep():
with rr_lock:
return next(rr)
def one_call(_):
ep = next_ep()
t0 = time.time()
try:
raw = ocr_request_once(ep, image_b64, timeout=60)
success = _ok(raw)
except Exception:
success = False
dt = time.time() - t0
with stat_lock:
latencies.append(dt)
return success
t_start = time.time()
with ThreadPoolExecutor(max_workers=concurrency) as ex:
for success in ex.map(one_call, range(requests_n)):
if success:
ok += 1
else:
err += 1
wall = time.time() - t_start
return {
"qps": ok / wall if wall > 0 else 0.0,
"ok": ok,
"err": err,
"wall": wall,
"avg_latency": sum(latencies) / len(latencies) if latencies else 0.0,
"err_rate": err / requests_n if requests_n else 0.0,
}
def run_aggregate(endpoints, image_b64, requests_n, concurrency):
"""先健康检查筛掉死端点,再在存活端点上做聚合负载测试。"""
print(f"健康检查 {len(endpoints)} 个端点 ...", flush=True)
alive, dead = health_check(endpoints, image_b64)
print(f" 存活: {len(alive)} 死亡: {len(dead)}")
if dead:
for ep in dead:
print(f" ✗ {ep}")
if not alive:
print("没有存活端点,无法测试。")
return
print("=" * 64)
print(f"聚合负载测试: {len(alive)} 个存活端点轮询分发,"
f"总请求 {requests_n},总并发 {concurrency}")
s = bench_aggregate(alive, image_b64, requests_n, concurrency)
print("-" * 64)
print(f" 合并 QPS = {s['qps']:.2f} 成功={s['ok']} 失败={s['err']}"
f" 平均时延={s['avg_latency']*1000:.0f}ms 错误率={s['err_rate']*100:.0f}%")
if alive:
print(f" 单端点平均 ≈ {s['qps']/len(alive):.2f} QPS"
f" ({len(alive)} 台合计 {s['qps']:.0f} QPS)")
print("=" * 64)
def sweep_endpoint(base_url, image_b64, start, max_c, per_worker):
"""自动加压找极限 QPS:并发从 start 逐级翻倍到 max_c,
QPS 不再随并发上升(或错误率升高)即判定到极限,停止。"""
print(f"自动加压 {base_url} (并发 {start}→{max_c} 逐级翻倍)")
print(f"{'并发':>6} {'QPS':>9} {'时延ms':>9} {'错误率':>8}")
print("-" * 36)
best_qps, best_c = 0.0, 0
prev_qps = 0.0
c = start
while c <= max_c:
reqs = max(c * per_worker, 30) # 每个并发至少发 per_worker 次,样本够稳
s = bench_endpoint(base_url, image_b64, reqs, c)
print(f"{c:>6} {s['qps']:>9.2f} {s['avg_latency']*1000:>9.0f} {s['err_rate']*100:>7.0f}%")
if s["qps"] > best_qps:
best_qps, best_c = s["qps"], c
if s["err_rate"] > 0.1:
print(" → 错误率升高,已超过服务承载,停止。")
break
if prev_qps > 0 and s["qps"] < prev_qps * 1.08:
print(" → QPS 不再随并发上升,已到极限,停止。")
break
prev_qps = s["qps"]
c *= 2
print("-" * 36)
print(f"极限 QPS ≈ {best_qps:.2f} (最佳并发 {best_c})")
return best_qps, best_c
def gen_random_images(n, size=(1280, 200)):
"""生成 n 张带随机文字的图片(让 OCR 真干活),返回 base64 列表。需要 Pillow。"""
try:
import io
import string
from PIL import Image, ImageDraw
except ImportError:
print("生成随机图需要 Pillow(pip install pillow);改用 --pool 指向真实图目录")
raise
chars = string.ascii_letters + string.digits + " ,.!?-"
pool = []
print(f"生成 {n} 张随机文字图...", flush=True)
for i in range(n):
bg = random.randint(200, 255)
img = Image.new("RGB", size, (bg, bg, bg))
d = ImageDraw.Draw(img)
for row in range(random.randint(1, 3)):
text = "".join(random.choice(chars) for _ in range(random.randint(10, 40)))
d.text((20, 20 + row * 50), text, fill=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
pool.append(base64.b64encode(buf.getvalue()).decode())
if (i + 1) % 1000 == 0:
print(f" 已生成 {i+1}/{n}", flush=True)
return pool
def build_image_pool(args):
"""构建测试图池(base64 列表):
--pool DIR 用真实图目录(最贴近实际负载) > --gen N 随机生成 > 单张样例图。"""
if getattr(args, "pool", None):
files = (glob.glob(os.path.join(args.pool, "**", "*.jpg"), recursive=True) +
glob.glob(os.path.join(args.pool, "**", "*.png"), recursive=True))
if not files:
print(f"目录 {args.pool} 下没有图片")
return []
random.shuffle(files)
cap = args.gen if args.gen else 5000
files = files[:cap]
print(f"从 {args.pool} 取 {len(files)} 张真实图作为图池")
return [img2base64(f) for f in files]
if getattr(args, "gen", 0):
return gen_random_images(args.gen)
sample = find_sample_image(args.image)
if not sample:
return []
print(f"单张样例图: {sample}")
return [img2base64(sample)]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image", nargs="?", default=None)
ap.add_argument("--url", action="append", default=None, help="OCR 端点(可多次)")
ap.add_argument("--requests", type=int, default=60)
ap.add_argument("--concurrency", type=int, default=10)
ap.add_argument("--aggregate", action="store_true",
help="聚合负载测试:请求轮询分发到所有存活端点,测合并总 QPS")
ap.add_argument("--sweep", action="store_true",
help="自动加压找极限 QPS(并发逐级翻倍直到 QPS 不再上升)")
ap.add_argument("--soak", action="store_true",
help="单端点持续满速压测(固定并发不停发,实时滚动QPS,Ctrl+C 停)")
ap.add_argument("--interval", type=int, default=5, help="soak 模式打印间隔秒")
ap.add_argument("--gen", type=int, default=0,
help="随机生成 N 张带文字的图作为图池(每次请求随机抽,避免被缓存)")
ap.add_argument("--pool", default=None,
help="用该目录下的真实图作为图池(最贴近实际负载,如 ./split/某视频)")
ap.add_argument("--start-concurrency", type=int, default=10, help="sweep 起始并发")
ap.add_argument("--max-concurrency", type=int, default=320, help="sweep 最大并发")
ap.add_argument("--per-worker", type=int, default=6, help="sweep 每档每并发发多少次请求")
ap.add_argument("--no-write", action="store_true")
args = ap.parse_args()
endpoints = get_endpoints(args.url)
if not endpoints:
print("没有可用的 OCR 端点")
return
# 构建图池:--pool 真实图目录 / --gen 随机生成 / 默认单张样例
image_pool = build_image_pool(args)
if not image_pool:
print("没有可用图片:给一张 jpg 路径、或用 --gen N / --pool 目录。")
return
print(f"图池大小: {len(image_pool)} 张 | 端点数: {len(endpoints)} 并发: {args.concurrency}")
print("=" * 64)
image_b64 = image_pool[0] # 非 soak 模式用单张(测纯吞吐)
# ---- soak 模式:单端点持续满速压测,每次随机抽图,直到 Ctrl+C ----
if args.soak:
soak_endpoint(endpoints[0], image_pool, args.concurrency, args.interval)
return
# ---- aggregate 模式:所有端点轮询分发,测合并总 QPS ----
if args.aggregate:
run_aggregate(endpoints, image_b64, args.requests, args.concurrency)
return
# ---- sweep 模式:自动加压找极限,不写权重文件 ----
if args.sweep:
for i, ep in enumerate(endpoints, 1):
print(f"\n[{i}/{len(endpoints)}]", end=" ")
sweep_endpoint(ep, image_b64, args.start_concurrency,
args.max_concurrency, args.per_worker)
return
results = {}
for i, ep in enumerate(endpoints, 1):
print(f"[{i}/{len(endpoints)}] 压测 {ep} ...", flush=True)
stat = bench_endpoint(ep, image_b64, args.requests, args.concurrency)
results[ep] = stat
print(f" QPS={stat['qps']:.2f} 成功={stat['ok']} 失败={stat['err']}"
f" 平均时延={stat['avg_latency']*1000:.0f}ms"
f" 错误率={stat['err_rate']*100:.0f}%")
if stat["err"] and stat.get("sample_err"):
print(f" 失败原因(采样): {stat['sample_err']}")
print("=" * 64)
print("汇总(按 QPS 降序):")
for ep, s in sorted(results.items(), key=lambda kv: kv[1]["qps"], reverse=True):
print(f" {ep:48s} QPS={s['qps']:6.2f} 时延={s['avg_latency']*1000:5.0f}ms"
f" 错误率={s['err_rate']*100:.0f}%")
total_qps = sum(s["qps"] for s in results.values())
print(f"\n合计可用 QPS ≈ {total_qps:.2f}")
# 写权重文件:QPS 即权重;错误率过高(>50%)的端点权重清零(不参与分发)
weights = {ep: (round(s["qps"], 3) if s["err_rate"] <= 0.5 else 0.0)
for ep, s in results.items()}
if args.no_write:
print("\n--no-write:不写权重文件。")
return
os.makedirs(os.path.dirname(WEIGHTS_FILE), exist_ok=True)
with open(WEIGHTS_FILE, "w", encoding="utf-8") as f:
json.dump(weights, f, ensure_ascii=False, indent=2)
print(f"\n已写入权重文件: {WEIGHTS_FILE}")
print("运行时会按此权重把 OCR 请求加权分发到各端点(重启进程或新批次生效)。")
if __name__ == "__main__":
main()