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"