Skip to content

Commit 5053c87

Browse files
authored
(torchx/workspace) Add API for caching workspace build artifacts between roles
Differential Revision: D84466900 Pull Request resolved: #1149
1 parent 426b91a commit 5053c87

File tree

7 files changed

+431
-165
lines changed

7 files changed

+431
-165
lines changed

torchx/runner/api.py

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -427,41 +427,25 @@ def dryrun(
427427
sched._pre_build_validate(app, scheduler, resolved_cfg)
428428

429429
if isinstance(sched, WorkspaceMixin):
430-
for i, role in enumerate(app.roles):
431-
role_workspace = role.workspace
432-
433-
if i == 0 and workspace:
434-
# NOTE: torchx originally took workspace as a runner arg and only applied the workspace to role[0]
435-
# later, torchx added support for the workspace attr in Role
436-
# for BC, give precedence to the workspace argument over the workspace attr for role[0]
437-
if role_workspace:
438-
logger.info(
439-
f"Using workspace={workspace} over role[{i}].workspace={role_workspace} for role[{i}]={role.name}."
440-
" To use the role's workspace attr pass: --workspace='' from CLI or workspace=None programmatically." # noqa: B950
441-
)
442-
role_workspace = workspace
443-
444-
if role_workspace:
445-
old_img = role.image
430+
if workspace:
431+
# NOTE: torchx originally took workspace as a runner arg and only applied the workspace to role[0]
432+
# later, torchx added support for the workspace attr in Role
433+
# for BC, give precedence to the workspace argument over the workspace attr for role[0]
434+
if app.roles[0].workspace:
446435
logger.info(
447-
f"Checking for changes in workspace `{role_workspace}` for role[{i}]={role.name}..."
448-
)
449-
# TODO kiuk@ once we deprecate the `workspace` argument in runner APIs we can simplify the signature of
450-
# build_workspace_and_update_role2() to just taking the role and resolved_cfg
451-
sched.build_workspace_and_update_role2(
452-
role, role_workspace, resolved_cfg
436+
"Overriding role[%d] (%s) workspace to `%s`"
437+
"To use the role's workspace attr pass: --workspace='' from CLI or workspace=None programmatically.",
438+
0,
439+
role.name,
440+
str(app.roles[0].workspace),
453441
)
442+
app.roles[0].workspace = (
443+
Workspace.from_str(workspace)
444+
if isinstance(workspace, str)
445+
else workspace
446+
)
454447

455-
if old_img != role.image:
456-
logger.info(
457-
f"Built new image `{role.image}` based on original image `{old_img}`"
458-
f" and changes in workspace `{role_workspace}` for role[{i}]={role.name}."
459-
)
460-
else:
461-
logger.info(
462-
f"Reusing original image `{old_img}` for role[{i}]={role.name}."
463-
" Either a patch was built or no changes to workspace was detected."
464-
)
448+
sched.build_workspaces(app.roles, resolved_cfg)
465449

466450
sched._validate(app, scheduler, resolved_cfg)
467451
dryrun_info = sched.submit_dryrun(app, resolved_cfg)

torchx/schedulers/api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def submit(
131131
self,
132132
app: A,
133133
cfg: T,
134-
workspace: Optional[Union[Workspace, str]] = None,
134+
workspace: str | Workspace | None = None,
135135
) -> str:
136136
"""
137137
Submits the application to be run by the scheduler.
@@ -145,7 +145,12 @@ def submit(
145145
resolved_cfg = self.run_opts().resolve(cfg)
146146
if workspace:
147147
assert isinstance(self, WorkspaceMixin)
148-
self.build_workspace_and_update_role2(app.roles[0], workspace, resolved_cfg)
148+
149+
if isinstance(workspace, str):
150+
workspace = Workspace.from_str(workspace)
151+
152+
app.roles[0].workspace = workspace
153+
self.build_workspaces(app.roles, resolved_cfg)
149154

150155
# pyre-fixme: submit_dryrun takes Generic type for resolved_cfg
151156
dryrun_info = self.submit_dryrun(app, resolved_cfg)

torchx/specs/api.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import pathlib
1616
import re
17+
import shutil
1718
import typing
1819
import warnings
1920
from dataclasses import asdict, dataclass, field
@@ -381,13 +382,56 @@ def __bool__(self) -> bool:
381382
"""False if no projects mapping. Lets us use workspace object in an if-statement"""
382383
return bool(self.projects)
383384

385+
def __eq__(self, other: object) -> bool:
386+
if not isinstance(other, Workspace):
387+
return False
388+
return self.projects == other.projects
389+
390+
def __hash__(self) -> int:
391+
# makes it possible to use Workspace as the key in the workspace build cache
392+
# see WorkspaceMixin.caching_build_workspace_and_update_role
393+
return hash(frozenset(self.projects.items()))
394+
384395
def is_unmapped_single_project(self) -> bool:
385396
"""
386397
Returns ``True`` if this workspace only has 1 project
387398
and its target mapping is an empty string.
388399
"""
389400
return len(self.projects) == 1 and not next(iter(self.projects.values()))
390401

402+
def merge_into(self, outdir: str | pathlib.Path) -> None:
403+
"""
404+
Copies each project dir of this workspace into the specified ``outdir``.
405+
Each project dir is copied into ``{outdir}/{target}`` where ``target`` is
406+
the target mapping of the project dir.
407+
408+
For example:
409+
410+
.. code-block:: python
411+
from os.path import expanduser
412+
413+
workspace = Workspace(
414+
projects={
415+
expanduser("~/workspace/torch"): "torch",
416+
expanduser("~/workspace/my_project": "")
417+
}
418+
)
419+
workspace.merge_into(expanduser("~/tmp"))
420+
421+
Copies:
422+
423+
* ``~/workspace/torch/**`` into ``~/tmp/torch/**``
424+
* ``~/workspace/my_project/**`` into ``~/tmp/**``
425+
426+
"""
427+
428+
for src, dst in self.projects.items():
429+
dst_path = pathlib.Path(outdir) / dst
430+
if pathlib.Path(src).is_file():
431+
shutil.copy2(src, dst_path)
432+
else: # src is dir
433+
shutil.copytree(src, dst_path, dirs_exist_ok=True)
434+
391435
@staticmethod
392436
def from_str(workspace: str | None) -> "Workspace":
393437
import yaml

torchx/specs/test/api_test.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
TORCHX_HOME,
4747
Workspace,
4848
)
49+
from torchx.test.fixtures import TestWithTmpDir
4950

5051

5152
class TorchXHomeTest(unittest.TestCase):
@@ -75,7 +76,7 @@ def test_TORCHX_HOME_override(self) -> None:
7576
self.assertTrue(conda_pack_out.is_dir())
7677

7778

78-
class WorkspaceTest(unittest.TestCase):
79+
class WorkspaceTest(TestWithTmpDir):
7980

8081
def test_bool(self) -> None:
8182
self.assertFalse(Workspace(projects={}))
@@ -149,6 +150,28 @@ def test_from_str_multi_project(self) -> None:
149150
).projects,
150151
)
151152

153+
def test_merge(self) -> None:
154+
self.touch("workspace/myproj/README.md")
155+
self.touch("workspace/myproj/bin/cli")
156+
157+
self.touch("workspace/torch/setup.py")
158+
self.touch("workspace/torch/torch/__init__.py")
159+
160+
w = Workspace(
161+
projects={
162+
str(self.tmpdir / "workspace/myproj"): "",
163+
str(self.tmpdir / "workspace/torch"): "torch",
164+
}
165+
)
166+
167+
outdir = self.tmpdir / "out"
168+
w.merge_into(outdir)
169+
170+
self.assertTrue((outdir / "README.md").is_file())
171+
self.assertTrue((outdir / "bin/cli").is_file())
172+
self.assertTrue((outdir / "torch/setup.py").is_file())
173+
self.assertTrue((outdir / "torch/torch/__init__.py").is_file())
174+
152175

153176
class AppDryRunInfoTest(unittest.TestCase):
154177
def test_repr(self) -> None:

torchx/test/fixtures.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ def touch(self, filepath: str) -> Path:
7676
f.touch()
7777
return f
7878

79+
def mkdir(self, dirpath: str) -> Path:
80+
"""
81+
Creates a directory in the test's tmpdir and returns the ``Path`` to the created directory
82+
"""
83+
d = self.tmpdir / dirpath
84+
d.mkdir(parents=True, exist_ok=True)
85+
return d
86+
7987
def write(self, filepath: str, content: Iterable[str]) -> Path:
8088
"""
8189
Creates a file given the filepath (can be a file name or a relative path) in the test's tmpdir
@@ -136,6 +144,109 @@ def read(self, filepath: Union[str, Path]) -> List[str]:
136144
with open(self.tmpdir / filepath, "r") as fin:
137145
return fin.readlines()
138146

147+
def assertDirIsEmpty(self, directory: str | Path) -> None:
148+
"""
149+
Asserts that the given directory exists but is empty.
150+
If ``dir`` is a relative path, it is assumed to be relative to this test's ``tmpdir``.
151+
"""
152+
directory = Path(directory)
153+
d = directory if directory.is_absolute() else self.tmpdir / directory
154+
self.assertTrue(d.is_dir(), f"{d} is not a directory")
155+
self.assertEqual(0, len(list(d.iterdir())), f"{d} is not empty")
156+
157+
def assertDirTree(self, root: str | Path, tree: dict[str, object]) -> None:
158+
"""
159+
Asserts that the given ``root`` has the directory structure as specified by ``tree``.
160+
If ``root`` is a relative path, it is assumed to be relative to this test's ``tmpdir``
161+
162+
Usage:
163+
164+
.. code-block:: python
165+
self.assertDirTree(
166+
"out",
167+
{
168+
"setup.py": "",
169+
"torchx": {
170+
"__init__.py": "",
171+
"version.py": "0.8.0dev0",
172+
"specs": {
173+
"__init__.py": "",
174+
"api.py": "",
175+
},
176+
},
177+
},
178+
)
179+
"""
180+
root = Path(root)
181+
d = root if root.is_absolute() else self.tmpdir / root
182+
self.assertTrue(d.is_dir(), f"{d} is not a directory")
183+
184+
def _assert_tree(current_dir: Path, subtree: dict[str, object]) -> None:
185+
# Check that the directory contains exactly the keys in subtree
186+
actual_entries = {p.name for p in current_dir.iterdir()}
187+
expected_entries = set(subtree.keys())
188+
self.assertSetEqual(
189+
expected_entries,
190+
actual_entries,
191+
f"contents of the dir `{current_dir}` do not match the expected",
192+
)
193+
for name, value in subtree.items():
194+
path = current_dir / name
195+
if isinstance(value, dict):
196+
self.assertTrue(path.is_dir(), f"{path} is not a directory")
197+
_assert_tree(path, value)
198+
else:
199+
self.assertTrue(path.is_file(), f"{path} is not a file")
200+
if value != "":
201+
with open(path, "r") as f:
202+
content = f.read().strip()
203+
self.assertEqual(
204+
content,
205+
value,
206+
f"file {path} content {content!r} does not match expected {value!r}",
207+
)
208+
209+
_assert_tree(d, tree)
210+
211+
def create_dir_tree(self, root: str, tree: dict[str, object]) -> Path:
212+
"""
213+
Creates the directory structure as specified by ``tree`` under ``self.tmpdir / root``
214+
215+
Usage:
216+
217+
.. code-block:: python
218+
self.createDirTree(
219+
"out",
220+
{
221+
"README.md": "foobar",
222+
"torchx": {
223+
"__init__.py": "",
224+
"version.py": "0.8.0dev0",
225+
"specs": {
226+
"__init__.py": "",
227+
"api.py": "",
228+
},
229+
},
230+
},
231+
)
232+
"""
233+
d = self.tmpdir / root
234+
d.mkdir(parents=True, exist_ok=True)
235+
236+
def _create_tree(current_dir: Path, subtree: dict[str, object]) -> None:
237+
for name, value in subtree.items():
238+
path = current_dir / name
239+
if isinstance(value, dict):
240+
path.mkdir(parents=True, exist_ok=True)
241+
_create_tree(path, value)
242+
else:
243+
path.parent.mkdir(parents=True, exist_ok=True)
244+
with open(path, "w") as f:
245+
f.write(str(value))
246+
247+
_create_tree(d, tree)
248+
return d
249+
139250

140251
Ret = TypeVar("Ret")
141252

0 commit comments

Comments
 (0)