Skip to content

Snakemake orchestration for the real-data pipeline (design for discussion) #848

Description

@cailmdaley

Snakemake orchestration for the real-data pipeline

Summary

Replace the shell-script orchestration of real-data ShapePipe runs (run_job_sp_canfar_v2.0.bashjob_sp_canfar_v2.0.bash, plus per-site sbatch reimplementations) with a Snakemake workflow in this repo. Rules call shapepipe_run -c <config> on the existing config chains, at the granularities the bash layer already uses; Snakemake's file-based DAG takes over the state the bash layer hand-rolls — completeness counts, done-logs, flock dedup, retry/force. Module code stays largely untouched. Once the DAG is explicit, "why did this tile fail, what is safe to rerun" become answerable instead of archaeological. Living PRD; the eventual PR just says "implements #848." Please keep poking holes.

Pipeline shape

🟦 tile-level 🟧 exposure-level (shared across tiles) 🟪 index 🟩 cleanup

flowchart TD
    subgraph S1["invocation 1 · PREPARE — one chain per tile (×~7300 at DR6)"]
        Git["tile_get_images<br/>(download)"] --> Uz[uncompress weight] --> Fe["find_exposures<br/>(read header)"]
    end
    subgraph S3["invocation 2 · COMPUTE"]
        IX[("parse time: dedup union of<br/>exposure lists → index.sqlite")]
        subgraph EX["one chain per DEDUPED exposure (×~20k) — each built once, shared by ~4 tiles"]
            Gie[exp_get_images] --> Sp[split into 40 CCDs] --> Ma[exp_mask] --> Psf["exp_psf: SExtractor → setools → PSFEx → PSF interp"]
        end
        subgraph TQ["one chain per tile (×~7300)"]
            Mh[tile_merge_headers] --> Sxt["tile_detect<br/>(SExtractor)"] --> Vi["tile_vignets<br/>(PSF interp + stamps)<br/>🗑 vignettes after ngmix"]
            Vi --> Ng1["ngmix chunk 1"]
            Vi --> Ngd["⋯"]
            Vi --> NgN["ngmix chunk N"]
            Ng1 --> Ms["merge_sep_cats — gather<br/>🗑 chunks after merge"]
            Ngd --> Ms
            NgN --> Ms
            Ms --> Mc["make_cat → final_cat ×1 per tile"]
        end
        Vi --> Cl["🗑 clean_exposure ×1 per exposure:<br/>fires when ALL its tiles' vignettes exist"]
    end
    Fe --> IX
    IX -.->|defines the job set| Gie
    Psf -->|"PSF models, stamps"| Vi
    Sp -->|headers| Mh
    Psf -.-> Cl

    classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c
    classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a
    classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c
    classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a
    class Git,Uz,Fe,Mh,Sxt,Vi,Ng1,Ngd,NgN,Ms,Mc tile
    class Gie,Sp,Ma,Psf exp
    class IX idx
    class Cl clean
Loading

Each box is one Snakemake job: one shapepipe_run on one unit (tile or exposure); modules parallelize over CCDs/objects inside the job. Arrows are declared input: edges; the scheduler orders everything from them, including the tile↔exposure fan and the rolling cleanup. Deletion (🗑) rolls forward with the run — each product is removed when its last reader finishes: the exposure store once its tiles' vignettes exist, the vignettes once ngmix has read them, the chunks at the merge (D5). Only clean_exposure is a rule; the rest is temp() bookkeeping by the scheduler, not jobs. The prepare products and final_cat persist.

Design

D1. Execution model: sp run is the entry point

A run is declared by a tile list. sp run brings the products on disk up to date with it. It is two snakemake invocations, because the exposure job set is determined from tile headers and a Snakemake DAG is fixed at parse time:

sp run [--tiles tiles.txt] [--profile nibi] [-n] [snakemake passthrough…]
│
├─ 1. PREPARE   snakemake prepare_all_tiles
│      Static per-tile DAG, known from the tile list alone:
│      download image, unzip weight, find_exposures.
│      Cheap, wide, idempotent — already-prepared tiles are no-ops.
│
└─ 2. COMPUTE   snakemake all
       At parse time: read each tile's exposure list (paths determined by
       the tile list), dedup the union into index.sqlite; tiles whose list
       is missing land in missing.json and are skipped. Then the DAG:
       exposure chains, tile chains, ngmix scatter/gather, final_cat per tile.

Snakemake flags (--jobs, -n, --forcerun, …) pass through to both invocations.

