Skip to content

Add resource usage measurement injection to CWL interpreter#5526

Merged
adamnovak merged 23 commits into
masterfrom
issues/5477-cpu-usage-docker-cwl
Jul 9, 2026
Merged

Add resource usage measurement injection to CWL interpreter#5526
adamnovak merged 23 commits into
masterfrom
issues/5477-cpu-usage-docker-cwl

Conversation

@avnig05

@avnig05 avnig05 commented May 28, 2026

Copy link
Copy Markdown
Collaborator

CWL and WDL have a shared runtime injection module that samples in-container CPU/memory usage and reports it back to Toil via runtime message files.

Changelog Entry

To be copied to the draft changelog by merger:

  • toil-cwl-runner --stats now records resource usage from Docker containers so toil stats can read it.
    (Edited by @adamnovak)

Reviewer Checklist

  • Make sure it is coming from issues/XXXX-fix-the-thing in the Toil repo, or from an external repo.
    • If it is coming from an external repo, make sure to pull it in for CI with:
      contrib/admin/test-pr otheruser theirbranchname issues/XXXX-fix-the-thing
      
    • If there is no associated issue, create one.
  • Read through the code changes. Make sure that it doesn't have:
    • Addition of trailing whitespace.
    • New variable or member names in camelCase that want to be in snake_case.
    • New functions without type hints.
    • New functions or classes without informative docstrings.
    • Changes to semantics not reflected in the relevant docstrings.
    • New or changed command line options for Toil workflows that are not reflected in docs/running/{cliOptions,cwl,wdl}.rst
    • New features without tests.
  • Comment on the lines of code where problems exist with a review comment. You can shift-click the line numbers in the diff to select multiple lines.
  • Finish the review with an overall description of your opinion.

Merger Checklist

  • Make sure the PR passed tests, including the Gitlab tests, for the most recent commit in its branch.
  • Make sure the PR has been reviewed. If not, review it. If it has been reviewed and any requested changes seem to have been addressed, proceed.
  • Merge with the Github "Squash and merge" feature.
    • If there are multiple authors' commits, add Co-authored-by to give credit to all contributing authors.
  • Copy its recommended changelog entry to the Draft Changelog.
  • Append the issue number in parentheses to the changelog entry.

@avnig05 avnig05 requested a review from adamnovak May 28, 2026 19:46
@adamnovak

Copy link
Copy Markdown
Member

@avnig05 It looks like this isn't passing type checking; if you make mypy you should be able to reproduce the problems:
https://ucsc-ci.com/databiosphere/toil/-/jobs/110094#L1195

It looks like mostly you need casts or # type: ignore comments at some places where you're using a fake object or looking in one of these Expando objects that are fundamentally dynamically-typed. But it looks like you might not be importing ResourceMonitor from the place where it's actually defined?

@adamnovak adamnovak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it should work for the Docker container case.

I think for the Singularity/CWL case, it might be double-counting the resource usage, because it will be counted via having a child process and counted again via the command injection.

I think it might be possible to simplify the hooking into the CWL code by overriding another method in ToilCommandLineTool that gets to run after the container finishes, which would let us avoid needing ToilContainerCommandLineJob and friends and the machinery to attach them.

I think the PR should be rebased to not be on top of the pre-squash commits from #5512.

