letify 2.1.3__tar.gz → 2.2.0__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.
- {letify-2.1.3 → letify-2.2.0}/PKG-INFO +1 -1
- {letify-2.1.3 → letify-2.2.0}/letify/__init__.py +1 -1
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/worker.py +67 -4
- {letify-2.1.3 → letify-2.2.0}/letify/providers/base.py +11 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/kaggle.py +39 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/bootstrap.py +10 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/session.py +22 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/pathdata.py +30 -4
- {letify-2.1.3 → letify-2.2.0}/letify/transport/link.py +3 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/pipeline.py +18 -5
- {letify-2.1.3 → letify-2.2.0}/letify-core/Cargo.lock +3 -3
- {letify-2.1.3 → letify-2.2.0}/letify-core/Cargo.toml +1 -1
- {letify-2.1.3 → letify-2.2.0}/letify-ext/package-lock.json +2 -2
- {letify-2.1.3 → letify-2.2.0}/letify-ext/package.json +1 -1
- {letify-2.1.3 → letify-2.2.0}/pyproject.toml +1 -1
- {letify-2.1.3 → letify-2.2.0}/tests/test_core.py +52 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle_link.py +56 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_parallel_placement.py +39 -5
- {letify-2.1.3 → letify-2.2.0}/tests/test_runtime.py +37 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_transport.py +25 -0
- {letify-2.1.3 → letify-2.2.0}/.github/workflows/ci.yml +0 -0
- {letify-2.1.3 → letify-2.2.0}/.github/workflows/docs.yml +0 -0
- {letify-2.1.3 → letify-2.2.0}/.github/workflows/publish.yml +0 -0
- {letify-2.1.3 → letify-2.2.0}/.gitignore +0 -0
- {letify-2.1.3 → letify-2.2.0}/.researchtree +0 -0
- {letify-2.1.3 → letify-2.2.0}/LICENSE +0 -0
- {letify-2.1.3 → letify-2.2.0}/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/01-getting-started.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/02-providers.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/05-concurrency.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/06-cost.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/08-releasing.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/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.1.3 → letify-2.2.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/locales/README_ko.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/docs/mkdocs.yml +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/cli.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/inventory.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/login.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/schema.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/secrets.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/config/writer.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/declare/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/declare/cache.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/declare/env.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/declare/function.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/declare/instance.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/errors.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/install.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/launcher.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/paths.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/codec.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/driver.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/handle.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/protocol/wire.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/colab.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/colab_files.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/elice.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/local.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/modal.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/modal_adapter.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/naming.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/shell.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/tunnel.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/providers/usage.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/capability.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/client.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/cuda.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/executor.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/frames.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/guard.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/tensor.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/trace.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/value.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/loader.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/remoting/probe.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/render.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/channel.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/lease.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/pool.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/runtime/telemetry.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/backends/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/backends/filesystem.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/backends/google_auth.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/backends/layout.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/backends/objects.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/cas.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/sendorder.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/store/volume.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/stubs.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/tools.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/__init__.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/agent.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/announce.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/nat.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/portmap.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/probe.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/quic.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/rendezvous.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/setup.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/sshopts.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify/transport/strategies.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/.gitignore +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/build.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/.vscodeignore +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/LICENSE +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/README.md +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/media/icon.svg +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/cli.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/extension.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/history.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/model.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/poll.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/src/render.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/test/model.test.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify-ext/tsconfig.json +0 -0
- {letify-2.1.3 → letify-2.2.0}/letify_providers.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/scripts/sync-main.sh +0 -0
- {letify-2.1.3 → letify-2.2.0}/scripts/version.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/conftest.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/fake_modal_adapter.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_cli.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_cli_output.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_colab_keepalive.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_config.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_data.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_data_streaming.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_declare.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_device.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_editor_sessions.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_elice.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_frames.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_install.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_link_log.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_notebook.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_paths.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_portmap.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_protocol.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_providers.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_quic.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_remoting.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_render.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_ship.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_store.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_strategies.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_stubs.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_tunnel_setup.py +0 -0
- {letify-2.1.3 → letify-2.2.0}/tests/test_version.py +0 -0
|
@@ -64,6 +64,21 @@ widen_pipe(_FRAME_FD)
|
|
|
64
64
|
_SENDER = (TextSender if _LETIFY_TEXT_FRAMES else Sender)(fd_writer(_FRAME_FD))
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
#: Written to a captured descriptor to find out where its reader has got to. A pipe is
|
|
68
|
+
#: read in order, so every byte written before this has been framed once it is reached.
|
|
69
|
+
#: It is never forwarded. Spec "Worker output".
|
|
70
|
+
_DRAIN_TOKEN = b"\x1a\x1aLETIFY-DRAIN-7f3a9c4e\x1a\x1a"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _held_back(data):
|
|
74
|
+
"""How many bytes at the end of ``data`` could be the start of a split token."""
|
|
75
|
+
most = min(len(_DRAIN_TOKEN) - 1, len(data))
|
|
76
|
+
for size in range(most, 0, -1):
|
|
77
|
+
if _DRAIN_TOKEN.startswith(data[len(data) - size:]):
|
|
78
|
+
return size
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
67
82
|
class _Pump(threading.Thread):
|
|
68
83
|
"""Reads one captured descriptor and sends what it reads as output frames."""
|
|
69
84
|
|
|
@@ -71,10 +86,23 @@ class _Pump(threading.Thread):
|
|
|
71
86
|
threading.Thread.__init__(self, daemon=True)
|
|
72
87
|
self.kind = kind
|
|
73
88
|
self.busy = False
|
|
89
|
+
self.fd = fd
|
|
90
|
+
#: Bytes withheld because they may be the start of a token split across two reads.
|
|
91
|
+
self.tail = b""
|
|
92
|
+
self.reached = threading.Event()
|
|
74
93
|
self.read_fd, write_fd = os.pipe()
|
|
75
94
|
os.dup2(write_fd, fd)
|
|
76
95
|
os.close(write_fd)
|
|
77
96
|
|
|
97
|
+
def send(self, data):
|
|
98
|
+
if not data:
|
|
99
|
+
return
|
|
100
|
+
self.busy = True
|
|
101
|
+
try:
|
|
102
|
+
_SENDER.frame(self.kind, 0, data)
|
|
103
|
+
finally:
|
|
104
|
+
self.busy = False
|
|
105
|
+
|
|
78
106
|
def run(self):
|
|
79
107
|
while True:
|
|
80
108
|
try:
|
|
@@ -83,16 +111,48 @@ class _Pump(threading.Thread):
|
|
|
83
111
|
break
|
|
84
112
|
if not data:
|
|
85
113
|
break
|
|
86
|
-
self.
|
|
114
|
+
data = self.tail + data
|
|
115
|
+
self.tail = b""
|
|
87
116
|
try:
|
|
88
|
-
|
|
117
|
+
while True:
|
|
118
|
+
at = data.find(_DRAIN_TOKEN)
|
|
119
|
+
if at < 0:
|
|
120
|
+
break
|
|
121
|
+
self.send(data[:at])
|
|
122
|
+
data = data[at + len(_DRAIN_TOKEN):]
|
|
123
|
+
self.reached.set()
|
|
124
|
+
keep = _held_back(data)
|
|
125
|
+
if keep:
|
|
126
|
+
self.tail = data[len(data) - keep:]
|
|
127
|
+
data = data[: len(data) - keep]
|
|
128
|
+
self.send(data)
|
|
89
129
|
except OSError:
|
|
90
130
|
break
|
|
91
|
-
finally:
|
|
92
|
-
self.busy = False
|
|
93
131
|
os.close(self.read_fd)
|
|
94
132
|
|
|
95
133
|
|
|
134
|
+
def _drain_output(timeout=2.0):
|
|
135
|
+
"""Wait until everything written to the captured descriptors has been framed.
|
|
136
|
+
|
|
137
|
+
Spec "Worker output": a call's reply waits for the output its body made, so the
|
|
138
|
+
caller's own next print comes after it. Waiting on the reading thread's state is not
|
|
139
|
+
enough, because it has read bytes it has not yet framed; the token goes through the
|
|
140
|
+
pipe behind those bytes instead. It gives up rather than lose a result.
|
|
141
|
+
"""
|
|
142
|
+
for stream in (sys.stdout, sys.stderr):
|
|
143
|
+
try:
|
|
144
|
+
stream.flush()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
for pump in _PUMPS:
|
|
148
|
+
pump.reached.clear()
|
|
149
|
+
try:
|
|
150
|
+
os.write(pump.fd, _DRAIN_TOKEN)
|
|
151
|
+
except OSError:
|
|
152
|
+
continue
|
|
153
|
+
pump.reached.wait(timeout)
|
|
154
|
+
|
|
155
|
+
|
|
96
156
|
_PUMPS = [_Pump(1, STDOUT), _Pump(2, STDERR)]
|
|
97
157
|
for _pump in _PUMPS:
|
|
98
158
|
_pump.start()
|
|
@@ -2066,6 +2126,9 @@ def _run(stream, request, settle):
|
|
|
2066
2126
|
request = None
|
|
2067
2127
|
if settle:
|
|
2068
2128
|
_settle()
|
|
2129
|
+
if name == "call":
|
|
2130
|
+
# Spec "Worker output": the body's output is framed before its result goes out.
|
|
2131
|
+
_drain_output()
|
|
2069
2132
|
_reply(stream, outcome)
|
|
2070
2133
|
|
|
2071
2134
|
|
|
@@ -273,6 +273,17 @@ class Provider(abc.ABC):
|
|
|
273
273
|
value = self.config.option("transfer_streams")
|
|
274
274
|
return max(1, int(value)) if isinstance(value, (int, float)) else STREAMS
|
|
275
275
|
|
|
276
|
+
@property
|
|
277
|
+
def transfer_connection_cost_s(self) -> float:
|
|
278
|
+
"""Seconds one more transfer connection takes to open.
|
|
279
|
+
|
|
280
|
+
Spec "Several connections at once": zero where the provider dials an address, so
|
|
281
|
+
the extra connections are free and only the round trip decides. A provider reached
|
|
282
|
+
by a hole punch overrides it, because each stream punches again.
|
|
283
|
+
"""
|
|
284
|
+
value = self.config.option("transfer_connection_cost_s")
|
|
285
|
+
return max(0.0, float(value)) if isinstance(value, (int, float)) else 0.0
|
|
286
|
+
|
|
276
287
|
@property
|
|
277
288
|
def transfer_parallel_rtt_ms(self) -> int:
|
|
278
289
|
"""The round trip above which a blob is split, from ``transfer_parallel_rtt_ms``.
|
|
@@ -1207,6 +1207,45 @@ class Kaggle(Provider):
|
|
|
1207
1207
|
name=name,
|
|
1208
1208
|
)
|
|
1209
1209
|
|
|
1210
|
+
@property
|
|
1211
|
+
def transfer_connection_cost_s(self) -> float:
|
|
1212
|
+
"""A punch, which is what one more stream costs here. Measured at about 10 s."""
|
|
1213
|
+
value = self.config.option("transfer_connection_cost_s")
|
|
1214
|
+
return max(0.0, float(value)) if isinstance(value, (int, float)) else 10.0
|
|
1215
|
+
|
|
1216
|
+
def link(self, runtime: Runtime | None = None) -> Any:
|
|
1217
|
+
"""The link carrying this runtime, or None when the bridge is carrying it.
|
|
1218
|
+
|
|
1219
|
+
The placement reads ``rtt_ms`` from it to decide how many streams to use.
|
|
1220
|
+
"""
|
|
1221
|
+
name = getattr(runtime, "name", None)
|
|
1222
|
+
return self._links.get(name) if name else None
|
|
1223
|
+
|
|
1224
|
+
def transfer_channel(self, runtime: Runtime | None) -> Channel:
|
|
1225
|
+
"""One more stream for a placement, on a connection of its own.
|
|
1226
|
+
|
|
1227
|
+
Spec "Several connections at once": the link is asked for its own SSH command,
|
|
1228
|
+
which lands on a local forwarding port of its own and so punches again. Reusing
|
|
1229
|
+
the session's command would multiplex onto the one connection instead, and
|
|
1230
|
+
multiplexed channels do not add up.
|
|
1231
|
+
"""
|
|
1232
|
+
import shlex as _shlex
|
|
1233
|
+
|
|
1234
|
+
from ..protocol.worker import BOOTSTRAP
|
|
1235
|
+
from ..runtime.channel import PersistentChannel
|
|
1236
|
+
|
|
1237
|
+
name = getattr(runtime, "name", None)
|
|
1238
|
+
link = self._links.get(name) if name else None
|
|
1239
|
+
if link is None:
|
|
1240
|
+
raise UnsupportedMode(
|
|
1241
|
+
f"{self.alias} is carried by the kernel bridge for {name}, which is one "
|
|
1242
|
+
f"connection and offers no second stream. A punched session does."
|
|
1243
|
+
)
|
|
1244
|
+
return PersistentChannel(
|
|
1245
|
+
link.ssh_command(f"{self.remote_python} -u -c {_shlex.quote(BOOTSTRAP)}"),
|
|
1246
|
+
name=f"{self.alias}-transfer",
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1210
1249
|
def open_channel(self, runtime: Runtime) -> Channel:
|
|
1211
1250
|
"""Start a session from the cookie and open one worker in one cell of it.
|
|
1212
1251
|
|
|
@@ -130,6 +130,16 @@ def project_dir(workspace_root: str, env: Env) -> str:
|
|
|
130
130
|
return f"{workspace_root.rstrip('/')}/project/{env.key}"
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def packages_in(files: dict[str, bytes]) -> int:
|
|
134
|
+
"""How many packages the lock names, for the line the environment build prints.
|
|
135
|
+
|
|
136
|
+
The lock is already read, so counting its ``[[package]]`` entries costs nothing and
|
|
137
|
+
tells the reader whether to expect seconds or minutes. Spec "Environment".
|
|
138
|
+
"""
|
|
139
|
+
lock = files.get("uv.lock") or b""
|
|
140
|
+
return lock.count(b"[[package]]")
|
|
141
|
+
|
|
142
|
+
|
|
133
143
|
def project_files(env: Env) -> dict[str, bytes]:
|
|
134
144
|
"""Read the files a runtime syncs from, refusing what cannot give a matching interpreter.
|
|
135
145
|
|
|
@@ -613,6 +613,19 @@ class Runtime:
|
|
|
613
613
|
|
|
614
614
|
return (environment_volume(self.provider),)
|
|
615
615
|
|
|
616
|
+
def _say(self, message: str) -> None:
|
|
617
|
+
"""One decision line about this runtime, as the transport's lines are written.
|
|
618
|
+
|
|
619
|
+
Spec "Environment": the build says it is happening. Silent when the launcher was
|
|
620
|
+
built with ``announce=False``.
|
|
621
|
+
"""
|
|
622
|
+
from ..transport.announce import printer
|
|
623
|
+
|
|
624
|
+
# On the instance dict, not getattr: a provider's __getattr__ answers accelerator
|
|
625
|
+
# names and raises UnknownInstance for anything else, which no default catches.
|
|
626
|
+
announce = self.provider.__dict__.get("announce", True)
|
|
627
|
+
printer(bool(announce))(f"{self.name}: {message}")
|
|
628
|
+
|
|
616
629
|
def install_env(self) -> None:
|
|
617
630
|
"""Build the project's environment in the session and move the worker onto it.
|
|
618
631
|
|
|
@@ -641,6 +654,7 @@ class Runtime:
|
|
|
641
654
|
digest = volume.cached_env(self.env, self.platform)
|
|
642
655
|
if not digest:
|
|
643
656
|
continue
|
|
657
|
+
self._say(f"restoring the environment, cached as {digest[:12]}")
|
|
644
658
|
archive = f"{where['parent']}/.{digest}.tar.gz"
|
|
645
659
|
volume.materialize(
|
|
646
660
|
self, digest, path=archive, unpack=True, target=where["parent"], links=True
|
|
@@ -658,6 +672,13 @@ class Runtime:
|
|
|
658
672
|
self.env, files, root=root, name=self.name, workspace=workspace,
|
|
659
673
|
archive=bool(archives),
|
|
660
674
|
)
|
|
675
|
+
# Spec "Environment": the longest step of a first session, and uv's own output
|
|
676
|
+
# is captured rather than streamed, so it says it is happening.
|
|
677
|
+
self._say(
|
|
678
|
+
f"building the environment, {bootstrap.packages_in(files)} packages "
|
|
679
|
+
f"from {self.env.lock}"
|
|
680
|
+
)
|
|
681
|
+
began = time.monotonic()
|
|
661
682
|
try:
|
|
662
683
|
self.eval(source, timeout=3600)
|
|
663
684
|
except RemoteError as exc:
|
|
@@ -665,6 +686,7 @@ class Runtime:
|
|
|
665
686
|
raise EnvironmentFailure(
|
|
666
687
|
message.removeprefix("RuntimeError: "), stderr=exc.remote_traceback
|
|
667
688
|
) from exc
|
|
689
|
+
self._say(f"environment built in {time.monotonic() - began:.1f} s")
|
|
668
690
|
self.env_source = "sync"
|
|
669
691
|
self.python = where["python"]
|
|
670
692
|
self.channel.switch_interpreter(where["python"])
|
|
@@ -801,19 +801,38 @@ class Stream:
|
|
|
801
801
|
)
|
|
802
802
|
|
|
803
803
|
|
|
804
|
-
def streams_for(
|
|
804
|
+
def streams_for(
|
|
805
|
+
*,
|
|
806
|
+
streams: int,
|
|
807
|
+
threshold_mib: int,
|
|
808
|
+
rtt_ms: float | None,
|
|
809
|
+
size: int,
|
|
810
|
+
rate_bps: float | None = None,
|
|
811
|
+
cost_s: float = 0.0,
|
|
812
|
+
) -> int:
|
|
805
813
|
"""How many connections this blob is worth splitting across.
|
|
806
814
|
|
|
807
815
|
Spec "Several connections at once": one below the size threshold, because opening
|
|
808
816
|
connections to send a few megabytes costs more than it saves; one on a short link,
|
|
809
|
-
because there a single stream already carries everything the link will carry;
|
|
810
|
-
|
|
817
|
+
because there a single stream already carries everything the link will carry; never
|
|
818
|
+
more streams than there are chunks to send; and, when a connection costs something to
|
|
819
|
+
open, only as many as finish the transfer sooner than one would.
|
|
811
820
|
"""
|
|
812
821
|
if streams <= 1 or size < threshold_mib * (1 << 20):
|
|
813
822
|
return 1
|
|
814
823
|
if rtt_ms is None or rtt_ms < PARALLEL_RTT_MS:
|
|
815
824
|
return 1
|
|
816
|
-
|
|
825
|
+
count = max(1, min(streams, (size + CHUNK - 1) // CHUNK))
|
|
826
|
+
if cost_s > 0 and count > 1:
|
|
827
|
+
if not rate_bps or rate_bps <= 0:
|
|
828
|
+
# Nothing to weigh the cost against, and the cost is real, so do not pay it.
|
|
829
|
+
return 1
|
|
830
|
+
alone = size / rate_bps
|
|
831
|
+
# The connections are opened at the same time, so the cost is paid once.
|
|
832
|
+
together = size / (rate_bps * count) + cost_s
|
|
833
|
+
if together >= alone:
|
|
834
|
+
return 1
|
|
835
|
+
return count
|
|
817
836
|
|
|
818
837
|
|
|
819
838
|
def _streams_for(runtime: Any, size: int) -> int:
|
|
@@ -832,9 +851,16 @@ def _streams_for(runtime: Any, size: int) -> int:
|
|
|
832
851
|
rtt = getattr(link, "rtt_ms", None)
|
|
833
852
|
except Exception:
|
|
834
853
|
rtt = None
|
|
854
|
+
rate = None
|
|
855
|
+
try:
|
|
856
|
+
rate = getattr(link, "upload_bps", None)
|
|
857
|
+
except Exception:
|
|
858
|
+
rate = None
|
|
835
859
|
return streams_for(
|
|
836
860
|
streams=int(getattr(provider, "transfer_streams", STREAMS) or 1),
|
|
837
861
|
threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
|
|
862
|
+
rate_bps=rate,
|
|
863
|
+
cost_s=float(getattr(provider, "transfer_connection_cost_s", 0.0) or 0.0),
|
|
838
864
|
rtt_ms=rtt,
|
|
839
865
|
size=size,
|
|
840
866
|
)
|
|
@@ -34,6 +34,9 @@ class Link:
|
|
|
34
34
|
self.rank = rank
|
|
35
35
|
#: The round trip the pipeline measured over this link, when it measured one.
|
|
36
36
|
self.rtt_ms: float | None = None
|
|
37
|
+
#: The probe's upload rate, which the placement weighs a connection's cost
|
|
38
|
+
#: against. Spec "Several connections at once".
|
|
39
|
+
self.upload_bps: float | None = None
|
|
37
40
|
|
|
38
41
|
def probe_stream(self) -> object | None:
|
|
39
42
|
"""A stream the probe can run over, or None when this link cannot carry it."""
|
|
@@ -209,6 +209,19 @@ def _below(pairs: Sequence[tuple[str, float, float]], ratio: float, against: str
|
|
|
209
209
|
]
|
|
210
210
|
|
|
211
211
|
|
|
212
|
+
def _measured_on(link: Any, measured: ProbeResult | None) -> Any:
|
|
213
|
+
"""Record on the link what the probe measured, and return the link.
|
|
214
|
+
|
|
215
|
+
Every path out of ``connect`` goes through this, because the placement reads
|
|
216
|
+
``rtt_ms`` to decide how many connections to use and a link with none keeps a single
|
|
217
|
+
stream. Spec "Several connections at once".
|
|
218
|
+
"""
|
|
219
|
+
if measured is not None:
|
|
220
|
+
link.rtt_ms = measured.rtt_ms
|
|
221
|
+
link.upload_bps = measured.upload_bps
|
|
222
|
+
return link
|
|
223
|
+
|
|
224
|
+
|
|
212
225
|
def _close(link: Any) -> None:
|
|
213
226
|
try:
|
|
214
227
|
link.close()
|
|
@@ -308,7 +321,7 @@ class Pipeline:
|
|
|
308
321
|
[*reasons, f"{link.strategy}: below the floor: {', '.join(floored)}"]
|
|
309
322
|
),
|
|
310
323
|
)
|
|
311
|
-
return link
|
|
324
|
+
return _measured_on(link, measured)
|
|
312
325
|
|
|
313
326
|
network = self.fingerprint() if self.cache is not None else None
|
|
314
327
|
if self.cache is not None and network is not None:
|
|
@@ -324,9 +337,9 @@ class Pipeline:
|
|
|
324
337
|
f"{skipped_note}{held_note}"
|
|
325
338
|
)
|
|
326
339
|
link, measured = self._race(applicable, reasons)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
340
|
+
# Kept on the link so status can say how far away the machine is, and so the
|
|
341
|
+
# placement knows whether splitting a blob across connections is worth it.
|
|
342
|
+
_measured_on(link, measured)
|
|
330
343
|
if self.cache is not None and network is not None and measured is not None:
|
|
331
344
|
self.cache.save(link.strategy, measured, network)
|
|
332
345
|
self._say(f"cache rewritten: {link.strategy}")
|
|
@@ -394,7 +407,7 @@ class Pipeline:
|
|
|
394
407
|
_close(link)
|
|
395
408
|
return None
|
|
396
409
|
self._say(f"cached {name} accepted")
|
|
397
|
-
return link
|
|
410
|
+
return _measured_on(link, measured)
|
|
398
411
|
|
|
399
412
|
# -- the race ------------------------------------------------------------
|
|
400
413
|
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.
|
|
141
|
+
version = "2.2.0"
|
|
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.
|
|
149
|
+
version = "2.2.0"
|
|
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.
|
|
167
|
+
version = "2.2.0"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.2.0",
|
|
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.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -553,3 +553,55 @@ def test_status_reports_the_inventory_against_what_is_reserved(launcher_from) ->
|
|
|
553
553
|
let = launcher_from('[lab]\nkind = "local"\n[lab.devices]\nCPU = { count = 2 }\n')
|
|
554
554
|
report = let.status()
|
|
555
555
|
assert report["devices"]["lab"]["CPU"] == {"count": 2, "reserved": 0, "indices": []}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_the_output_of_a_call_comes_before_what_the_caller_prints_next(let, cpu, capsys) -> None:
|
|
559
|
+
"""Spec "Worker output": a call's reply waits for the output the body made.
|
|
560
|
+
|
|
561
|
+
The worker frames output on a thread of its own, so the reply could overtake it and
|
|
562
|
+
the caller's next print would land first, which is what a terminal shows out of order.
|
|
563
|
+
A live CI run failed the weaker form of this with `assert 'epoch 1 loss 0.5' in ''`.
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
@let.function(device=cpu, host=letify.remote)
|
|
567
|
+
def noisy() -> int:
|
|
568
|
+
for index in range(200):
|
|
569
|
+
print(f"line {index} of the body")
|
|
570
|
+
return 7
|
|
571
|
+
|
|
572
|
+
assert noisy() == 7
|
|
573
|
+
print("the caller speaks after the call")
|
|
574
|
+
out = capsys.readouterr().out
|
|
575
|
+
assert "line 0 of the body" in out
|
|
576
|
+
assert "line 199 of the body" in out
|
|
577
|
+
assert out.index("line 199 of the body") < out.index("the caller speaks after the call")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def test_output_that_looks_like_the_drain_token_still_arrives(let, cpu, capsys) -> None:
|
|
581
|
+
"""Spec "Worker output": the token is never forwarded, and output is never eaten.
|
|
582
|
+
|
|
583
|
+
The reader withholds a tail that could be the start of a split token, so a body whose
|
|
584
|
+
own output ends in that prefix must still see all of it arrive.
|
|
585
|
+
"""
|
|
586
|
+
import re
|
|
587
|
+
|
|
588
|
+
from letify.protocol import worker
|
|
589
|
+
|
|
590
|
+
found = re.search(r'_DRAIN_TOKEN = b"([^"]+)"', worker.SOURCE)
|
|
591
|
+
assert found, "the worker no longer names a drain token"
|
|
592
|
+
token = found.group(1).encode().decode("unicode_escape")
|
|
593
|
+
prefix = token[: len(token) - 1]
|
|
594
|
+
|
|
595
|
+
@let.function(device=cpu, host=letify.remote)
|
|
596
|
+
def speaks(text: str) -> int:
|
|
597
|
+
import sys
|
|
598
|
+
|
|
599
|
+
sys.stdout.write("before|" + text)
|
|
600
|
+
sys.stdout.flush()
|
|
601
|
+
sys.stdout.write("|after\n")
|
|
602
|
+
return len(text)
|
|
603
|
+
|
|
604
|
+
assert speaks(prefix) == len(prefix)
|
|
605
|
+
out = capsys.readouterr().out
|
|
606
|
+
assert "before|" in out and "|after" in out
|
|
607
|
+
assert token not in out
|
|
@@ -6,8 +6,10 @@ the strategies of Choosing a link are raced over it.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
9
10
|
from conftest import provider_of
|
|
10
11
|
|
|
12
|
+
from letify.errors import UnsupportedMode
|
|
11
13
|
from letify.providers.kaggle import Kaggle
|
|
12
14
|
|
|
13
15
|
|
|
@@ -243,3 +245,57 @@ def test_a_kaggle_runtime_pins_its_host_key_under_its_own_alias() -> None:
|
|
|
243
245
|
assert target.host_key_alias == "letify-kaggle_a-letify-cpu-abc123"
|
|
244
246
|
command = target.forwarded_ssh(40000)
|
|
245
247
|
assert command[command.index("HostKeyAlias=letify-kaggle_a-letify-cpu-abc123")]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_a_punched_kaggle_transfer_stream_asks_the_link_for_a_connection(monkeypatch) -> None:
|
|
251
|
+
"""Spec "Several connections at once": each stream gets a connection of its own.
|
|
252
|
+
|
|
253
|
+
Multiplexed channels do not add up. Measured on one punched Kaggle link at a 193 ms
|
|
254
|
+
round trip, four concurrent `ssh cat > /dev/null`, each asking the link for its own
|
|
255
|
+
command, carried 6.10 MiB/s against 1.57 MiB/s for one, while four channels
|
|
256
|
+
multiplexed onto the session's command carried 1.14 times one.
|
|
257
|
+
"""
|
|
258
|
+
built: list[list[str]] = []
|
|
259
|
+
asked: list[str] = []
|
|
260
|
+
provider = kaggle()
|
|
261
|
+
|
|
262
|
+
class Link:
|
|
263
|
+
"""Each ssh_command lands on a port of its own, as a punched link's does."""
|
|
264
|
+
|
|
265
|
+
strategy = "tcp_punch"
|
|
266
|
+
rtt_ms = 138.0
|
|
267
|
+
|
|
268
|
+
def ssh_command(self, remote_command=None):
|
|
269
|
+
asked.append("session" if not asked else "transfer")
|
|
270
|
+
return ["ssh", "-p", str(40000 + len(asked)), "root@127.0.0.1",
|
|
271
|
+
remote_command or ""]
|
|
272
|
+
|
|
273
|
+
class Fake:
|
|
274
|
+
def __init__(self, command, name=None, **kwargs):
|
|
275
|
+
built.append(command)
|
|
276
|
+
self.name = name
|
|
277
|
+
|
|
278
|
+
class Runtime:
|
|
279
|
+
name = "k-1"
|
|
280
|
+
|
|
281
|
+
monkeypatch.setattr(provider, "link_over", lambda channel, name=None: Link())
|
|
282
|
+
monkeypatch.setattr("letify.runtime.channel.PersistentChannel", Fake)
|
|
283
|
+
provider.channel_over(object(), name="k-1")
|
|
284
|
+
assert provider.link(Runtime()).rtt_ms == 138.0
|
|
285
|
+
|
|
286
|
+
provider.transfer_channel(Runtime())
|
|
287
|
+
assert len(built) == 2
|
|
288
|
+
assert built[1] != built[0], "a transfer channel asks the link for a command of its own"
|
|
289
|
+
assert asked == ["session", "transfer"]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_a_kaggle_session_on_the_bridge_offers_no_transfer_channel() -> None:
|
|
293
|
+
"""Spec: a session that never punched is carried by the bridge, which has no second channel."""
|
|
294
|
+
provider = kaggle()
|
|
295
|
+
|
|
296
|
+
class Runtime:
|
|
297
|
+
name = "never-punched"
|
|
298
|
+
|
|
299
|
+
assert provider.link(Runtime()) is None
|
|
300
|
+
with pytest.raises(UnsupportedMode):
|
|
301
|
+
provider.transfer_channel(Runtime())
|
|
@@ -304,14 +304,13 @@ def test_an_account_sets_the_round_trip_threshold() -> None:
|
|
|
304
304
|
|
|
305
305
|
def test_a_provider_with_no_transfer_channel_keeps_one_stream() -> None:
|
|
306
306
|
# Spec "Several connections at once": a provider whose channel is not a connection it
|
|
307
|
-
# can open more of, such as Modal's sandbox pipes
|
|
308
|
-
#
|
|
309
|
-
|
|
307
|
+
# can open more of, such as Modal's sandbox pipes, has no transfer channel, and asking
|
|
308
|
+
# it for one would fail inside the placement. Kaggle has one, because a session that
|
|
309
|
+
# punched is reached over SSH and the link can punch again for each stream.
|
|
310
310
|
from letify.providers.modal import Modal
|
|
311
311
|
from letify.store import pathdata
|
|
312
312
|
|
|
313
|
-
|
|
314
|
-
assert not hasattr(cls, "transfer_channel"), cls.__name__
|
|
313
|
+
assert not hasattr(Modal, "transfer_channel")
|
|
315
314
|
|
|
316
315
|
class Bare:
|
|
317
316
|
transfer_streams = 4
|
|
@@ -349,3 +348,38 @@ def test_the_guard_does_not_go_through_a_provider_lookup() -> None:
|
|
|
349
348
|
provider = Picky()
|
|
350
349
|
|
|
351
350
|
assert pathdata._streams_for(Runtime(), 256 << 20) == 1
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_streams_that_cost_a_connection_are_opened_only_when_they_pay() -> None:
|
|
354
|
+
"""Spec "Several connections at once": a connection that costs something pays for itself.
|
|
355
|
+
|
|
356
|
+
A Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream
|
|
357
|
+
and 9.5 MiB/s on four, because three more hole punches cost more than the transfer
|
|
358
|
+
saved.
|
|
359
|
+
"""
|
|
360
|
+
from letify.store.pathdata import streams_for
|
|
361
|
+
|
|
362
|
+
MIB = 1 << 20
|
|
363
|
+
near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB)
|
|
364
|
+
# Free connections: the near link still splits, as a forward SSH account does.
|
|
365
|
+
assert streams_for(**near, rate_bps=56.8 * MIB, cost_s=0.0) == 4
|
|
366
|
+
# A punch each: 128 MiB at 56.8 MiB/s takes 2.3 s, and one punch alone costs 10 s.
|
|
367
|
+
assert streams_for(**near, rate_bps=56.8 * MIB, cost_s=10.0) == 1
|
|
368
|
+
# Far and slow enough that the punch is worth it.
|
|
369
|
+
assert streams_for(
|
|
370
|
+
streams=4, threshold_mib=64, rtt_ms=193.0, size=1024 * MIB,
|
|
371
|
+
rate_bps=12.8 * MIB, cost_s=10.0,
|
|
372
|
+
) == 4
|
|
373
|
+
# No rate to estimate with: the cost cannot be weighed, so one stream.
|
|
374
|
+
assert streams_for(**near, rate_bps=None, cost_s=10.0) == 1
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_a_provider_reports_what_one_more_connection_costs() -> None:
|
|
378
|
+
"""Spec: 0 for a provider that dials an address, about 10 for one reached by a punch."""
|
|
379
|
+
from conftest import provider_of
|
|
380
|
+
|
|
381
|
+
from letify.providers.kaggle import Kaggle
|
|
382
|
+
from letify.providers.shell import Shell
|
|
383
|
+
|
|
384
|
+
assert float(provider_of(Shell, "dept_gpu").transfer_connection_cost_s) == 0.0
|
|
385
|
+
assert float(provider_of(Kaggle, "kaggle_a").transfer_connection_cost_s) >= 5.0
|