feat(argo): run-sweep template + batched-mode in argo-lob-sweep.sh

Closes the operational gap from P6: when a sweep grid YAML carries
sim_variants, argo-lob-sweep.sh now emits ONE run-sweep-batched task
instead of N fan-out run-cell tasks. The new run-sweep template
invokes `fxt-backtest sweep` against the full grid (base64-encoded
inline as Argo parameter to avoid YAML special-char encoding traps).

The binary's P6 sweep() function handles cell × variant fan-out
internally via BatchedSimConfig::from_grid, so one pod processes all
4 windows × 140 variants sequentially. Trade-off: no inter-window
parallelism in this rev (4 quarters sequential in one pod ≈ firm-bound
2h wall per spec §9). 3-pod scale-out is P7 future work — needs the
script to split the YAML into per-window sub-grids and emit one
run-sweep task per sub-grid.

Backward-compat: legacy (no sim_variants) flow unchanged. The dry-run
of sweep_smoke.yaml continues to emit run-cell-* fan-out tasks; the
dry-run of sweep_deployability.yaml emits one run-sweep-batched task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-19 18:15:22 +02:00
parent b1f8cd4389
commit ba2850b448
2 changed files with 114 additions and 18 deletions

View File

@@ -307,6 +307,64 @@ spec:
echo "=== cell $CELL done ==="
ls -lh "$OUT"
# ── run-sweep: P6 batched flow. Invokes `fxt-backtest sweep` ───
# against the FULL grid YAML inside a single GPU pod. When the YAML
# carries sim_variants, each cell expands into n_parallel=variants.len()
# backtests sharing one forward pass. argo-lob-sweep.sh emits ONE
# run-sweep task (instead of N fan-out run-cell tasks) when --batched
# is set or sim_variants is detected in the YAML.
- name: run-sweep
inputs:
parameters:
- name: sha
- name: grid-yaml-b64 # base64-encoded grid YAML, written to a file in-pod
nodeSelector:
k8s.scaleway.com/pool-name: "{{workflow.parameters.gpu-pool}}"
topology.kubernetes.io/zone: fr-par-2
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
container:
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/foxhunt-training-runtime:latest
imagePullPolicy: Always
command: ["/bin/bash", "-c"]
resources:
requests:
cpu: "4"
memory: 16Gi
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: 32Gi
nvidia.com/gpu: "1"
volumeMounts:
- name: training-data
mountPath: /mnt/training-data
- name: feature-cache
mountPath: /feature-cache
args:
- |
set -e
SHA="{{inputs.parameters.sha}}"
BIN="/mnt/training-data/bin/$SHA/fxt-backtest"
OUT="{{workflow.parameters.sweep-root}}/{{workflow.parameters.sweep-tag}}"
mkdir -p "$OUT"
# Write the grid YAML to a known path. base64 in/out keeps Argo
# parameter encoding stable across YAML special characters
# (curly braces from data_template + sim_variants).
GRID_YAML=/tmp/sweep-grid.yaml
echo "{{inputs.parameters.grid-yaml-b64}}" | base64 -d > "$GRID_YAML"
echo "=== running fxt-backtest sweep on $(hostname) → $OUT ==="
echo "=== grid yaml: ==="
head -20 "$GRID_YAML"
echo "=== ... ==="
"$BIN" sweep --grid "$GRID_YAML" --out "$OUT"
echo "=== sweep complete ==="
ls -lh "$OUT"
# ── aggregate: runs fxt-backtest aggregate on the GPU pool. ───
# The binary is dynamically linked against libcuda.so.1, which the
# ci-compile-cpu pool's host doesn't expose; the aggregate workload

View File

