首次提交
This commit is contained in:
463
tools/bench_ocr_endpoints.py
Normal file
463
tools/bench_ocr_endpoints.py
Normal file
@@ -0,0 +1,463 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user