Partial (per-branch) dependencies for remote workflows via law.eager - #1
Open
kandrosov wants to merge 4 commits into
Open
Partial (per-branch) dependencies for remote workflows via law.eager#1kandrosov wants to merge 4 commits into
kandrosov wants to merge 4 commits into
Conversation
…l closed on unmapped branches
…resubmit blocked work
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A remote workflow is a single luigi task, and luigi never runs a task before all of its
requirements are complete. A workflow that requires another workflow therefore cannot submit a
single job until the required workflow has finished entirely — even for branches whose own
inputs landed hours earlier.
LocalWorkflowdoes not have this problem, because its proxy yieldsbranch tasks as luigi dynamic requirements and luigi then resolves dependencies per branch.
Two facts from the source bound the solution space:
Scheduler._schedulable,plus a second
check_unfulfilled_depsgate in the worker), with no configuration escape. Soconcurrency can only come from changing what is declared.
submitting process, and the workflow instance is the unit luigi schedules. That is why
examples/sequential_htcondor_at_cernhas to create one workflow instance per chunk via thesignificant
branchesparameter.That workaround does not scale. Its cost grows with the number of chunks rather than the number of
stages, and it duplicates work whenever two downstream branches share an upstream branch. Measured
on the example added here (12 upstream branches feeding 6 downstream branches with overlapping
requirements):
--workersfor full concurrencyWhat this adds
One marker, in one place. The per-branch mapping is read off the
requires()the task already has,so nothing is declared twice:
Measured with the mock batch system in the new example (
Producehas 12 branches, branch 0 runs25x longer than the rest;
Combinehas 4 branches needing 3 consecutiveProducebranches each):the three unblocked
Combinejobs are submitted at t = 3.0 s instead of t = 14.5 s, and the fourthright after the straggler lands. On real HTCondor at CERN with a 150 s straggler,
Combinejobswere submitted at t = 135 s and t = 151 s while the last
Producebranch finished at t = 244 s.How it works
law.eager(w)replaces the requirement with anEagerRequirement, alaw.WrapperTaskthatrequires everything
wrequires, but notwitself. luigi therefore still buildsw's owninputs, bundles and credentials in the usual order, and — being a wrapper task — the marker is
complete once those are, so the depending workflow starts right away. Nothing reports itself
complete when it is not.
output()is forwarded, soworkflow_input()is unaffected, andrequires()is forwarded, so--print-status,--print-depsand--remove-outputtraversethrough it (it shows up in the tree with an
eagerflag).win a daemon thread (ThreadEagerDriver), sowsubmits and polls its jobs while the depending workflow is already working. Each stage keeps its
own job data, job grouping, polling interval and backend, which is what lets a CRAB stage feed
an HTCondor stage. No new luigi task is created — this matters, because luigi's own scheduling
cost grows roughly quadratically with graph size (measured: 45 s at 4 000 tasks, 188 s at 8 000),
so a luigi node per branch is not viable at production scale.
BaseRemoteWorkflowProxyregisters every branch chunk injob_data.unsubmitted_jobsand re-runssubmit()on every poll iteration — that is how--parallel-jobsthrottling works. A job whoserequirements are not met yet is skipped without being popped, so
len(job_data), and with itpoll()'sn_jobssnapshot, never changes.existence check per requirement per poll iteration rather than one per branch — and is
accumulated monotonically, so a temporarily unreachable storage cannot revoke it. Job status is
deliberately not used as the signal: a job can report FINISHED without its outputs existing.
the right to drive is claimed in a
law.util.mp_managerdictionary shared across the processes ofone
law run, and the others observe its state through the same registry. Without the claim therequirement's jobs are submitted twice and the second driver crashes; the
shared_requirementscenario covers it.are still missing — is failed with a diagnosis naming them
(
missing branch(es) of eager requirement(s), produce: [5] (requirement failed with: ...))instead of hanging. It goes through the normal failed-job path, so
toleranceandacceptanceapply unchanged.
Workflows with many branches can state the mapping directly instead of letting law derive it from
requires(), which instantiates one task per dependency edge:The difference is not cosmetic at scale: on a 7 600-branch consumer with 380 000 edges, deriving
takes about 1 590 s while the stated mapping takes 0.14 s.
What is unchanged
Everything that does not use the marker.
submit()skips the gate entirely when no requirement iseager, and
BaseWorkflowProxy.requires()returns its input object unchanged when no marker ispresent — the added cost there is one lazy structure scan, measured at 1.3 ms over a
2 000-object requirement structure.
--workflow localis unaffected: the marker is unwrapped fornon-remote proxies, so luigi resolves branch dependencies as before (the
localscenario assertsthat no
EagerRequirementreaches the graph).Three call sites that assumed luigi's worker-injected callbacks always exist are now guarded the way
publish_messageandpublish_progressalready were:scheduler_messages,set_tracking_urlanddecrease_running_resourcesare attached byTaskProcess.forward_reporter_attributesonly while aworker runs the task, and are removed afterwards. Any code that runs a proxy outside a worker hit
AttributeErrorbefore.Polling status lines are prefixed with the task family when several stages poll at once, since they
otherwise interleave without saying which stage they belong to.
Limitations
branch map is produced by a
dynamic_workflow_conditionon the eager requirement, there are nobranches to hold back, and submitting the placeholder branch would submit a job for nothing. That
combination is refused with an explicit message rather than guessed at. Letting a branch map
grow as upstream branches land is a separate, larger change.
requirement that nothing depends on failed. That is the intended semantics — a branch waits only
for what it consumes — and the failure is logged as a warning.
no_pollthere is no loop that could release held-back jobs, so nothing is driven and,unless everything is already in place, nothing is submitted.
its own aggregation policy on top. The readiness gate only filters the candidate set and
poll_callbackstill decides when a wave goes out. That combination has not been exercisedagainst a real CRAB task.
Validation
examples/partial_dependencies/contains a HelloWorld payload and a mock batch system — realsubprocesses, real job ids, real polling, state on disk — so the code path through
law.workflow.remoteis the one HTCondor, Slurm and CRAB take, without needing a batch system. Afull pass takes a few minutes:
source setup.sh law index --verbose python test.py14 scenarios, all passing:
barriereageroverlapchainupstream_failureblocked_not_retriedbatchingtasks_per_job > 1a job waits for the slowest branch it coversupstream_completelocal--workflow localis unchangedresumeshared_requirementdriven_requirementsdynamic_branch_mapchunked_workaroundThe same payload was run end to end on real HTCondor at CERN (
--workflow htcondor), and--print-status -1and--print-depswere checked by hand.flake8is clean on the changed files.Three defects in earlier revisions of this branch were each found and then confirmed by reverting
the fix and watching the scenario fail: the wrapper originally faked
complete()and so skipped thedriven workflow's own subtree; a restart resubmitted permanently-blocked jobs through the retry path;
and the per-branch derivation matched requirements by
task_id, which fails open — returning anempty dependency map, read as "every branch is ready" — when a project refers to the same workflow
with a different branch selection. Matching now ignores the branch-selecting parameters, and
branches that still cannot be mapped are gated on the real
complete()of their requirements.Files
law/workflow/eager.pylaw/workflow/remote.pysubmit(), the eager state on the proxy, the guardslaw/workflow/base.pyBaseWorkflowProxy.requires(); guardscheduler_messageslaw/__init__.pylaw.eagerdocs/api/workflow/eager.rstexamples/partial_dependencies/