@@ -104,10 +104,11 @@ if [[ ! -f "$TPL" ]]; then
fi
RENDER_PY=$(python3 - "$GRID" <<'PYEOF'
import json, os, sys, yaml
import base64, json, os, sys, yaml
with open(sys.argv[1], "r") as f:
grid = yaml.safe_load(f)
raw_yaml = f.read()
grid = yaml.safe_load(raw_yaml)
base = grid.get("base") or {}
cells = grid.get("cells") or []
@@ -118,11 +119,23 @@ if not cells:
def base_or(key, default):
return base.get(key, default)
# P6 batched-flow detection: when base.sim_variants is non-empty, the
# binary handles cell+variant fan-out internally via BatchedSimConfig::from_grid.
# We emit ONE Argo `run-sweep` task that invokes `fxt-backtest sweep`
# against the full grid. For inter-window parallelism (3-pod scale,
# P7-future), split the YAML into per-window sub-grids and emit one
# run-sweep task per sub-grid.
sim_variants = base.get("sim_variants") or []
batched = len(sim_variants) > 0
out = {
"n_cells": len(cells),
"batched": batched,
"base": {
"data": base_or("data", ""),
"predecoded_dir": base.get("predecoded_dir") or base.get("data", ""),
# `data` may be absent under the batched flow (replaced by
# `data_template`); legacy path uses scalar `data`.
"data": base_or("data", "") or "",
"predecoded_dir": base.get("predecoded_dir") or base.get("data") or "",
"n_parallel": base_or("n_parallel", 1),
"latency_ns": base_or("latency_ns", 100_000_000),
"target_annual_vol_units": base_or("target_annual_vol_units", 50.0),
@@ -137,20 +150,38 @@ out = {
# Render per-cell WorkflowTask stanzas + collect dependency names.
tasks = []
deps = []
for c in cells:
name = c.get("name")
if not name:
print("ERROR: cell missing 'name'", file=sys.stderr)
sys.exit(1)
stride = c.get("decision_stride", base_or("decision_stride", 4))
latency = c.get("latency_ns", out["base"]["latency_ns"])
target_vol = c.get("target_annual_vol_units", out["base"]["target_annual_vol_units"])
ann_factor = c.get("annualisation_factor", out["base"]["annualisation_factor"])
max_lots = c.get("max_lots", out["base"]["max_lots"])
max_events = c.get("max_events", out["base"]["max_events"])
seed = c.get("seed", out["base"]["seed"])
if batched:
# Single run-sweep task: binary processes all cells × variants in one pod.
# base64-encode the full grid YAML so Argo parameter encoding doesn't
# trip on YAML-special characters (data_template's `{window}`,
# sim_variants' braces, comments, etc.).
grid_b64 = base64.b64encode(raw_yaml.encode("utf-8")).decode("ascii")
tasks.append(f'''
- name: run-sweep-batched
template: run-sweep
dependencies: [ensure-binary]
arguments:
parameters:
- name: sha
value: "{{{{tasks.ensure-binary.outputs.parameters.sha}}}}"
- name: grid-yaml-b64
value: "{grid_b64}"''')
deps.append("run-sweep-batched")
else:
for c in cells:
name = c.get("name")
if not name:
print("ERROR: cell missing 'name'", file=sys.stderr)
sys.exit(1)
stride = c.get("decision_stride", base_or("decision_stride", 4))
latency = c.get("latency_ns", out["base"]["latency_ns"])
target_vol = c.get("target_annual_vol_units", out["base"]["target_annual_vol_units"])
ann_factor = c.get("annualisation_factor", out["base"]["annualisation_factor"])
max_lots = c.get("max_lots", out["base"]["max_lots"])
max_events = c.get("max_events", out["base"]["max_events"])
seed = c.get("seed", out["base"]["seed"])
tasks.append(f'''
- name: run-cell-{name}
template: run-cell
dependencies: [ensure-binary]
@@ -174,7 +205,7 @@ for c in cells:
value: "{max_events}"
- name: seed
value: "{seed}"''')
deps.append(f"run-cell-{name}")
deps.append(f"run-cell-{name}")
out["cell_tasks"] = "".join(tasks)
out["aggregate_deps"] = ", ".join(["ensure-binary", *deps])
@@ -188,6 +219,7 @@ export SHORT_SHA SWEEP_TAG
# Extract render outputs via python -c (no jq dependency).
N_CELLS=$(echo "$RENDER_PY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["n_cells"])')
BATCHED=$(echo "$RENDER_PY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["batched"])')
BASE_DATA=$( echo "$RENDER_PY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["data"])')
BASE_PREDECODED=$( echo "$RENDER_PY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["predecoded_dir"])')
BASE_N_PARALLEL=$( echo "$RENDER_PY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["n_parallel"])')
@@ -249,6 +281,12 @@ if [[ "$DRY_RUN" == "true" ]]; then
exit 0
fi
if [[ "$BATCHED" == "True" ]]; then
echo " mode: batched (single run-sweep task, sim_variants packed inside the pod)"
else
echo " mode: legacy fan-out ($N_CELLS run-cell tasks)"
fi
# Apply the rendered template (covers any in-flight template edits) then submit.
echo "$RENDERED" | kubectl apply -f - -n foxhunt
argo submit -n foxhunt "${SUBMIT_ARGS[@]}"