Duration and Hold¶
By default, the scheduler prioritizes eligible steps by tail time, i.e. the longest time path from that step to any terminal node of the workflow graph.
Steps with longer tail times are dispatched first, in the spirit of
critical-path scheduling,
which generally results in the shortest overall execution time.
A step’s own contribution to its tail time is its duration,
an estimate of its wall time in seconds.
This works well once all step durations are known,
which requires that the workflow has run at least once,
or that the durations have been estimated and passed in explicitly.
For the first execution of a workflow from scratch, StepUp has no measured durations.
A newly declared step defaults to a duration of 1.0 second,
which is a poor estimate for a step that actually takes a few milliseconds or several minutes.
You can pass a better estimate explicitly with the duration keyword argument,
accepted by step() and all step-generating API functions
(run(), script(), call(), render_jinja(), etc.).
Because it is only used to break ties before the first measurement exists,
there is no need to keep it accurate:
a rough guess (“a few seconds” vs. “a few minutes”) is already enough
to help the scheduler make good decisions on a clean build.
At the end of a build, unless the --no-duration option is set,
StepUp records the measured wall time of each step in the workflow graph database,
so that the next build can use those measurements to schedule more efficiently.
These measurements overwrite any user-specified duration arguments,
which are only used for steps that have never run before.
A well-chosen duration is not always enough on its own, though.
The scheduler can only prioritize among steps that are currently eligible for dispatch,
and steps become eligible one by one, as soon as they are declared with no missing inputs.
When a plan.py declares several such steps in a row and a job slot is free,
the first one declared can grab that slot immediately,
regardless of how long it or the steps declared after it are expected to take.
The hold() context manager solves this
by holding back the steps declared inside its block:
none of them become eligible until the block exits,
so the whole batch is compared by tail time at once,
instead of being raced by declaration order.
hold() is re-entrant, so nesting with hold(): blocks
(directly, or through a shared helper function) is safe;
steps declared in any of the nested blocks
only become eligible once the outermost block exits.
Example¶
Example source files: docs/advanced_topics/duration_and_hold/
The plan.py below declares four steps with three job slots available (-j 3):
two fast ones (2.0 and 2.1 seconds) and two slow ones (4.0 and 4.1 seconds),
in that order, i.e. the two slow steps are declared last.
plan.py itself occupies one of the three slots while it runs,
leaving two slots free.
Without hold(), the two fast steps, declared first, immediately grab those two free slots,
before the scheduler even knows the slow steps exist.
By the time the slow steps are declared, no slot is left for them:
one gets the slot plan.py frees up once it finishes,
but the other has to wait for a fast step to finish too.
As a result, one of the two slow steps is delayed
and ends up running alone at the end of the build.
Wrapping the declarations in hold(), and giving each step a duration estimate,
makes the scheduler compare all four by tail time as soon as the block releases,
so both free slots go to the slow steps instead, even though they were declared last.
#!/usr/bin/env python3
from stepup.core.api import hold, run
with hold():
run("sleep 2.0", duration=2.0)
run("sleep 2.1", duration=2.1)
run("sleep 4.0", duration=4.0)
run("sleep 4.1", duration=4.1)
To run the example, make the plan executable and run it:
You should get the following terminal output:
DIRECTOR │ Listening on /tmp/stepup-########/director (StepUp Core 4.0.0)
STARTUP │ (Re)initialized boot script
PHASE │ build
START │ ./plan.py
START │ sleep 4.1
START │ sleep 4.0
SUCCESS │ ./plan.py
START │ sleep 2.1
SUCCESS │ sleep 2.1
START │ sleep 2.0
SUCCESS │ sleep 4.0
SUCCESS │ sleep 4.1
SUCCESS │ sleep 2.0
DIRECTOR │ Ran 5 job(s).
DIRECTOR │ Trying to remove 0 deletable file(s) and empty director(y|ies)
DIRECTOR │ See you!
Notice that both slow steps start together, right alongside plan.py itself,
before plan.py even reports success.
Only afterward the two fast steps run, one at a time,
in the single slot plan.py leaves behind once it finishes.
The whole build takes a bit over four seconds,
essentially just the two slow steps running in parallel,
with the two fast steps fitting into the leftover capacity.
Try the Following¶
-
Remove the
with hold():block (keep the fourrun()calls, unindented), remove.stepup/graph.dbto force a clean build, and rerunsb -j 3. Now the two fast steps both start immediately, grabbing the two slots that are free whileplan.pyis still running, before either slow step even exists in the workflow graph. Onceplan.py’s own slot frees up, the slowest step (4.1 seconds) starts, as this is the one with the longest tail time. As soon as one of the two fast steps finishes, the other slow step (4.0 seconds) starts. The build now takes a bit over six seconds, two seconds slower than withhold(), purely because of the order in which the steps happened to be declared. -
With
hold()restored, remove.stepup/graph.dband runsb -j 3again. You should get the same output as in the example above. -
Run
sb -j 3once more, without removing anything this time. All four steps are skipped, since nothing changed. -
Change one of the
sleep 4.1commands, e.g. tosleep 4.2, and rerunsb -j 3. Only that step reruns, since only its command changed.