Comment thread src/toil/cwl/cwltoil.py Outdated
Comment on lines +1213 to +1215
def _file_mounts_from_pathmapper(
job: ContainerCommandLineJob,
) -> list[tuple[str, str]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a docstring to at least explain which item in the result tuples is which.

Comment thread src/toil/cwl/cwltoil.py Outdated
Comment on lines +1105 to +1129
class ToilContainerCommandLineJob(ContainerCommandLineJob):
"""Container job that collects resource stats from injected in-container code."""

def _execute(
self,
runtime: list[str],
env: MutableMapping[str, str],
runtimeContext: cwltool.context.RuntimeContext,
monitor_function: Callable[["subprocess.Popen[str]"], None] | None = None,
) -> None:
super()._execute(runtime, env, runtimeContext, monitor_function)
handle_injection_messages_from_outdir(self.outdir)


class ToilDockerCommandLineJob(ToilContainerCommandLineJob, DockerCommandLineJob):
"""Docker container job with Toil runtime injection support."""


class ToilPodmanCommandLineJob(ToilContainerCommandLineJob, PodmanCommandLineJob):
"""Podman container job with Toil runtime injection support."""


class ToilSingularityCommandLineJob(ToilContainerCommandLineJob, SingularityCommandLineJob):
"""Singularity container job with Toil runtime injection support."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach of hooking the ContainerCommandLineJob in addition to the CommandLineTool is a little awkward. We have to have all these extra classes for each container system, and we already don't have one for UDockerCommandLineJob and it's hard to tell whether that's because we don't need one. And the hook here only works in concert with the hook that ToilCommandLineTool applies, so we end up with one feature spread out over several classes.

Instead of hooking ContainerCommandLineJob._execute(), did you consider hooking CommandLineTool.collect_output_ports() instead? It looks like that has access to the outdir, and it gets sent into _execute() and called inside there after the container has run. And if we did it that way we could keep all the hook logic together inside ToilCommandLineTool and not need to worry as much about the container type.

Comment thread src/toil/cwl/cwltoil.py Outdated
Comment on lines +1235 to +1242
for job in super().job(job_order, output_callbacks, runtimeContext):
if isinstance(job, ContainerCommandLineJob) and self._uses_container(
runtimeContext
):
file_mounts = self._file_mounts_from_pathmapper(job)
script = command_line_to_shell_script(job.command_line)
script = add_injections(script, file_mounts)
job.command_line = shell_script_to_command_line(script)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to inject the monitoring logic even when we're using Singularity (or I think UDocker?) where we should already see CPU and memory usage in the stats because it happens under a child process of Toil? Will that lead to double-counting of CPU usage?

Comment thread src/toil/lib/interpreter.py Outdated
###
# Runtime code injection system
# When a workflow steps runs inside a container, the Toil worker process on the host
# often cannot see how much CPU and RAM that step actually used. This system allows

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The word often is doing some vigorous handwaving here, and trying to hide that this text does not convey an understanding of how or why (or, more importantly for avoiding double-counting of resource usage, when) this will be the case.

Comment thread src/toil/lib/interpreter.py Outdated
present, and a flat argv list otherwise.
"""
if (
len(command_line) >= 3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might want to be == or we'll be able to throw away arguments.

Comment on lines +499 to +505
"--outdir",
str(tmp_path / "output_dir"),
str(cwl_file),
str(inputs_file),
"--jobStore",
str(job_store),
"--stats",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing in here to make Docker be used, but I looked at the toil-cwl-runner options and it has --singularity, --podman, and --no-container, but not a --docker. So I guess Docker is the default container engine. Maybe we need a comment here to remind us that the test depends on that?

Comment thread src/toil/test/cwl/cwlTest.py Outdated
Comment on lines +2297 to +2306
# Include malformed and partial lines to exercise parser robustness.
message_file.write_text(
"CPU\t1000000\n"
"Memory\t1024\n"
"CPU\t4000000\n"
"Memory\t2048\n"
"Odd\tline\n"
"CPU\t5000000",
encoding="utf-8",
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that's noticeably absent here is a line with the wrong number of fields, and in particular too few fields.

Comment thread src/toil/test/cwl/cwlTest.py Outdated
from toil.lib import interpreter

message_file = tmp_path / "resources.tsv"
# Include malformed and partial lines to exercise parser robustness.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A some of what's being tested here (like the fact that final lines without a terminating newline are ignored) seems to be based on the implementation code, rather than anything that the docstring actually promises will be true about the function under test.

If it's an important enough point about the function's behavior that it's worth testing, it's probably worth documenting in the docstring. Otherwise, the next person to touch the function is going to tinker with the internals in a way that doesn't appear to change anything about what the function promises, and then be hit with test failures because there are other secret things that need to be true about the function.

Comment thread src/toil/test/cwl/cwlTest.py Outdated
job = object.__new__(cwltoil.ToilDockerCommandLineJob)
job.command_line = ["echo", "hello"]
job.pathmapper = FakePathMapper(FakeMapperEntry(str(host_input), "/work/input.txt"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There aren't any actual assertions in this test, so I don't think it's genuinely testing what its docstring claims it is testing.

Comment on lines 821 to 875

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are leftover changes from #5512. It looks like your new commit c4c69a4 is on top of the commits from the branch for that PR, and not on top of the squashed commit that was created when the PR was merged.

I would recommend rebasing onto the current mainline Toil, and keeping just the commit about this feature in the branch for this PR:

  1. Be on the branch for this PR.
  2. Do git fetch origin (assuming your origin remote points to this Github project)
  3. Do git rebase -i origin/master
  4. In the editor, delete all the lines for the commits from the old feature, and only keep the line for the commit that's about the new feature.
  5. Save and quit
  6. Git should rewrite your history so now your issues/5477-cpu-usage-docker-cwl branch just has the new commit for this feature.
  7. Do a git push origin issues/5477-cpu-usage-docker-cwl -f to force-push your rewritten history to Github and update the PR.

@adamnovak

Copy link
Copy Markdown
Member

@avnig05 Since you're gone for the summer, I'm going to take on landing this myself.

@adamnovak adamnovak force-pushed the issues/5477-cpu-usage-docker-cwl branch from 02594d1 to ef5d547 Compare July 1, 2026 20:19
@adamnovak adamnovak changed the title Issues/5477 cpu usage docker cwl Add resource usage measurement injection to CWL interpreter Jul 1, 2026
@adamnovak adamnovak dismissed their stale review July 1, 2026 20:21

I made the changes I wanted.

@mr-c mr-c left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/toil/lib/interpreter.py Outdated

The resulting command required Bash to be available.
"""
return ["bash", "-c", script]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some containers and hosts don't have bash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only use this when doing the injection, which only has to worry about containers, but indeed we'd run into a problem if the container doesn't have Bash.

The script we inject contains bash-isms, and uses head, cut, and awk, which are not shell built-ins and would have to be available inside the container for the monitoring to actually work.

We could translate the script back to /bin/sh (maybe also pulling in a dependency on something else for the math), and add some tests for what happens if a user container doesn't ship standard tools an only ships their user binary (the monitoring can't work, but we need it to fail in a safe way). Or we could figure out how to get something smart enough to do all this injected into the container (maybe a Linux binary we mount?). Or we could try and figure out how to do the monitoring outside the container, but that probably would require changes in both cwltool and miniwdl.

@adamnovak

Copy link
Copy Markdown
Member

How does this interact with the built-in resource monitoring in cwltool?

This duplicates the functionality of the cwltool built-in memory monitoring, and adds CPU usage monitoring.

We could call out to the cwltool memory usage monitoring and only use our own code for CPU usage monitoring, but since Toil needs code for both on the WDL side it seemed simplest to just monitor both the same way on the CWL side as well.

If cwltool grew built-in CPU monitoring we could stop trying to tack on our own logic and use that instead.

@adamnovak

Copy link
Copy Markdown
Member

For some discussion about the design on the WDL side, see chanzuckerberg/miniwdl#672. Mike Lin wrote up a plugin for MiniWDL demonstrating within-container monitoring, but that wasn't really quite published as installable and also ran the statistics in-band through the user-visible standard error stream, so I implemented it again with the same basic approach in Toil. If we really want to cut the whole command injection concept from Toil, we'd need to build out CPU monitoring in cwltool, and spruce up that plugin in MiniWDL so Toil could depend on it instead.

@adamnovak

Copy link
Copy Markdown
Member

The CWL conformance tests are failing on initial_workdir_empty_writable_docker, which might indicate that we're not actually allowed to hide the communications channel for the resource monitoring inside the working directory.

@adamnovak

Copy link
Copy Markdown
Member

OK, I have code to fix the need for Bash or other utilities in my Toil fork, but I still haven't worked out how to inject another mount from where I'm working.

@adamnovak

Copy link
Copy Markdown
Member

The remaining problem here is the docker_entrypoint CWL test.

If I need to inject a script into the container, I need to figure out what the entrypoint is, put it into the script, and override the container's entrypoint with the shell.

(Another complication is that it's entirely possible to make a Docker container without any shell at all, just with the application binary. We might end up having to stuff a busybox in there. At which point why am I not sending something more useful? And will user code care if it isn't PID 1?)

@mr-c

mr-c commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

For containers you can monitor memory and CPU usage outside the container

@adamnovak

Copy link
Copy Markdown
Member

For containers you can monitor memory and CPU usage outside the container

Only if I do my own integration of the point-in-time CPU percentage from docker stats. The total CPU usage in core-seconds is not available except from the Docker API (see https://stackoverflow.com/questions/30271942/get-docker-container-cpu-usage-as-percentage and https://github.com/TheClimateCorporation/docker/blob/master/docs/reference/commandline/stats.md#formatting). And I don't think that the stats samples are very timestamped the way cwltool'sdocker_monitor collects them now: https://github.com/common-workflow-language/cwltool/blob/1bf74499ca1c4a5f98e7cffb0ad4aa89aa98cb9e/cwltool/job.py#L906-L911. So integrating over time would be hard.

I guess I could try and talk to the Docker API from the Toil CWL interpreter layer. But I don't like having that contact surface (cwltool itself never seems to use the Docker API), and then I probably end up with toil-and-cwl-specific code to do it, that I think wouldn't be able to be used on the WDL side because that's all Docker Swarm.

If cwltool-side stuff gains support for measuring (or back-calculating) core-seconds used, I could call into it for CWL, and that would be way better than this. But I'm trying to avoid getting into the existing monitoring code. (I'm also trying to put this project down because I am close to being out of available time to work on it, and I'm reluctant to throw out the whole approach I'd initially set Avni on and not merge any of her work.)

@adamnovak adamnovak merged commit 53f018b into master Jul 9, 2026
3 checks passed
@adamnovak adamnovak deleted the issues/5477-cpu-usage-docker-cwl branch July 9, 2026 21:02
Comment thread src/toil/cwl/cwltoil.py
Comment on lines +1173 to +1176
class ToilDockerCommandLineJob(DockerCommandLineJob):
"""Container job that collects resource stats from injected in-container code."""

def _execute(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sub-class of DockerCommandLineJob (itself a sub-class of ContainerCommandLineJob) doesn't override ContainerCommandLineJob.run() which does its own process monitoring: https://github.com/common-workflow-language/cwltool/blob/1bf74499ca1c4a5f98e7cffb0ad4aa89aa98cb9e/cwltool/job.py#L849-L858

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.

3 participants