Skip to content

Make lc seamless on NERSC Perlmutter - #194

Open
EiffL wants to merge 4 commits into
mainfrom
nersc-seamless
Open

Make lc seamless on NERSC Perlmutter#194
EiffL wants to merge 4 commits into
mainfrom
nersc-seamless

Conversation

@EiffL

@EiffL EiffL commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes the gaps the Perlmutter spike found (#192), so a researcher on NERSC can lc init and lc materialize with an empty environment and nothing to configure. Every change here was verified by running it on Perlmutter, on a 2-node interactive allocation.

The problems, and the fixes

uv's home-filesystem state is unusable from compute nodes — in two independent ways. flock on $HOME fails outright (os error 524), so every uv-touching verb died until the user exported UV_CACHE_DIR. And an interpreter stored there starts slowly enough to blow uv's own 60 s ceiling and fail a sync mid-run with Python startup timed out. Measured on the same compute node, 5 runs each:

interpreter store python -c pass
$SCRATCH (Lustre) 0.22, 0.06, 0.05, 0.05, 0.05 s
$HOME (GPFS) 9.21, 1.22, 1.38, 1.08, 0.41 s

So venue.site_env() supplies both UV_CACHE_DIR and UV_PYTHON_INSTALL_DIR from $SCRATCH/.lightcone/ on a known center — spec §4's "the site registry supplies UV_CACHE_DIR on Perlmutter" row, finally made real, and _UV_KEPT already kept both variables for exactly this story. Per variable, ambient always wins. Silent by design: a default doing its job is not a warning. Deterministic in the ambient environment, so every node of an allocation derives the same answer with nothing handed down.

An ambient MOUNT_HOME=1 silently bind-mounted $HOME into every recipe container while the manifest attested fs: declared. podman-hpc's site modules are env-gated, so child_env now scrubs MOUNT_*. ENABLE_* is deliberately kept — those bind CUDA and MPI system libraries, the same generosity class as the /dev,/sys grants, and a GPU recipe on Perlmutter reaches CUDA through exactly that mechanism. One predicate in the one env composer covers both wrapper-spawn paths, so no Backend protocol change was needed. uv_scrub_warningscrub_warning, naming both families.

Rebuilding an image under its own tag wedged the shared squash store. The tag is deterministic but builds are not bit-reproducible, so a rebuild migrated under an unchanged tag put a second same-named image into podman-hpc's read-only store — after which every storage operation failed, runs included. container._heal_squash probes and rmsqis the stale copy before the load (a wedged store fails the load too). An unrecognized probe outcome is left for migrate's own loud path, so the heal can never break a healthy run.

Every clean SLURM run ended looking like a crashsrun: forcing job termination, task N: Killed, and scheduler lose-tasks warnings. Cause: the driver held every future for the run's lifetime, so retire_workers saw each key as client-wanted and tried to replicate it onto peers that were also retiring, stalling past the reap grace until srun was killed. _Dask.completed now releases each future as its result lands. Warnings on an unclean path still fire, honestly.

Also: the containerized sync now creates uv's cache directory before bind-mounting it — in that mode uv never runs on the host, so nothing else makes it and podman died with a raw statfs error about a path the user never named (latent before; the cache supply made it reachable). And the Landlock downgrade note names the boot-time LSM list first: Perlmutter is kernel 6.4 with Landlock simply absent from that list, so the old text offered three causes and none of them applied.

Verification

Live, on nid[004167-004168]:

Check Result
lc init on a compute node, empty environment works — the flock wall is gone; cache and interpreters land on $SCRATCH/.lightcone/
Project venv interpreter startup 0.04–0.05 s (was 0.4–9.2 s)
Direct 4-universe multi-node materialize outputs spread across both nodes, clean tree, zero teardown noise
Containerized multi-node materialize in-image on both nodes, mechanism: podman-hpc, fs: declared, image id pinned
Squash heal a manufactured duplicate-name wedge (which failed every podman operation) healed and ran clean; it also fired naturally on a later rebuild
MOUNT_HOME=1 lc run ls $HOME denied + the warning naming the drop. Mutation-checked: bare podman-hpc with the same variable does mount it
Fresh containerized first run, nothing exported lc initlc materialize → committed result in 61 s, clean tree

The future-release fix also removed a suite flake and tripled suite speed on a compute node — same suite, same node, only the interpreter store moved: 9 failed / 42:16 → 568 passed, 0 failed / 13:45.

Notes for reviewers

  • conftest scrubs NERSC_HOST suite-wide (deliberately — the suite must exercise the non-site path), so the tests do not get the interpreter fix automatically. Anyone running pytest on a NERSC compute node should export UV_PYTHON_INSTALL_DIR=$SCRATCH/.lightcone/uv-python or expect the flake; real users get it from child_env.
  • project now imports venue lazily inside child_env (venue imports ProjectError at module scope). The alternatives each broke a recorded invariant — moving the site table would contradict "supporting another center is one row and nothing else moves", and merging at call sites would spread env composition to three places.
  • --ignore=tests/test_container_smoke.py silently does nothing in this repo (testpaths wins); --deselect is the spelling that works.
  • Out of scope by decision, unchanged: the smoke suite's _inspect_id KeyError: 'podman-hpc' and the bare-podman-on-network-filesystem failures (both from Perlmutter spike: login-node findings (layer 7) #192's first half), and OMP_NUM_THREADS, which is documented rather than touched — lc does not take over a variable it does not own.

