OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scripts / mesher-contention-test.sh

160 lines · bash ref:
#!/usr/bin/env bash
# mesher-contention-test — does background tile generation slow the CPU mesher?
#
# THE UNATTENDED VERSION of frametime-load-test.sh. That one needs someone
# playing, because an idle camera measures an idle renderer. This one needs
# nobody: it measures dc-terrain's CPU dual-contouring throughput — the exact
# pipeline the game's worker threads run — with and without the generator.
#
# WHY THIS IS A FAIR PROXY, AND WHERE IT IS NOT. The generator is CPU-only, so
# CPU and memory-bandwidth contention IS the mechanism by which it would cost
# frames. Meshing throughput is where that shows up first: the streaming spec
# measured the mesher as a fixed drain rate below the arrival rate of a moving
# player, so anything that lowers it directly lengthens the queue.
#   It does NOT measure render-thread or GPU contention. It cannot tell you the
# 99th-percentile frame time. It answers "does the generator steal the CPU the
# terrain needs", which is the part that does not require a human.
#
# ALTERNATES clean/loaded rather than running all-clean then all-loaded, for the
# same reason the frame-time test uses phases in one run: thermal ramp and
# background drift are monotonic, and alternating cancels them.
#
# WARN --jobs DOES NOT RAISE THE LOAD, and raising it is not the remedy. api.py
# runs `app.run(..., threaded=False)`, so the inference service handles ONE
# request at a time and extra client jobs merely queue. MEASURED 2026-09-03:
# two fresh-tile requests fired simultaneously finished at +131 s and +256 s --
# exactly serialised. The load knob is the SERVICE's thread count
# (OMP_NUM_THREADS there; ~6.1 of 16 cores uncapped), not this flag. --jobs is
# kept only to keep the queue non-empty across a tile boundary.
#
# WARN THE LOAD MUST USE FRESH TILES. The pipeline's 'direct' strategy is an
# in-memory LRU, so re-requesting a region inside an already-generated tile
# returns in ~0.01 s and measures the CACHE, not the generator. start_load walks
# tx upward from a far origin for exactly this reason; do not "optimise" it into
# reusing coordinates.
#
#   scripts/mesher-contention-test.sh [--rounds 9] [--chunks 200] [--jobs 2] [--nice 19]
set -uo pipefail

ROUNDS=5; CHUNKS=200; JOBS=2; NICE=19
URL="http://127.0.0.1:8830"
BENCH=/tmp/bench_dc
OUT=/tmp/mesher-contention.csv

while [ $# -gt 0 ]; do
  case "$1" in
    --rounds) ROUNDS="$2"; shift 2 ;;
    --chunks) CHUNKS="$2"; shift 2 ;;
    --jobs)   JOBS="$2"; shift 2 ;;
    --nice)   NICE="$2"; shift 2 ;;
    --url)    URL="$2"; shift 2 ;;
    --bench)  BENCH="$2"; shift 2 ;;
    --out)    OUT="$2"; shift 2 ;;
    *) echo "unknown arg: $1" >&2; exit 2 ;;
  esac
done

[ -x "$BENCH" ] || { echo "FATAL: no benchmark at $BENCH" >&2
  echo "  cd ../dc-terrain && g++ -std=c++17 -O3 -fopenmp -I libdc/include \\" >&2
  echo "    test/benchmark_cpu_dc_throughput.cpp libdc/src/dc_{contour,octree,qef,density}.cpp \\" >&2
  echo "    -o $BENCH -lm -lpthread" >&2; exit 1; }

curl -s -m 5 -o /dev/null "$URL/coarse?i1=0&j1=0&i2=2&j2=2" || {
  echo "FATAL: no CPU inference service at $URL" >&2; exit 1; }

run_bench () {  # -> chunks/s for the threaded run
  (cd "$(dirname "$BENCH")" && "$BENCH" "$CHUNKS" 2>/dev/null) \
    | grep -oE "\| [0-9]+ chunks/s" | tail -1 | grep -oE "[0-9]+"
}

start_load () {
  LOAD_PIDS=()
  for i in $(seq 1 "$JOBS"); do
    ( tx=$((2000 + i * 53))
      while :; do
        nice -n "$NICE" curl -s -m 900 -o /dev/null \
          "$URL/terrain?i1=$((tx*256))&j1=0&i2=$(((tx+1)*256))&j2=256&scale=1"
        tx=$((tx+1))
      done ) &
    LOAD_PIDS+=($!)
  done
  sleep 8   # let the generator actually get to work before measuring
}
stop_load () {
  for p in "${LOAD_PIDS[@]:-}"; do kill "$p" 2>/dev/null; done
  pkill -P $$ curl 2>/dev/null
  sleep 3
}
trap stop_load EXIT

echo "mesher-contention-test  rounds=$ROUNDS chunks=$CHUNKS jobs=$JOBS nice=$NICE"

# WARM-UP, DISCARDED. The first run reads high — cold caches and an unclamped
# turbo clock. Leaving it in put a 245 next to a 206 and inflated the clean-run
# spread to 22%, which swamped a real 7% drop. A noise floor measured on a warm
# machine is the only one worth comparing against.
printf "  warm-up (discarded) "; w=$(run_bench); echo "$w chunks/s"

echo "phase,round,chunks_per_s" > "$OUT"
for r in $(seq 1 "$ROUNDS"); do
  c=$(run_bench); echo "clean,$r,$c" >> "$OUT";  printf "  round %d  clean %6s" "$r" "$c"
  start_load
  l=$(run_bench); echo "loaded,$r,$l" >> "$OUT"; printf "   loaded %6s\n" "$l"
  stop_load
done
echo
python3 - "$OUT" <<'PY'
import sys, csv, statistics as st
rows=[r for r in csv.DictReader(open(sys.argv[1])) if r["chunks_per_s"]]
clean=sorted(float(r["chunks_per_s"]) for r in rows if r["phase"]=="clean")
load =sorted(float(r["chunks_per_s"]) for r in rows if r["phase"]=="loaded")
if len(clean)<4 or len(load)<4: sys.exit("need >=4 rounds for a rank test")

def q(v,p):
    i=(len(v)-1)*p; lo=int(i)
    return v[lo] if lo==i else v[lo]+(v[lo+1]-v[lo])*(i-lo)

mc,ml=st.median(clean),st.median(load)
print(f"  clean  median {mc:7.1f} chunks/s   IQR {q(clean,.25):.0f}-{q(clean,.75):.0f}   range {min(clean):.0f}-{max(clean):.0f}")
print(f"  loaded median {ml:7.1f} chunks/s   IQR {q(load,.25):.0f}-{q(load,.75):.0f}   range {min(load):.0f}-{max(load):.0f}")
print(f"  throughput drop: {100.0*(mc-ml)/mc:+.1f} %")

# NOISE FLOOR IS IQR, NOT max-min. max-min is dominated by TURBO on the first
# clean runs of a session -- a 253 next to a stable 193 reported a 31% floor
# where the IQR said 8%, so the script hid its own sensitivity by ~3.5x and then
# advised raising a load it could already resolve. Sustained load SUPPRESSES
# turbo, so the loaded population is systematically tighter than the clean one
# and the two spreads are not comparable by range at all.
iqr=100.0*(q(clean,.75)-q(clean,.25))/mc
print(f"  clean IQR spread: {iqr:.1f} %   (max-min would claim {100.0*(max(clean)-min(clean))/mc:.1f} % -- turbo, ignore it)")

# Mann-Whitney U, normal approximation, tie-averaged ranks.
n1,n2=len(clean),len(load); allv=sorted(clean+load)
def rank(x):
    idx=[i for i,v in enumerate(allv) if v==x]
    return sum(idx)/len(idx)+1.0
U=sum(rank(x) for x in clean)-n1*(n1+1)/2.0
mu=n1*n2/2.0; sd=(n1*n2*(n1+n2+1)/12.0)**0.5
z=(U-mu)/sd if sd else 0.0
mdd=100.0*1.96*st.pstdev(clean)/(mc*(n1**0.5)) if n1>1 else float("nan")
print(f"  Mann-Whitney z={z:+.2f}   smallest drop resolvable here: ~{mdd:.1f} %")

if z > 1.96:
    print("\nVERDICT: the generator IS stealing mesher throughput, significantly.")
    print("         Cap the SERVICE (OMP_NUM_THREADS), raise --nice, or throttle its")
    print("         duty cycle. Raising --jobs is NOT the knob -- see the header.")
    sys.exit(1)
if z < -1.96:
    print("\nVERDICT: loaded is significantly FASTER, which is a clock artefact and")
    print("         not a result -- an idle box sits at a lower P-state than a loaded")
    print("         one. Read as no contention, and distrust the absolute numbers.")
    sys.exit(0)
print(f"\nVERDICT: NO significant contention (|z| < 1.96), bounded under ~{mdd:.1f} %.")
print("         Measures CPU + memory-bandwidth contention against the MESHER and")
print("         nothing else: not the render thread, not the GPU, not p99 frame")
print("         time. For those use scripts/frametime-load-test.sh, which needs a")
print("         human at the controls -- an idle camera measures an idle renderer.")
print("         To find the CEILING, raise the SERVICE's thread cap, not --jobs.")
sys.exit(0)
PY