1245 lines
60 KiB
Python
1245 lines
60 KiB
Python
#!/usr/bin/env python3
|
|
"""显式构建/运行独立二进制基准;不属于 cargo test 默认目标。"""
|
|
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
import http.client
|
|
from itertools import product
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import select
|
|
import shutil
|
|
import socket
|
|
import statistics
|
|
import subprocess
|
|
import time
|
|
|
|
from build import (APPLICATION_CONFIGURATION, BENCHMARK_COMMAND, ENTRIES, ROOT, build, database_setting, fingerprint, load_postgres_setting,
|
|
native_runtime_setting, postgres_case_url, postgres_database_case,
|
|
write_private_json)
|
|
from live import lifecycle_seconds, load_live
|
|
from process import Budget, Process, observe, summarize
|
|
from settings import PEER_CONFIGURATION
|
|
|
|
HTTP_IMPLEMENTATIONS = ("dever", "runtime", "hyper")
|
|
HTTP2_DISCONNECT_SECONDS = 0.3
|
|
HTTP_BODY_BYTES = {"/plain": 5, "/json": 11, "/bytes": 65_536}
|
|
ORM_ENTRIES = ("idle", "crud", "list", "cursor", "stream", "pool", "http")
|
|
ORM_COUNT_PATH = "/benchmark/item/count"
|
|
ORM_CANCEL_PATH = "/benchmark/item/cancel"
|
|
|
|
|
|
def application_command(executable):
|
|
return [executable, BENCHMARK_COMMAND, "{}"]
|
|
|
|
|
|
def fixture_command(executable, entry):
|
|
fixture = next(name for name, entries in ENTRIES.items() if entry in entries)
|
|
return [executable, f"benchmark.{fixture}.{entry}", "{}"]
|
|
|
|
|
|
def positive(value):
|
|
number = float(value)
|
|
if not math.isfinite(number) or number <= 0:
|
|
raise argparse.ArgumentTypeError("must be finite and positive")
|
|
return number
|
|
|
|
|
|
def positive_int(value):
|
|
number = int(value)
|
|
if number <= 0:
|
|
raise argparse.ArgumentTypeError("must be positive")
|
|
return number
|
|
|
|
|
|
def csv_positive_ints(value, name):
|
|
parts = value.split(",")
|
|
if any(not part for part in parts):
|
|
raise ValueError(f"{name} must contain unique positive integers")
|
|
values = tuple(positive_int(part) for part in parts)
|
|
if len(values) != len(set(values)):
|
|
raise ValueError(f"{name} must contain unique positive integers")
|
|
return values
|
|
|
|
|
|
def parse_client_cpus(value):
|
|
parts = value.split(",")
|
|
if any(not part for part in parts):
|
|
raise ValueError("client-cpus must contain unique nonnegative integers")
|
|
try:
|
|
cpus = tuple(int(part) for part in parts)
|
|
except ValueError as error:
|
|
raise ValueError("client-cpus must contain unique nonnegative integers") from error
|
|
if any(cpu < 0 for cpu in cpus) or len(cpus) != len(set(cpus)):
|
|
raise ValueError("client-cpus must contain unique nonnegative integers")
|
|
return cpus
|
|
|
|
|
|
def validate_cpu_placement(server_cpu, client_cpus, allowed):
|
|
if server_cpu is not None and server_cpu not in allowed:
|
|
raise ValueError(f"CPU {server_cpu} is outside allowed CPUs {sorted(allowed)}")
|
|
if client_cpus is not None:
|
|
for cpu in client_cpus:
|
|
if cpu not in allowed:
|
|
raise ValueError(f"CPU {cpu} is outside allowed CPUs {sorted(allowed)}")
|
|
if server_cpu in client_cpus:
|
|
raise ValueError("server and client must use different CPUs")
|
|
|
|
|
|
def parse_implementations(value):
|
|
implementations = tuple(value.split(","))
|
|
if (any(not part for part in implementations)
|
|
or len(implementations) != len(set(implementations))
|
|
or any(part not in HTTP_IMPLEMENTATIONS for part in implementations)):
|
|
raise ValueError("implementations must contain unique dever, runtime or hyper values")
|
|
return implementations
|
|
|
|
|
|
def implementation_order(implementations, repeat):
|
|
return implementations if repeat % 2 == 0 else tuple(reversed(implementations))
|
|
|
|
|
|
def parser():
|
|
cli = argparse.ArgumentParser(description=__doc__)
|
|
commands = cli.add_subparsers(dest="action", required=True)
|
|
prepare = commands.add_parser("build", help="build release native fixtures; no measurements")
|
|
prepare.add_argument("--output", type=Path, required=True)
|
|
prepare.add_argument("--selection", choices=("all", "cms-profiles", "profiles", "protocols"), default="all",
|
|
help="build all fixtures, CMS/profiles, profiles, or runtime/network protocols")
|
|
prepare.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
|
|
prepare.add_argument("--cargo", default="cargo")
|
|
for name in ("network_bench", "live_bench", "async_bench"):
|
|
prepare.add_argument("--" + name.replace("_", "-"), type=Path,
|
|
default=ROOT / "target/native-runtime/release/examples" / name)
|
|
prepare.add_argument("--workers", type=positive_int, default=1)
|
|
prepare.add_argument("--connections", type=positive_int, default=32)
|
|
prepare.add_argument("--http2-streams", type=positive_int, default=16)
|
|
prepare.add_argument("--http2-stream-window-bytes", type=positive_int, default=65_535)
|
|
prepare.add_argument("--http2-connection-window-bytes", type=positive_int, default=262_144)
|
|
prepare.add_argument("--pending", type=positive_int, default=256)
|
|
prepare.add_argument("--iterations", type=positive_int, default=16384)
|
|
prepare.add_argument("--orm-iterations", type=positive_int, default=128)
|
|
prepare.add_argument("--orm-rows", type=positive_int, default=128)
|
|
prepare.add_argument("--orm-concurrency", type=positive_int, default=8)
|
|
prepare.add_argument("--orm-cancel-work", type=positive_int, default=1_000_000)
|
|
prepare.add_argument("--orm-sqlite-connections", type=positive_int, default=1)
|
|
measure = commands.add_parser("run", help="measure already-built binaries only")
|
|
measure.add_argument("--artifacts", type=Path, required=True)
|
|
measure.add_argument("--output", type=Path, required=True)
|
|
measure.add_argument("--suite", choices=("all", "memory", "async", "http", "live", "http2", "http2-recovery", "orm", "cms"), default="all")
|
|
measure.add_argument("--duration", type=positive, default=10)
|
|
measure.add_argument("--warmup", type=positive, default=2)
|
|
measure.add_argument("--idle-seconds", type=positive, default=1)
|
|
measure.add_argument("--interval", type=positive, default=0.05)
|
|
measure.add_argument("--repeats", type=positive_int, default=3)
|
|
measure.add_argument("--rates", default="100,1000")
|
|
measure.add_argument("--paths", default="/plain,/json,/bytes")
|
|
measure.add_argument("--concurrency", type=positive_int, default=16)
|
|
measure.add_argument("--connection-mode", choices=("reuse", "new", "both"), default="reuse")
|
|
measure.add_argument("--implementations", default=",".join(HTTP_IMPLEMENTATIONS))
|
|
measure.add_argument("--http2-connections", default="1,4")
|
|
measure.add_argument("--recovery-connections", type=positive_int, default=1)
|
|
measure.add_argument("--recovery-rate", type=positive_int, default=1_000)
|
|
measure.add_argument("--recovery-path", choices=("/plain", "/json", "/bytes"), default="/plain")
|
|
measure.add_argument("--connection-tiers", default="32")
|
|
measure.add_argument("--cycles", type=positive_int, default=3)
|
|
measure.add_argument("--server-cpu", type=int)
|
|
measure.add_argument("--client-cpus", type=parse_client_cpus)
|
|
measure.add_argument("--client-workers", type=positive_int, default=1)
|
|
measure.add_argument("--cgroup-parent", type=Path)
|
|
measure.add_argument("--memory-mib", type=positive_int)
|
|
measure.add_argument("--cpu-quota", type=positive)
|
|
return cli
|
|
|
|
|
|
@contextmanager
|
|
def server(args, command, directory, peer_settings):
|
|
budget = Budget(args.cgroup_parent, args.memory_mib, args.cpu_quota)
|
|
failure = None
|
|
try:
|
|
network_settings = peer_settings if Path(command[0]).name == "network_bench" else None
|
|
with Process(command, directory, peer_settings=network_settings,
|
|
cpus=(args.server_cpu,) if args.server_cpu is not None else None,
|
|
budget=budget) as process:
|
|
yield process, budget
|
|
except BaseException as error:
|
|
failure = error
|
|
raise
|
|
finally:
|
|
try:
|
|
budget.close()
|
|
require_no_oom(budget.final)
|
|
except BaseException as cleanup_error:
|
|
if failure is None:
|
|
raise
|
|
failure.add_note(f"cannot close benchmark budget: {cleanup_error}")
|
|
|
|
|
|
def percentile(values, fraction):
|
|
ordered = sorted(values)
|
|
return ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
|
|
|
|
|
|
def orm_measurements(text, expected_name, expected_samples, expected_checksum, elapsed_seconds):
|
|
samples = []
|
|
for line in text.splitlines():
|
|
if not line.startswith("SAMPLE|"):
|
|
continue
|
|
fields = line.split("|")
|
|
if len(fields) != 5:
|
|
raise ValueError(f"invalid ORM sample: {line}")
|
|
_, name, operations, nanos, checksum = fields
|
|
sample = {"name": name, "operations": int(operations), "nanos": int(nanos),
|
|
"checksum": int(checksum)}
|
|
if (name != expected_name or sample["operations"] <= 0 or sample["nanos"] <= 0
|
|
or sample["checksum"] != expected_checksum
|
|
or len(samples) >= expected_samples):
|
|
raise ValueError(f"invalid ORM sample: {line}")
|
|
samples.append(sample)
|
|
if len(samples) != expected_samples:
|
|
raise ValueError(
|
|
f"ORM {expected_name} returned {len(samples)} samples, expected {expected_samples}"
|
|
)
|
|
latencies = [sample["nanos"] / 1_000_000 for sample in samples]
|
|
operations = sum(sample["operations"] for sample in samples)
|
|
if elapsed_seconds <= 0:
|
|
raise ValueError("ORM measurement duration must be positive")
|
|
return {
|
|
"samples": len(samples),
|
|
"operations": operations,
|
|
"elapsed_seconds": elapsed_seconds,
|
|
"throughput_operations_per_second": operations / elapsed_seconds,
|
|
"latency_ms": {
|
|
"p50": percentile(latencies, 0.50),
|
|
"p95": percentile(latencies, 0.95),
|
|
"p99": percentile(latencies, 0.99),
|
|
},
|
|
"raw": samples,
|
|
}
|
|
|
|
|
|
def expected_orm_checksum(entry, settings):
|
|
rows = settings["orm_rows"]
|
|
return {
|
|
"crud": 2,
|
|
"list": rows + min(32, rows),
|
|
"cursor": min(32, rows),
|
|
"stream": rows,
|
|
"pool": rows,
|
|
}[entry]
|
|
|
|
|
|
def async_measurements(text, settings):
|
|
samples = []
|
|
for line in text.splitlines():
|
|
if not line.startswith("SAMPLE|"):
|
|
continue
|
|
_, name, count, nanos, checksum = line.split("|")
|
|
count, nanos, checksum = int(count), int(nanos), int(checksum)
|
|
expected_count = settings["pending"] if name == "cancel" else settings["iterations"]
|
|
expected_checksum = count if name == "cancel" else count * ord("x")
|
|
if name not in ("tasks", "channels", "cancel") or count != expected_count or checksum != expected_checksum or nanos <= 0:
|
|
raise ValueError(f"invalid benchmark result: {line}")
|
|
samples.append({"name": name, "operations": count, "nanos": nanos, "checksum": checksum})
|
|
if not samples:
|
|
raise ValueError("missing timing samples")
|
|
expected_samples = 1 if samples[0]["name"] == "cancel" else 10
|
|
if len(samples) != expected_samples or len({sample["name"] for sample in samples}) != 1:
|
|
raise ValueError("incomplete or mixed timing samples")
|
|
measured = samples if expected_samples == 1 else samples[1:]
|
|
per_operation = [sample["nanos"] / sample["operations"] for sample in measured]
|
|
return {"raw": samples, "warmup_samples": expected_samples != 1,
|
|
"median_ns_per_operation": statistics.median(per_operation),
|
|
"p95_ns_per_operation_sample": percentile(per_operation, 0.95)}
|
|
|
|
|
|
def write_case(output, case):
|
|
with (output / "cases.jsonl").open("a") as record:
|
|
record.write(json.dumps(case, ensure_ascii=False) + "\n")
|
|
return case
|
|
|
|
|
|
def validate_load(result):
|
|
counters = ("scheduled", "sent", "completed", "succeeded", "errors", "dropped",
|
|
"dropped_capacity", "dispatch_expired", "latency_samples", "request_latency_samples", "dispatch_lag_samples")
|
|
if any(type(result[key]) is not int or result[key] < 0 for key in counters):
|
|
raise ValueError("load counts must be nonnegative integers")
|
|
if (result["scheduled"] != result["sent"] + result["dropped"]
|
|
or result["sent"] != result["completed"]
|
|
or result["completed"] != result["succeeded"] + result["errors"]
|
|
or result["dropped"] != result["dropped_capacity"] + result["dispatch_expired"]
|
|
or result["latency_samples"] != result["succeeded"]
|
|
or result["request_latency_samples"] != result["succeeded"]
|
|
or result["dispatch_lag_samples"] > result["completed"]):
|
|
raise ValueError("load counts do not account for all scheduled requests")
|
|
for metric, count in (("latency_ms", "latency_samples"), ("request_latency_ms", "request_latency_samples"), ("dispatch_lag_ms", "dispatch_lag_samples")):
|
|
values = result[metric]
|
|
if result[count] == 0:
|
|
if values is not None:
|
|
raise ValueError("empty histogram must be null")
|
|
elif (values is None or values.get("sample_count") != result[count]
|
|
or not 0 <= values["p50"] <= values["p95"] <= values["p99"]):
|
|
raise ValueError("invalid latency percentiles")
|
|
if result["elapsed_seconds"] <= 0 or not math.isfinite(result["actual_success_qps"]):
|
|
raise ValueError("invalid load duration/throughput")
|
|
if result["http_version"] not in ("h1", "h2"):
|
|
raise ValueError("invalid HTTP version in load result")
|
|
for name in ("physical_connections", "streams_per_connection", "request_slots"):
|
|
if type(result[name]) is not int or result[name] <= 0:
|
|
raise ValueError("invalid connection shape in load result")
|
|
if result["request_slots"] != result["physical_connections"] * result["streams_per_connection"]:
|
|
raise ValueError("load result connection shape is inconsistent")
|
|
if type(result["successful_handshakes"]) is not int or result["successful_handshakes"] < 0:
|
|
raise ValueError("invalid handshake count in load result")
|
|
if result["http_version"] == "h2" and result["successful_handshakes"] != result["physical_connections"]:
|
|
raise ValueError("HTTP/2 load did not establish its configured physical connections")
|
|
|
|
|
|
def require_no_request_errors(result, stage):
|
|
if result["errors"]:
|
|
raise RuntimeError(f"{stage} completed with {result['errors']} request errors")
|
|
|
|
|
|
def require_successful_requests(result, stage):
|
|
if result["succeeded"] == 0:
|
|
raise RuntimeError(f"{stage} completed without a successful request")
|
|
|
|
|
|
def require_no_oom(budget):
|
|
if budget is None:
|
|
return
|
|
events = budget["events"]
|
|
if any(events[name] for name in ("oom", "oom_kill", "oom_group_kill")):
|
|
raise RuntimeError(f"benchmark cgroup recorded OOM events: {events}")
|
|
|
|
|
|
def memory_cases(args, settings, peer_settings):
|
|
cases = []
|
|
for repeat in range(args.repeats):
|
|
for entry in ("sync_idle", "async_idle", "parked", "http", "https"):
|
|
name = f"memory-{entry}-{repeat}"
|
|
print(name, flush=True)
|
|
binary = "cancel" if entry == "parked" else entry
|
|
command = fixture_command(args.artifacts / binary, binary)
|
|
with server(args, command, args.output / name, peer_settings) as (process, budget):
|
|
ready = process.ready()
|
|
# 采样窗口从 READY 后开始,避免把启动页错误当作空闲基线。
|
|
first = len(process.samples)
|
|
observe([process], args.idle_seconds, args.interval)
|
|
if process.child.poll() is not None:
|
|
raise RuntimeError(f"idle fixture ended before sampling completed: {entry}")
|
|
case = {"suite": "memory", "entry": entry, "repeat": repeat,
|
|
"ready_seconds": process.ready_seconds, "ready_value": ready,
|
|
"resources": summarize(process.samples[first:]), "raw": process.samples,
|
|
"budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
return cases
|
|
|
|
|
|
def async_cases(args, settings, peer_settings):
|
|
cases = []
|
|
workloads = [("dever", "root", "tasks", fixture_command(args.artifacts / "tasks", "tasks")),
|
|
("dever", "worker", "tasks", fixture_command(args.artifacts / "tasks_on_worker", "tasks_on_worker"))]
|
|
for mode in ("root", "worker"):
|
|
workloads.append(("runtime", mode, "tasks", [args.artifacts / "async_bench", mode,
|
|
settings["workers"], settings["task_capacity"], settings["iterations"]]))
|
|
workloads.extend(("dever", "root", entry, fixture_command(args.artifacts / entry, entry))
|
|
for entry in ("channels", "cancel"))
|
|
for repeat in range(args.repeats):
|
|
for implementation, mode, entry, command in workloads[::1 if repeat % 2 == 0 else -1]:
|
|
name = f"async-{implementation}-{mode}-{entry}-{repeat}"
|
|
print(name, flush=True)
|
|
with server(args, command, args.output / name, peer_settings) as (process, budget):
|
|
process.ready()
|
|
deadline = time.monotonic() + 60
|
|
while process.child.poll() is None:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"async case exceeded 60 seconds: {entry}")
|
|
process.sample()
|
|
time.sleep(args.interval)
|
|
process.check()
|
|
case = {"suite": "async", "entry": entry, "repeat": repeat,
|
|
"implementation": implementation, "mode": mode,
|
|
"timing": async_measurements(process.text(), settings),
|
|
"resources": summarize(process.samples), "raw": process.samples,
|
|
"budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
return cases
|
|
|
|
|
|
def load(args, process, directory, http_version, url, path, rate, duration,
|
|
physical_connections, streams_per_connection, reuse, timeout_ms, *, orm_count=None):
|
|
command = [args.artifacts / "network_bench", "load", http_version, url, path, rate, duration,
|
|
physical_connections, streams_per_connection, timeout_ms, str(reuse).lower(),
|
|
args.artifacts / "tls/root.pem"]
|
|
client_settings = {"workers": args.client_workers,
|
|
"http2_stream_window_bytes": args.http2_stream_window_bytes,
|
|
"http2_connection_window_bytes": args.http2_connection_window_bytes}
|
|
if orm_count is not None:
|
|
client_settings["orm_count"] = orm_count
|
|
with Process(command, directory, cpus=args.client_cpus, peer_settings=client_settings) as client:
|
|
deadline = time.monotonic() + duration + timeout_ms / 1000 + 10
|
|
while client.child.poll() is None:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"load client exceeded deadline: {directory}")
|
|
process.sample()
|
|
process.check()
|
|
if process.child.poll() is not None:
|
|
raise RuntimeError("server exited during load")
|
|
client.sample()
|
|
time.sleep(args.interval)
|
|
client.check()
|
|
result = json.loads(client.text())
|
|
validate_load(result)
|
|
expected_shape = (http_version, physical_connections, streams_per_connection)
|
|
actual_shape = (result["http_version"], result["physical_connections"],
|
|
result["streams_per_connection"])
|
|
if actual_shape != expected_shape:
|
|
raise ValueError(f"load result shape {actual_shape} does not match {expected_shape}")
|
|
result["client_resources"] = summarize(client.samples)
|
|
result["client_raw"] = client.samples
|
|
return result
|
|
|
|
|
|
def http_cases(args, settings, peer_settings):
|
|
cases = []
|
|
modes = [True, False] if args.connection_mode == "both" else [args.connection_mode == "reuse"]
|
|
for repeat in range(args.repeats):
|
|
# 各轮倒转顺序,降低热机/后台负载变化对某个实现的固定偏置。
|
|
implementations = implementation_order(args.implementations, repeat)
|
|
workloads = product(("http", "https"), args.paths.split(","), args.rates.split(","), modes, implementations)
|
|
for protocol, path, rate, reuse, implementation in workloads:
|
|
name = f"{implementation}-{protocol}-{path[1:]}-{rate}-{reuse}-{repeat}"
|
|
print(name, flush=True)
|
|
command = fixture_command(args.artifacts / protocol, protocol) if implementation == "dever" else [args.artifacts / "network_bench", f"{implementation}-{protocol}"]
|
|
with server(args, command, args.output / name, peer_settings) as (process, budget):
|
|
port = process.ready()
|
|
url = f"{protocol}://localhost:{port}"
|
|
warmup = load(args, process, process.directory / "warmup", "h1", url, path,
|
|
rate, args.warmup, args.concurrency, 1, reuse, settings["timeout_ms"])
|
|
require_no_request_errors(warmup, "HTTP warmup")
|
|
require_successful_requests(warmup, "HTTP warmup")
|
|
first = len(process.samples)
|
|
result = load(args, process, process.directory / "load", "h1", url, path,
|
|
rate, args.duration, args.concurrency, 1, reuse, settings["timeout_ms"])
|
|
require_no_request_errors(result, "HTTP measurement")
|
|
require_successful_requests(result, "HTTP measurement")
|
|
case = {"suite": "http", "implementation": implementation,
|
|
"protocol": protocol, "path": path, "rate": float(rate),
|
|
"reuse": reuse, "repeat": repeat, "warmup": warmup, "load": result,
|
|
"resources": summarize(process.samples[first:]),
|
|
"raw": process.samples, "budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
return cases
|
|
|
|
|
|
def http2_server_command(args, implementation, transport):
|
|
if implementation == "dever":
|
|
entry = "http2" if transport == "http" else "https2"
|
|
return fixture_command(args.artifacts / entry, entry)
|
|
mode = "http2" if transport == "http" else "https2"
|
|
return [args.artifacts / "network_bench", f"{implementation}-{mode}"]
|
|
|
|
|
|
def observe_disconnected(args, process, baseline_fds):
|
|
first = len(process.samples)
|
|
observe([process], HTTP2_DISCONNECT_SECONDS, args.interval)
|
|
samples = process.samples[first:]
|
|
resources = summarize(samples)
|
|
last = samples[-1]
|
|
fd_delta = last["fds"] - baseline_fds
|
|
if fd_delta != 0:
|
|
raise RuntimeError(f"HTTP/2 connections were not released: FD delta {fd_delta}")
|
|
return {"last": last, "resources": resources, "fd_delta": fd_delta}
|
|
|
|
|
|
def http2_cases(args, settings, peer_settings):
|
|
cases = []
|
|
streams = settings["http2_streams"]
|
|
for repeat in range(args.repeats):
|
|
implementations = implementation_order(args.implementations, repeat)
|
|
workloads = product(("http", "https"), args.paths.split(","), args.rates.split(","),
|
|
args.http2_connections, implementations)
|
|
for transport, path, rate, connections, implementation in workloads:
|
|
name = (f"{implementation}-h2-{transport}-{path[1:]}-{rate}-"
|
|
f"{connections}x{streams}-{repeat}")
|
|
print(name, flush=True)
|
|
command = http2_server_command(args, implementation, transport)
|
|
with server(args, command, args.output / name, peer_settings) as (process, budget):
|
|
port = process.ready()
|
|
url = f"{transport}://localhost:{port}"
|
|
baseline_first = len(process.samples)
|
|
observe([process], HTTP2_DISCONNECT_SECONDS, args.interval)
|
|
baseline_samples = process.samples[baseline_first:]
|
|
baseline = summarize(baseline_samples)
|
|
baseline_fds = baseline_samples[-1]["fds"]
|
|
warmup = load(args, process, process.directory / "warmup", "h2", url, path,
|
|
rate, args.warmup, connections, streams, True, settings["timeout_ms"])
|
|
warmup_disconnected = observe_disconnected(args, process, baseline_fds)
|
|
first = len(process.samples)
|
|
result = load(args, process, process.directory / "load", "h2", url, path,
|
|
rate, args.duration, connections, streams, True, settings["timeout_ms"])
|
|
load_end = len(process.samples)
|
|
disconnected = observe_disconnected(args, process, baseline_fds)
|
|
case = {"suite": "http2", "implementation": implementation,
|
|
"http_version": "h2", "transport": transport, "path": path,
|
|
"body_bytes": HTTP_BODY_BYTES[path],
|
|
"rate": float(rate), "physical_connections": connections,
|
|
"streams_per_connection": streams,
|
|
"request_slots": connections * streams,
|
|
"stream_window_bytes": settings["http2_stream_window_bytes"],
|
|
"connection_window_bytes": settings["http2_connection_window_bytes"],
|
|
"frame_bytes": 16_384, "repeat": repeat,
|
|
"baseline": baseline, "warmup": warmup,
|
|
"warmup_disconnected": warmup_disconnected,
|
|
"load": result, "resources": summarize(process.samples[first:load_end]),
|
|
"disconnected": disconnected, "raw": process.samples,
|
|
"budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
require_no_request_errors(warmup, "HTTP/2 warmup")
|
|
require_no_request_errors(result, "HTTP/2 load")
|
|
require_no_oom(case["budget_after_stop"])
|
|
return cases
|
|
|
|
|
|
def http2_recovery_cases(args, settings, peer_settings):
|
|
cases = []
|
|
streams = settings["http2_streams"]
|
|
connections = args.recovery_connections
|
|
for repeat in range(args.repeats):
|
|
for transport, implementation in product(("http", "https"),
|
|
implementation_order(args.implementations, repeat)):
|
|
name = f"{implementation}-h2-recovery-{transport}-{connections}x{streams}-{repeat}"
|
|
print(name, flush=True)
|
|
command = http2_server_command(args, implementation, transport)
|
|
with server(args, command, args.output / name, peer_settings) as (process, budget):
|
|
port = process.ready()
|
|
url = f"{transport}://localhost:{port}"
|
|
baseline_first = len(process.samples)
|
|
observe([process], HTTP2_DISCONNECT_SECONDS, args.interval)
|
|
baseline_samples = process.samples[baseline_first:]
|
|
baseline = summarize(baseline_samples)
|
|
baseline_fds = baseline_samples[-1]["fds"]
|
|
first = len(process.samples)
|
|
cycles = []
|
|
for cycle in range(args.cycles):
|
|
result = load(args, process, process.directory / f"cycle-{cycle}", "h2", url,
|
|
args.recovery_path, args.recovery_rate, args.duration,
|
|
connections, streams, True, settings["timeout_ms"])
|
|
if result["succeeded"] == 0:
|
|
raise RuntimeError(f"HTTP/2 recovery cycle {cycle} did not complete cleanly")
|
|
require_no_request_errors(result, f"HTTP/2 recovery cycle {cycle}")
|
|
disconnected = observe_disconnected(args, process, baseline_fds)
|
|
cycles.append({"cycle": cycle, "load": result, "disconnected": disconnected})
|
|
case = {"suite": "http2-recovery", "implementation": implementation,
|
|
"http_version": "h2", "transport": transport,
|
|
"path": args.recovery_path, "rate": args.recovery_rate,
|
|
"physical_connections": connections,
|
|
"streams_per_connection": streams,
|
|
"request_slots": connections * streams,
|
|
"stream_window_bytes": settings["http2_stream_window_bytes"],
|
|
"connection_window_bytes": settings["http2_connection_window_bytes"],
|
|
"frame_bytes": 16_384, "cycles": cycles,
|
|
"repeat": repeat, "baseline": baseline,
|
|
"resources": summarize(process.samples[first:]),
|
|
"raw": process.samples, "budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
require_no_oom(case["budget_after_stop"])
|
|
return cases
|
|
|
|
|
|
def http2_summary(cases):
|
|
groups = {}
|
|
for case in cases:
|
|
if case["suite"] != "http2":
|
|
continue
|
|
key = (case["implementation"], case["transport"], case["path"], case["rate"],
|
|
case["physical_connections"], case["streams_per_connection"])
|
|
groups.setdefault(key, []).append(case)
|
|
summary = []
|
|
for key, repeated in sorted(groups.items()):
|
|
implementation, transport, path, rate, connections, streams = key
|
|
p99_values = [case["load"]["request_latency_ms"]["p99"] for case in repeated
|
|
if case["load"]["request_latency_ms"] is not None]
|
|
summary.append({
|
|
"implementation": implementation,
|
|
"transport": transport,
|
|
"path": path,
|
|
"rate": rate,
|
|
"physical_connections": connections,
|
|
"streams_per_connection": streams,
|
|
"request_slots": connections * streams,
|
|
"repeats": len(repeated),
|
|
"actual_success_qps_median": statistics.median(
|
|
case["load"]["actual_success_qps"] for case in repeated),
|
|
"request_latency_p99_ms_median": statistics.median(p99_values) if p99_values else None,
|
|
"dropped_median": statistics.median(case["load"]["dropped"] for case in repeated),
|
|
"server_rss_bytes_median": statistics.median(
|
|
case["resources"]["rss_bytes_median"] for case in repeated),
|
|
"server_pss_bytes_median": statistics.median(
|
|
case["resources"]["pss_bytes_median"] for case in repeated),
|
|
"server_rss_high_water_bytes_median": statistics.median(
|
|
case["resources"]["rss_high_water_bytes"] for case in repeated),
|
|
})
|
|
return summary
|
|
|
|
|
|
def stage_case_binary(args, case_name, binary_name):
|
|
bundle = args.output / "bundles" / case_name
|
|
bundle.mkdir(parents=True, exist_ok=False)
|
|
executable = bundle / "app"
|
|
shutil.copy2(args.artifacts / binary_name, executable)
|
|
return executable
|
|
|
|
|
|
def stage_live_bundle(args, settings, case_name, protocol):
|
|
executable = stage_case_binary(args, case_name, protocol)
|
|
config = executable.parent / "config"
|
|
config.mkdir()
|
|
write_private_json(config / "setting.json", native_runtime_setting(settings))
|
|
return executable
|
|
|
|
|
|
def live_cases(args, settings, peer_settings):
|
|
cases = []
|
|
for repeat, protocol, connections in product(range(args.repeats), ("tcp", "ws", "sse"),
|
|
map(int, args.connection_tiers.split(","))):
|
|
name = f"live-{protocol}-{connections}-{repeat}"
|
|
print(name, flush=True)
|
|
executable = stage_live_bundle(args, settings, name, protocol)
|
|
with server(args, fixture_command(executable, protocol), args.output / name, peer_settings) as (process, budget):
|
|
port = process.ready()
|
|
first = len(process.samples)
|
|
observe([process], 0.3, args.interval)
|
|
baseline = summarize(process.samples[first:])
|
|
measured = load_live(args, process, port, protocol, connections, settings["timeout_ms"])
|
|
disconnected = [phase["last"] for phase in measured["phases"] if phase["phase"] == "disconnected"]
|
|
fd_deltas = [sample["fds"] - baseline["fds_median"] for sample in disconnected]
|
|
case = {"suite": "live", "protocol": protocol, "connections": connections,
|
|
"repeat": repeat, "baseline": baseline, **measured,
|
|
"fd_delta_after_cycles": fd_deltas,
|
|
"rss_delta_after_cycles": [sample["rss_bytes"] - baseline["rss_bytes_median"] for sample in disconnected],
|
|
"budget_before_stop": budget.sample()}
|
|
case["budget_after_stop"] = budget.final
|
|
cases.append(write_case(args.output, case))
|
|
if any(delta != 0 for delta in fd_deltas):
|
|
raise RuntimeError(f"live {protocol} did not release file descriptors: {fd_deltas}")
|
|
return cases
|
|
|
|
|
|
def report_arguments(args):
|
|
values = {key: str(value) if isinstance(value, Path) else value
|
|
for key, value in vars(args).items() if not key.startswith("_")}
|
|
return values
|
|
|
|
|
|
def required_binaries(args):
|
|
suites = {
|
|
"memory": {"sync_idle", "async_idle", "cancel", "http", "https"},
|
|
"async": {"tasks", "tasks_on_worker", "channels", "cancel", "async_bench"},
|
|
"http": {"http", "https", "network_bench"},
|
|
"live": {"tcp", "ws", "sse", "live_bench"},
|
|
"http2": {"http2", "https2", "network_bench"},
|
|
"http2-recovery": {"http2", "https2", "network_bench"},
|
|
"orm": {"network_bench", *(f"sqlite-{entry}" for entry in ORM_ENTRIES)},
|
|
"cms": {"cms"},
|
|
}
|
|
if args._postgres_setting is not None:
|
|
suites["orm"].update(f"postgres-{entry}" for entry in ORM_ENTRIES)
|
|
if "md" in args._cms_sources:
|
|
suites["cms"].add("cms-md")
|
|
selected = suites if args.suite == "all" else {args.suite: suites[args.suite]}
|
|
return set().union(*selected.values())
|
|
|
|
|
|
def verify_artifacts(directory, manifest, required):
|
|
if manifest.get("peer_configuration") != PEER_CONFIGURATION:
|
|
raise ValueError("benchmark artifacts use an obsolete configuration contract; rebuild them")
|
|
if manifest.get("application_configuration") != APPLICATION_CONFIGURATION:
|
|
raise ValueError("benchmark artifacts use an obsolete application contract; rebuild with the current LLVM compiler")
|
|
binaries = manifest["binaries"]
|
|
missing = sorted(required - binaries.keys())
|
|
if missing:
|
|
raise ValueError(f"manifest is missing required binaries: {', '.join(missing)}")
|
|
for group in (binaries, manifest["files"]):
|
|
for name, recorded in group.items():
|
|
actual = fingerprint(directory / name)
|
|
if actual != {"sha256": recorded["sha256"], "bytes": recorded["bytes"]}:
|
|
raise ValueError(f"artifact changed since build: {name}")
|
|
|
|
|
|
def stage_orm_bundle(args, settings, driver, entry, repeat, *, binary_entry=None,
|
|
pool_capacity=None):
|
|
case_name = f"orm-{driver}-{entry}-{repeat}"
|
|
binary_name = f"{driver}-{binary_entry or entry}"
|
|
executable = stage_case_binary(args, case_name, binary_name)
|
|
bundle = executable.parent
|
|
if driver == "postgres":
|
|
setting = database_setting(
|
|
driver,
|
|
settings,
|
|
args._postgres_setting,
|
|
postgres_database_case(args.output, case_name),
|
|
)
|
|
else:
|
|
setting = database_setting(driver, settings)
|
|
if pool_capacity is not None:
|
|
setting["database"]["default"]["max_connections"] = pool_capacity
|
|
if entry in ("http", "cancel"):
|
|
with socket.socket() as reservation:
|
|
reservation.bind(("127.0.0.1", 0))
|
|
port = reservation.getsockname()[1]
|
|
setting["http"] = {"host": "127.0.0.1", "port": port}
|
|
config = bundle / "config"
|
|
config.mkdir()
|
|
setting_path = config / "setting.json"
|
|
write_private_json(setting_path, setting)
|
|
return case_name, executable, args.output / case_name, binary_name
|
|
|
|
|
|
def orm_observability(settings, driver, pool_capacity=None):
|
|
capacity = pool_capacity or settings[f"orm_{driver}_connections"]
|
|
return {
|
|
"configured_pool_capacity": capacity,
|
|
"pool_wait": {"status": "unavailable",
|
|
"reason": "runtime does not expose pool acquisition timing"},
|
|
"sql_count": {"status": "unavailable",
|
|
"reason": "runtime does not expose executed statement counters"},
|
|
"database_connections": {
|
|
"status": "unavailable",
|
|
"reason": "process FD count is reported, but database connection identity is not observable",
|
|
},
|
|
}
|
|
|
|
|
|
def finite_orm_case(args, settings, peer_settings, driver, entry, repeat):
|
|
name, executable, directory, binary_name = stage_orm_bundle(
|
|
args, settings, driver, entry, repeat
|
|
)
|
|
print(name, flush=True)
|
|
if entry in ("stream", "pool"):
|
|
seed_orm_bundle(executable)
|
|
with server(args, application_command(executable), directory, peer_settings) as (process, budget):
|
|
ready = process.ready()
|
|
first = max(0, len(process.samples) - 1)
|
|
measured_at = time.monotonic()
|
|
deadline = measured_at + max(30, args.duration + args.warmup + 20)
|
|
while process.child.poll() is None:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"ORM case exceeded deadline: {name}")
|
|
process.sample()
|
|
process.check()
|
|
time.sleep(args.interval)
|
|
elapsed = time.monotonic() - measured_at
|
|
process.check()
|
|
expected_samples = 1 if entry == "cancel" else settings["orm_iterations"]
|
|
measured = orm_measurements(
|
|
process.text(), entry, expected_samples,
|
|
expected_orm_checksum(entry, settings), elapsed,
|
|
)
|
|
samples = process.samples[first:]
|
|
if not samples:
|
|
raise ValueError(f"ORM case ended before a resource sample: {name}")
|
|
case = {
|
|
"suite": "orm",
|
|
"driver": driver,
|
|
"entry": entry,
|
|
"repeat": repeat,
|
|
"ready_seconds": process.ready_seconds,
|
|
"ready_value": ready,
|
|
"binary": {"name": binary_name, **fingerprint(executable)},
|
|
"measurement": measured,
|
|
"resources": summarize(samples),
|
|
"raw": process.samples,
|
|
"observability": orm_observability(settings, driver),
|
|
"budget_before_stop": budget.sample(),
|
|
}
|
|
case["budget_after_stop"] = budget.final
|
|
require_no_oom(case["budget_after_stop"])
|
|
return write_case(args.output, case)
|
|
|
|
|
|
def idle_orm_case(args, settings, peer_settings, driver, repeat):
|
|
name, executable, directory, binary_name = stage_orm_bundle(
|
|
args, settings, driver, "idle", repeat
|
|
)
|
|
print(name, flush=True)
|
|
with server(args, application_command(executable), directory, peer_settings) as (process, budget):
|
|
ready = process.ready()
|
|
first = len(process.samples)
|
|
observe([process], args.idle_seconds, args.interval)
|
|
samples = process.samples[first:]
|
|
case = {
|
|
"suite": "orm",
|
|
"driver": driver,
|
|
"entry": "idle",
|
|
"repeat": repeat,
|
|
"ready_seconds": process.ready_seconds,
|
|
"ready_value": ready,
|
|
"binary": {"name": binary_name, **fingerprint(executable)},
|
|
"resources": summarize(samples),
|
|
"raw": process.samples,
|
|
"observability": orm_observability(settings, driver),
|
|
"budget_before_stop": budget.sample(),
|
|
}
|
|
case["budget_after_stop"] = budget.final
|
|
require_no_oom(case["budget_after_stop"])
|
|
return write_case(args.output, case)
|
|
|
|
|
|
def http_orm_case(args, settings, peer_settings, driver, repeat):
|
|
name, executable, directory, binary_name = stage_orm_bundle(
|
|
args, settings, driver, "http", repeat
|
|
)
|
|
print(name, flush=True)
|
|
rate = positive_int(args.rates.split(",")[0])
|
|
seed_orm_bundle(executable)
|
|
expected_count = settings["orm_rows"]
|
|
port = orm_http_port(executable)
|
|
with server(args, [executable], directory, peer_settings) as (process, budget):
|
|
wait_orm_ready(process, port, expected_count)
|
|
url = f"http://127.0.0.1:{port}"
|
|
warmup = load(args, process, process.directory / "warmup", "h1", url, ORM_COUNT_PATH,
|
|
rate, args.warmup, args.concurrency, 1, True, settings["timeout_ms"],
|
|
orm_count=expected_count)
|
|
first = len(process.samples)
|
|
result = load(args, process, process.directory / "load", "h1", url, ORM_COUNT_PATH,
|
|
rate, args.duration, args.concurrency, 1, True, settings["timeout_ms"],
|
|
orm_count=expected_count)
|
|
case = {
|
|
"suite": "orm",
|
|
"driver": driver,
|
|
"entry": "http",
|
|
"repeat": repeat,
|
|
"rate": rate,
|
|
"warmup": warmup,
|
|
"load": result,
|
|
"binary": {"name": binary_name, **fingerprint(executable)},
|
|
"resources": summarize(process.samples[first:]),
|
|
"raw": process.samples,
|
|
"observability": orm_observability(settings, driver),
|
|
"budget_before_stop": budget.sample(),
|
|
}
|
|
case["budget_after_stop"] = budget.final
|
|
require_no_request_errors(warmup, "ORM HTTP warmup")
|
|
require_no_request_errors(result, "ORM HTTP load")
|
|
require_successful_requests(warmup, "ORM HTTP warmup")
|
|
require_successful_requests(result, "ORM HTTP load")
|
|
require_no_oom(case["budget_after_stop"])
|
|
return write_case(args.output, case)
|
|
|
|
|
|
def orm_http_port(executable):
|
|
return json.loads((executable.parent / "config/setting.json").read_text())["http"]["port"]
|
|
|
|
|
|
def seed_orm_bundle(executable):
|
|
result = subprocess.run([executable, "benchmark.item.seed", "{}"], cwd=executable.parent,
|
|
capture_output=True, text=True, timeout=30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"ORM seed failed: {result.stderr or result.stdout}")
|
|
|
|
|
|
def http_data(port, path, timeout):
|
|
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
|
try:
|
|
connection.request("GET", path, headers={"Connection": "close"})
|
|
response = connection.getresponse()
|
|
body = response.read().decode("utf-8")
|
|
if response.status != 200:
|
|
raise RuntimeError(f"ORM probe {path} returned HTTP {response.status}")
|
|
if response.getheader("Content-Type") != "application/json; charset=utf-8":
|
|
raise ValueError(f"ORM probe {path} returned a non-JSON response")
|
|
envelope = json.loads(body)
|
|
if (type(envelope) is not dict or set(envelope) != {"code", "message", "data"}
|
|
or type(envelope["code"]) is not int or envelope["code"] != 0
|
|
or envelope["message"] != "ok"):
|
|
raise ValueError(f"ORM probe {path} returned an invalid API envelope")
|
|
return envelope["data"]
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def orm_count(port, timeout):
|
|
count = http_data(port, ORM_COUNT_PATH, timeout)
|
|
if type(count) is not int or count < 0:
|
|
raise ValueError("ORM count API returned a nonnegative-integer violation")
|
|
return count
|
|
|
|
|
|
def wait_orm_ready(process, port, expected_count):
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
process.sample()
|
|
process.check()
|
|
if process.child.poll() is not None:
|
|
raise RuntimeError(f"ORM HTTP service exited before readiness: {process.directory}")
|
|
try:
|
|
count = orm_count(port, 0.2)
|
|
except (ConnectionError, TimeoutError, socket.timeout):
|
|
time.sleep(0.01)
|
|
continue
|
|
if count != expected_count:
|
|
raise RuntimeError(f"ORM HTTP service started with {count} rows, expected {expected_count}")
|
|
process.ready_seconds = time.monotonic() - process.started
|
|
return
|
|
raise TimeoutError(f"ORM HTTP readiness timeout: {process.directory}")
|
|
|
|
|
|
def transaction_cancel_measurement(remaining, elapsed_seconds):
|
|
if remaining != 0:
|
|
raise RuntimeError(f"cancelled transaction committed {remaining} row(s)")
|
|
if elapsed_seconds <= 0:
|
|
raise ValueError("transaction cancellation duration must be positive")
|
|
latency = elapsed_seconds * 1000
|
|
return {
|
|
"samples": 1,
|
|
"operations": 1,
|
|
"elapsed_seconds": elapsed_seconds,
|
|
"throughput_operations_per_second": 1 / elapsed_seconds,
|
|
"latency_ms": {"p50": latency, "p95": latency, "p99": latency},
|
|
"raw": [{"name": "cancel", "operations": 1,
|
|
"nanos": round(elapsed_seconds * 1_000_000_000),
|
|
"checksum": remaining}],
|
|
}
|
|
|
|
|
|
def cancel_orm_case(args, settings, peer_settings, driver, repeat):
|
|
name, executable, directory, binary_name = stage_orm_bundle(
|
|
args, settings, driver, "cancel", repeat, binary_entry="http", pool_capacity=1
|
|
)
|
|
print(name, flush=True)
|
|
port = orm_http_port(executable)
|
|
with server(args, [executable], directory, peer_settings) as (process, budget):
|
|
wait_orm_ready(process, port, 0)
|
|
baseline_count = orm_count(port, 0.2)
|
|
if baseline_count != 0:
|
|
raise RuntimeError(f"cancel fixture started with {baseline_count} row(s)")
|
|
first = len(process.samples)
|
|
cancel = socket.create_connection(("127.0.0.1", port), timeout=settings["timeout_ms"] / 1000)
|
|
try:
|
|
cancel.sendall(
|
|
b"POST /benchmark/item/cancel HTTP/1.1\r\nHost: localhost\r\n"
|
|
b"Content-Type: application/json\r\nContent-Length: 2\r\n"
|
|
b"Connection: close\r\n\r\n{}"
|
|
)
|
|
blocked = False
|
|
handler_timeout = settings["timeout_ms"] / 1000
|
|
probe_deadline = time.monotonic() + handler_timeout * 0.5
|
|
probe_attempts = 0
|
|
for probe_attempts in range(1, 65):
|
|
if time.monotonic() >= probe_deadline:
|
|
break
|
|
process.sample()
|
|
process.check()
|
|
if select.select([cancel], [], [], 0)[0]:
|
|
raise RuntimeError("transaction cancel request completed before pool saturation")
|
|
try:
|
|
orm_count(port, 0.05)
|
|
except (TimeoutError, socket.timeout):
|
|
blocked = True
|
|
break
|
|
if not blocked:
|
|
raise TimeoutError("transaction did not occupy the only pool connection")
|
|
cancelled_at = time.monotonic()
|
|
finally:
|
|
try:
|
|
cancel.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
cancel.close()
|
|
|
|
recovery_deadline = cancelled_at + handler_timeout * 0.75
|
|
remaining = None
|
|
recovery_attempts = 0
|
|
for recovery_attempts in range(1, 51):
|
|
if time.monotonic() >= recovery_deadline:
|
|
break
|
|
process.sample()
|
|
process.check()
|
|
try:
|
|
remaining = orm_count(port, 0.2)
|
|
break
|
|
except (TimeoutError, socket.timeout):
|
|
continue
|
|
if remaining is None:
|
|
raise TimeoutError("database connection did not return after request cancellation")
|
|
elapsed = time.monotonic() - cancelled_at
|
|
samples = process.samples[first:]
|
|
case = {
|
|
"suite": "orm",
|
|
"driver": driver,
|
|
"entry": "cancel",
|
|
"repeat": repeat,
|
|
"binary": {"name": binary_name, **fingerprint(executable)},
|
|
"measurement": transaction_cancel_measurement(remaining, elapsed),
|
|
"resources": summarize(samples),
|
|
"raw": process.samples,
|
|
"observability": orm_observability(settings, driver, pool_capacity=1),
|
|
"baseline_count": baseline_count,
|
|
"pool_saturation_observed": blocked,
|
|
"client_disconnected_before_completion": True,
|
|
"connection_recovered_before_handler_timeout": True,
|
|
"probe_attempts": probe_attempts,
|
|
"recovery_attempts": recovery_attempts,
|
|
"budget_before_stop": budget.sample(),
|
|
}
|
|
case["budget_after_stop"] = budget.final
|
|
require_no_oom(case["budget_after_stop"])
|
|
return write_case(args.output, case)
|
|
|
|
|
|
def orm_cases(args, settings, peer_settings):
|
|
drivers = ["sqlite"]
|
|
if args._postgres_setting is not None:
|
|
drivers.append("postgres")
|
|
cases = []
|
|
for repeat in range(args.repeats):
|
|
for driver in drivers:
|
|
cases.append(idle_orm_case(args, settings, peer_settings, driver, repeat))
|
|
for entry in ("crud", "list", "cursor", "stream", "pool"):
|
|
cases.append(finite_orm_case(args, settings, peer_settings, driver, entry, repeat))
|
|
cases.append(cancel_orm_case(args, settings, peer_settings, driver, repeat))
|
|
cases.append(http_orm_case(args, settings, peer_settings, driver, repeat))
|
|
return cases
|
|
|
|
|
|
def orm_summary(cases):
|
|
groups = {}
|
|
for case in cases:
|
|
if case["suite"] != "orm":
|
|
continue
|
|
groups.setdefault((case["driver"], case["entry"]), []).append(case)
|
|
summary = []
|
|
for (driver, entry), repeated in sorted(groups.items()):
|
|
item = {
|
|
"driver": driver,
|
|
"entry": entry,
|
|
"repeats": len(repeated),
|
|
"binary_bytes": repeated[0]["binary"]["bytes"],
|
|
"rss_bytes_median": statistics.median(
|
|
case["resources"]["rss_bytes_median"] for case in repeated),
|
|
"pss_bytes_median": statistics.median(
|
|
case["resources"]["pss_bytes_median"] for case in repeated),
|
|
"rss_high_water_bytes_median": statistics.median(
|
|
case["resources"]["rss_high_water_bytes"] for case in repeated),
|
|
}
|
|
measurements = [case["measurement"] for case in repeated if "measurement" in case]
|
|
loads = [case["load"] for case in repeated if "load" in case]
|
|
if measurements:
|
|
item.update({
|
|
"throughput_operations_per_second_median": statistics.median(
|
|
value["throughput_operations_per_second"] for value in measurements),
|
|
"latency_p50_ms_median": statistics.median(
|
|
value["latency_ms"]["p50"] for value in measurements),
|
|
"latency_p95_ms_median": statistics.median(
|
|
value["latency_ms"]["p95"] for value in measurements),
|
|
"latency_p99_ms_median": statistics.median(
|
|
value["latency_ms"]["p99"] for value in measurements),
|
|
})
|
|
elif loads:
|
|
item.update({
|
|
"throughput_operations_per_second_median": statistics.median(
|
|
value["actual_success_qps"] for value in loads),
|
|
"latency_p50_ms_median": statistics.median(
|
|
value["request_latency_ms"]["p50"] for value in loads),
|
|
"latency_p95_ms_median": statistics.median(
|
|
value["request_latency_ms"]["p95"] for value in loads),
|
|
"latency_p99_ms_median": statistics.median(
|
|
value["request_latency_ms"]["p99"] for value in loads),
|
|
})
|
|
summary.append(item)
|
|
return summary
|
|
|
|
|
|
def cms_cases(args, _settings, _peer_settings):
|
|
from cms import run_case
|
|
|
|
cases = []
|
|
for repeat in range(args.repeats):
|
|
for source in args._cms_sources:
|
|
name = f"cms-{source}-{repeat}"
|
|
binary_name = "cms" if source == "dever" else "cms-md"
|
|
executable = args.artifacts / binary_name
|
|
identity = {
|
|
"executable": str(executable),
|
|
"config": str(args.artifacts / "source" / f"cms-{source}" / "config/setting.json"),
|
|
"fingerprint": fingerprint(executable),
|
|
}
|
|
print(name, flush=True)
|
|
result = run_case(
|
|
identity, args.output / name, articles=16,
|
|
cgroup_parent=args.cgroup_parent, memory_mib=args.memory_mib,
|
|
idle_seconds=args.idle_seconds, interval=args.interval,
|
|
server_cpu=args.server_cpu, cpu_quota=args.cpu_quota,
|
|
)
|
|
require_no_oom(result["cgroup"])
|
|
case = {"suite": "cms", "source": source, "repeat": repeat,
|
|
"workload_mode": "sequential_publish",
|
|
"binary": {"name": binary_name, **identity["fingerprint"]}, **result}
|
|
cases.append(write_case(args.output, case))
|
|
if len({json.dumps(case["contract"], sort_keys=True) for case in cases}) != 1:
|
|
raise RuntimeError("Dever and Markdown CMS changed the publish contract")
|
|
return cases
|
|
|
|
|
|
def cms_summary(cases):
|
|
repeated = [case for case in cases if case["suite"] == "cms"]
|
|
if not repeated:
|
|
return None
|
|
return {source: cms_source_summary([case for case in repeated
|
|
if case["source"] == source])
|
|
for source in sorted({case["source"] for case in repeated})}
|
|
|
|
|
|
def cms_source_summary(repeated):
|
|
peaks = [case["cgroup"]["peak_bytes"] for case in repeated
|
|
if case["cgroup"] is not None]
|
|
return {
|
|
"repeats": len(repeated),
|
|
"workload_mode": "sequential_publish",
|
|
"binary_bytes": repeated[0]["binary"]["bytes"],
|
|
"articles": repeated[0]["contract"]["articles"],
|
|
"ready_seconds_median": statistics.median(
|
|
case["ready_seconds"] for case in repeated),
|
|
"workload_seconds_median": statistics.median(
|
|
case["workload_seconds"] for case in repeated),
|
|
"published_articles_per_second_median": statistics.median(
|
|
case["published_articles_per_second"] for case in repeated),
|
|
"rss_bytes_median": statistics.median(
|
|
case["resources"]["rss_bytes_median"] for case in repeated),
|
|
"pss_bytes_median": statistics.median(
|
|
case["resources"]["pss_bytes_median"] for case in repeated),
|
|
"rss_high_water_bytes_median": statistics.median(
|
|
case["resources"]["rss_high_water_bytes"] for case in repeated),
|
|
"cgroup_peak_bytes_median": statistics.median(peaks) if peaks else None,
|
|
}
|
|
|
|
|
|
def measure(args):
|
|
args.artifacts = args.artifacts.resolve()
|
|
args.output = args.output.resolve()
|
|
args._postgres_setting = (
|
|
load_postgres_setting() if args.suite in ("all", "orm") else None
|
|
)
|
|
manifest = json.loads((args.artifacts / "manifest.json").read_text())
|
|
args._cms_sources = ("dever", "md") if "cms-md" in manifest["binaries"] else ("dever",)
|
|
verify_artifacts(args.artifacts, manifest, required_binaries(args))
|
|
settings = manifest["settings"]
|
|
args.http2_stream_window_bytes = settings["http2_stream_window_bytes"]
|
|
args.http2_connection_window_bytes = settings["http2_connection_window_bytes"]
|
|
args.implementations = parse_implementations(args.implementations)
|
|
args.http2_connections = csv_positive_ints(args.http2_connections, "http2-connections")
|
|
if args.suite in ("all", "http") and args.concurrency > settings["connections"]:
|
|
raise ValueError("client concurrency must not exceed compiled server connections")
|
|
if args.idle_seconds >= settings["idle_sample_ms"] / 1000:
|
|
raise ValueError("idle window must be shorter than the parked fixture's 3-second hold")
|
|
if any(path not in ("/plain", "/json", "/bytes") for path in args.paths.split(",")):
|
|
raise ValueError("paths must be /plain, /json or /bytes")
|
|
for rate in args.rates.split(","):
|
|
positive_int(rate)
|
|
if args.duration + args.warmup + 20 >= settings["lifetime_ms"] / 1000:
|
|
raise ValueError("measurement exceeds fixture lifetime")
|
|
if args.suite in ("all", "http2", "http2-recovery"):
|
|
streams = settings["http2_streams"]
|
|
physical_connections = (*args.http2_connections, args.recovery_connections)
|
|
if max(physical_connections) > settings["connections"]:
|
|
raise ValueError("HTTP/2 physical connections exceed compiled server capacity")
|
|
if max(physical_connections) * streams > 65_536:
|
|
raise ValueError("HTTP/2 request slots exceed the language limit")
|
|
if max(physical_connections) * (streams + 1) > settings["task_capacity"]:
|
|
raise ValueError("HTTP/2 topology exceeds compiled task capacity")
|
|
if (args.suite in ("all", "http2-recovery")
|
|
and args.cycles * (args.duration + HTTP2_DISCONNECT_SECONDS) + 20
|
|
>= settings["lifetime_ms"] / 1000):
|
|
raise ValueError("HTTP/2 recovery cycles exceed fixture lifetime")
|
|
if args.suite in ("all", "live"):
|
|
tiers = [positive_int(value) for value in args.connection_tiers.split(",")]
|
|
if max(tiers) > min(settings["connections"], 4096) or args.cycles > 100:
|
|
raise ValueError("long connection workload exceeds configured capacity")
|
|
if not 0.3 <= args.duration <= 60 or args.interval > 0.1:
|
|
raise ValueError("live duration must be 0.3..60 seconds and interval <= 0.1 seconds")
|
|
if lifecycle_seconds(args.cycles, args.duration, settings["timeout_ms"]) >= settings["lifetime_ms"] / 1000:
|
|
raise ValueError("long connection cycles exceed fixture lifetime")
|
|
if args.suite in ("all", "orm"):
|
|
if settings["orm_concurrency"] > settings["orm_iterations"]:
|
|
raise ValueError("ORM concurrency must not exceed ORM iterations")
|
|
allowed = os.sched_getaffinity(0)
|
|
validate_cpu_placement(args.server_cpu, args.client_cpus, allowed)
|
|
peer_settings = {name: settings[name] for name in (
|
|
"workers", "connections", "task_capacity", "timeout_ms", "lifetime_ms",
|
|
"http2_streams", "http2_stream_window_bytes", "http2_connection_window_bytes")}
|
|
peer_settings.update({"cert_path": str(args.artifacts / "tls/server.pem"),
|
|
"key_path": str(args.artifacts / "tls/server-key.pem")})
|
|
args.output.mkdir(parents=True, exist_ok=False)
|
|
report = {"status": "running", "manifest": manifest,
|
|
"environment": {"platform": platform.platform(), "cpu_count": os.cpu_count(),
|
|
"client_workers": args.client_workers,
|
|
"client_cpus": list(args.client_cpus) if args.client_cpus is not None else sorted(allowed),
|
|
"allowed_cpus": sorted(allowed), "kernel": platform.release(),
|
|
"cpuinfo": Path("/proc/cpuinfo").read_text(),
|
|
"parent_cgroup": Path("/proc/self/cgroup").read_text()},
|
|
"arguments": report_arguments(args),
|
|
"cases": []}
|
|
failure = None
|
|
try:
|
|
for suite, execute in (("memory", memory_cases), ("async", async_cases),
|
|
("http", http_cases), ("live", live_cases),
|
|
("http2", http2_cases),
|
|
("http2-recovery", http2_recovery_cases),
|
|
("orm", orm_cases), ("cms", cms_cases)):
|
|
if args.suite in ("all", suite):
|
|
report["cases"].extend(execute(args, settings, peer_settings))
|
|
report["status"] = "complete"
|
|
except BaseException as error:
|
|
failure = error
|
|
report["status"] = "failed"
|
|
report["error"] = str(error)
|
|
raise
|
|
finally:
|
|
try:
|
|
# 即使中途失败,也保留已完成 case 与原始日志。
|
|
records = args.output / "cases.jsonl"
|
|
report["cases"] = [json.loads(line) for line in records.read_text().splitlines()] if records.exists() else []
|
|
report["summary"] = {"http2": http2_summary(report["cases"]),
|
|
"orm": orm_summary(report["cases"]),
|
|
"cms": cms_summary(report["cases"])}
|
|
(args.output / "report.json").write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
)
|
|
except BaseException as cleanup_error:
|
|
if failure is None:
|
|
raise
|
|
failure.add_note(f"cannot persist benchmark report: {cleanup_error}")
|
|
print(f"report: {args.output / 'report.json'}", flush=True)
|
|
|
|
|
|
def main():
|
|
args = parser().parse_args()
|
|
if args.action == "build":
|
|
if (args.workers > 64 or args.connections > 32760 or args.pending > 65520
|
|
or args.iterations > 1_048_576 or args.http2_streams > 65_536
|
|
or args.connections * args.http2_streams > 65_536
|
|
or args.http2_stream_window_bytes > 2_147_483_647
|
|
or not 65_535 <= args.http2_connection_window_bytes <= 2_147_483_647
|
|
or args.orm_iterations > 4096 or args.orm_rows > 10_000
|
|
or args.orm_concurrency > 256 or args.orm_cancel_work > 10_000_000
|
|
or args.orm_sqlite_connections > 64
|
|
or args.orm_concurrency > args.orm_iterations):
|
|
raise ValueError("configuration exceeds bounded fixture limits")
|
|
build(args)
|
|
else:
|
|
measure(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|