The two invocations are joined by a tile ↔ exposure index, built at parse time and persisted as index.sqlite. It accumulates across invocations, so the clean_exposure consumer sets span the whole campaign, not just the current tile list.

Each exposure is processed exactly once, and cleaned automatically once its tiles have extracted what they need. In miniature — a run requesting tiles A and B, where A's header lists exposures 101, 102 and B's lists 102, 103:

flowchart LR
    subgraph P["PREPARE"]
        TA["tile A prepared"]
        TB["tile B prepared"]
    end
    TA -->|"101, 102"| IX
    TB -->|"102, 103"| IX
    IX[("index:<br/>101, 102, 103")]
    subgraph C["COMPUTE"]
        E1[exp 101 chain] --> A[tile A chain] --> FA[final_cat A]
        E2["exp 102 chain (shared)"] --> A
        E2 --> B[tile B chain] --> FB[final_cat B]
        E3[exp 103 chain] --> B
        A --> C1[clean exp 101]
        A --> C2[clean exp 102]
        B --> C2
        B --> C3[clean exp 103]
    end
    IX -.-> E1
    IX -.-> E2
    IX -.-> E3

    classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c
    classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a
    classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c
    classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a
    class TA,TB,A,B,FA,FB tile
    class E1,E2,E3 exp
    class IX idx
    class C1,C2,C3 clean
Loading

Two tiles in, three exposure chains out (not four — 102 is deduplicated), built once each, read back by their tiles, cleaned once every consuming tile has extracted its stamps (D5).

D2. Rules call shapepipe_run directly — no wrapper

A rule is one shell line against a committed config, on one unit (tile or exposure), at the existing job-bit boundaries:

SP_UNIT_NUM=-2605805 shapepipe_run -c $SP_CONFIG/config_exp_Ma.ini -b {threads} \
    && completeness.py check exp_mask {output}

Everything the bash layer's per-unit machinery did dissolves into three mechanisms ShapePipe already has (verified against the code):

  • Committed configs. Each stage's deterministic output/run_sp_<prefix>/ is the rule's directory() output — Snakemake creates it and deletes it before reruns. Every INPUT_DIR is the literal upstream path (the ngmix configs already work this way), which retires the last: resolver and log_run_sp.txt machinery entirely. NUMBER_LIST = $SP_UNIT_NUM inline.
  • Env interpolation. Configs expand $VARS in every path field and fail loudly on unset ones. SP_CONFIG points at the committed config dir (no symlink), SP_RUN at the unit's work dir, SP_UNIT_NUM carries the unit ID.
  • Existing CLI. -b {threads} already overrides SMP_BATCH_SIZE.

After shapepipe_run exits, completeness.py check counts outputs against the committed floor table, writes the manifest, and exits by the floor (D3). Hand-running a unit is the same shell line. The two per-unit list files (tile_numbers.txt; the per-exposure list, a find_exposures product) are one-line upstream rule outputs. The on-the-fly mask chain runs with USE_EXT_STAR=False; an external-star variant is one committed INPUT_DIR plus a star-cat download rule.

The one worthwhile module addition, optional: a generic --set SECTION.KEY=VALUE on shapepipe_run (~15 lines at the single config chokepoint) as a uniform override surface. The design does not depend on it. The real migration work is the config sweep: ~13 committed configs rewritten with literal INPUT_DIRs, committed RUN_DATETIME, env'd NUMBER_LIST.

No rule declares a glob. Rows marked (phase A) build during PREPARE; everything else runs in COMPUTE.

Both stores are shardedtiles/<2-digit-prefix>/<ID>/, exp/<prefix>/<expbase>/ — so no directory exceeds ~1k entries at full-UNIONS scale. Sharding serves humans and the filesystem's metadata server; every path the DAG uses comes from the index. Prefix digits are chosen for uniformity against the real exposure list.

