HPC Integration
FlexLock provides seamless integration with HPC cluster schedulers (PBS and Slurm) for running experiments at scale.
Overview
FlexLock’s HPC integration allows you to:
✅ Submit jobs to PBS or Slurm queues with a single parameter
✅ Monitor job status in real-time with
flexlock-status✅ Wait for job completion or submit and continue
✅ Run parameter sweeps across cluster nodes
✅ Use Singularity containers for reproducible environments
✅ Automatic task database for distributed job management
Quick Start
Basic HPC Submission
from flexlock.api import Project
proj = Project(defaults='configs.defaults')
config = proj.get('train')
# Submit to PBS and wait for completion
result = proj.submit(
config,
pbs_config='configs/pbs.yaml',
wait=True
)
print(f"Accuracy: {result.accuracy}")
Non-Blocking Submission
# Submit and continue without waiting
result = proj.submit(
config,
pbs_config='configs/pbs.yaml',
wait=False
)
print("Job submitted")
# Job continues running on cluster
Monitor Job Status
# Real-time monitoring
flexlock-status outputs/train/run.lock.tasks.db --watch
# Check failed tasks
flexlock-status outputs/train/run.lock.tasks.db --failed --verbose
Files Written by an HPC Submission
An HPC submission (and a local isolated=True run) goes through a task
queue. Its files all live inside the submitted save_dir:
File |
Content |
|---|---|
|
SQLite task queue: one row per task with its status, error and full snapshot. Point |
|
YAML dump of the task table, refreshed by the submitter and by the worker when the queue drains. |
|
The run’s receipt (see below). |
|
Scheduler scripts and logs. |
For a single run with save_dir='outputs/train/run_0001' the DB is
outputs/train/run_0001/run.lock.tasks.db. For a sweep it sits in the sweep
root, next to the per-item directories (outputs/sweep/run.lock.tasks.db,
items in outputs/sweep/sweep_0000/, …).
run.lock of a single HPC run
At submit time the executor writes a placeholder run.lock whose config
holds only save_dir and _snapshot_. The real config is resolved on the
compute node (deferred resolvers such as ${run_lock:...} and ${latest:...}
fire there). Since FlexLock 0.8.3 the worker then replaces the placeholder
with the task’s full snapshot (resolved config, fingerprint, git state,
lineage, and the submit-time note). It does this atomically, before the user
function runs. A single HPC run therefore has the same run.lock as a local
run, and ${run_lock:...}, flexlock show, flexlock-diff and
flexlock-run -c run.lock work on it.
A sweep root keeps its placeholder: it has no single config. Each item’s
snapshot is in the task DB, and flexlock show rebuilds it from there.
Runs submitted with FlexLock < 0.8.3 still have a placeholder run.lock.
Readers (${run_lock:...}, show/diff/ls/gc/tag, load_stage,
flexlock-run -c run.lock) fall back to the snapshot stored in
run.lock.tasks.db, so they keep working. To rewrite those files once
(the placeholder is kept as run.lock.placeholder.bak):
flexlock repair-locks results/ --dry-run # list the dirs that would change
flexlock repair-locks results/
or from Python:
from flexlock.run_record import materialize_lock, load_lock_data
load_lock_data("results/train_0005") # effective lock, no side effects
materialize_lock("results/train_0005") # True if run.lock was rewritten
Forced reruns
force=True on a single HPC or isolated submission deletes the run’s
run.complete and its run.lock.tasks.db, then re-queues and re-executes the
task. Before 0.8.3 the old done row in the DB made the forced submit a
silent no-op.
PBS Configuration
Basic PBS Config
Create configs/pbs.yaml:
startup_lines:
- "#PBS -l select=mem=4gb:ncpus=1"
- '#PBS -N my_job'
- '#PBS -l walltime=00:30:00'
- '#PBS -q default'
- '#PBS -V' # Export environment
- 'cd $PBS_O_WORKDIR'
- 'eval "$(pixi shell-hook)"' # Activate environment
python_exe: python
GPU Jobs
startup_lines:
- "#PBS -l select=mem=32gb:ncpus=8:ngpus=1"
- '#PBS -N gpu_training'
- '#PBS -l walltime=12:00:00'
- '#PBS -q gpu'
- '#PBS -V'
- 'cd $PBS_O_WORKDIR'
- 'module load cuda/11.8'
- 'eval "$(pixi shell-hook)"'
python_exe: python
High-Memory Jobs
startup_lines:
- "#PBS -l select=mem=128gb:ncpus=32"
- '#PBS -N big_memory'
- '#PBS -l walltime=48:00:00'
- '#PBS -q highmem'
- '#PBS -V'
- 'cd $PBS_O_WORKDIR'
- 'eval "$(pixi shell-hook)"'
python_exe: python
Slurm Configuration
Basic Slurm Config
Create configs/slurm.yaml:
startup_lines:
- "#SBATCH --mem=4G"
- "#SBATCH --cpus-per-task=1"
- "#SBATCH --time=00:30:00"
- "#SBATCH --partition=default"
- "#SBATCH --job-name=my_job"
- 'cd $SLURM_SUBMIT_DIR'
- 'source activate myenv'
python_exe: python
GPU with Slurm
startup_lines:
- "#SBATCH --mem=32G"
- "#SBATCH --cpus-per-task=8"
- "#SBATCH --gres=gpu:1"
- "#SBATCH --time=12:00:00"
- "#SBATCH --partition=gpu"
- 'cd $SLURM_SUBMIT_DIR'
- 'module load cuda/11.8'
- 'source activate myenv'
python_exe: python
Pre-flight checks: validation and dry-run
FlexLock validates every Slurm submission against a short checklist drawn from the most common silent failures. Each missing piece produces a warning (not an error — power-user setups may intentionally omit them):
Missing |
Symptom on the cluster |
|---|---|
|
Job lands on the default queue, often CPU when GPU was intended |
|
Relative paths in user code resolve against |
Env activation ( |
Imports fail on the compute node |
|
Job runs on CPU silently |
Warnings appear in the submission log:
WARNING flexlock.backends.slurm: Slurm config: No --partition directive
To preview the would-be submission script without sending anything to the
cluster, pass dry_run=True:
proj.submit(cfg, slurm_config='configs/slurm_gpu.yaml', dry_run=True)
Prints the rendered script plus any validation warnings, then returns
None without submitting. Works without a real Slurm cluster — useful
for verifying paths and directives locally before queueing.
# Same from the CLI:
flexlock-run -d configs/defaults.py:defaults -s train \
--slurm-config configs/slurm_gpu.yaml --dry-run
Singularity Containers
Singularity integration happens through the python_exe parameter:
PBS + Singularity
# configs/pbs_singularity.yaml
startup_lines:
- "#PBS -l select=mem=4gb:ncpus=1"
- '#PBS -N container_job'
- '#PBS -l walltime=00:30:00'
- '#PBS -q default'
- '#PBS -V'
- 'cd $PBS_O_WORKDIR'
- 'module load singularity' # If needed
python_exe: >-
singularity run
--bind $(pwd):/workspace
--pwd /workspace
my_env.sif python
Key points:
--bind: Mount host directories into container--pwd: Set working directory inside containermy_env.sif: Your container imagepython: Command to run inside container
Slurm + Singularity
# configs/slurm_singularity.yaml
startup_lines:
- "#SBATCH --mem=4G"
- "#SBATCH --cpus-per-task=1"
- 'cd $SLURM_SUBMIT_DIR'
python_exe: >-
singularity run
--bind $(pwd):/workspace
--pwd /workspace
env.sif python
GPU Containers
python_exe: >-
singularity run --nv
--bind $(pwd):/workspace
--bind /data:/data
gpu-env.sif python
Note: --nv enables NVIDIA GPU access.
Parameter Sweeps on HPC
Run multiple experiments in parallel across cluster nodes:
Basic Sweep
from flexlock.api import Project
proj = Project(defaults='configs.defaults')
config = proj.get('train')
# Define parameter grid
sweep_grid = [
{"lr": 0.001, "epochs": 10},
{"lr": 0.01, "epochs": 10},
{"lr": 0.05, "epochs": 10},
{"lr": 0.1, "epochs": 10},
]
# Submit sweep to HPC
results = proj.submit(
config,
sweep=sweep_grid,
pbs_config='configs/pbs.yaml',
wait=True,
n_jobs=4 # Submit 4 parallel jobs
)
# Find best configuration
best_idx = max(range(len(results)),
key=lambda i: results[i].get('accuracy', 0))
best = results[best_idx]
print(f"Best: lr={best.lr}, accuracy={best.accuracy}")
Large Sweeps
For large sweeps, use job arrays:
# configs/pbs_array.yaml
startup_lines:
- "#PBS -J 0-99" # Job array with 100 tasks
- "#PBS -l select=mem=4gb:ncpus=1"
- '#PBS -l walltime=01:00:00'
- '#PBS -V'
- 'cd $PBS_O_WORKDIR'
python_exe: python
Each array task pulls work from the shared task database.
Wait Behavior
The wait parameter controls whether submit() blocks:
wait=True (Blocking)
# Blocks until job completes
result = proj.submit(
config,
pbs_config='pbs.yaml',
wait=True # Blocks here
)
# Results immediately available
print(result.accuracy)
How it works:
Submit job to cluster
Poll task database every 1 second (configurable)
Log progress every 10 seconds
Return when all tasks complete
Use cases:
Sequential pipelines
Immediate result analysis
CI/CD workflows
wait=False (Non-Blocking)
# Returns immediately
result = proj.submit(
config,
pbs_config='pbs.yaml',
wait=False # Returns immediately
)
print("Job submitted, continuing...")
# result.status == "SUBMITTED"
# Job continues on cluster
Use cases:
Submit many jobs at once
Long-running experiments
Interactive workflows
Monitor later:
flexlock-status outputs/train/run.lock.tasks.db --watch
Monitoring Jobs
Real-Time Status
# Watch job progress
flexlock-status outputs/sweep/run.lock.tasks.db --watch
Output:
============================================================
Task Status Summary
============================================================
Pending: 12
Running: 3
Done: 45
Failed: 2
------------------------------------------------------------
Total: 62
Progress: 75.8% (47/62 completed)
Status: ⏳ In progress
============================================================
Refreshing in 10s... (Ctrl+C to stop)
Job Logs (Slurm)
When a job is submitted via Slurm, stdout and stderr are written to slurm-<JOBID>.out in the directory where the job was submitted (the project root). The calling process does not relay this output.
To follow a running job:
tail -f slurm-<JOBID>.out
The job ID is printed in the submission log: "Submitted SlurmBackend job <JOBID>".
This file is the first place to look when a job fails silently.
Output:
============================================================
Task Status Summary
============================================================
Pending: 12
Running: 3
Done: 45
Failed: 2
------------------------------------------------------------
Total: 62
Progress: 75.8% (47/62 completed)
Status: ⏳ In progress
============================================================
Refreshing in 10s... (Ctrl+C to stop)
Check Failed Tasks
flexlock-status outputs/sweep/run.lock.tasks.db --failed --verbose
Shows:
Task configurations that failed
Full error tracebacks
Node where failure occurred
Timestamps
List All Tasks
# All tasks
flexlock-status outputs/sweep/run.lock.tasks.db --all
# Filter by status
flexlock-status outputs/sweep/run.lock.tasks.db --all --status running
flexlock-status outputs/sweep/run.lock.tasks.db --all --status failed
Smart Caching with HPC
FlexLock’s smart run detection works seamlessly with HPC:
# First run - executes on cluster
result = proj.submit(
config,
pbs_config='pbs.yaml',
smart_run=True, # Check cache
wait=True
)
# Second run - uses cached results!
result = proj.submit(
config,
pbs_config='pbs.yaml',
smart_run=True, # Cache hit!
wait=True
)
# No job submitted, instant results
Benefits:
Skip completed work
Resume failed sweeps
Incremental parameter exploration
Multi-Stage Pipelines on HPC
Run complex pipelines on the cluster:
from flexlock.api import Project
proj = Project(defaults='pipeline.defaults')
# Stage 1: Preprocess (on cluster)
preprocess = proj.submit(
proj.get('preprocess'),
pbs_config='pbs.yaml',
smart_run=True,
wait=True # Wait for preprocessing
)
# Stage 2: Train (uses preprocessing output)
train = proj.submit(
proj.get('train'),
pbs_config='pbs_gpu.yaml', # Different queue
smart_run=True,
wait=True
)
# Stage 3: Evaluate
evaluate = proj.submit(
proj.get('evaluate'),
pbs_config='pbs.yaml',
smart_run=True,
wait=True
)
print(f"Final accuracy: {evaluate.accuracy}")
Advanced Usage
Custom Timeout
from flexlock.parallel import ParallelExecutor
executor = ParallelExecutor(
func=my_function,
tasks=task_list,
cfg=config,
pbs_config='pbs.yaml'
)
# Wait with 2-hour timeout
success = executor.run(
wait=True,
timeout=7200, # 2 hours
poll_interval=30 # Check every 30s
)
if not success:
print("Jobs did not complete in time")
Job Dependencies
# Sequential execution
preprocess = proj.submit(cfg1, pbs_config='pbs.yaml', wait=True)
train = proj.submit(cfg2, pbs_config='pbs.yaml', wait=True)
Multiple Queue Types
# Fast queue for preprocessing
preprocess = proj.submit(
preprocess_cfg,
pbs_config='configs/pbs_fast.yaml'
)
# GPU queue for training
train = proj.submit(
train_cfg,
pbs_config='configs/pbs_gpu.yaml'
)
# Highmem queue for analysis
analyze = proj.submit(
analyze_cfg,
pbs_config='configs/pbs_highmem.yaml'
)
Troubleshooting
Job Fails Immediately
Check PBS error logs:
cat outputs/job/pbs_logs/pbs.err
Common causes:
Environment not activated
Python not found
Wrong working directory
Fix: Ensure PBS config has proper setup:
startup_lines:
- 'cd $PBS_O_WORKDIR' # Critical!
- 'eval "$(pixi shell-hook)"' # Or your env activation
Job Stuck in Queue
Check queue status:
qstat -Q # PBS
squeue # Slurm
Possible issues:
Insufficient resources
Queue limits reached
Wrong queue specified
Task Database Locked
Symptom: “database is locked” errors or workers idling while tasks remain.
Cause: Many workers writing to one SQLite file on a shared filesystem.
What FlexLock already does: write transactions use BEGIN IMMEDIATE
with jittered exponential backoff (FLEXLOCK_DB_RETRY_ATTEMPTS,
FLEXLOCK_DB_RETRY_MAX_BACKOFF) on top of a 30 s busy_timeout.
If it persists: claim several tasks per transaction, which divides lock traffic by the batch size:
export FLEXLOCK_CLAIM_BATCH=32 # a dead worker strands up to 32 claimed tasks
See Shared filesystems for the underlying limits.
Code changed while a job was queued or running
A run records its code as a git tree at submit time. Python loads a module when
it is first imported, so an edit made while the job waits in the queue (or
before a lazy import) runs code that tree does not describe. When a run ends,
FlexLock checks every loaded module in a tracked repo against the recorded tree
and lists mismatches under code_drift in the run record; flexlock diff
shows them as a warning. To avoid drift, don’t edit tracked code until queued
jobs have started and imported it.
Jobs Not Completing
Check with flexlock-status:
flexlock-status outputs/job/run.lock.tasks.db --all
Look for:
Tasks stuck in “running” status
Failed tasks with errors
Results Not Loading
Verify results file exists:
ls outputs/job/results.json
If missing:
Check if job actually completed
Verify save_dir in config
Check PBS output logs
Best Practices
✅ Do:
Test locally first: Run with
smart_run=Truelocally before HPCUse wait=True for pipelines: Ensures stages complete in order
Monitor actively: Use
flexlock-status --watchfor first runsSet reasonable walltimes: Don’t request more than needed
Use containers: For reproducibility across systems
Smart caching: Use
smart_run=Trueto skip completed work
❌ Don’t:
Submit to login nodes: Always use the scheduler
Hardcode paths: Use
$PBS_O_WORKDIRor relative pathsIgnore resource limits: Stay within your allocation
Submit thousands of tiny jobs: Use job arrays or batch tasks
Skip environment activation: Jobs will fail mysteriously
Performance Tips
1. Batch Small Tasks
# Bad: 1000 tiny jobs
proj.submit(cfg, sweep=big_grid, n_jobs=1000)
# Good: 100 jobs, each processing ~10 tasks
proj.submit(cfg, sweep=big_grid, n_jobs=100)
2. Use Smart Caching
# Skip completed work automatically
result = proj.submit(
cfg,
sweep=grid,
smart_run=True, # Critical!
pbs_config='pbs.yaml'
)
3. Parallel Preprocessing
# Preprocess once locally
preprocess = proj.submit(preprocess_cfg, smart_run=True)
# All HPC jobs use same preprocessed data
results = proj.submit(
train_cfg,
sweep=grid,
pbs_config='pbs.yaml',
smart_run=True
)
4. Choose Right Queue
# Quick jobs - fast queue
walltime: 00:15:00
queue: fast
# Long jobs - normal queue
walltime: 48:00:00
queue: long
# GPU jobs - GPU queue
queue: gpu
Example: Complete HPC Workflow
#!/usr/bin/env python3
"""Complete HPC workflow example."""
from flexlock.api import Project
from loguru import logger
# Enable logging
logger.enable("flexlock")
def main():
proj = Project(defaults='configs.defaults')
# Define sweep
sweep_grid = [
{"lr": 0.001, "epochs": 10, "batch_size": 32},
{"lr": 0.01, "epochs": 10, "batch_size": 32},
{"lr": 0.05, "epochs": 10, "batch_size": 32},
{"lr": 0.01, "epochs": 20, "batch_size": 64},
]
logger.info(f"Submitting {len(sweep_grid)} jobs to HPC")
# Submit to cluster
results = proj.submit(
proj.get('train'),
sweep=sweep_grid,
pbs_config='configs/pbs_gpu.yaml',
smart_run=True, # Skip completed
wait=True, # Block until done
n_jobs=4 # Parallel jobs
)
# Analyze results
logger.info("All jobs completed!")
best_idx = max(range(len(results)),
key=lambda i: results[i].get('accuracy', 0))
best = results[best_idx]
logger.info(f"Best configuration:")
logger.info(f" lr={best.lr}")
logger.info(f" epochs={best.epochs}")
logger.info(f" batch_size={best.batch_size}")
logger.info(f" accuracy={best.accuracy:.2%}")
if __name__ == "__main__":
main()
Run it:
# Submit and wait
python workflow.py
# Monitor in another terminal
flexlock-status outputs/train/run.lock.tasks.db --watch
See Also
Python API: Project class and ParallelExecutor usage
CLI Reference: Command-line tools for HPC workflows
Reference: Environment variables for parallel execution
Summary
FlexLock makes HPC integration simple:
Add one parameter:
pbs_config='pbs.yaml'Monitor easily:
flexlock-status db_path --watchSmart caching: Automatic result reuse
Container support: Reproducible environments
Flexible waiting: Block or continue as needed
Start simple, scale to thousands of cluster jobs! 🚀
Chaining scheduler jobs
--after JOBID[:JOBID...] adds an afterok dependency to a Slurm or PBS
submission. Each upstream job must finish successfully before this job starts.
The dependency appears before shell commands in the rendered script and in
--dry-run output. Use either --after or a dependency directive in the
profile’s startup_lines; specifying both raises an error.
With --after, unpinned ${run:preset,select} references bind at worker stage
start, after the scheduler dependency succeeds, and record the chosen upstream
run in lineage. strict checks still apply at that point. Explicit pins such
as ${run:preset,select,0005} bind at submission. Keep output directories,
targets, and code repository paths concrete at submission. Automatic cache
matching is skipped while run references remain deferred; an explicit skip
policy still resumes completed outputs.
--print-job-id submits without waiting and prints only the scheduler ID on
stdout, so shell command substitution can capture it. It works for single runs,
sweeps, and composite pipelines; the whole submitted collection has one job ID.
It requires an HPC backend and cannot be combined with preview or enqueue flags.
A cached or already-complete submission has no new job ID and raises an error.
Pass --after when draining an enqueue file, rather than while creating it.
train_job=$(flexlock-run -d myproject.configs -s train \
--slurm-config slurm/gpu.yaml --print-job-id)
flexlock-run -d myproject.configs -s evaluate \
--slurm-config slurm/cpu.yaml --after "$train_job" --print-job-id
The Python API accepts a list of IDs or a colon-separated string:
train = project.submit("train", slurm_config="slurm/gpu.yaml", wait=False)
evaluation = project.submit(
"evaluate", slurm_config="slurm/cpu.yaml", after=[train.job_id], wait=False,
)
print(evaluation.job_id)
Project.submit_pipeline(..., after=[...], wait=False) also supports this;
every submitted stage result exposes the same job_id. Local and cached
results have job_id=None.
Use ${run:myproject.configs,train}/checkpoints/best.pt in the downstream
config to locate the upstream artifact when the dependent worker starts.
Per-stage scheduler profiles are the next planned extension.