File size: 12,870 Bytes
71dabc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
#!/usr/bin/env python3
"""serve_cpu.py 压测:串行基线 / SLO 突发 / 100 并发持续 / 峰值内存 / 微批增益对照。

流程:
  1. 启动 scripts/serve_cpu.py 子进程,轮询 /health 至就绪,预热 3 条;
  2. 100 条串行基线(单条文本/请求,逐个发):总耗时、串行 RPS、延迟;
  3. SLO 突发:100 条同时发出(Barrier 对齐),量"整批总完成时间"(从发起到最慢一条
     响应,即 max(单请求延迟)),重复 5 轮取中位与最差;硬性 SLO:≤1000ms 且零失败;
  4. 100 并发持续压 1000 条(100 线程 × 100 文本 × 10 轮):RPS、p50/p95、失败数;
  5. 服务进程峰值内存:powershell Get-Process 的 PeakWorkingSet64(进程生命周期峰值,
     含模型权重与全部压测期间),单位 MB;SLO ≤ 3GB;
  6. 微批增益对照:关闭服务,以 --no-microbatch 重启(逐条前向),同口径再压 1000 条,
     batch_gain = 微批 RPS / 逐条 RPS;
  7. 关闭全部服务进程。

用法:
  python scripts/bench_serve.py                 # 完整压测
  python scripts/bench_serve.py --quick         # 快速冒烟(50 并发 200 条、突发 1 轮)
"""
import argparse
import json
import os
import statistics
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor

import requests

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = sys.executable


def log(msg):
    print(f"[bench] {time.strftime('%H:%M:%S')} {msg}", flush=True)


def start_server(extra_args, port):
    cmd = [PY, os.path.join(HERE, "serve_cpu.py"), "--port", str(port)] + extra_args
    log_path = os.path.join(tempfile.gettempdir(), f"serve_bench_{port}.log")
    logf = open(log_path, "w", encoding="utf-8")
    proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT, cwd=ROOT)
    proc.real_pid = None  # venv python.exe 是 trampoline,真实解释器 pid 从 /health 读
    t0 = time.perf_counter()
    while time.perf_counter() - t0 < 300:
        try:
            r = requests.get(f"http://127.0.0.1:{port}/health", timeout=2)
            if r.status_code == 200 and r.json().get("status") == "ok":
                proc.real_pid = r.json().get("pid")
                log(f"服务就绪 (trampoline pid={proc.pid}, 解释器 pid={proc.real_pid}, "
                    f"{time.perf_counter() - t0:.1f}s) args={extra_args or '(默认 微批)'}")
                return proc
        except Exception:
            pass
        time.sleep(0.5)
    proc.terminate()
    tail = open(log_path, encoding="utf-8").read()[-2000:]
    raise RuntimeError(f"服务 300s 未就绪,日志 {log_path} 尾部:\n{tail}")


def stop_server(proc):
    if proc and proc.poll() is None:
        # taskkill /T 杀整棵进程树(trampoline + 真实解释器),避免孤儿占端口
        subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
                       capture_output=True, timeout=30)
        try:
            proc.wait(timeout=15)
        except subprocess.TimeoutExpired:
            proc.kill()
    log(f"服务进程已关闭 (pid={getattr(proc, 'real_pid', None) or (proc.pid if proc else '?')})")


def peak_working_set_mb(pid):
    """powershell Get-Process: PeakWorkingSet64 = 进程启动以来 WorkingSet 峰值(bytes)。"""
    r = subprocess.run(
        ["powershell", "-NoProfile", "-Command",
         f"(Get-Process -Id {pid}).PeakWorkingSet64 / 1MB"],
        capture_output=True, text=True, timeout=30)
    return float(r.stdout.strip())


class Client:
    """每线程一个 Session(HTTP keep-alive)。"""

    def __init__(self, base):
        self.base = base
        self._local = threading.local()

    @property
    def session(self):
        if not hasattr(self._local, "s"):
            self._local.s = requests.Session()
            self._local.s.headers["Content-Type"] = "application/json"
        return self._local.s

    def predict_one(self, text, timeout=30):
        """返回 (latency_ms, ok, err)。"""
        a = time.perf_counter()
        try:
            r = self.session.post(self.base + "/predict", json={"texts": [text]},
                                  timeout=timeout)
            ok = (r.status_code == 200 and len(r.json().get("results", [])) == 1)
            return (time.perf_counter() - a) * 1000, ok, None if ok else f"http {r.status_code}"
        except Exception as e:
            return (time.perf_counter() - a) * 1000, False, str(e)[:120]

    def predict_batch(self, texts, timeout=60):
        a = time.perf_counter()
        try:
            r = self.session.post(self.base + "/predict", json={"texts": texts},
                                  timeout=timeout)
            ok = (r.status_code == 200 and len(r.json().get("results", [])) == len(texts))
            return (time.perf_counter() - a) * 1000, ok, None if ok else f"http {r.status_code}"
        except Exception as e:
            return (time.perf_counter() - a) * 1000, False, str(e)[:120]