rule unit config (example/cfis) declared output path
tile_get_images+tile_find_exposures (phase A) tile config_tile_Git_*, Uz, Fe run_sp_tile_Git/, run_sp_tile_Uz/, run_sp_tile_Fe/.../exp_numbers-<IDra>-<IDdec>.txt
(index) run build_index.py, threshold-gated index/run_index.sqlite, index/missing.json
exp_get_images exposure config_exp_Gie_* run_sp_exp_Gie/
exp_split exposure config_exp_Sp.ini run_sp_exp_Sp/
exp_mask exposure config_exp_Ma_onthefly.ini run_sp_exp_Ma/
exp_psf exposure config_exp_psfex.ini run_sp_exp_SxSePsfPi/
tile_exp_forest tile (symlink curation) per-tile $SP_EXP view — convenience, not a DAG edge
tile_merge_headers tile config_tile_Mh_exp.ini .../log_exp_headers-<IDra>-<IDdec>.sqlite
tile_detect tile uc / sx_nomask / sx+onthefly-mask run_sp_tile_<Uc|Sx>/
tile_vignets tile config_tile_PiViVi_*.ini run_sp_tile_PiViVi/
tile_ngmix tile × chunk config_tile_Ng template, env ID range run_sp_tile_ngmix_Ng<k>u/
tile_merge_cats tile config_merge_sep_cats.ini run_sp_tile_msc/
tile_make_cat tile config_tile_Mc_*.ini final_cat-<IDra>-<IDdec>.fits

tile_merge_headers output carries the tile suffix because downstream stages consume it via NUMBERING_SCHEME -000-000. tile_detect has three variants (uc, sx_nomask, sx+onthefly-mask); the config selector carries the choice, and DR6 will standardise on one. The data/image_sims axis is kept, but sims parity is a named follow-up; the first pass targets data.

Per-CCD stages declare one manifest file plus the product directory(). Each job writes a small manifest listing sub-units present/missing with failure reasons parsed from the module logs, checked against the committed threshold table (completeness.py). This keeps legitimate CCD attrition (masked, too few stars) below the declared-output layer, where Snakemake would hard-fail it, and keeps declared paths at DR6 in the thousands rather than millions. DES and Rubin landed on the same shape.

