ab.sh 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. #!/usr/bin/env bash
  2. #
  3. # A/B throughput comparison between two git refs.
  4. #
  5. # Usage: ./ab.sh [--base REF] [--head REF] [--rounds N] [--duration S]
  6. # [--connections N] [--threads N] [--path PATH] [--tls]
  7. # [--large-mib N] [--timeout S]
  8. #
  9. # --path selects the workload. The harness serves:
  10. # / small body via set_content(); the response line, the
  11. # headers and the body already share a single write(), so
  12. # this is the least sensitive case
  13. # /large large body via set_content()
  14. # /static/small.js 1 KiB file from a mount point, where the headers and the
  15. # body are two separate writes
  16. # /static/large.bin same, with the body large enough to dominate
  17. #
  18. # --large-mib sizes the two large workloads (default 1).
  19. #
  20. # --tls runs the same workload over HTTPS, which writes through
  21. # SSLSocketStream instead of SocketStream.
  22. #
  23. # --timeout is bombardier's per-request timeout. Its 2s default aborts large
  24. # TLS responses, and the run then fails on the non-2xx check.
  25. #
  26. # Absolute numbers from a single run are meaningless: on a quiet 8-core laptop
  27. # the same binary varies by +/-20% run to run, and shared CI runners are worse.
  28. # So both refs are built and then measured alternately in the same session, and
  29. # only the ratio of the medians is reported.
  30. #
  31. # Requires: bombardier, python3, g++ (or $CXX), git.
  32. set -euo pipefail
  33. BASE_REF="master"
  34. HEAD_REF="HEAD"
  35. ROUNDS=5
  36. DURATION="5s"
  37. CONNECTIONS=10
  38. THREADS=""
  39. PORT=8080
  40. REQ_PATH="/"
  41. TLS=0
  42. LARGE_MIB=1
  43. TIMEOUT="30s"
  44. while [ $# -gt 0 ]; do
  45. case "$1" in
  46. --base) BASE_REF="$2"; shift 2 ;;
  47. --head) HEAD_REF="$2"; shift 2 ;;
  48. --rounds) ROUNDS="$2"; shift 2 ;;
  49. --duration) DURATION="$2"; shift 2 ;;
  50. --connections) CONNECTIONS="$2"; shift 2 ;;
  51. --threads) THREADS="$2"; shift 2 ;;
  52. --path) REQ_PATH="$2"; shift 2 ;;
  53. --large-mib) LARGE_MIB="$2"; shift 2 ;;
  54. --timeout) TIMEOUT="$2"; shift 2 ;;
  55. --tls) TLS=1; shift ;;
  56. *) echo "Unknown option: $1" >&2; exit 1 ;;
  57. esac
  58. done
  59. command -v bombardier >/dev/null || { echo "Error: bombardier not found" >&2; exit 1; }
  60. command -v python3 >/dev/null || { echo "Error: python3 not found" >&2; exit 1; }
  61. REPO_ROOT=$(git rev-parse --show-toplevel)
  62. CXX=${CXX:-g++}
  63. # Default the thread pool to the core count. The committed benchmark Makefile
  64. # hardcodes 16, which heavily oversubscribes a 2-4 vCPU CI runner and inflates
  65. # the variance we are trying to see through.
  66. if [ -z "$THREADS" ]; then
  67. THREADS=$(python3 -c 'import os; print(os.cpu_count() or 4)')
  68. fi
  69. WORKDIR=$(mktemp -d)
  70. cleanup() {
  71. pkill -f "$WORKDIR/.*/server-ab" 2>/dev/null || true
  72. git -C "$REPO_ROOT" worktree remove --force "$WORKDIR/base" 2>/dev/null || true
  73. git -C "$REPO_ROOT" worktree remove --force "$WORKDIR/head" 2>/dev/null || true
  74. rm -rf "$WORKDIR"
  75. }
  76. trap cleanup EXIT
  77. BASE_SHA=$(git -C "$REPO_ROOT" rev-parse --short "$BASE_REF")
  78. HEAD_SHA=$(git -C "$REPO_ROOT" rev-parse --short "$HEAD_REF")
  79. echo "==> base: $BASE_REF ($BASE_SHA)"
  80. echo "==> head: $HEAD_REF ($HEAD_SHA)"
  81. echo "==> rounds=$ROUNDS duration=$DURATION connections=$CONNECTIONS threads=$THREADS"
  82. echo "==> path=$REQ_PATH tls=$TLS large=${LARGE_MIB}MiB"
  83. echo ""
  84. if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
  85. echo "Note: base and head are the same commit; this measures harness noise."
  86. echo ""
  87. fi
  88. # --- Toolchain bits that depend on --tls ---
  89. SCHEME="http"
  90. INSECURE=""
  91. TLS_CXXFLAGS=""
  92. TLS_LDFLAGS=""
  93. TLS_ARGS=""
  94. if [ "$TLS" = "1" ]; then
  95. SCHEME="https"
  96. INSECURE="-k"
  97. TLS_CXXFLAGS="-DCPPHTTPLIB_OPENSSL_SUPPORT"
  98. TLS_LDFLAGS="-lssl -lcrypto"
  99. if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists openssl; then
  100. TLS_CXXFLAGS="$TLS_CXXFLAGS $(pkg-config --cflags openssl)"
  101. TLS_LDFLAGS="$(pkg-config --libs openssl)"
  102. elif command -v brew >/dev/null 2>&1 && brew --prefix openssl >/dev/null 2>&1; then
  103. OPENSSL_PREFIX=$(brew --prefix openssl)
  104. TLS_CXXFLAGS="$TLS_CXXFLAGS -I$OPENSSL_PREFIX/include"
  105. TLS_LDFLAGS="-L$OPENSSL_PREFIX/lib -lssl -lcrypto"
  106. fi
  107. if [ "$(uname -s)" = "Darwin" ]; then
  108. TLS_LDFLAGS="$TLS_LDFLAGS -framework CoreFoundation -framework Security"
  109. fi
  110. TLS_ARGS="--cert $REPO_ROOT/test/cert.pem --key $REPO_ROOT/test/key.pem"
  111. for f in "$REPO_ROOT/test/cert.pem" "$REPO_ROOT/test/key.pem"; do
  112. [ -f "$f" ] || { echo "Error: $f not found" >&2; exit 1; }
  113. done
  114. fi
  115. # --- Build both refs ---
  116. # The harness source always comes from the invoking worktree, so both refs run
  117. # an identical workload and a ref that predates a harness change stays
  118. # measurable. Only httplib.h varies, through -I.
  119. HARNESS="$REPO_ROOT/benchmark/cpp-httplib/main.cpp"
  120. [ -f "$HARNESS" ] || { echo "Error: $HARNESS not found" >&2; exit 1; }
  121. build() {
  122. local name=$1 ref=$2
  123. git -C "$REPO_ROOT" worktree add --detach --quiet "$WORKDIR/$name" "$ref"
  124. "$CXX" -o "$WORKDIR/$name/server-ab" -O2 -std=c++11 \
  125. -I"$WORKDIR/$name" \
  126. -DCPPHTTPLIB_THREAD_POOL_COUNT="$THREADS" \
  127. $TLS_CXXFLAGS \
  128. "$HARNESS" -lpthread $TLS_LDFLAGS
  129. }
  130. echo "==> Building..."
  131. build base "$BASE_REF"
  132. build head "$HEAD_REF"
  133. # --- Measure one ref once, echo rps ---
  134. measure() {
  135. local name=$1
  136. local json rc
  137. "$WORKDIR/$name/server-ab" --port "$PORT" --dir "$WORKDIR/$name-www" \
  138. --large-mib "$LARGE_MIB" $TLS_ARGS >/dev/null 2>&1 &
  139. local pid=$!
  140. # Wait for the listener (no dependency on nc)
  141. local i
  142. for i in $(seq 1 200); do
  143. if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then exec 3>&- 3<&-; break; fi
  144. sleep 0.05
  145. done
  146. set +e
  147. json=$(bombardier -c "$CONNECTIONS" -d "$DURATION" -t "$TIMEOUT" -o json -p r $INSECURE \
  148. "$SCHEME://127.0.0.1:$PORT$REQ_PATH" 2>/dev/null)
  149. rc=$?
  150. set -e
  151. kill "$pid" 2>/dev/null || true
  152. wait "$pid" 2>/dev/null || true
  153. # Wait for the port to be released before the next run
  154. for i in $(seq 1 200); do
  155. if ! (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then break; fi
  156. exec 3>&- 3<&-
  157. sleep 0.05
  158. done
  159. if [ $rc -ne 0 ] || [ -z "$json" ]; then
  160. echo "Error: bombardier failed for $name" >&2
  161. exit 1
  162. fi
  163. python3 -c '
  164. import json, sys
  165. r = json.load(sys.stdin)["result"]
  166. total = sum(r[k] for k in ("req1xx","req2xx","req3xx","req4xx","req5xx","others"))
  167. bad = total - r["req2xx"]
  168. if bad:
  169. sys.stderr.write("Error: %d non-2xx/error responses\n" % bad)
  170. sys.exit(1)
  171. print("%.1f" % (total / r["timeTakenSeconds"]))
  172. ' <<<"$json"
  173. }
  174. # --- Alternate, flipping the order each round to cancel ordering bias ---
  175. BASE_RESULTS=()
  176. HEAD_RESULTS=()
  177. echo ""
  178. echo "==> Measuring..."
  179. for ((r = 1; r <= ROUNDS; r++)); do
  180. if (( r % 2 == 1 )); then order=("base" "head"); else order=("head" "base"); fi
  181. line=" round $r:"
  182. for name in "${order[@]}"; do
  183. rps=$(measure "$name")
  184. if [ "$name" = "base" ]; then BASE_RESULTS+=("$rps"); else HEAD_RESULTS+=("$rps"); fi
  185. line="$line $name=$rps"
  186. done
  187. echo "$line"
  188. done
  189. # --- Report ---
  190. SUMMARY=$(python3 -c '
  191. import statistics, sys
  192. from itertools import combinations
  193. base = [float(x) for x in sys.argv[1].split()]
  194. head = [float(x) for x in sys.argv[2].split()]
  195. bm, hm = statistics.median(base), statistics.median(head)
  196. def spread(v):
  197. return (max(v) - min(v)) / statistics.median(v) * 100
  198. def u_stat(a, b):
  199. """Mann-Whitney U: number of (a, b) pairs where a > b, ties count a half."""
  200. return sum((x > y) + 0.5 * (x == y) for x in a for y in b)
  201. def exact_p(a, b):
  202. """Two-sided permutation p-value. A single slow round cannot swing this
  203. the way a min/max spread check can."""
  204. n1, n2 = len(a), len(b)
  205. pooled = a + b
  206. observed = abs(u_stat(a, b) - n1 * n2 / 2)
  207. total = extreme = 0
  208. for idx in combinations(range(n1 + n2), n1):
  209. s = set(idx)
  210. ga = [pooled[i] for i in idx]
  211. gb = [pooled[i] for i in range(n1 + n2) if i not in s]
  212. total += 1
  213. if abs(u_stat(ga, gb) - n1 * n2 / 2) >= observed:
  214. extreme += 1
  215. return extreme / total
  216. print("| | median req/s | min | max | spread |")
  217. print("|---|---|---|---|---|")
  218. print("| base | %.0f | %.0f | %.0f | %.1f%% |" % (bm, min(base), max(base), spread(base)))
  219. print("| head | %.0f | %.0f | %.0f | %.1f%% |" % (hm, min(head), max(head), spread(head)))
  220. print("")
  221. print("**ratio: %.3fx** (%+.1f%%)" % (hm / bm, (hm / bm - 1) * 100))
  222. print("")
  223. if len(base) + len(head) > 20:
  224. print("> %d rounds: skipping the permutation test (too many combinations)."
  225. % len(base))
  226. else:
  227. p = exact_p(base, head)
  228. if p <= 0.05:
  229. print("> Separation is consistent across rounds (permutation p = %.3f)." % p)
  230. else:
  231. print("> Not separated from noise (permutation p = %.3f). Inconclusive;" % p)
  232. print("> raise --rounds or --duration, or run on a quieter machine.")
  233. min_p = exact_p(list(range(len(base))),
  234. list(range(len(base), len(base) + len(head))))
  235. if min_p > 0.05:
  236. print(">")
  237. print("> With %d rounds even perfect separation only reaches p = %.3f,"
  238. % (len(base), min_p))
  239. print("> so this test can never call a win. Use --rounds 4 or more.")
  240. ' "${BASE_RESULTS[*]}" "${HEAD_RESULTS[*]}")
  241. echo ""
  242. echo "$SUMMARY"
  243. if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
  244. {
  245. echo "## Benchmark A/B"
  246. echo ""
  247. echo "- base: \`$BASE_REF\` ($BASE_SHA)"
  248. echo "- head: \`$HEAD_REF\` ($HEAD_SHA)"
  249. echo "- rounds=$ROUNDS duration=$DURATION connections=$CONNECTIONS threads=$THREADS"
  250. echo ""
  251. echo "$SUMMARY"
  252. } >> "$GITHUB_STEP_SUMMARY"
  253. fi