Docs land with the code: a "NERSC (Perlmutter) specifics" section in the cluster guide, the duplicate-squash entry in troubleshooting, and four API pages. CLAUDE.md carries the decisions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DyNDEbtjpob2CJSQMnEiuf

The Perlmutter spike (#192) proved both materialize modes work
multi-node, then found four things that made real use rough. Each is
fixed here and verified by running it on the machine.

The site registry supplies uv's plumbing. On a known center, unset
UV_CACHE_DIR and UV_PYTHON_INSTALL_DIR are filled from
$SCRATCH/.lightcone/ — spec §4's row, made real. Both move state off a
home filesystem that measurement found unusable from compute nodes, in
two independent ways: flock fails there outright (os error 524), which
killed every uv-touching verb, and an interpreter stored there starts in
0.4–9 s rather than 0.05 s, enough under load to exceed uv's own 60 s
ceiling and fail a sync mid-run. Per variable, ambient always wins;
silent by design, and deterministic in the ambient environment, so every
node of an allocation derives the same answer with nothing handed down.

MOUNT_* is scrubbed; ENABLE_* is kept. podman-hpc's site modules are
env-gated, and the mount gates bind $HOME/$SCRATCH/$CFS into every
recipe container — undeclared inputs under a manifest attesting
fs: declared. The library gates stay: CUDA and MPI system libraries are
the /dev,/sys generosity class, and the site's own GPU mechanism has to
keep working. One predicate in the one env composer covers both
wrapper-spawn paths; uv_scrub_warning becomes scrub_warning and names
both families.

A stale squashed image is healed before the load. The tag is
deterministic and builds are not bit-reproducible, so a rebuild migrated
under an unchanged tag put a second same-named image into podman-hpc's
read-only squash store, after which every storage operation failed.
An unrecognized probe outcome is left for migrate's own loud path, so
the heal cannot break a healthy run.

The driver releases each future as its result is consumed. A key still
client-held at teardown makes retire_workers replicate it onto peers
that are also retiring, so the run stalled past the reap grace and srun
was killed: every clean SLURM run ended in "srun: forcing job
termination". Warnings on an unclean path still fire.

Also: the containerized sync creates uv's cache directory before
bind-mounting it (nothing on the host makes it in that mode, so podman
died with a raw statfs error about a path the user never named), and the
Landlock downgrade note names the boot-time LSM list first — Perlmutter
is kernel 6.4 with Landlock simply absent from it.

Verified on a 2-node interactive allocation: a fresh project with an
empty environment goes from lc init to a committed result with no setup;
direct and containerized multi-node runs spread across both nodes and
end silently; a manufactured duplicate-name wedge healed and ran clean.
Full suite on a compute node: 568 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyNDEbtjpob2CJSQMnEiuf
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

✅ Eval

Metric Value
Outputs check success
Agent run success
Turns 29
Tool calls 27
Cost $0.72
Agent wall time 2m13s
Model claude-sonnet-5
lc status
  mode:    direct
  sandbox: landlock (fs: declared, network: allowed)
  crate:   up to date with the outputs

  · current  baseline/best_fit        1c4293c
  · current  baseline/hubble_diagram  1c4293c
  · current  baseline/residuals       1c4293c

3 current
Confusion & pain points (Claude analysis)

Confusion & pain points

  • The initial recon command failed (exit 2) because it chained ls universes/, cat data/..., ls scripts/, git status, and lc status together in one && pipeline — lc status (or an earlier segment) errored out and the agent lost visibility into the tail of the output (git status/lc status results were truncated/never seen). It had to re-run a second, similar combined probe to get pyproject.toml, git status, and lc status output cleanly. Batching many unrelated diagnostic commands with && is fragile; separate calls (or ;) would have surfaced the actual failure instead of masking it.
  • The agent spent two full tool calls hand-rolling awk/grep/sort sanity checks against the raw data file (counting unique values, checking min/max redshift, verifying the z-cut boundary) rather than trusting the spec/data README or writing this into the analysis script itself — a manual data-exploration detour outside any tool the harness provided for inspecting declared inputs.
  • No shared-module mechanism exists in the recipe grammar for common code (e.g., the LCDM distance-modulus model used by the fit and both plotting scripts), forcing the agent to duplicate the model function verbatim across fit.py, plot_hubble.py, and plot_residuals.py. This is a real spec/product gap worth flagging: recipes can't import a project-local helper module cleanly within the declared input/output contract, so any shared logic must be copy-pasted.
  • The license/publication-view mechanic ([project].license in pyproject.toml silently gating RO-Crate generation) was only discovered after the first lc materialize, which printed a ! warning about it — the agent hadn't anticipated this from the spec and had to make a second commit+materialize pass specifically to enable publication. This is a legitimate two-pass workflow given how the tool surfaces it, but it wasn't discoverable up front from astra.yaml or the scaffold docs.
  • Otherwise the run was clean: no failed lc/astra/uv/git commands past the initial recon hiccup, no sandbox denials, and astra validate / lc materialize --check / lc status all passed on the first real attempt.

Full trace: agent-trace artifact on this run.

EiffL and others added 3 commits August 21, 2026 08:29
Review findings from the NERSC work, both verified against the real
podman-hpc on Perlmutter.

Site modules are named in the attestation. The MOUNT_*/ENABLE_* split
kept ENABLE_* on the stated ground that those modules bind system
libraries only, "never a channel undeclared inputs arrive through" —
measurably false: ENABLE_CVMFS binds the whole populated /cvmfs
hierarchy, which is reference data a recipe can read without declaring
it, and ENABLE_MPICH_SS adds --privileged plus the host's network, pid
and ipc namespaces, after which the mount table is not the boundary at
all. Only ENABLE_GPU and ENABLE_NCCL* fit the original description.

They stay working — they are how a GPU or MPI recipe reaches the
hardware it was written for — so the honesty comes from the record:
container.site_modules() names the gates that are set, OCIBackend
carries them as a resolved field (the user_flags pattern, so wrap stays
pure), and Attestation.site_modules puts them in every manifest. It is
the one attestation value not derived from lc's own argv, because the
runtime applies them from its own environment, and the docstring says
so. A per-module allowlist was rejected: the module table is
site-configurable, so a center adding a data-binding module lc has
never heard of would slip through a hardcoded judgement.

The squash heal removes by id. `rmsqi <tag>` resolves a single record
(remove_image → get_img_info takes the first name match), so against
the two-same-name state being healed it could take the current image
and leave the stale one to re-wedge the store on the next migrate. Each
stale id is now removed by its own call; only the wedged branch spells
the tag, because a store that cannot be listed cannot be enumerated,
and it re-probes bounded by _HEAL_ATTEMPTS since each blind pass is a
real deletion.

Also: creating uv's cache directory raises ProjectError rather than a
bare OSError, which no caller translates — a full quota or an
unwritable UV_CACHE_DIR would have surfaced as a traceback out of
lc materialize instead of a refusal naming the path.

Recorded rather than fixed: the heal can drop layers a concurrently
running allocation is still executing from (leaving the store wedged
breaks that run too, and cross-run coordination is not something a heal
can offer).

Verified: a real containerized run with ENABLE_CVMFS=1 ENABLE_GPU=1
records both in hermeticity.site_modules, and `lc run ls /cvmfs` does
list the repositories — the widening is real and the manifest now says
so. 573 passed on a compute node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyNDEbtjpob2CJSQMnEiuf
CI caught the attestation naming a module the host does not have:
matching `ENABLE_*` picked up GitHub's own ENABLE_RUNNER_TRACING, so a
manifest would have recorded a site module that was never applied —
the exact dishonesty `site_modules` exists to prevent, pointed the
other way.

The gates are asked of the mechanism instead of guessed. Each module in
podman-hpc's table declares its own `env:` key, and `podman-hpc infohpc`
reports where the table lives (a site can move it), so lc reads the real
gate names and keeps the ones the environment sets. That is the same
probe-don't-assume rule `git check-attr` and `uv cache dir` already
follow.

A table that cannot be read reports nothing rather than refusing, the
uv_version discipline: attestation must not fail a run.

Verified against the live table on Perlmutter — ENABLE_CVMFS and
ENABLE_GPU recorded, ENABLE_RUNNER_TRACING ignored, MOUNT_HOME absent
because child_env scrubbed it. The tests now stub a module table of
their own, so they no longer depend on the host's environment: that
dependency is why this passed locally and failed on all four CI legs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaQvhUZHHrGCMZxKLdAfgR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant