#!/usr/bin/env bash # autoresearch-frank-archon.sh — RUNS ON DANKSERVER, driven over ssh by the # dev-box autoresearch.sh. It is the canonical frank-archon benchmark entry # point for the autoresearch loop. # # Method (mandated by the Benchmarking Policy): the production service serves # real users and shares the two R9700s, so a bench beside it starves the GPUs # and corrupts results. This script therefore: # 1. stops the production frank-archon unit (root, relink symlink: stop, # NOT mask — mask refuses to replace a symlinked unit file), # 2. boots a scratch radiance container named `arbench` on the SAME two # cards with the candidate engine argv (the env file the loop edits), # 3. polls /health until the listener is live (it binds AFTER the full # load, so a bare curl is an honest gate), # 4. runs ONE self-contained completion request locally (127.0.0.1, no LAN # jitter): a fixed ~140k-char real-text prompt (~32k tokens) decoded for # 256 tokens, greedy. The engine's `timings` object reports the honest # prefill and decode rates for that single request. # 5. prints METRIC lines and tears the scratch down, # 6. restores production (systemctl start), via a trap so a failed bench # never leaves frank-archon down (the stale-probe-tail lesson). # # Determinism: the prompt is built from a fixed, sorted corpus of repo files # with a fixed random seed, so prompt_n is stable across runs. Sampling is # greedy (temperature 0), so the decode length is fixed; the only residual # nondeterminism is the wht6 lossy TP all-reduce, which is a constant across # runs and cancels in A/B comparisons. # # The scratch mirrors the production container contract exactly (serve-frank- # primary.sh): --network host, --init, seccomp=unconfined (io_uring for the # n-gram table), memlock unbounded (the ~132 GiB pinned ledger), /dev/kfd + # /dev/dri, the .rad ro, the tune cache mount. The ONLY differences: the # container name (arbench, so prod's identity is untouched) and the prefix cache # switched OFF (--prefix-cache-mib 0), so every prefill is cold — the tier is # host RAM now, there is no per-run disk directory to wipe. set -uo pipefail ENV_FILE="${1:-/home/dank/serverAppConfigs/scripts/frank-archon.env}" SCRATCH=arbench PORT=7770 HEALTH="http://127.0.0.1:$PORT/health" COMPLETE="http://127.0.0.1:$PORT/v1/completions" BODY=/tmp/arbench-body.json LOG=/home/dank/logs/arbench.log restore() { podman rm -f "$SCRATCH" >/dev/null 2>&1 || true sudo systemctl start frank-archon >/dev/null 2>&1 || true } trap restore EXIT # --- source the candidate config (same file the production unit reads) ------ if [ ! -f "$ENV_FILE" ]; then echo "no env file at $ENV_FILE" >&2; exit 1 fi # shellcheck disable=SC1090 set -a; . "$ENV_FILE"; set +a # --- stop production; the scratch needs both cards to itself ---------------- sudo systemctl stop frank-archon >/dev/null 2>&1 || true podman rm -f "$SCRATCH" >/dev/null 2>&1 || true # --- the prefix cache is switched off below, so there is no scratch KV tree --- # to wipe; the cold prefill comes from --prefix-cache-mib 0. mkdir -p "$FRANK_ARCHON_STATE_DIR/tune" # --- boot the scratch: the production run line, scratch name + fresh KV ------ podman run -d --name "$SCRATCH" \ --network host \ --init \ --security-opt seccomp=unconfined \ --ulimit memlock=-1:-1 \ --device /dev/kfd --device /dev/dri \ -v "$FRANK_ARCHON_MODEL_DIR":/models:ro \ -v "$FRANK_ARCHON_STATE_DIR/tune":/opt/radiance/share/radiance/tune \ -v "$FRANK_ARCHON_TEMPLATE":/templates/template.jinja:ro \ "$FRANK_ARCHON_IMAGE" \ --model "/models/$FRANK_ARCHON_MODEL_FILE" \ --host :: --port "$PORT" \ --served-model-name "$FRANK_ARCHON_SERVED_NAME" \ --override-chat-template /templates/template.jinja \ --tp "$FRANK_ARCHON_TP" --tp-wire "$FRANK_ARCHON_TP_WIRE" \ --max-num-seqs "$FRANK_ARCHON_MAX_SEQS" \ --max-model-len "$FRANK_ARCHON_MAX_MODEL_LEN" \ --placement "$FRANK_ARCHON_PLACEMENT" \ --host-pool-mib "$FRANK_ARCHON_HOST_POOL_MIB" \ --gpu-headroom-mib "$FRANK_ARCHON_GPU_HEADROOM_MIB" \ --kv-cache-dtype "$FRANK_ARCHON_KV_DTYPE" \ --prefix-cache-mib 0 \ --busy-prefill-chunk "${FRANK_ARCHON_BUSY_PREFILL_CHUNK:-auto}" \ --ngram-placement "$FRANK_ARCHON_NGRAM_PLACEMENT" \ --num-speculative-tokens "$FRANK_ARCHON_SPEC_TOKENS" \ --max-num-batched-tokens "$FRANK_ARCHON_MAX_BATCHED_TOKENS" \ ${FRANK_ARCHON_EXTRA_ARGS:+$FRANK_ARCHON_EXTRA_ARGS} \ > "$LOG" 2>&1 if [ $? -ne 0 ]; then echo "scratch boot failed; see $LOG" >&2; exit 1; fi # --- poll health (listener binds after the full load; cold boot ~110 s) ------ ok=0 for _ in $(seq 1 360); do code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH" 2>/dev/null || echo 000) if [ "$code" = "200" ]; then ok=1; break; fi sleep 2 done if [ "$ok" -ne 1 ]; then echo "scratch never reached health 200; tail of $LOG:" >&2 tail -30 "$LOG" >&2; exit 1 fi # --- build the workload: a fixed ~140k-char real-text prompt (repo corpus) --- python3 - "$BODY" <<'PY' import json, sys, random, glob random.seed(1234) root = "/home/dank/serverAppConfigs" files = sorted( glob.glob(f"{root}/serverAppConfigs-docs/**/*.md", recursive=True) + glob.glob(f"{root}/scripts/*.sh") + glob.glob(f"{root}/scripts/*.py") + glob.glob(f"{root}/AGENTS.md") ) corpus = [] total = 0 for f in files: try: t = open(f, encoding="utf-8", errors="ignore").read() except Exception: continue if not t.strip(): continue corpus.append(t) total += len(t) if total > 240000: break corpus = "\n".join(corpus) TGT = 140000 parts, n = [], 0 while n < TGT: start = random.randint(0, max(0, len(corpus) - 500)) seg = corpus[start:start + random.randint(200, 500)] parts.append(seg) n += len(seg) + 1 prompt = " ".join(parts) json.dump( {"model": "frank-archon", "prompt": prompt, "max_tokens": 256, "temperature": 0.0, "stream": False, "stop": ["<|im_end|>"]}, open(sys.argv[1], "w"), ) PY if [ ! -f "$BODY" ]; then echo "prompt build failed" >&2; exit 1; fi # --- the bench request (prefill ~32k + decode 256, greedy) ------------------- RESP=$(curl -s --max-time 600 -X POST "$COMPLETE" \ -H 'Content-Type: application/json' -d "@$BODY") echo "$RESP" > /tmp/arbench-resp.json # --- parse the timings into METRIC lines ------------------------------------- python3 - <<'PY' import json, sys try: r = json.load(open("/tmp/arbench-resp.json")) except Exception as e: sys.stderr.write(f"unparseable response: {e}\n"); sys.exit(1) if "timings" not in r: sys.stderr.write(f"no timings in response: {json.dumps(r)[:500]}\n"); sys.exit(1) t = r["timings"] dtps = t.get("predicted_per_second") ptps = t.get("prompt_per_second") pms = t.get("prompt_ms") dn = t.get("draft_n", 0) da = t.get("draft_n_accepted", 0) if dtps is None: sys.stderr.write("predicted_per_second null (no decode produced)\n"); sys.exit(1) print(f"METRIC decode_tps={dtps:.4f}") if ptps is not None: print(f"METRIC prefill_tps={ptps:.4f}") if pms is not None: print(f"METRIC ttft_ms={pms:.4f}") if dn: print(f"METRIC draft_accept={da/dn:.4f}") print(f"INFO prompt_n={t.get('prompt_n')} cache_n={t.get('cache_n')} predicted_n={t.get('predicted_n')}") PY