Resources And Scheduling¶
Every @task can declare the resources it needs and how it should be scheduled.
The runtime uses these declarations to pack ready tasks against the run’s
resource budget and to decide ordering and retry behaviour.
Declaring Resource Requirements¶
Pass resource arguments to @task. Resources declare what a task needs,
never where it runs. The scheduler packs them against the --jobs, --cores,
--memory, and --gpus budgets passed to ginkgo run.
@task(threads=4, memory="8Gi")
def align_reads(sample_id: str, reads: file) -> file:
...
@task(gpu=1, gpu_type="nvidia-tesla-t4", memory="16Gi")
def train_model(dataset: folder) -> file:
...
threads=Ndeclares the CPU footprint. Tasks that readthreadsas a function parameter receive it automatically; shell tasks also seeGINKGO_THREADSin their subprocess environment. Setexport_thread_env=Trueto additionally exportOMP_NUM_THREADSand related BLAS/OpenMP variables.memory="8Gi"declares the memory footprint. Format is Kubernetes-style (512Mi,4Gi,16Gi). Remote executors map this to pod resource requests.gpu=Ndeclares a GPU requirement. It is satisfied from the local--gpusbudget when it fits, dispatched to the remote executor when one is configured (--executor), and a build error otherwise.gpu_typeselects the accelerator for remote execution, overriding the executor-level default.remote=Trueexplicitly dispatches a python task to the run’s default executor (whichever--executornames); running without--executoris a build error rather than a silent local fallback.executor="name"pins a python task to one executor declared under[remote.executors], whatever the run default is — an unknown name is a build error. See remote execution.
The local --gpus budget is scheduler bookkeeping: it stops ginkgo
oversubscribing GPUs across concurrent tasks, but it does not pin devices —
every local task still sees all GPUs (no CUDA_VISIBLE_DEVICES isolation).
Custom Resource Dimensions¶
Threads, memory, and GPUs cover hardware. Some tasks are constrained by
something else entirely — a third-party API’s rate limit, a shared database’s
connection pool — and custom lets the scheduler pack against those too:
@task(resources={"api_calls": 2})
def fetch_records(query: str) -> file:
...
Each dimension is budgeted separately from [resources.budgets] in the
runtime config, and/or repeated --resource name=value flags:
[resources.budgets]
api_calls = 10
ginkgo run flow.py --resource api_calls=10 --resource db_connections=4
The CLI flag wins over the config value per dimension. A dimension that
tasks request but that has no budget name in either source is
unconstrained — the same opt-in behaviour as --memory. Custom names
cannot shadow threads, memory, gpu, or gpu_type; use the dedicated
argument for those instead.
Unlike the built-in dimensions, custom demands are not zeroed out for remote-placed tasks — they count wherever the task runs. An API quota or a database connection pool doesn’t stop applying because the task went to Kubernetes, so a saturated custom budget can hold back remote dispatch as well as local.
Measured Usage¶
Declared resources are a guess; ginkgo also records what a task actually
used, so right-sizing a declaration doesn’t require guesswork. Every task
run measures peak memory (RSS) and CPU time — resource.getrusage for
Python tasks, periodic ps sampling of the subprocess tree for shell,
notebook, script, and subworkflow tasks — and persists both alongside the
declared threads/memory in the run manifest. Measurement happens on
failure too: usage recorded right before an OOM kill is exactly what you
need to size the retry.
ginkgo report surfaces it as a Peak RSS column in the task ledger
(measured / declared, e.g. 3.2 GiB / 16 GiB); ginkgo runs show --json
includes the raw resource_usage record per task. See
Assets and Reports.
Across Runs¶
One run tells you what a task used that time. The question worth asking is what it needs in general, which is a question about many runs:
$ ginkgo history align_reads --resources
Peak RSS over 12 executions (28 runs, 16 cached)
p50 3.1 GiB p95 4.8 GiB max 5.2 GiB
declared 16 GiB · p95 is 30% of declared
CPU time
p50 4m 12s p95 5m 30s total 52m
declared 4 threads
The distribution covers every execution on record; --limit bounds only the
per-run table printed underneath it. --by-label splits a fanned-out task into
one summary per branch, widest peak first — one branch of a fan-out over
chromosomes is not like another, and the widest is the one that has to size the
declaration. --json returns the aggregate alongside the rows.
Three things in that output are there to stop the numbers overstating themselves:
The sample count. Only real executions measure anything, so a task with 28 runs and 16 cache hits has 12 samples.
ntravels with the percentiles so a small sample is visible rather than implied.Failed attempts are excluded, and counted. A task killed at its 16 GiB ceiling used more than 16 GiB — that peak is a lower bound, not a measurement, and averaging it in would drag the distribution towards the value that already failed. Those samples are reported separately, with the floor they establish (
3 failed attempts excluded (peak ≥ 16 GiB)).Percentiles are nearest-rank. Every figure printed is a value some run actually reached. Interpolating between two samples would report a peak no run ever hit, from a sampler whose resolution is a periodic
psof the process tree — a short spike can be missed entirely.
Where a retry escalated under memory_retry_multiplier, the declaration and
the budget the attempt ran against are recorded separately, and the summary
says so (declared 16 GiB · ran against 32 GiB after escalation). Comparing a
30 GiB peak against the 16 GiB declaration would call an attempt an overrun
when it fitted the budget it was actually given.
The same numbers are columns on the tasks table — peak_rss_bytes,
cpu_seconds, declared_threads, declared_memory_gb, effective_memory_gb
— so ginkgo query can ask its own questions of them without unpacking JSON.
They are null for anything unmeasured: a cache hit, or a task that never
started.
Site Overrides¶
The same workflow file can run on a laptop, an HPC node, and the cloud with
per-site sizing. A [resources.overrides] table in the runtime config merges
over the decorator declarations, keyed by task name:
[resources.overrides.align_reads]
threads = 16
memory = "64Gi"
[resources.overrides."variant_*"] # fnmatch glob
memory = "32Gi"
A selector matches a task’s short name (align_reads) or fully qualified
name (workflow.modules.align.align_reads), and may be an fnmatch glob
over either. Exact matches beat globs; among globs, the first selector in
config order wins. Keys an override omits keep their declared values. An
override’s custom table replaces the declared custom dict wholesale
rather than merging key by key.
Overridden threads flow everywhere the declaration would: the scheduler,
the dry-run plan, the injected threads parameter, and GINKGO_THREADS.
Note that for tasks declaring a threads parameter the injected value is a
task input, so a site override changes those tasks’ cache keys — the same
way editing the declaration would.
Retrying With More Memory¶
For tools whose memory needs are input-dependent, declare a baseline and let retries escalate instead of sizing every run for the worst case:
@task(memory="16Gi", retries=2, memory_retry_multiplier=2)
def sort_bam(bam: file) -> file: # attempts run at 16, 32, then 64 GiB
...
Escalation applies exponentially per retry attempt. Locally it is capped at
the run’s --memory budget so a retry always remains dispatchable (a task
notice reports the escalated figure); remote-placed tasks escalate uncapped
because the executor satisfies their request.
Priority¶
# Highest-priority tasks run first when several are ready at once.
@task(priority=10)
def critical_path_step(...): ...
priority orders tasks that become ready at the same time. It is a strict
tiebreaker: it never lets a higher-priority task block a larger set of
lower-priority tasks from running.
Retry Policies¶
# Retry up to 3 times, only on IOError, with exponential backoff.
@task(retries=3, retry_on=IOError, retry_backoff=1.0)
def network_fetch(...): ...
# Retry only specific exit codes on shell tasks.
@task(kind="shell", retries=2, retry_on_exit_codes=(137,)) # OOM kills
def memory_intensive_step(...): ...
retries sets how many times a failed task is re-attempted. Narrow what counts
as retryable with retry_on (exception types, for Python tasks) or
retry_on_exit_codes (for shell tasks).
Retries with a non-zero retry_backoff pause the task in a waiting_retry
state for a computed delay before the scheduler picks it up again. The delay
grows by retry_backoff_multiplier on each attempt and is capped at
retry_backoff_max.
When a Failure Should Not Stop the Run¶
Retries cannot help with a malformed input: it fails identically every time. By default the first failure a task’s retries cannot absorb stops the run — in-flight tasks finish, nothing new is dispatched. In a wide fan-out that throws away the work of every healthy branch.
# One bad sample must not cost the other 4,999.
@task(retries=2, on_failure="ignore")
def load_sample(sample: str) -> file: ...
on_failure="ignore" applies after retries are exhausted. The failed task’s
siblings keep running, and so does everything that does not depend on it.
ginkgo run --keep-going says the same thing about every task in the run,
without editing the workflow.
Two things this does not do:
It does not make the run pass. The task is recorded
failed, the run is recordedfailed, andginkgo runexits 3 rather than 0 — a status of its own, so a script can tell “stopped at the first failure” (1) from “ran everything it could, and some of it failed” (3).It does not run the tasks downstream. A task missing an input cannot run, so every task below the failure is reported
skipped, naming the failure it is waiting on. An aggregator over a fan-out is downstream of every branch, so one ignored branch failure skips it — including when the aggregator is what the flow returns, in which case the run produces no result and says so — which, since a flow’s return value usually sits downstream of everything, is how most such runs end today. The successful branches are still cached, and the notebooks and assets they produced are still listed and still there, so fixing the input and re-running does only the work that is left.
Anything ginkgo itself rejects about a task attempt — a return value that
breaks the task’s declared contract, say — is ignored on the same terms as an
error the task body raised. --keep-going is a statement about the whole run,
so use it when you want the run carried past every kind of per-task failure,
and on_failure="ignore" on the one task whose failure you expect.
See Also¶
Tasks and Flows — the task authoring model.
Remote Execution — running tasks on Kubernetes or GCP Batch.
CLI — the
--jobs,--cores,--memory,--gpus, and--resourcerun budgets.Assets and Reports — the Peak RSS column in
ginkgo report.