def pct(sorted_lat, p):
    return sorted_lat[min(len(sorted_lat) - 1, max(0, int(len(sorted_lat) * p) - 1))]


def summarize(lats, fails):
    s = sorted(lats)
    return {"p50_ms": round(pct(s, 0.5), 1), "p95_ms": round(pct(s, 0.95), 1),
            "fail": fails}


def serial_baseline(client, texts):
    log(f"[1/5] 串行基线:{len(texts)} 条逐个发 ...")
    lats, fails = [], 0
    t0 = time.perf_counter()
    for t in texts:
        lat, ok, _ = client.predict_one(t)
        lats.append(lat)
        fails += 0 if ok else 1
    total = time.perf_counter() - t0
    out = {"total_ms": round(total * 1000, 1), "rps": round(len(texts) / total, 2),
           **summarize(lats, fails)}
    log(f"      串行: {out['total_ms']}ms  RPS={out['rps']}  p50={out['p50_ms']}ms  "
        f"p95={out['p95_ms']}ms  fail={out['fail']}")
    return out


def burst_test(texts, client, rounds):
    """100 条同时发出,整批总完成时间 = max(单请求延迟)。
    线程池 + 每线程 Session keep-alive 连接复用(先跑 1 轮预热建连,不计入)。"""
    n = len(texts)
    log(f"[2/5] SLO 突发:{n} 条同时发出 × {rounds} 轮(另有 1 轮建连预热不计)...")
    lat = [[None] * n for _ in range(rounds)]
    ok = [[False] * n for _ in range(rounds)]
    ex = ThreadPoolExecutor(max_workers=n)
    bar = {}

    def one(i, rd):
        b0 = time.perf_counter()
        bar["barrier"].wait()  # 全员对齐后同时开跑
        l, o, _ = client.predict_one(texts[i])
        lat[rd][i] = (time.perf_counter() - b0) * 1000
        ok[rd][i] = o

    def run_round(rd):
        t0_box = {}
        bar["barrier"] = threading.Barrier(
            n + 1, action=lambda: t0_box.update(t0=time.perf_counter()))
        futs = [ex.submit(one, i, rd) for i in range(n)]
        bar["barrier"].wait()  # 主线程最后到,全员同时释放
        for f in futs:
            f.result()
        return (time.perf_counter() - t0_box["t0"]) * 1000

    run_round(-1)  # 建连预热轮
    results = []
    for rd in range(rounds):
        total_ms = run_round(rd)
        nf = sum(1 for o in ok[rd] if not o)
        results.append({"total_ms": round(total_ms, 1),
                        "slowest_ms": round(max(lat[rd]), 1), "fail": nf})
        log(f"      轮 {rd + 1}/{rounds}: 整批总完成={results[-1]['total_ms']}ms  "
            f"最慢单条={results[-1]['slowest_ms']}ms  fail={nf}")
        time.sleep(0.3)  # 轮间让服务队列彻底清空
    ex.shutdown()
    return results


