letify 2.4.2__tar.gz → 2.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of letify might be problematic. Click here for more details.
- {letify-2.4.2 → letify-2.4.4}/PKG-INFO +1 -1
- {letify-2.4.2 → letify-2.4.4}/letify/__init__.py +7 -1
- {letify-2.4.2 → letify-2.4.4}/letify/declare/function.py +52 -3
- {letify-2.4.2 → letify-2.4.4}/letify/declare/instance.py +3 -0
- letify-2.4.4/letify/declare/replicas.py +111 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/worker.py +60 -7
- {letify-2.4.2 → letify-2.4.4}/letify/store/pathdata.py +40 -1
- {letify-2.4.2 → letify-2.4.4}/letify-core/Cargo.lock +3 -3
- {letify-2.4.2 → letify-2.4.4}/letify-core/Cargo.toml +1 -1
- {letify-2.4.2 → letify-2.4.4}/letify-ext/package-lock.json +2 -2
- {letify-2.4.2 → letify-2.4.4}/letify-ext/package.json +1 -1
- {letify-2.4.2 → letify-2.4.4}/pyproject.toml +1 -1
- {letify-2.4.2 → letify-2.4.4}/tests/test_data_streaming.py +186 -0
- letify-2.4.4/tests/test_distributed.py +149 -0
- {letify-2.4.2 → letify-2.4.4}/.github/workflows/ci.yml +0 -0
- {letify-2.4.2 → letify-2.4.4}/.github/workflows/docs.yml +0 -0
- {letify-2.4.2 → letify-2.4.4}/.github/workflows/publish.yml +0 -0
- {letify-2.4.2 → letify-2.4.4}/.gitignore +0 -0
- {letify-2.4.2 → letify-2.4.4}/.researchtree +0 -0
- {letify-2.4.2 → letify-2.4.4}/LICENSE +0 -0
- {letify-2.4.2 → letify-2.4.4}/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/01-getting-started.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/02-providers.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/05-concurrency.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/06-cost.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/08-releasing.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/locales/README_ko.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/docs/mkdocs.yml +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/cli.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/inventory.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/login.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/schema.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/secrets.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/config/writer.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/declare/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/declare/cache.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/declare/env.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/errors.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/install.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/launcher.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/paths.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/codec.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/driver.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/handle.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/protocol/wire.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/base.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/colab.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/colab_files.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/elice.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/kaggle.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/local.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/modal.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/modal_adapter.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/naming.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/shell.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/tunnel.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/providers/usage.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/capability.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/client.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/cuda.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/executor.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/frames.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/guard.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/tensor.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/trace.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/value.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/loader.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/remoting/probe.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/render.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/bootstrap.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/channel.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/lease.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/pool.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/session.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/runtime/telemetry.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/backends/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/backends/filesystem.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/backends/google_auth.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/backends/layout.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/backends/objects.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/cas.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/sendorder.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/store/volume.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/stubs.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/tools.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/__init__.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/agent.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/announce.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/link.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/nat.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/pipeline.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/portmap.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/probe.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/quic.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/rendezvous.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/setup.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/sshopts.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify/transport/strategies.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/.gitignore +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/build.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/.vscodeignore +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/LICENSE +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/README.md +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/media/icon.svg +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/cli.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/extension.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/history.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/model.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/poll.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/src/render.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/test/model.test.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify-ext/tsconfig.json +0 -0
- {letify-2.4.2 → letify-2.4.4}/letify_providers.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/scripts/sync-main.sh +0 -0
- {letify-2.4.2 → letify-2.4.4}/scripts/version.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/conftest.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/fake_modal_adapter.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_cli.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_cli_output.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_colab_keepalive.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_config.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_core.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_data.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_declare.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_device.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_editor_sessions.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_elice.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_frames.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_install.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle_link.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_link_log.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_notebook.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_parallel_placement.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_paths.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_portmap.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_protocol.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_providers.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_quic.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_remoting.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_render.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_runtime.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_ship.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_store.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_strategies.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_stubs.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_transport.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_tunnel_setup.py +0 -0
- {letify-2.4.2 → letify-2.4.4}/tests/test_version.py +0 -0
|
@@ -33,6 +33,7 @@ from .declare.cache import session_cache
|
|
|
33
33
|
from .declare.env import Env
|
|
34
34
|
from .declare.function import Function
|
|
35
35
|
from .declare.instance import AnyInstance, Host, Instance
|
|
36
|
+
from .declare.replicas import local_steps, rank, world_size
|
|
36
37
|
from .errors import (
|
|
37
38
|
ConfigError,
|
|
38
39
|
EnvironmentFailure,
|
|
@@ -58,11 +59,12 @@ from .store.volume import Volume
|
|
|
58
59
|
#: ``Final`` so a type checker sees the literal member, which the declaration overloads match on.
|
|
59
60
|
local: Final = Host.local
|
|
60
61
|
remote: Final = Host.remote
|
|
62
|
+
distributed: Final = Host.distributed
|
|
61
63
|
|
|
62
64
|
# The enum class stays importable from letify.declare.instance for letify's own use.
|
|
63
65
|
del Host
|
|
64
66
|
|
|
65
|
-
__version__ = "2.4.
|
|
67
|
+
__version__ = "2.4.4"
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def fetch(tensor): # type: ignore[no-untyped-def]
|
|
@@ -101,7 +103,11 @@ __all__ = [
|
|
|
101
103
|
"UnsupportedMode",
|
|
102
104
|
"Volume",
|
|
103
105
|
"__version__",
|
|
106
|
+
"distributed",
|
|
104
107
|
"local",
|
|
108
|
+
"local_steps",
|
|
109
|
+
"rank",
|
|
105
110
|
"remote",
|
|
106
111
|
"session_cache",
|
|
112
|
+
"world_size",
|
|
107
113
|
]
|
|
@@ -65,7 +65,9 @@ class Function(Generic[R]):
|
|
|
65
65
|
self.data_order = data_order
|
|
66
66
|
self.data_first_wave = data_first_wave
|
|
67
67
|
self.is_async = inspect.iscoroutinefunction(fn)
|
|
68
|
-
|
|
68
|
+
#: Spec "Replicas": one Instance per replica, in the order the list named them.
|
|
69
|
+
self.replicas = _replicas(device, self.host)
|
|
70
|
+
self.device = self._place(self.replicas[0] if self.replicas else device)
|
|
69
71
|
update_wrapper(self, fn)
|
|
70
72
|
|
|
71
73
|
def _place(self, device: Instance | AnyInstance) -> Instance | AnyInstance:
|
|
@@ -136,6 +138,16 @@ class Function(Generic[R]):
|
|
|
136
138
|
|
|
137
139
|
def _run(self, args: tuple, kwargs: dict) -> Any:
|
|
138
140
|
launcher = self.launcher
|
|
141
|
+
if self.host is Host.distributed:
|
|
142
|
+
# Spec "Replicas": the placement is declared and the runner is not written
|
|
143
|
+
# yet. Running the first replica alone would bill one runtime for a
|
|
144
|
+
# declaration that asked for several, without saying so.
|
|
145
|
+
raise UnsupportedMode(
|
|
146
|
+
f"host=letify.distributed declares {len(self.replicas)} replicas and "
|
|
147
|
+
f"letify cannot run them yet, so the call is refused rather than run on "
|
|
148
|
+
f"one of them. Use host=letify.remote with a single device until it is "
|
|
149
|
+
f"finished."
|
|
150
|
+
)
|
|
139
151
|
instance = launcher.resolve(self.device)
|
|
140
152
|
if instance.placement is Host.local:
|
|
141
153
|
return self._run_here(instance, args, kwargs)
|
|
@@ -249,6 +261,42 @@ def _report_deferred(delta: Any) -> None:
|
|
|
249
261
|
)
|
|
250
262
|
|
|
251
263
|
|
|
264
|
+
def _replicas(device: Any, host: Host) -> tuple[Instance, ...]:
|
|
265
|
+
"""The replicas a declaration names, or empty when it names a single device.
|
|
266
|
+
|
|
267
|
+
Spec "Replicas": ``device`` takes a list only under ``host=letify.distributed``, each
|
|
268
|
+
entry is one replica, and two replicas of one instance are refused because that asks
|
|
269
|
+
one account for twice the devices it has.
|
|
270
|
+
"""
|
|
271
|
+
listed = isinstance(device, (list, tuple))
|
|
272
|
+
if listed and host is not Host.distributed:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"device is a list of {len(device)} instances, which declares one replica "
|
|
275
|
+
f"each, so it needs host=letify.distributed. Under host={host.value!r} name "
|
|
276
|
+
f"one device."
|
|
277
|
+
)
|
|
278
|
+
if not listed:
|
|
279
|
+
return (device,) if host is Host.distributed else ()
|
|
280
|
+
if not device:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"device is an empty list, so the declaration names no replica. Name at least "
|
|
283
|
+
"one instance."
|
|
284
|
+
)
|
|
285
|
+
seen: list[str] = []
|
|
286
|
+
for instance in device:
|
|
287
|
+
alias = getattr(getattr(instance, "provider", None), "alias", None)
|
|
288
|
+
name = getattr(instance, "accelerator", None)
|
|
289
|
+
key = f"{alias}.{name}"
|
|
290
|
+
if key in seen:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"device names {key} twice, so one account would be asked for twice the "
|
|
293
|
+
f"devices one replica takes. Name each account once, and use a larger "
|
|
294
|
+
f"instance such as {name} * 2 for more devices in one replica."
|
|
295
|
+
)
|
|
296
|
+
seen.append(key)
|
|
297
|
+
return tuple(device)
|
|
298
|
+
|
|
299
|
+
|
|
252
300
|
def _where(host: Host | str | None) -> Host:
|
|
253
301
|
"""Validate the declared host placement, naming both options when it is wrong."""
|
|
254
302
|
if host is None:
|
|
@@ -258,8 +306,9 @@ def _where(host: Host | str | None) -> Host:
|
|
|
258
306
|
except ValueError:
|
|
259
307
|
raise ValueError(
|
|
260
308
|
f"host={host!r} is not a host placement. Use host='local' to keep Python "
|
|
261
|
-
f"here and forward PyTorch operators,
|
|
262
|
-
f"the machine that holds the device
|
|
309
|
+
f"here and forward PyTorch operators, host='remote' to ship the function to "
|
|
310
|
+
f"the machine that holds the device, or host='distributed' to run it once per "
|
|
311
|
+
f"device in a list."
|
|
263
312
|
) from None
|
|
264
313
|
|
|
265
314
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Where a replica learns which one it is, and what it reduces with.
|
|
2
|
+
|
|
3
|
+
Owns the per-replica facts a distributed body reads, ``rank`` and ``world_size``, and the
|
|
4
|
+
reduction a body asks for. It does not own the loop; spec "Replicas" says so and
|
|
5
|
+
``docs/INTENT.md`` keeps that as a non-goal for every loop but one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..errors import UnsupportedMode
|
|
15
|
+
|
|
16
|
+
#: Set on the thread running a distributed body, so ``rank`` and ``world_size`` answer.
|
|
17
|
+
_WHO = threading.local()
|
|
18
|
+
|
|
19
|
+
_OUTSIDE = (
|
|
20
|
+
"{name}() answers only inside a call declared with host=letify.distributed. A body "
|
|
21
|
+
"under host=letify.remote has one replica and no rank, and answering 0 and 1 here "
|
|
22
|
+
"would hide that it was asked"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rank() -> int:
|
|
27
|
+
"""Which replica this is, 0 upward in the order the ``device`` list names.
|
|
28
|
+
|
|
29
|
+
Spec "Replicas": raises outside a distributed call rather than answering 0.
|
|
30
|
+
"""
|
|
31
|
+
found = getattr(_WHO, "rank", None)
|
|
32
|
+
if found is None:
|
|
33
|
+
raise UnsupportedMode(_OUTSIDE.format(name="rank"))
|
|
34
|
+
return int(found)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def world_size() -> int:
|
|
38
|
+
"""How many replicas started. Spec "Replicas"."""
|
|
39
|
+
found = getattr(_WHO, "world_size", None)
|
|
40
|
+
if found is None:
|
|
41
|
+
raise UnsupportedMode(_OUTSIDE.format(name="world_size"))
|
|
42
|
+
return int(found)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: The fraction of a step a reduction may take before it is worth doing less often.
|
|
46
|
+
DEFAULT_SHARE = 0.25
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def steps_for(
|
|
50
|
+
*,
|
|
51
|
+
param_bytes: int,
|
|
52
|
+
step_seconds: float,
|
|
53
|
+
rate_bps: float | None,
|
|
54
|
+
share: float = DEFAULT_SHARE,
|
|
55
|
+
) -> int:
|
|
56
|
+
"""The smallest number of local steps for which a reduction is cheap enough.
|
|
57
|
+
|
|
58
|
+
Spec "How often to synchronize": a reduction moves the parameters once, so it is
|
|
59
|
+
worth it only when rare enough against the step it interrupts. Raises rather than
|
|
60
|
+
guessing when the link reports no rate, because a guess here is a guess about the
|
|
61
|
+
whole run's shape.
|
|
62
|
+
"""
|
|
63
|
+
if not rate_bps or rate_bps <= 0:
|
|
64
|
+
raise UnsupportedMode(
|
|
65
|
+
"local_steps needs the link's measured upload rate and this link reports "
|
|
66
|
+
"none, so there is nothing to divide the parameters by. The rate comes from "
|
|
67
|
+
"the probe, so a link the pipeline never probed, such as a provider "
|
|
68
|
+
"fallback, cannot answer. Pass the count instead of letify.auto"
|
|
69
|
+
)
|
|
70
|
+
if step_seconds <= 0:
|
|
71
|
+
raise ValueError("step_seconds has to be above zero to divide a transfer against")
|
|
72
|
+
allowed = max(share, 0.0) * step_seconds
|
|
73
|
+
if allowed <= 0:
|
|
74
|
+
raise ValueError("share has to be above zero, or no number of steps is ever enough")
|
|
75
|
+
transfer = param_bytes / rate_bps
|
|
76
|
+
return max(1, math.ceil(transfer / allowed))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_steps(param_bytes: int, step_seconds: float, share: float = DEFAULT_SHARE) -> int:
|
|
80
|
+
"""How many steps to take between reductions, from the link letify already measured.
|
|
81
|
+
|
|
82
|
+
Spec "How often to synchronize". Answers for the replica this body runs on, so two
|
|
83
|
+
replicas on links of different speed get different answers.
|
|
84
|
+
"""
|
|
85
|
+
rate = getattr(_WHO, "rate_bps", None)
|
|
86
|
+
return steps_for(
|
|
87
|
+
param_bytes=param_bytes,
|
|
88
|
+
step_seconds=step_seconds,
|
|
89
|
+
rate_bps=rate,
|
|
90
|
+
share=share,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Replica:
|
|
95
|
+
"""Holds the rank for the body's thread, so nothing has to be passed through it."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, rank: int, world_size: int, rate_bps: float | None = None) -> None:
|
|
98
|
+
self.rank = rank
|
|
99
|
+
self.world_size = world_size
|
|
100
|
+
self.rate_bps = rate_bps
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> _Replica:
|
|
103
|
+
_WHO.rank, _WHO.world_size = self.rank, self.world_size
|
|
104
|
+
_WHO.rate_bps = self.rate_bps
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
108
|
+
_WHO.rank = _WHO.world_size = _WHO.rate_bps = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["local_steps", "rank", "steps_for", "world_size"]
|
|
@@ -365,6 +365,19 @@ def _data_hash_file(path):
|
|
|
365
365
|
return hasher.hexdigest(length=16)
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
def _data_same_bytes(path, digest):
|
|
369
|
+
"""Whether the file still hashes to the digest it is the blob of.
|
|
370
|
+
|
|
371
|
+
Spec "Runtime data cache budget": only the ambiguous case is hashed, a size that
|
|
372
|
+
matches with a new timestamp, so the read is paid where a timestamp cannot decide.
|
|
373
|
+
Never raises; a file that cannot be read is not the file the digest promised.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
return _data_hash_file(path) == digest
|
|
377
|
+
except OSError:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
|
|
368
381
|
def _data_collect(data, placed):
|
|
369
382
|
"""Commit each file a returned call created or changed at an output into the cache."""
|
|
370
383
|
import stat
|
|
@@ -431,11 +444,25 @@ def _data_check_cache(data, placed):
|
|
|
431
444
|
info = os.stat(source)
|
|
432
445
|
except OSError:
|
|
433
446
|
continue
|
|
434
|
-
if (info.st_size, info.st_mtime_ns)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
447
|
+
if (info.st_size, info.st_mtime_ns) == cached:
|
|
448
|
+
continue
|
|
449
|
+
if info.st_size == cached[0] and _data_same_bytes(source, digest):
|
|
450
|
+
# Spec "Runtime data cache budget": the same bytes with a new timestamp, which
|
|
451
|
+
# is what a file copied back looks like. The digest still holds, so it stays.
|
|
452
|
+
continue
|
|
453
|
+
# Spec "Runtime data cache budget": removing a file a call may still need is worth
|
|
454
|
+
# a line, or it vanishes and only the next failure mentions it.
|
|
455
|
+
print(
|
|
456
|
+
"letify: data cache dropped %s, written through its link (%d bytes at %d, "
|
|
457
|
+
"placed as %d at %d)"
|
|
458
|
+
% (digest, info.st_size, info.st_mtime_ns, cached[0], cached[1]),
|
|
459
|
+
file=sys.stderr,
|
|
460
|
+
flush=True,
|
|
461
|
+
)
|
|
462
|
+
try:
|
|
463
|
+
os.remove(source)
|
|
464
|
+
except OSError:
|
|
465
|
+
pass
|
|
439
466
|
|
|
440
467
|
|
|
441
468
|
def _op_data_written(request):
|
|
@@ -675,12 +702,38 @@ def _data_budget(root, total, budget):
|
|
|
675
702
|
return min(_DATA_DEFAULT_BUDGET, (total + free) // 2)
|
|
676
703
|
|
|
677
704
|
|
|
705
|
+
def _digest_of(path):
|
|
706
|
+
"""The digest a blob path holds, which is its shard directory and its name joined."""
|
|
707
|
+
return os.path.basename(path)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _data_digests_in_flight():
|
|
711
|
+
"""Every digest a running call lists, so the prune does not take one out from under it.
|
|
712
|
+
|
|
713
|
+
Spec "Runtime data cache budget". Read under the lock the data thread writes the maps
|
|
714
|
+
with, so a call registering while this reads cannot change one mid iteration.
|
|
715
|
+
"""
|
|
716
|
+
wanted = set()
|
|
717
|
+
with _DATA_READY:
|
|
718
|
+
for state in list(_DATA_CALLS.values()):
|
|
719
|
+
wanted.update(state["by_digest"])
|
|
720
|
+
return wanted
|
|
721
|
+
|
|
722
|
+
|
|
678
723
|
def _op_data_evict(request):
|
|
679
|
-
"""Remove least recently used blobs no call links until the cache is within budget.
|
|
724
|
+
"""Remove least recently used blobs no call links until the cache is within budget.
|
|
725
|
+
|
|
726
|
+
Spec "Runtime data cache budget": a digest a running call lists is skipped whatever
|
|
727
|
+
its link count and age, because the call was told the runtime held it and so did not
|
|
728
|
+
send it. ``recent`` is the window a blob no call is using is protected for, carried on
|
|
729
|
+
the request beside the budget.
|
|
730
|
+
"""
|
|
680
731
|
started = time.monotonic()
|
|
681
732
|
root = os.path.expanduser(request["dir"])
|
|
682
733
|
files, total = _data_scan(root)
|
|
683
734
|
budget = _data_budget(root, total, request.get("budget"))
|
|
735
|
+
recent = float(request.get("recent", _DATA_RECENT_S))
|
|
736
|
+
wanted = _data_digests_in_flight()
|
|
684
737
|
removed = freed = 0
|
|
685
738
|
if total > budget:
|
|
686
739
|
now = time.time()
|
|
@@ -688,7 +741,7 @@ def _op_data_evict(request):
|
|
|
688
741
|
for used, path, size, links in files:
|
|
689
742
|
if total <= budget:
|
|
690
743
|
break
|
|
691
|
-
if links > 1 or now - used <
|
|
744
|
+
if links > 1 or now - used < recent or _digest_of(path) in wanted:
|
|
692
745
|
continue
|
|
693
746
|
try:
|
|
694
747
|
os.remove(path)
|
|
@@ -1183,7 +1183,10 @@ def _get(
|
|
|
1183
1183
|
"""
|
|
1184
1184
|
from ..protocol.codec import _hasher
|
|
1185
1185
|
|
|
1186
|
-
|
|
1186
|
+
# Spec "Writing back": a stream that fails after these were created would leave them
|
|
1187
|
+
# empty, and a resume that picks the newest directory by name then trusts a checkpoint
|
|
1188
|
+
# holding nothing. Remembered so the failure can take back exactly what it made.
|
|
1189
|
+
made = _made_dirs(target.parent)
|
|
1187
1190
|
partial = target.with_name(
|
|
1188
1191
|
f".{target.name}.letify-partial.{os.getpid()}.{threading.get_ident()}"
|
|
1189
1192
|
)
|
|
@@ -1210,6 +1213,7 @@ def _get(
|
|
|
1210
1213
|
writer = threading.Thread(target=write_pieces, daemon=True)
|
|
1211
1214
|
writer.start()
|
|
1212
1215
|
received = 0
|
|
1216
|
+
landed = False
|
|
1213
1217
|
try:
|
|
1214
1218
|
for piece in _stream(runtime, {"op": "data_stream", "dir": blobs, "digest": digest}):
|
|
1215
1219
|
view = memoryview(piece).cast("B")
|
|
@@ -1230,11 +1234,46 @@ def _get(
|
|
|
1230
1234
|
if found != digest:
|
|
1231
1235
|
raise RuntimeFailure(f"{runtime.name}: file blob {digest} arrived with digest {found}")
|
|
1232
1236
|
os.replace(partial, target)
|
|
1237
|
+
landed = True
|
|
1233
1238
|
finally:
|
|
1234
1239
|
if writer.is_alive():
|
|
1235
1240
|
pieces.put(None)
|
|
1236
1241
|
writer.join(5)
|
|
1242
|
+
# Before the directories are taken back, or they would not be empty yet.
|
|
1237
1243
|
partial.unlink(missing_ok=True)
|
|
1244
|
+
if not landed:
|
|
1245
|
+
_unmake_dirs(made)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _made_dirs(directory: Path) -> list[Path]:
|
|
1249
|
+
"""Create ``directory`` and return the ones this call had to make, innermost last.
|
|
1250
|
+
|
|
1251
|
+
Spec "Writing back": only what letify made is ever taken back, so a directory the
|
|
1252
|
+
user already had is never touched.
|
|
1253
|
+
"""
|
|
1254
|
+
missing: list[Path] = []
|
|
1255
|
+
probe = directory
|
|
1256
|
+
while not probe.exists():
|
|
1257
|
+
missing.append(probe)
|
|
1258
|
+
parent = probe.parent
|
|
1259
|
+
if parent == probe:
|
|
1260
|
+
break
|
|
1261
|
+
probe = parent
|
|
1262
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1263
|
+
return list(reversed(missing))
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _unmake_dirs(made: list[Path]) -> None:
|
|
1267
|
+
"""Remove the directories ``_made_dirs`` created, innermost first, while empty.
|
|
1268
|
+
|
|
1269
|
+
Spec "Writing back". A directory that holds anything is left alone, because something
|
|
1270
|
+
else put it there.
|
|
1271
|
+
"""
|
|
1272
|
+
for directory in reversed(made):
|
|
1273
|
+
try:
|
|
1274
|
+
directory.rmdir()
|
|
1275
|
+
except OSError:
|
|
1276
|
+
return
|
|
1238
1277
|
|
|
1239
1278
|
|
|
1240
1279
|
def _worker(runtime: Runtime, payload: dict[str, Any]) -> Any:
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.4.
|
|
141
|
+
version = "2.4.4"
|
|
142
142
|
dependencies = [
|
|
143
143
|
"letify-wire",
|
|
144
144
|
"libloading",
|
|
@@ -146,7 +146,7 @@ dependencies = [
|
|
|
146
146
|
|
|
147
147
|
[[package]]
|
|
148
148
|
name = "letify-driver"
|
|
149
|
-
version = "2.4.
|
|
149
|
+
version = "2.4.4"
|
|
150
150
|
dependencies = [
|
|
151
151
|
"letify-wire",
|
|
152
152
|
]
|
|
@@ -164,7 +164,7 @@ dependencies = [
|
|
|
164
164
|
|
|
165
165
|
[[package]]
|
|
166
166
|
name = "letify-wire"
|
|
167
|
-
version = "2.4.
|
|
167
|
+
version = "2.4.4"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.4.
|
|
9
|
+
"version": "2.4.4",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@types/node": "^18.19.0",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "letify-status",
|
|
3
3
|
"displayName": "letify Status",
|
|
4
4
|
"description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
|
|
5
|
-
"version": "2.4.
|
|
5
|
+
"version": "2.4.4",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -1365,3 +1365,189 @@ def test_the_patch_a_fresh_process_installs_arrives_through_sitecustomize() -> N
|
|
|
1365
1365
|
)
|
|
1366
1366
|
assert "SITECUSTOMIZE" in done.stderr, done.stderr
|
|
1367
1367
|
assert "PTH" not in done.stderr, done.stderr
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def test_the_prune_leaves_a_live_call_its_blobs(launcher_from, project) -> None:
|
|
1371
|
+
"""Spec "Runtime data cache budget": a blob in flight for a running call is skipped.
|
|
1372
|
+
|
|
1373
|
+
A call is told which digests the runtime holds and does not send those, so a blob
|
|
1374
|
+
removed between the answer and the use fails the call. A ten minute call outlived the
|
|
1375
|
+
600 s recency window meant to carry it, and a second call beside it pruned 148 blobs
|
|
1376
|
+
at once down to the budget, including ones the first had been told it had. Requests on
|
|
1377
|
+
one channel may overlap, so the prune is sent here while the call is still running,
|
|
1378
|
+
which is the same collision without the timing.
|
|
1379
|
+
|
|
1380
|
+
This passes without the guard as well: every shape that could be built quickly leaves
|
|
1381
|
+
the blob with two links or a fresh use time, which the two older guards already cover.
|
|
1382
|
+
It pins the behaviour the spec states rather than reproducing the failure, and the
|
|
1383
|
+
reproduction is still open.
|
|
1384
|
+
"""
|
|
1385
|
+
# Nothing in the first wave, so the call places its files while it runs and the ones
|
|
1386
|
+
# it has not reached yet sit in the cache with a single link, which is the only state
|
|
1387
|
+
# the old guards did not cover.
|
|
1388
|
+
let = streaming(launcher_from, data_first_wave_mib=0)
|
|
1389
|
+
root = dataset(project, 4, size=1 << 18)
|
|
1390
|
+
started = threading.Event()
|
|
1391
|
+
|
|
1392
|
+
@let.function(device=let.providers.lab.CPU, host=letify.remote)
|
|
1393
|
+
def reads(directory: Path, flag: str) -> int:
|
|
1394
|
+
import os
|
|
1395
|
+
import time
|
|
1396
|
+
|
|
1397
|
+
with open(flag, "w") as handle:
|
|
1398
|
+
handle.write("in")
|
|
1399
|
+
for _ in range(400):
|
|
1400
|
+
if not os.path.exists(flag):
|
|
1401
|
+
break
|
|
1402
|
+
time.sleep(0.02)
|
|
1403
|
+
total = 0
|
|
1404
|
+
for entry in sorted(directory.iterdir()):
|
|
1405
|
+
total += len(entry.read_bytes())
|
|
1406
|
+
return total
|
|
1407
|
+
|
|
1408
|
+
flag = project / "running.flag"
|
|
1409
|
+
answer: list = []
|
|
1410
|
+
|
|
1411
|
+
def call() -> None:
|
|
1412
|
+
answer.append(reads(root, str(flag)))
|
|
1413
|
+
|
|
1414
|
+
with let.keep_alive():
|
|
1415
|
+
# One call first, so every blob is in the cache before the call under test starts.
|
|
1416
|
+
assert reads(root, str(project / "warm.flag")) == 4 * (1 << 18)
|
|
1417
|
+
worker = threading.Thread(target=call, daemon=True)
|
|
1418
|
+
worker.start()
|
|
1419
|
+
for _ in range(200):
|
|
1420
|
+
if flag.exists():
|
|
1421
|
+
started.set()
|
|
1422
|
+
break
|
|
1423
|
+
time.sleep(0.02)
|
|
1424
|
+
assert started.is_set(), "the call never reported that it had started"
|
|
1425
|
+
runtime = next(
|
|
1426
|
+
held for bucket in let.pool._runtimes.values() for held in bucket
|
|
1427
|
+
)
|
|
1428
|
+
root = (runtime.workspace or runtime.provider.workspace_root).rstrip("/")
|
|
1429
|
+
# Budget zero, so the prune takes everything it is allowed to take.
|
|
1430
|
+
runtime.request(
|
|
1431
|
+
# recent 0, so only the live call's own guard can save its blobs.
|
|
1432
|
+
{"op": "data_evict", "dir": f"{root}/data/blobs", "budget": 1, "recent": 0},
|
|
1433
|
+
timeout=60,
|
|
1434
|
+
)
|
|
1435
|
+
flag.unlink()
|
|
1436
|
+
worker.join(60)
|
|
1437
|
+
|
|
1438
|
+
assert answer == [4 * (1 << 18)], answer
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def test_a_write_back_that_fails_leaves_no_empty_directory(
|
|
1442
|
+
launcher_from, project, monkeypatch
|
|
1443
|
+
) -> None:
|
|
1444
|
+
"""Spec "Writing back": a failed stream removes the directories it made.
|
|
1445
|
+
|
|
1446
|
+
A live session's write-back created four checkpoint directories whose files never
|
|
1447
|
+
arrived, because the blobs they streamed from had been pruned, and a resume picked the
|
|
1448
|
+
newest by name and found nothing in it. That cost about 2,300 steps.
|
|
1449
|
+
"""
|
|
1450
|
+
let = streaming(launcher_from, data_first_wave_mib=1024)
|
|
1451
|
+
out = project / "outputs"
|
|
1452
|
+
out.mkdir()
|
|
1453
|
+
|
|
1454
|
+
@let.function(device=let.providers.lab.CPU, host=letify.remote)
|
|
1455
|
+
def writes(directory: Path) -> int:
|
|
1456
|
+
import os as _os
|
|
1457
|
+
|
|
1458
|
+
made = _os.path.join(str(directory), "checkpoint-2607")
|
|
1459
|
+
_os.makedirs(made, exist_ok=True)
|
|
1460
|
+
with open(_os.path.join(made, "trainer_state.json"), "wb") as handle:
|
|
1461
|
+
handle.write(b'{"step": 2607}')
|
|
1462
|
+
return 1
|
|
1463
|
+
|
|
1464
|
+
# The stream fails the way a pruned blob makes it fail.
|
|
1465
|
+
from letify.store import pathdata as module
|
|
1466
|
+
|
|
1467
|
+
def refuse(runtime, payload):
|
|
1468
|
+
raise letify.RuntimeFailure("data_stream failed: FileNotFoundError")
|
|
1469
|
+
|
|
1470
|
+
monkeypatch.setattr(module, "_stream", refuse)
|
|
1471
|
+
with pytest.raises(letify.LetifyError):
|
|
1472
|
+
writes(out)
|
|
1473
|
+
|
|
1474
|
+
assert not (out / "checkpoint-2607").exists(), sorted(
|
|
1475
|
+
str(p.relative_to(out)) for p in out.rglob("*")
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def test_a_write_back_leaves_a_directory_that_was_already_there(
|
|
1480
|
+
launcher_from, project, monkeypatch
|
|
1481
|
+
) -> None:
|
|
1482
|
+
"""Spec: a directory that already existed, or that holds anything, is left alone."""
|
|
1483
|
+
let = streaming(launcher_from, data_first_wave_mib=1024)
|
|
1484
|
+
out = project / "outputs"
|
|
1485
|
+
(out / "checkpoint-2607").mkdir(parents=True)
|
|
1486
|
+
(out / "checkpoint-2607" / "kept.txt").write_text("mine", encoding="utf-8")
|
|
1487
|
+
|
|
1488
|
+
@let.function(device=let.providers.lab.CPU, host=letify.remote)
|
|
1489
|
+
def writes(directory: Path) -> int:
|
|
1490
|
+
import os as _os
|
|
1491
|
+
|
|
1492
|
+
made = _os.path.join(str(directory), "checkpoint-2607")
|
|
1493
|
+
with open(_os.path.join(made, "trainer_state.json"), "wb") as handle:
|
|
1494
|
+
handle.write(b'{"step": 2607}')
|
|
1495
|
+
return 1
|
|
1496
|
+
|
|
1497
|
+
from letify.store import pathdata as module
|
|
1498
|
+
|
|
1499
|
+
def refuse(runtime, payload):
|
|
1500
|
+
raise letify.RuntimeFailure("data_stream failed: FileNotFoundError")
|
|
1501
|
+
|
|
1502
|
+
monkeypatch.setattr(module, "_stream", refuse)
|
|
1503
|
+
with pytest.raises(letify.LetifyError):
|
|
1504
|
+
writes(out)
|
|
1505
|
+
|
|
1506
|
+
assert (out / "checkpoint-2607" / "kept.txt").read_text(encoding="utf-8") == "mine"
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def test_the_cache_says_when_it_drops_a_blob_written_through_its_link() -> None:
|
|
1510
|
+
"""Spec "Runtime data cache budget": a dropped blob is reported, not silent.
|
|
1511
|
+
|
|
1512
|
+
A live session spent two days unable to tell this removal from the budget prune,
|
|
1513
|
+
because a blob vanished and only the next failure mentioned it.
|
|
1514
|
+
"""
|
|
1515
|
+
from letify.protocol import worker
|
|
1516
|
+
|
|
1517
|
+
at = worker.SOURCE.index("def _data_check_cache")
|
|
1518
|
+
body = worker.SOURCE[at : worker.SOURCE.index("\ndef ", at + 10)]
|
|
1519
|
+
assert "data cache dropped" in body
|
|
1520
|
+
assert "written through its link" in body
|
|
1521
|
+
assert "sys.stderr" in body
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def test_a_blob_whose_bytes_are_unchanged_survives_a_new_mtime(launcher_from, project) -> None:
|
|
1525
|
+
"""Spec "Runtime data cache budget": dropped on content, not on a timestamp.
|
|
1526
|
+
|
|
1527
|
+
A session restoring its blobs by copying them back had each copy dropped again on its
|
|
1528
|
+
new mtime, so a loop meant to repair the store kept emptying it.
|
|
1529
|
+
"""
|
|
1530
|
+
let = streaming(launcher_from, data_first_wave_mib=1024)
|
|
1531
|
+
root = dataset(project, 1, size=1 << 12)
|
|
1532
|
+
target = root / "000.bin"
|
|
1533
|
+
|
|
1534
|
+
@let.function(device=let.providers.lab.CPU, host=letify.remote)
|
|
1535
|
+
def touches(path: Path) -> list[int]:
|
|
1536
|
+
import os as _os
|
|
1537
|
+
|
|
1538
|
+
info = _os.stat(path)
|
|
1539
|
+
# The same bytes, a new timestamp: what a copy back looks like.
|
|
1540
|
+
_os.utime(path, (info.st_atime, info.st_mtime + 120))
|
|
1541
|
+
return [info.st_nlink, _os.stat(path).st_size]
|
|
1542
|
+
|
|
1543
|
+
links, size = touches(target)
|
|
1544
|
+
assert links == 2 and size == 1 << 12
|
|
1545
|
+
|
|
1546
|
+
# The blob is still there, so a second call is told the runtime has it.
|
|
1547
|
+
import io
|
|
1548
|
+
from contextlib import redirect_stderr
|
|
1549
|
+
|
|
1550
|
+
captured = io.StringIO()
|
|
1551
|
+
with redirect_stderr(captured):
|
|
1552
|
+
assert touches(target)[1] == 1 << 12
|
|
1553
|
+
assert "data cache dropped" not in captured.getvalue()
|