ab.sh 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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]
  7. #
  8. # Absolute numbers from a single run are meaningless: on a quiet 8-core laptop
  9. # the same binary varies by +/-20% run to run, and shared CI runners are worse.
  10. # So both refs are built and then measured alternately in the same session, and
  11. # only the ratio of the medians is reported.
  12. #
  13. # Requires: bombardier, python3, g++ (or $CXX), git.
  14. set -euo pipefail
  15. BASE_REF="master"
  16. HEAD_REF="HEAD"
  17. ROUNDS=5
  18. DURATION="5s"
  19. CONNECTIONS=10
  20. THREADS=""
  21. PORT=8080
  22. while [ $# -gt 0 ]; do
  23. case "$1" in
  24. --base) BASE_REF="$2"; shift 2 ;;
  25. --head) HEAD_REF="$2"; shift 2 ;;
  26. --rounds) ROUNDS="$2"; shift 2 ;;
  27. --duration) DURATION="$2"; shift 2 ;;
  28. --connections) CONNECTIONS="$2"; shift 2 ;;
  29. --threads) THREADS="$2"; shift 2 ;;
  30. *) echo "Unknown option: $1" >&2; exit 1 ;;
  31. esac
  32. done
  33. command -v bombardier >/dev/null || { echo "Error: bombardier not found" >&2; exit 1; }
  34. command -v python3 >/dev/null || { echo "Error: python3 not found" >&2; exit 1; }
  35. REPO_ROOT=$(git rev-parse --show-toplevel)
  36. CXX=${CXX:-g++}
  37. # Default the thread pool to the core count. The committed benchmark Makefile
  38. # hardcodes 16, which heavily oversubscribes a 2-4 vCPU CI runner and inflates
  39. # the variance we are trying to see through.
  40. if [ -z "$THREADS" ]; then
  41. THREADS=$(python3 -c 'import os; print(os.cpu_count() or 4)')
  42. fi
  43. WORKDIR=$(mktemp -d)
  44. cleanup() {
  45. pkill -f "$WORKDIR/.*/server-ab" 2>/dev/null || true
  46. git -C "$REPO_ROOT" worktree remove --force "$WORKDIR/base" 2>/dev/null || true
  47. git -C "$REPO_ROOT" worktree remove --force "$WORKDIR/head" 2>/dev/null || true
  48. rm -rf "$WORKDIR"
  49. }
  50. trap cleanup EXIT
  51. BASE_SHA=$(git -C "$REPO_ROOT" rev-parse --short "$BASE_REF")
  52. HEAD_SHA=$(git -C "$REPO_ROOT" rev-parse --short "$HEAD_REF")
  53. echo "==> base: $BASE_REF ($BASE_SHA)"
  54. echo "==> head: $HEAD_REF ($HEAD_SHA)"
  55. echo "==> rounds=$ROUNDS duration=$DURATION connections=$CONNECTIONS threads=$THREADS"
  56. echo ""
  57. if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
  58. echo "Note: base and head are the same commit; this measures harness noise."
  59. echo ""
  60. fi
  61. # --- Build both refs ---
  62. build() {
  63. local name=$1 ref=$2
  64. git -C "$REPO_ROOT" worktree add --detach --quiet "$WORKDIR/$name" "$ref"
  65. if [ ! -f "$WORKDIR/$name/benchmark/cpp-httplib/main.cpp" ]; then
  66. echo "Error: benchmark/cpp-httplib/main.cpp missing in $ref" >&2
  67. exit 1
  68. fi
  69. "$CXX" -o "$WORKDIR/$name/server-ab" -O2 -std=c++11 \
  70. -I"$WORKDIR/$name" \
  71. -DCPPHTTPLIB_THREAD_POOL_COUNT="$THREADS" \
  72. "$WORKDIR/$name/benchmark/cpp-httplib/main.cpp" -lpthread
  73. }
  74. echo "==> Building..."
  75. build base "$BASE_REF"
  76. build head "$HEAD_REF"
  77. # --- Measure one ref once, echo rps ---
  78. measure() {
  79. local name=$1
  80. local json rc
  81. "$WORKDIR/$name/server-ab" >/dev/null 2>&1 &
  82. local pid=$!
  83. # Wait for the listener (no dependency on nc)
  84. local i
  85. for i in $(seq 1 200); do
  86. if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then exec 3>&- 3<&-; break; fi
  87. sleep 0.05
  88. done
  89. set +e
  90. json=$(bombardier -c "$CONNECTIONS" -d "$DURATION" -o json -p r \
  91. "http://127.0.0.1:$PORT/" 2>/dev/null)
  92. rc=$?
  93. set -e
  94. kill "$pid" 2>/dev/null || true
  95. wait "$pid" 2>/dev/null || true
  96. # Wait for the port to be released before the next run
  97. for i in $(seq 1 200); do
  98. if ! (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then break; fi
  99. exec 3>&- 3<&-
  100. sleep 0.05
  101. done
  102. if [ $rc -ne 0 ] || [ -z "$json" ]; then
  103. echo "Error: bombardier failed for $name" >&2
  104. exit 1
  105. fi
  106. python3 -c '
  107. import json, sys
  108. r = json.load(sys.stdin)["result"]
  109. total = sum(r[k] for k in ("req1xx","req2xx","req3xx","req4xx","req5xx","others"))
  110. bad = total - r["req2xx"]
  111. if bad:
  112. sys.stderr.write("Error: %d non-2xx/error responses\n" % bad)
  113. sys.exit(1)
  114. print("%.1f" % (total / r["timeTakenSeconds"]))
  115. ' <<<"$json"
  116. }
  117. # --- Alternate, flipping the order each round to cancel ordering bias ---
  118. BASE_RESULTS=()
  119. HEAD_RESULTS=()
  120. echo ""
  121. echo "==> Measuring..."
  122. for ((r = 1; r <= ROUNDS; r++)); do
  123. if (( r % 2 == 1 )); then order=("base" "head"); else order=("head" "base"); fi
  124. line=" round $r:"
  125. for name in "${order[@]}"; do
  126. rps=$(measure "$name")
  127. if [ "$name" = "base" ]; then BASE_RESULTS+=("$rps"); else HEAD_RESULTS+=("$rps"); fi
  128. line="$line $name=$rps"
  129. done
  130. echo "$line"
  131. done
  132. # --- Report ---
  133. SUMMARY=$(python3 -c '
  134. import statistics, sys
  135. from itertools import combinations
  136. base = [float(x) for x in sys.argv[1].split()]
  137. head = [float(x) for x in sys.argv[2].split()]
  138. bm, hm = statistics.median(base), statistics.median(head)
  139. def spread(v):
  140. return (max(v) - min(v)) / statistics.median(v) * 100
  141. def u_stat(a, b):
  142. """Mann-Whitney U: number of (a, b) pairs where a > b, ties count a half."""
  143. return sum((x > y) + 0.5 * (x == y) for x in a for y in b)
  144. def exact_p(a, b):
  145. """Two-sided permutation p-value. A single slow round cannot swing this
  146. the way a min/max spread check can."""
  147. n1, n2 = len(a), len(b)
  148. pooled = a + b
  149. observed = abs(u_stat(a, b) - n1 * n2 / 2)
  150. total = extreme = 0
  151. for idx in combinations(range(n1 + n2), n1):
  152. s = set(idx)
  153. ga = [pooled[i] for i in idx]
  154. gb = [pooled[i] for i in range(n1 + n2) if i not in s]
  155. total += 1
  156. if abs(u_stat(ga, gb) - n1 * n2 / 2) >= observed:
  157. extreme += 1
  158. return extreme / total
  159. print("| | median req/s | min | max | spread |")
  160. print("|---|---|---|---|---|")
  161. print("| base | %.0f | %.0f | %.0f | %.1f%% |" % (bm, min(base), max(base), spread(base)))
  162. print("| head | %.0f | %.0f | %.0f | %.1f%% |" % (hm, min(head), max(head), spread(head)))
  163. print("")
  164. print("**ratio: %.3fx** (%+.1f%%)" % (hm / bm, (hm / bm - 1) * 100))
  165. print("")
  166. if len(base) + len(head) > 20:
  167. print("> %d rounds: skipping the permutation test (too many combinations)."
  168. % len(base))
  169. else:
  170. p = exact_p(base, head)
  171. if p <= 0.05:
  172. print("> Separation is consistent across rounds (permutation p = %.3f)." % p)
  173. else:
  174. print("> Not separated from noise (permutation p = %.3f). Inconclusive;" % p)
  175. print("> raise --rounds or --duration, or run on a quieter machine.")
  176. min_p = exact_p(list(range(len(base))),
  177. list(range(len(base), len(base) + len(head))))
  178. if min_p > 0.05:
  179. print(">")
  180. print("> With %d rounds even perfect separation only reaches p = %.3f,"
  181. % (len(base), min_p))
  182. print("> so this test can never call a win. Use --rounds 4 or more.")
  183. ' "${BASE_RESULTS[*]}" "${HEAD_RESULTS[*]}")
  184. echo ""
  185. echo "$SUMMARY"
  186. if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
  187. {
  188. echo "## Benchmark A/B"
  189. echo ""
  190. echo "- base: \`$BASE_REF\` ($BASE_SHA)"
  191. echo "- head: \`$HEAD_REF\` ($HEAD_SHA)"
  192. echo "- rounds=$ROUNDS duration=$DURATION connections=$CONNECTIONS threads=$THREADS"
  193. echo ""
  194. echo "$SUMMARY"
  195. } >> "$GITHUB_STEP_SUMMARY"
  196. fi