NUMBER_LIST is set only for stages whose NUMBERING_SCHEME is the unit ID; the committed configs for *_get_images/exp_mask/exp_psf omit it (a -<ID> entry fails #746 startup validation there, and a 40-entry expansion would turn tolerated per-CCD attrition into a whole-exposure failure).

DAG edges point at real exposure outputs. Rules that read exposure products (tile_merge_headers, tile_vignets, PSF-interp) declare the exposure directory() outputs as input:; tile_exp_forest builds a per-tile symlink view and points $SP_EXP at it purely so ShapePipe configs use one path — a convenience, never an edge.

D3. Failure handling: count floors

completeness.py check — the second half of every rule's shell line — counts outputs against per-runner floors and exits 0 above the floor, nonzero below. Zero output is always fatal. The thresholds are the ported bash complete_check table, committed in completeness.py (stdlib-only, imported by the check and the report); below is its human rendering.

stage check
tile get_images get_images_runner:4 (2 for sims)
tile uncompress uncompress_fits_runner:1
tile find_exposures find_exposures_runner:1
exp get_images get_images_runner:6 (3 sims)
exp split split_exp_runner:121 (40 CCD × 3 + 1 header)
exp mask mask_runner:40
exp psf sextractor_runner:80 psfex_runner:80 psfex_interp_runner:40::warn setools_runner:80:rand_split
tile merge_headers merge_headers_runner:1
tile detect (sx) sextractor_runner:2
tile detect (uc) get_images_runner:2 read_ext_sexcat_runner:1
tile vignets (data) psfex_interp_runner:1 vignetmaker_runner_run_1:1 vignetmaker_runner_run_2:4 (:3 sims)
tile ngmix ngmix_runner:1
tile make_cat make_cat_runner:1

:warn means a short count does not fail the unit (expected sparse-CCD PSF loss); :subpath counts a nested dir. So exp_psf tolerates <40 PSF-interp CCDs but fails hard on 0-of-40, a short SExtractor/PSFEx count, or a nonzero exit. Tolerated shortfalls pass, and the manifest records them.

Three mechanisms cover the three real situations:

  • Transient (vcp hiccups, node flakiness, OOM): per-rule retries: with attempt-scaled resources (mem_mb = base * attempt). A deterministic failure exhausts retries and fails.
  • Hard (real bug): the job exits nonzero. With keep-going: true, everything independent continues; only the failed downstream cone waits.
  • Below-floor-but-expected (too few stars on some CCDs): absorbed by :warn, recorded in the manifest. No separate class, no whitelist.

Rerun triggers stay at the v9 default {mtime, params, input, software-env, code}, so a changed config, param, or rule shell line reruns its cone natively.

sp report reads the index and the manifests and emits the success/failure tables — per tile, per exposure, per CCD, with a failure-reason column — the successor to summary_tiles.py. It is a standalone verb, runnable at any time including mid-run, so it can describe failures rather than be blocked by them; onsuccess/onerror handlers call it so every invocation ends with one. Job-level statistics (runtimes, provenance) come from Snakemake's own --report.

D4. Execution: the SLURM executor, one profile

Snakemake submits one SLURM job per rule (--executor slurm), each with its rule's exact resources — cpus_per_task = threads, mem_mb, runtime — and each wrapped in apptainer exec by the SDM. SLURM enforces limits per job, so attempt-scaled retries are real requests, and the cluster backfills around our jobs instead of us renting idle cores. The profile's jobs: cap sits at the cluster's per-user submission limit (queried at P1, not invented); snakemake feeds the queue as jobs finish, so DR6's ~170k total jobs never need to queue at once. Multi-node scaling is inherent. This mode ran end-to-end in a probe on nibi; P0's compute ran in a single-allocation variant, and P1 adopts the executor as the standard mode.

Two consequences to manage:

  • Short rules pay queue latency per job. Chunky rules (exp_psf, ngmix) amortize it; the minutes-short ones (uncompress, merges) fuse into their neighbours via group: labels in the profile, tuned at P1/P2. The chains are kept group:-fusable (no mid-chain localrules, no pipe outputs).
  • The orchestrator is a light, long-lived process. The full DR6 DAG parses in ~11 min / ~4 GB, so snakemake runs under tmux on the login node or in a 1-core long-walltime shepherd job. Ctrl-C scancels outstanding jobs (executor behaviour); an orphaned run's jobs are one manual scancel.

threads == SMP fork width. A rule's threads must equal ShapePipe's internal fork width, or the SLURM request decouples from the true process count. Enforced mechanically: every rule passes -b {threads}, so the two are one number by construction. mem_mb = threads × measured per-worker footprint (ngmix ≈ 3–4 GB/worker after #843).

ngmix scatter/gather (the fix for the 13-hour straggler chunk):

  • Chunking uses Snakemake's native scattergather directive. Chunk count is a parse-time constant, overridable with --set-scatter; N=1 recovers one ngmix job per tile.
  • Each chunk's rule computes its ID range at execution time from the tile's own object count: equal shares, closed ranges, no open-ended final chunk (the old root cause: ID_OBJ_MAX=-1 on the last chunk). A params: function cannot do this — params evaluate before the sexcat exists.
  • Chunks write nothing shared; each writes its own run_sp_tile_ngmix_Ng<k>u/, and merge_sep_cats — DAG-serialized after all chunks — is the gather.
  • Thread/arena hygiene: MALLOC_ARENA_MAX=2, MALLOC_TRIM_THRESHOLD_=0, OMP/OPENBLAS/MKL_NUM_THREADS=1.

RNG. Chunking is only safe with SEED_FROM_POSITION=True (#796): each object's RNG derives from its own (ra, dec, ccd), so results are bit-identical under any chunking — N is a pure throughput knob and every rerun reproduces. It becomes the production mode; the tile-seed mode (one ordered stream across the tile, results dependent on chunk boundaries) is retired. Chunking is on by default: smaller per-job memory and disk high-water mark, better packing, N tuned at P1.

benchmark: on the heavy rules (ngmix, exp_psf) gives per-unit wall/RSS/CPU TSVs — the feed for the measured mem_mb values above.

# profiles/nibi/config.yaml
executor: slurm
jobs: <per-user MaxSubmitJobs>       # query the cluster at P1; do not invent
default-resources: {mem_mb: 2000, runtime: 2h, slurm_account: def-mjhudson}
software-deployment-method: apptainer
apptainer-args: "--cleanenv --env OMP_NUM_THREADS=1 --env MALLOC_ARENA_MAX=2 --env PYTHONPATH=<prod worktree>/src --home /home/cdaley --bind /project --bind /scratch"
keep-going: true
show-failed-logs: true
rerun-incomplete: true
latency-wait: 60
# no rerun-triggers override — full default triggers (D3)

Snakemake wraps every job in apptainer exec itself; the workflow never calls apptainer. Rules point container: at the local /project .sif, never docker:// — no registry pull mid-campaign. This retires the hand-rolled in_container machinery.

The apptainer-args line carries the proven container recipe: $HOME for the CADC cert, thread/arena caps (one openblas pool per worker), and the P0 PYTHONPATH pin at a frozen commit; production rebuilds the sif at the validated commit and drops the pin. The committed launcher (workflow/bin/sp) runs module load apptainer before exec-ing snakemake.

CANFAR is a future profiles/canfar/ wrapping the skaha REST API. The Snakefile is unchanged.

D5. Storage and reclamation

Storage placement. Durable, low-volume products (final_cat, the index, reports, .snakemake/) belong on /project, so scratch purge never resurrects finished-tile reruns. Bulk intermediates live on /scratch, each batch sized to finish inside the ~60-day purge. (/project placement is currently blocked by group quota.)

Disk economy. Un-cleaned intermediates measure ~34 GB/tile — at DR6 (~7300 tiles), beyond any scratch allocation — so intermediates are reclaimed during the campaign. Two tiers, split by who sees each product's full consumer set:

  • Intra-tile intermediates → temp(). Their consumer set closes inside one invocation, so native temp() reclaims them when the last reader finishes. --notemp keeps them for debugging.
  • Cross-tile exposure store → in-DAG clean_exposure rules. The index lists every consuming tile in the campaign, so one generated clean_exposure rule per exposure takes every consumer's vignette output — the last stage that reads exposure products; everything after it reads tile-level files — as input:, deletes the exposure's intermediates, and leaves a tombstone. Writer, then readers, then cleaner — DAG-ordered, race-free, rolling mid-run while ngmix still grinds; only exposures at the processing frontier stay pinned. It is temp() semantics applied to the campaign's consumer set instead of one invocation's.

A clean: config flag gates the clean rules; flipping it later reclaims retroactively, since the missing tombstones schedule exactly the clean jobs. A tile appended after its exposures were cleaned reschedules those chains — the accepted cost of a late append.

One Snakemake workdir per output root; invocations strictly sequential. All invocations share provenance metadata, and the workdir lock serializes them; the launcher refuses --nolock. Position-seeded RNG (D4) makes every product bit-reproducible, so an unintended recompute costs compute time, not correctness — -n before a real run shows exactly what a code/params drift would rerun.

D6. Repo layout and migration

workflow/
  Snakefile              # parse-time index load; onsuccess/onerror → sp report; includes rule files
  rules/{prepare,exposure,tile}.smk
  scripts/{build_index.py, build_forest.py, run_report.py, completeness.py}
  bin/sp                 # launcher: module load apptainer → snakemake --profile profiles/nibi
profiles/nibi/config.yaml

Pin snakemake>=9,<10 plus snakemake-executor-plugin-slurm. The v8→v9 breaks are minor here: --use-singularity--sdm, executors became plugins, full rerun-triggers is the default.

The line-count check: four small scripts (index, forest, report, completeness table) plus committed configs replace ~2000 lines across three bash levels, each site's sbatch wrapper, and the run-log machinery. Less code, in one place, testable, with the DAG delegated to a tool whose whole job is DAGs. If an implementation PR does not deliver that, this design has failed.

Phased migration, each phase independently useful, bash layers untouched until Martin retires them (P0/P1 use a separate output tree — no shared exp/ with a bash-driven run during validation):

  1. P0 — prepare + exposure stages on a 4-tile P3 subset. Delivered — see the results comment below.
  2. P1 — full tile chain through final_cat; retire job_p3_batch1.sh for new runs. Validation is statistical parity against the bash baseline: join on object ID, science columns within tolerances — not bit parity (the RNG mode deliberately changes, D4).
  3. P2 — scale via batched invocations sized to scratch purge windows (50-tile → P3 → ~7300 tiles); exercise the count-floor, retries, rerun propagation, and rolling clean_exposure reclamation for real; tune jobs: and group: fusion against measured queue behaviour.
  4. P3 — CANFAR profile.

Out of scope for the first pass (each a named follow-up): image_sims parity (the sims im_pipeline step will call these same per-unit rules — the two pipelines converge on one workflow); the CANFAR profile.

Open questions

  1. Batch sizing for multi-invocation scaling: how many tile-disjoint sky partitions, how large (bounded above by the 60-day purge, below by exposure-overlap waste at batch borders)? Decided at P2.
  2. make_cat over partial tiles: if k of 40 CCDs failed PSF, the tile completes with fewer epochs. Keep that (report records the gaps), or add a quality gate?
  3. ngmix chunk configs: one committed template with env-interpolated ID range ($NGMIX_ID_MIN/$NGMIX_ID_MAX, exported by the chunk rule) should replace generated per-chunk configs entirely — confirm the range fields interpolate.
  4. Repo layout: workflow/ at top level per Snakemake convention, or under scripts/ next to the bash it replaces?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions