File size: 16,401 Bytes
f6e66b8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | #!/usr/bin/env bash
# =============================================================================
# run_longeval.sh -- batch RULER / NIAH / VAL generative evaluation for bluscriptCP.
#
# Scores what the model GENERATES (substring match), unlike probe_1b.sh which
# measures teacher-forced NLL over an answer span already present in the input.
#
# Requires a binary built with generation support:
# make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j$(nproc)
#
# Usage:
# ./run_longeval.sh # all tasks, all checkpoints
# TASKS="niah_single vt" ./run_longeval.sh # subset
# CKPTS="arm_c" N=20 ./run_longeval.sh # quick smoke
#
# Resumable: each (ckpt,task) writes a .done marker; re-running skips finished
# pairs. Kill it any time -- completed pairs survive.
# =============================================================================
set -uo pipefail
cd "$(dirname "$0")"
ROOT="$(cd ../.. && pwd)"
export LD_LIBRARY_PATH="$ROOT/Tensor-Implementations/lib:$ROOT/Profiler/lib:${LD_LIBRARY_PATH:-}"
export OMPI_ALLOW_RUN_AS_ROOT=1 OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1
EXE=./build/bluscriptCP_exec
PY=${PY:-python3}
OUT=${OUT:-longeval_runs}
CK=checkpoints_bluscriptcp/rank_0
# CP_T is the RoPE cache length. The C++ side pads each decode step up to a
# multiple of 256 (the Hopper split kernel's T_q guard), so
# pad_up(PROMPT_LEN + MAX_NEW) must be <= CP_T or it aborts up front.
T=${T:-16384}
# COST: a forward at T=16384 fp32 measures 17.8 s on H200 / ~25 s on H100
# (probe_arm_c: 21 jobs x 8 windows in 12.5 min at slots=4). Naive re-forward
# decoding costs ONE such forward per generated token, so the defaults matter:
# fp32 MAX_NEW=32 N=50 -> 190 GPU-h <- do not
# bf16, early stop, 4 tasks at ~6 forwards + niah_multivalue at ~24 (its
# MIN_TOK=14 blocks an early break) -> weighted (4*6+24)/5 = 9.6 /prompt
# -> ~18.7 GPU-h for 1000 prompts -> ~4.7 h on 4 GPUs. Step 2 measures this
# on BOTH niah_single and niah_multivalue rather than assuming it.
# HARD CONSTRAINT: PROMPT_LEN + MAX_NEW must be <= 16384 AND pad up to exactly it.
# bluscriptCP.cpp:2446 dies when pad_up(P_LEN+MAX_NEW) > CP_T, and PAD=256, so
# pad_up(16368+24)=16640 > 16384 would abort EVERY job. 16360+24 = 16384 exactly.
MAX_NEW=${MAX_NEW:-24}
PROMPT_LEN=${PROMPT_LEN:-16360}
N=${N:-50}
BF16=${BF16:-1}
GPUS=${GPUS:-0} # comma list, e.g. "0,1,2,3" -> (ckpt,task) jobs fan out
NP=${NP:-1} # CP_SIZE per job; GPUs are used for THROUGHPUT, not size
YARN=${YARN:-4}
# YARN_ORIG_MAXPOS (YARNOps.cpp:57) defaults to 1024, but the base model was
# trained at context_length 4096 -- hence the warning EVERY run emits:
# seq_len(16384) != scale(4.000)*orig_maxpos(1024.0)=4096.0
# i.e. the YaRN ramp targets 4096 while the cache is SIZED 16384, so positions
# past ~4k sit outside the range it was parameterised for. YARN_ORIG=4096 makes
# scale*orig == seq_len exactly. CAUTION: this is a train/eval MISMATCH -- the
# checkpoints were trained under the 1024 default, so a correctly-parameterised
# cache is not one they ever saw. Diagnostic, not a guaranteed fix.
YARN_ORIG=${YARN_ORIG:-}
GREEDY=${GREEDY:-1} # deterministic: benchmarks must be reproducible
# Needle depth grid. MUST match needle_gen.py's p_frac points or the generative
# curve cannot be overlaid on the NLL curve. Both place the needle at a fraction
# of ABSOLUTE POSITION (needle_gen p0.25 -> slot 4087 -> fraction 0.25), so the
# grids correspond directly.
DEPTHS=${DEPTHS:-"0.0,0.25,0.5,0.75,1.0"}
# Minimum substantive tokens before a newline may end generation. 3 suits a
# 7-digit number or a single word, but niah_multivalue needs THREE values
# (~12-15 tokens): at 3 a model that emits value 1 then a newline would stop with
# 1 of 3 and score MISS -- re-creating the bug the guard exists to prevent,
# precisely for the long-answer task.
declare -A MINTOK=( [niah_multivalue]=14 )
TASKS=${TASKS:-"niah_single niah_multikey niah_multivalue vt val_forward val_backward"}
# name:checkpoint-file
CKPTS=${CKPTS:-"base arm_a arm_b arm_c"}
declare -A CKPT_FILE=(
[base]=blumodelcp_run7_step_750.ckpt
[arm_a]=blumodelcp_run12_step_400.ckpt
[arm_b]=blumodelcp_run15_step_400.ckpt
[arm_c]=blumodelcp_run16_step_400.ckpt
)
A1B="CP_N_EMBD=1536 CP_N_LAYER=36 CP_N_HEAD=12 CP_N_KVHEAD=4 CP_FFN=4096 CP_WEIGHT_TYING=0"
ARCH="$A1B CP_ATTN_MODE=ring CP_ATTN_FUSION=split"
mkdir -p "$OUT"/{prompts,gen,state,scores}
M="$OUT/master.log"
say() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$M"; }
[ -x "$EXE" ] || { echo "ERROR: $EXE missing -- build first:"; \
echo " make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j\$(nproc)"; exit 1; }
# Fail early on a binary built without generation support rather than after an
# hour of prompt generation.
if ! grep -q CP_GENERATE "$EXE" 2>/dev/null; then
echo "ERROR: $EXE has no CP_GENERATE support (stale build). Rebuild:"
echo " make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j\$(nproc)"
exit 1
fi
say "longeval: T=$T prompt_len=$PROMPT_LEN max_new=$MAX_NEW n=$N greedy=$GREEDY gpus=$GPUS"
# ---- 1. prompts (shared across checkpoints; identical bytes => fair compare) --
HAY=$(ls Data_Loader/Data/flux_val_*.bin 2>/dev/null | head -1)
[ -z "$HAY" ] && HAY=$(ls Data_Loader/Data/flux_train_*.bin 2>/dev/null | head -1)
say "haystack: ${HAY:-<synthetic fallback>}"
[ -z "$HAY" ] && say " WARN: synthetic filler is a weaker distractor than real text -- it INFLATES accuracy. Record this."
# A bare "-f the .bin exists" test silently reuses prompts from a different run:
# a smoke run at N=2 then poisons a main run at N=50 (the C++ dies on "short read
# on prompt 2" AFTER flushing 2 rows, which the old [ -s ] scoring gate happily
# scored and marked .done, permanently). The signature covers everything that
# changes prompt content -- N, length, depths, seed, AND the haystack, so a
# synthetic-filler run is not reused once the flux shard arrives.
SIG="N=$N|LEN=$PROMPT_LEN|DEPTHS=$DEPTHS|SEED=0|HAY=$(basename "${HAY:-none}")|HAYSZ=$(stat -c %s "${HAY:-/nonexistent}" 2>/dev/null || echo 0)"
if [ "$(cat "$OUT/prompts/.signature" 2>/dev/null)" != "$SIG" ]; then
say "prompt signature changed -- regenerating prompts and clearing state"
rm -f "$OUT"/prompts/*_prompts.bin "$OUT"/prompts/*_meta.json
# MUST clear state too: the generation skip below tests .gen, so leaving those
# markers would skip regeneration and score OLD tokens against the NEW
# meta.json -- attributing accuracy to depths the tokens never came from.
rm -f "$OUT"/state/*
fi
GEN_OK=1
for TASK in $TASKS; do
if [ ! -f "$OUT/prompts/${TASK}_prompts.bin" ]; then
say "generating prompts: $TASK"
$PY Tests/bluscriptcp/longeval_gen.py --task "$TASK" --prompt-len "$PROMPT_LEN" \
--n "$N" --seed 0 --depths "$DEPTHS" ${HAY:+--haystack "$HAY"} \
--out-dir "$OUT/prompts" \
>>"$M" 2>&1 || { say " FAILED to generate $TASK"; GEN_OK=0; continue; }
fi
# exact stride: the C++ reader slices the stream at N x PROMPT_LEN x 4 bytes
WANT=$(( N * PROMPT_LEN * 4 ))
GOT=$(stat -c %s "$OUT/prompts/${TASK}_prompts.bin" 2>/dev/null || echo 0)
[ "$GOT" = "$WANT" ] || { say " BAD SIZE $TASK: ${GOT}B want ${WANT}B"; GEN_OK=0; }
done
# Only claim the signature if every task really produced prompts, else the next
# run would match it and never retry the missing ones.
[ "$GEN_OK" = 1 ] && echo "$SIG" > "$OUT/prompts/.signature"
# ---- 2. generate (fanned out over GPUs) -------------------------------------
# Each (checkpoint, task) pair is one independent job needing NP GPUs, so the
# GPU list is used for THROUGHPUT: slots = nGPU / NP jobs run concurrently.
# 24 pairs (4 ckpts x 6 tasks) on 8 GPUs at NP=1 is 8-way parallel.
IFS=',' read -r -a GPUARR <<< "$GPUS"
NGPU=${#GPUARR[@]}
# One job needs ~24.3 GB (measured) of an 80 GB card and drives it to only ~42%
# util -- B=1 decoding of a 1.06B model cannot saturate an H100, and memory is
# nowhere near the limit. So oversubscribe: JOBS_PER_GPU>1 runs several jobs per
# card and buys ~1.8x at 2/GPU. 3/GPU is ~73 GB of 81 -- it fits but leaves
# little headroom, so it is opt-in rather than the default.
JOBS_PER_GPU=${JOBS_PER_GPU:-1}
SLOTS=$(( (NGPU / NP) * JOBS_PER_GPU )); [ "$SLOTS" -lt 1 ] && SLOTS=1
say "fan-out: ${NGPU} GPU(s), NP=$NP -> $SLOTS concurrent job(s)"
jobs=()
for NAME in $CKPTS; do
F="${CKPT_FILE[$NAME]:-}"
[ -n "$F" ] || { say "unknown checkpoint key: $NAME"; continue; }
[ -f "$CK/$F" ] || { say "SKIP $NAME: $CK/$F not present"; continue; }
for TASK in $TASKS; do
# Skip on .gen, not .done: .done is written only by the scoring loop, which
# runs after `wait` for ALL generation, so a teardown mid-run would otherwise
# lose every completed pair. Safe because a signature change clears state/.
[ -f "$OUT/state/${NAME}.${TASK}.gen" ] && { say " $NAME/$TASK already generated"; continue; }
[ -f "$OUT/prompts/${TASK}_prompts.bin" ] || { say " SKIP $NAME/$TASK: no prompts"; continue; }
jobs+=("$NAME:$TASK")
done
done
say "${#jobs[@]} job(s) to run"
running=0; g=0
for J in "${jobs[@]:-}"; do
[ -z "$J" ] && continue
NAME=${J%%:*}; TASK=${J##*:}
F="${CKPT_FILE[$NAME]}"
PB="$OUT/prompts/${TASK}_prompts.bin"
G="$OUT/gen/${NAME}_${TASK}.csv"
# g cycles over SLOTS, which now EXCEEDS the GPU count when JOBS_PER_GPU>1,
# so the device offset must wrap modulo NGPU -- otherwise slots beyond the
# last GPU would compute an empty CUDA_VISIBLE_DEVICES and every job there
# would silently land on device 0.
dev_off=$(( (g * NP) % NGPU ))
devs=$(IFS=,; echo "${GPUARR[*]:$dev_off:$NP}")
say " [gpu $devs] $NAME / $TASK"
(
env $ARCH CP_SIZE=$NP CP_T=$T CP_B=1 YARN_SCALE=$YARN \
${BF16:+CP_BF16=$BF16} \
${YARN_ORIG:+YARN_ORIG_MAXPOS=$YARN_ORIG} \
CP_INIT_FROM="$PWD/$CK/$F" \
CP_GENERATE=1 CP_GEN_TOKENS_BIN="$PWD/$PB" \
CP_GEN_PROMPT_LEN=$PROMPT_LEN CP_GEN_N=$N CP_GEN_MAX_NEW=$MAX_NEW \
CP_GEN_GREEDY=$GREEDY CP_GEN_OUT="$PWD/$G" \
CP_GEN_MIN_TOKENS=${MINTOK[$TASK]:-3} \
CUDA_VISIBLE_DEVICES=$devs \
mpirun --allow-run-as-root -np $NP "$EXE" \
> "$OUT/gen/${NAME}_${TASK}.log" 2>&1 \
&& [ -s "$G" ] && touch "$OUT/state/${NAME}.${TASK}.gen"
) &
g=$(( (g + 1) % SLOTS )); running=$(( running + 1 ))
if [ "$running" -ge "$SLOTS" ]; then wait -n; running=$(( running - 1 )); fi
done
wait
# ---- 2b. score (cheap, CPU-only, after all generation) ----------------------
for NAME in $CKPTS; do
for TASK in $TASKS; do
G="$OUT/gen/${NAME}_${TASK}.csv"
MK="$OUT/state/${NAME}.${TASK}.done"
[ -f "$MK" ] && continue
if [ ! -s "$G" ]; then
[ -f "$OUT/gen/${NAME}_${TASK}.log" ] && \
say " NO OUTPUT $NAME/$TASK -- see $OUT/gen/${NAME}_${TASK}.log"
continue
fi
# A non-empty file is NOT a complete one. If the C++ died partway (e.g. a
# short read on a stale prompt file) it still flushed the rows it managed,
# and scoring those would report an accuracy over a handful of prompts and
# then mark the pair .done. Note: `$(grep -c . f || echo 0)` is WRONG --
# grep -c prints 0 AND exits 1, so the substitution yields $'0\n0' and
# [ -eq ] throws. stat above is fine; it prints nothing on failure.
ROWS=$(grep -c . "$G" 2>/dev/null); ROWS=${ROWS:-0}
if [ "$ROWS" -ne "$N" ]; then
say " INCOMPLETE $NAME/$TASK ($ROWS/$N rows) -- not scoring, not marking done"
continue
fi
say " scoring $NAME / $TASK"
$PY Tests/bluscriptcp/longeval_score.py --gen "$G" \
--meta "$OUT/prompts/${TASK}_meta.json" \
--out "$OUT/scores/${NAME}_${TASK}.csv" --show 2 | tee -a "$M"
touch "$MK"
done
done
# ---- 3. summary -------------------------------------------------------------
say "building summary"
# TASKS must come through argv: the heredoc is QUOTED (<<'PY'), so $TASKS would
# never expand inside it.
$PY - "$OUT" "$TASKS" <<'PY' | tee -a "$M"
import csv, math, os, sys, collections
out, tasks = sys.argv[1], sys.argv[2].split()
d = os.path.join(out, "scores")
# Filenames are <model>_<task>.csv and BOTH halves contain underscores, so
# fn.split("_", 1) mis-parses every arm ("arm_b_niah_single" -> "arm").
# Match the known task list from the tail instead; for/else skips non-matches so
# `name` cannot silently carry over from the previous iteration.
per_task = collections.defaultdict(dict) # task -> model -> (hits, n)
per_depth = collections.defaultdict(dict) # depth -> model -> (hits, n)
per_task_depth = collections.defaultdict(dict) # (task,depth) -> model -> (hits,n)
for fn in sorted(os.listdir(d)) if os.path.isdir(d) else []:
if not fn.endswith(".csv"):
continue
for task in tasks:
if fn.endswith("_" + task + ".csv"):
name = fn[:-(len(task) + 5)]
break
else:
continue
rows = list(csv.DictReader(open(os.path.join(d, fn))))
if not rows:
continue
per_task[task][name] = (sum(int(r["ok"]) for r in rows), len(rows))
for r in rows:
dep = float(r["depth"])
h, n = per_task_depth[(task, dep)].get(name, (0, 0))
per_task_depth[(task, dep)][name] = (h + int(r["ok"]), n + 1)
if not per_task:
print(" no scores yet"); raise SystemExit
models = sorted({m for t in per_task.values() for m in t})
pct = lambda hn: 100.0 * hn[0] / hn[1] if hn[1] else float("nan")
print("\n=== per-task accuracy % (secondary/descriptive: ~10 per depth cell) ===")
print(" " + "task".ljust(18) + "".join(m.rjust(9) for m in models))
for task in sorted(per_task):
print(" " + task.ljust(18) + "".join(
(f"{pct(per_task[task][m]):8.1f}" if m in per_task[task] else " -").rjust(9)
for m in models))
# Degenerate-task exclusion, pre-registered: a task at <=1% overall for EVERY
# model adds n without information and would shrink the pooled CI spuriously.
degenerate = {t for t in per_task
if all(pct(per_task[t][m]) <= 1.0 for m in per_task[t])}
if degenerate:
print(f"\n degenerate (<=1% for every model): {sorted(degenerate)}")
# If EVERY task is degenerate the exclusion would empty the primary table and
# report nothing at all -- a floor effect is a result, not an absence of one.
# Fall back to pooling everything and say so, rather than printing a blank table.
if len(degenerate) == len(per_task):
print(" ALL tasks degenerate -> FLOOR EFFECT. Pooling everything anyway;")
print(" differences between models are untestable at this setting (no power).")
degenerate = set()
for (task, dep), byname in per_task_depth.items():
if task in degenerate:
continue
for name, (h, n) in byname.items():
ph, pn = per_depth[dep].get(name, (0, 0))
per_depth[dep][name] = (ph + h, pn + n)
def wilson(h, n):
"""95% Wilson half-width in points -- honest near 0, unlike the normal approx."""
if not n:
return float("nan")
z, p = 1.96, h / n
return 100.0 * z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
print("\n=== PRIMARY: accuracy % pooled by depth across tasks (+-95% Wilson) ===")
print(" " + "depth".ljust(8) + "".join(m.rjust(16) for m in models))
for dep in sorted(per_depth):
cells = []
for m in models:
if m in per_depth[dep]:
h, n = per_depth[dep][m]
cells.append(f"{pct((h,n)):6.1f}+-{wilson(h,n):4.1f}".rjust(16))
else:
cells.append("-".rjust(16))
print(" " + f"{dep:<8.2f}" + "".join(cells))
with open(os.path.join(out, "summary.csv"), "w", newline="") as f:
w = csv.writer(f); w.writerow(["task"] + models)
for task in sorted(per_task):
w.writerow([task] + [f"{pct(per_task[task][m]):.1f}" if m in per_task[task] else ""
for m in models])
with open(os.path.join(out, "depth_summary.csv"), "w", newline="") as f:
w = csv.writer(f); w.writerow(["depth", "model", "hits", "n", "pct", "wilson95"])
for dep in sorted(per_depth):
for m in models:
if m in per_depth[dep]:
h, n = per_depth[dep][m]
w.writerow([dep, m, h, n, f"{pct((h,n)):.1f}", f"{wilson(h,n):.1f}"])
print(f"\n wrote {out}/summary.csv and {out}/depth_summary.csv")
PY
say "DONE -> $OUT/summary.csv"
|