def sustained_test(texts, client, total_reqs, concurrency):
    """concurrency 并发共 total_reqs 条(单文本/请求),返回 RPS 与延迟分布。"""
    log(f"[3/5] 持续压测:{concurrency} 并发共 {total_reqs} 条 ...")
    lat, fails = [], 0
    lock = threading.Lock()

    def job(i):
        nonlocal fails
        t = texts[i % len(texts)]
        l, ok, _ = client.predict_one(t)
        with lock:
            lat.append(l)
            if not ok:
                fails += 1

    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=concurrency) as ex:
        list(ex.map(job, range(total_reqs)))
    elapsed = time.perf_counter() - t0
    out = {"elapsed_s": round(elapsed, 2), "rps": round(total_reqs / elapsed, 2),
           **summarize(lat, fails)}
    log(f"      持续: {elapsed:.1f}s  RPS={out['rps']}  p50={out['p50_ms']}ms  "
        f"p95={out['p95_ms']}ms  fail={out['fail']}")
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--port", type=int, default=8765)
    ap.add_argument("--val", default="data/processed/val.jsonl")
    ap.add_argument("--n-texts", type=int, default=100, help="压测文本条数(val 前 N 条)")
    ap.add_argument("--burst-rounds", type=int, default=5)
    ap.add_argument("--sustained-total", type=int, default=1000)
    ap.add_argument("--concurrency", type=int, default=100)
    ap.add_argument("--out", default="outputs/serve_bench.json")
    ap.add_argument("--quick", action="store_true",
                    help="快速冒烟:文本50条、突发2轮、持续200条")
    args = ap.parse_args()
    if args.quick:
        args.n_texts, args.burst_rounds, args.sustained_total = 50, 2, 200

    with open(os.path.join(ROOT, args.val), encoding="utf-8") as f:
        texts = [json.loads(l)["text"] for l in f if l.strip()][:args.n_texts]
    log(f"压测文本: {args.val} 前 {len(texts)} 条(真实长度分布)")
    base = f"http://127.0.0.1:{args.port}"

    result = {"val": args.val, "n_texts": len(texts), "concurrency": args.concurrency,
              "sustained_total": args.sustained_total, "burst_rounds": args.burst_rounds}

    # ---- 第一个服务:默认微批 ----
    proc = start_server([], args.port)
    client = Client(base)
    client.predict_batch(["预热1", "预热2"])  # 触发首次前向
    try:
        result["serial_100"] = serial_baseline(client, texts)
        bursts = burst_test(texts, client, args.burst_rounds)
        result["burst"] = {"rounds": bursts,
                           "median_ms": round(statistics.median(b["total_ms"] for b in bursts), 1),
                           "worst_ms": max(b["total_ms"] for b in bursts),
                           "total_fail": sum(b["fail"] for b in bursts)}
        log(f"      突发汇总: 中位={result['burst']['median_ms']}ms  "
            f"最差={result['burst']['worst_ms']}ms  总失败={result['burst']['total_fail']}")
        result["sustained_1000"] = sustained_test(texts, client,
                                                  args.sustained_total, args.concurrency)
        mem_mb = peak_working_set_mb(proc.real_pid or proc.pid)
        result["mem_peak_working_set_mb"] = round(mem_mb, 1)
        log(f"[4/5] 服务进程峰值内存 (PeakWorkingSet64): {mem_mb:.0f} MB")
    finally:
        stop_server(proc)

    # ---- 第二个服务:--no-microbatch 对照 ----
    log("[5/5] 微批对照:--no-microbatch 重启服务 ...")
    proc = start_server(["--no-microbatch"], args.port + 1)
    client = Client(f"http://127.0.0.1:{args.port + 1}")
    try:
        client.predict_batch(["预热1", "预热2"])
        result["no_microbatch"] = sustained_test(texts, client,
                                                 args.sustained_total, args.concurrency)
    finally:
        stop_server(proc)

    result["batch_gain"] = round(result["sustained_1000"]["rps"] /
                                 max(result["no_microbatch"]["rps"], 1e-9), 3)

    b = result["burst"]
    result["slo"] = {
        "burst_median_ms": b["median_ms"],
        "burst_worst_ms": b["worst_ms"],
        "total_fail": b["total_fail"] + result["sustained_1000"]["fail"] + result["serial_100"]["fail"],
        "mem_mb": result["mem_peak_working_set_mb"],
        "burst_le_1000ms": b["worst_ms"] <= 1000,
        "mem_le_3g": result["mem_peak_working_set_mb"] <= 3072,
    }
    result["slo"]["met"] = (result["slo"]["burst_le_1000ms"] and
                            result["slo"]["total_fail"] == 0 and
                            result["slo"]["mem_le_3g"])
    log(f"SLO: burst最差={b['worst_ms']}ms(≤1000? {result['slo']['burst_le_1000ms']})  "
        f"失败={result['slo']['total_fail']}  内存={result['slo']['mem_mb']}MB(≤3072? "
        f"{result['slo']['mem_le_3g']})  达标={result['slo']['met']}  "
        f"微批增益={result['batch_gain']}x")

    with open(os.path.join(ROOT, args.out), "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    log(f"结果已写 {args.out}")


if __name__ == "__main__":
    main()