Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request optimizes sandbox creation and image caching performance. Key changes include caching the default OCI specification per runsc binary to avoid redundant process spawning, introducing a fast path with shared locks for image cache hits, and delegating image pulling and pinning entirely to the backend to prevent redundant lookups and clean up failed creations. Feedback on these changes suggests optimizing the _short_of_free_space helper to reduce statvfs system calls to at most one, and opening lock files in append mode ('a') instead of write mode ('w') to avoid unnecessary file truncation and metadata contention under high concurrency.
| def _short_of_free_space(images_dir: str) -> int: | ||
| """The free-space floor if the cache's filesystem is below it, else 0. | ||
|
|
||
| At most two statvfs calls, cheap enough for every cache hit. | ||
| """ | ||
| min_free = image_cache_min_free_bytes(images_dir) | ||
| free = _free_bytes(images_dir) if min_free > 0 else None | ||
| return min_free if free is not None and free < min_free else 0 |
There was a problem hiding this comment.
The _short_of_free_space function currently calls image_cache_min_free_bytes and _free_bytes, both of which may invoke shutil.disk_usage (which performs a statvfs system call). This results in up to two statvfs calls on every single cache hit. We can optimize this to at most one statvfs call by directly calling shutil.disk_usage once and using its returned total and free fields.
def _short_of_free_space(images_dir: str) -> int:
"""The free-space floor if the cache's filesystem is below it, else 0.
At most one statvfs call, cheap enough for every cache hit.
"""
raw = os.environ.get(_IMAGE_CACHE_MIN_FREE_BYTES_ENV)
if raw is not None and raw.strip():
try:
min_free = max(int(raw), 0)
except ValueError:
min_free = None
else:
min_free = None
try:
if min_free is not None:
if min_free == 0:
return 0
free = shutil.disk_usage(images_dir).free
return min_free if free < min_free else 0
else:
usage = shutil.disk_usage(images_dir)
min_free = usage.total // 10
return min_free if usage.free < min_free else 0
except OSError:
return 0There was a problem hiding this comment.
Not changing this: two statvfs calls cost a few microseconds against a 0.09 ms cache hit, and folding them into one would duplicate how image_cache_min_free_bytes reads RAY_SANDBOX_IMAGE_CACHE_MIN_FREE_BYTES.
| # sandboxes starting on a node at once don't queue on one another. Pulls | ||
| # and eviction take the lock exclusively, so neither runs while a user | ||
| # registers here. | ||
| with open(lock_path, "w", encoding="utf-8") as f_lock: |
There was a problem hiding this comment.
Opening the lock file with mode "w" truncates the file. Under high concurrency (e.g., many sandboxes starting at once and hitting the cache), multiple processes concurrently truncating the same lock file causes unnecessary write I/O and metadata contention on the filesystem. Since the lock file is only used for flock and its content is irrelevant, opening it with mode "a" (append mode) is a safer and more efficient alternative that avoids truncation while still creating the file if it does not exist.
| with open(lock_path, "w", encoding="utf-8") as f_lock: | |
| with open(lock_path, "a", encoding="utf-8") as f_lock: |
There was a problem hiding this comment.
Done in 12ec976: both opens in pull_and_extract_container_image use "a" now. flock doesn't care about the mode, and a cache hit no longer truncates the file.
| except Exception: | ||
| pass | ||
|
|
||
| with open(lock_path, "w", encoding="utf-8") as f_lock: |
There was a problem hiding this comment.
Similarly, opening the lock file with mode "w" here truncates the file. Using mode "a" (append mode) is preferred to avoid unnecessary write I/O and metadata updates on the filesystem.
| with open(lock_path, "w", encoding="utf-8") as f_lock: | |
| with open(lock_path, "a", encoding="utf-8") as f_lock: |
12ec976 to
0ca0654
Compare
|
@pcmoritz Rebased onto master, with one change after your approval. This PR used to pull the image before making the sandbox's directory, but #67022 now has the create take the bundle's owner file first, so that a reclaim can release the image pin of a worker that dies mid-create. The pull now stays after the owner file, and a |
Every create repeated work whose answer rarely changes: - `SandboxRuntime.create` looked the image up in the cache, and then the backend's create looked it up again. - Each lookup first scanned the whole image cache for eviction, sizing every cached image and the in-use marker of every live sandbox, and then took the image's exclusive lock, so creates of one image queued. - Each bundle ran `runsc spec`, a Go process, for the same default spec. - A read-only sandbox with a workdir logged the same warning per create. Now the backend's lookup is the only one. It runs once the bundle's owner file exists, so a reclaim can release the pin of a worker that dies, and a failed pull still reaches the caller as raised. A cache hit registers its sandbox under a shared lock and skips the eviction scan unless the disk is below the free-space floor, which it checks with two statvfs calls: running sandboxes' overlays can fill the disk without any pull. A pull scans under both the size cap and the floor, under the image's exclusive lock. The default spec is cached per runsc binary (path and mtime), and the warning is logged once per process. Signed-off-by: xyuzh <xinyzng@gmail.com>
Every cache hit opened the image's lock file with "w", which truncates it, only to flock it. Open it with "a" instead: the lock doesn't care about the mode, and a hit no longer writes to the file. Signed-off-by: xyuzh <xinyzng@gmail.com>
0ca0654 to
82c1022
Compare
- image_utils: master's ray-project#66469 finishes interrupted deletions at the top of every pull. That stays on every call, cache hits included (one listdir), so a node that only hits its cache still gets the space back. Eviction stays on the pull path and below the free-space floor. - image_manager: ray-project#66470 boots a readonly sandbox with a workdir from a kernel overlay, so the warning this PR logged once per process is gone. The once-per-process rule moves to the warning that remains, for workers that can't mount an overlay and fall back to a writable root. - gvisor: kept master's `image_dir = pull_image(...)` with this PR's comment on when the pin is taken. Signed-off-by: xyuzh <xinyzng@gmail.com>
|
@pcmoritz I merged master in ea07691. Two things changed after your approval, both where master moved code this PR touches:
Real gVisor in a privileged container (aarch64, runsc release-20261026.0): |
…mpty directory The sandbox directory can hold more than bundles (ray-project#67731 adds the owner locks' directory), so look for bundle directories. Signed-off-by: xyuzh <xinyzng@gmail.com>
Signed-off-by: xyuzh <xinyzng@gmail.com>
## Description Every runsc CLI call the gVisor backend makes (`run`, `state`, `exec`, `kill`, `delete`, `spec`) is a Go process, and Go sizes its runtime to every CPU it can see. On a 64-core node without a CPU limit, 64 concurrent `runsc exec` calls cost 0.023 CPU-s each. gVisor's containerd shim runs runsc with `GOMAXPROCS=2` for this reason. This PR runs those calls with `GOMAXPROCS=2` too (`runsc_env()` in `backend/gvisor.py`), unless the worker's environment sets it. runsc doesn't pass the variable on to the sandbox or the gofer, so `nproc` in a sandbox still reports every CPU. Measured on 2026-10-14 against base 00a3adb, on a 64-core CoreWeave node: 64 sandboxes each ran 20 `true` execs back to back, all at once; medians of 5 alternating runs per side. | CPUs runsc sees | master | this PR | | --- | --- | --- | | 64 (no CPU limit) | 1,855 execs/s, 0.0226 CPU-s each | 2,436 execs/s, 0.0129 CPU-s each | | 16 (`taskset`) | 907 execs/s, 0.0162 CPU-s each | 990 execs/s, 0.0146 CPU-s each | In pods with CPU limits, Go already sizes itself to the limit and the gain is small: 2-4% more execs and creates through the gRPC facade on four 30-CPU pods, with `GOMAXPROCS=2` set in the Ray workers' environment (runsc inherits it). ## Related issues Split out of #66821. ## Additional information - Not a duplicate: no open PR sets `GOMAXPROCS` for runsc (I searched for "sandbox GOMAXPROCS" and "runsc spec"). #66932 and #67022 add runsc calls, which should pass `env=runsc_env()` if this lands first. #67074 and #67076 edit the same `runsc state` and `runsc spec` calls; one-line merges. - Tests: `test_runsc_env_caps_gomaxprocs_unless_set` checks the default and an override; `test_runsc_run_starts_with_gomaxprocs` boots a sandbox and reads its `runsc run`'s environment. Both fail on master. `test_gpu_checks_run_runsc_with_runsc_env` covers the two GPU checks' runsc calls that came in with #65898. - Tested at 3cfe602, with master 6c61f9a (which has #67074) merged in, in a privileged container (aarch64, runsc release-20261026.0, erofs-utils 1.9.4): `TEST_SANDBOX=1 pytest tests/test_{gvisor_backend,checkpoint_restore,image_manager,image_utils,sandbox_runtime,overlayfs,sandbox_config,cgroup,resource_limits}.py`: 480 passed, 25 skipped, and the same two GPU tests fail as on master (473 passed), since that container's Ray predates `get_cdi_kind`. pre-commit passes. - I used Claude Code to help write and test this change. --------- Signed-off-by: xyuzh <xinyzng@gmail.com> Co-authored-by: Philipp Moritz <pcmoritz@gmail.com>
Signed-off-by: xyuzh <xinyzng@gmail.com>
…head Signed-off-by: xyuzh <xinyzng@gmail.com>
Master's ray-project#67874 added test_layer_cache_reclaimed_before_images, which uses a cache hit to start the eviction scan. With this PR a hit scans only when the disk is below the free-space floor, and the size cap waits for the next pull, as the docs say ("Before each pull"). The test's size case now pulls a second image to start the scan; the free-space case still uses a hit. Signed-off-by: xyuzh <xinyzng@gmail.com>
|
Merged master in 5df6c08 and adapted one test in 67226c2. Master's #67874 added macOS: 468 passed, and the same 31 environment failures as master. Real gVisor (runsc release-20261026.0): @pcmoritz, this is the only change since your approval. |
| min_free = _short_of_free_space(images_dir) | ||
| if min_free: | ||
| # Only the floor: a hit doesn't grow the cache, so the size | ||
| # cap waits for the next pull. | ||
| evict_least_recently_used_images( | ||
| images_dir, None, keep=safe_name, min_free_bytes=min_free | ||
| ) | ||
| return _finish() | ||
|
|
||
| # A pull makes room first. A cache hit (every sandbox start after | ||
| # the first) skips this scan, which sizes every cached image, | ||
| # unless the disk is below the floor. Under this image's lock, | ||
| # which the scan never takes (keep). | ||
| max_cache = image_cache_max_bytes(images_dir) | ||
| min_free = image_cache_min_free_bytes(images_dir) | ||
| if max_cache > 0 or min_free > 0: | ||
| evict_least_recently_used_images( | ||
| images_dir, | ||
| max_cache if max_cache > 0 else None, | ||
| keep=safe_name, | ||
| min_free_bytes=min_free, | ||
| ) |
There was a problem hiding this comment.
Checking RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES only before a pull (while the new image contributes 0 bytes) and skipping it after build_erofs_image and on cache hits leaves the cache above its byte cap indefinitely once a pull or later unpin crosses it. Have you considered recording each EROFS image's size in .extracted and re-checking max_cache after build_erofs_image?
There was a problem hiding this comment.
Fixed for pulls in a7d3c36: a pull scans for the size cap again once its own image is in the cache, so a pull that crosses the cap evicts right away. Test: test_a_pull_that_takes_the_cache_over_its_size_cap_evicts_at_once. An image that was pinned when the cap was crossed still waits for the next pull after it's released, since a hit doesn't grow the cache. Recording sizes in .extracted would make a check on every hit affordable; I left that out of this PR.
| # Finish deleting what a killed worker moved aside. One listdir, so a | ||
| # cache hit pays it too, and a node that only ever hits its cache still | ||
| # gets that space back. | ||
| _remove_stale_deletions(images_dir) |
There was a problem hiding this comment.
Running _remove_stale_deletions unlocked on every cache hit races active evictions that moved a directory to .deleting. and are running shutil.rmtree outside the image lock, making concurrent cache hits block on synchronous rmtree.
There was a problem hiding this comment.
Fixed in a7d3c36: on a cache hit the sweep skips a .deleting.* directory that changed in the last minute, which is one an eviction is still deleting, and takes the rest. A pull still sweeps everything after it releases the lock. Test: test_a_cache_hit_leaves_a_deletion_in_progress_to_its_owner.
There was a problem hiding this comment.
Update: 25e7de9 replaces the one-minute rule with a lock. A deletion holds a lock on the directory it removes, and a cache hit skips a directory whose lock is held, so it's exact for a deletion of any length.
| # lives (its EROFS image is the sandbox's root filesystem). Pinned | ||
| # only once the owner file names the image, so whenever this | ||
| # process dies, a reclaim can release the pin. | ||
| image_dir = self._image_manager.pull_image( | ||
| config.image, | ||
| timeout_seconds=config.timeout_seconds, |
There was a problem hiding this comment.
Have you considered passing the already-pinned image_dir from _start_sandbox into prepare_oci_bundle / create_oci_spec so each create doesn't call pull_image twice on a cache hit?
There was a problem hiding this comment.
Not changing this: with this PR a cache hit costs about 32 µs (5,000 hits on a cached busybox image in the test container), so the second lookup isn't worth threading the pinned directory through prepare_oci_bundle and create_oci_spec, which are part of BaseImageManager's interface.
| with _default_spec_lock: | ||
| cached = _default_spec_cache.get(key) if key else None | ||
| if cached is None: | ||
| from ray.experimental.sandbox.backend.gvisor import runsc_env | ||
|
|
||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| subprocess.run(["runsc", "spec"], cwd=temp_dir, check=True, env=runsc_env()) | ||
| config_path = os.path.join(temp_dir, "config.json") | ||
| with open(config_path, "r", encoding="utf-8") as f: | ||
| cached = json.load(f) | ||
| if key: | ||
| with _default_spec_lock: | ||
| _default_spec_cache[key] = cached |
There was a problem hiding this comment.
_default_spec_lock isn't held across subprocess.run(['runsc', 'spec']), so a cold multi-thread burst spawns concurrent runsc spec processes; also consider passing runsc from shutil.which('runsc') rather than re-searching PATH.
There was a problem hiding this comment.
Fixed in a7d3c36: the lock is held while runsc spec runs, so a burst of first creates runs it once, and it runs the binary that shutil.which found and the cache is keyed on. Test: test_a_burst_of_first_creates_runs_runsc_spec_once.
| # The backend pulls the image (a cache hit after the first create) | ||
| # and pins it for the sandbox's lifetime. |
There was a problem hiding this comment.
PR description nit: item 1 already landed on master (this diff only adds a comment here), and item 3's warning in image_manager.py now covers the writable-root overlayfs fallback rather than readonly workdir.
There was a problem hiding this comment.
Fixed in the description: the first bullet now says only what this diff does there, and the warning is described as the writable-root fallback.
Signed-off-by: xyuzh <xinyzng@gmail.com>
… hits off running deletions - A pull scans for the size cap again once its own image counts. The scan before the pull can't see that image, and cache hits don't check the cap, so the cache stayed over it until the next pull. - A cache hit finishes only the deletions that have sat untouched for a minute. One that an eviction started a moment ago is left to it, so the hit doesn't wait for another process's rmtree. - The first creates of a burst run 'runsc spec' once: the lock is held while it runs, and it runs the binary that the cache is keyed on. Signed-off-by: xyuzh <xinyzng@gmail.com>
xyuzh
left a comment
There was a problem hiding this comment.
Thanks for the review. a7d3c36 addresses the size cap, the stale-deletion sweep and the runsc spec lock, and the description is corrected.
@pcmoritz, the change since your approval is that commit: a pull checks the size cap a second time after its image is built, a cache hit only sweeps up moved-aside directories that have sat for a minute, and runsc spec runs under the cache's lock.
| min_free = _short_of_free_space(images_dir) | ||
| if min_free: | ||
| # Only the floor: a hit doesn't grow the cache, so the size | ||
| # cap waits for the next pull. | ||
| evict_least_recently_used_images( | ||
| images_dir, None, keep=safe_name, min_free_bytes=min_free | ||
| ) | ||
| return _finish() | ||
|
|
||
| # A pull makes room first. A cache hit (every sandbox start after | ||
| # the first) skips this scan, which sizes every cached image, | ||
| # unless the disk is below the floor. Under this image's lock, | ||
| # which the scan never takes (keep). | ||
| max_cache = image_cache_max_bytes(images_dir) | ||
| min_free = image_cache_min_free_bytes(images_dir) | ||
| if max_cache > 0 or min_free > 0: | ||
| evict_least_recently_used_images( | ||
| images_dir, | ||
| max_cache if max_cache > 0 else None, | ||
| keep=safe_name, | ||
| min_free_bytes=min_free, | ||
| ) |
There was a problem hiding this comment.
Fixed for pulls in a7d3c36: a pull scans for the size cap again once its own image is in the cache, so a pull that crosses the cap evicts right away. Test: test_a_pull_that_takes_the_cache_over_its_size_cap_evicts_at_once. An image that was pinned when the cap was crossed still waits for the next pull after it's released, since a hit doesn't grow the cache. Recording sizes in .extracted would make a check on every hit affordable; I left that out of this PR.
| # Finish deleting what a killed worker moved aside. One listdir, so a | ||
| # cache hit pays it too, and a node that only ever hits its cache still | ||
| # gets that space back. | ||
| _remove_stale_deletions(images_dir) |
There was a problem hiding this comment.
Fixed in a7d3c36: on a cache hit the sweep skips a .deleting.* directory that changed in the last minute, which is one an eviction is still deleting, and takes the rest. A pull still sweeps everything after it releases the lock. Test: test_a_cache_hit_leaves_a_deletion_in_progress_to_its_owner.
| # lives (its EROFS image is the sandbox's root filesystem). Pinned | ||
| # only once the owner file names the image, so whenever this | ||
| # process dies, a reclaim can release the pin. | ||
| image_dir = self._image_manager.pull_image( | ||
| config.image, | ||
| timeout_seconds=config.timeout_seconds, |
There was a problem hiding this comment.
Not changing this: with this PR a cache hit costs about 32 µs (5,000 hits on a cached busybox image in the test container), so the second lookup isn't worth threading the pinned directory through prepare_oci_bundle and create_oci_spec, which are part of BaseImageManager's interface.
| with _default_spec_lock: | ||
| cached = _default_spec_cache.get(key) if key else None | ||
| if cached is None: | ||
| from ray.experimental.sandbox.backend.gvisor import runsc_env | ||
|
|
||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| subprocess.run(["runsc", "spec"], cwd=temp_dir, check=True, env=runsc_env()) | ||
| config_path = os.path.join(temp_dir, "config.json") | ||
| with open(config_path, "r", encoding="utf-8") as f: | ||
| cached = json.load(f) | ||
| if key: | ||
| with _default_spec_lock: | ||
| _default_spec_cache[key] = cached |
There was a problem hiding this comment.
Fixed in a7d3c36: the lock is held while runsc spec runs, so a burst of first creates runs it once, and it runs the binary that shutil.which found and the cache is keyed on. Test: test_a_burst_of_first_creates_runs_runsc_spec_once.
| # The backend pulls the image (a cache hit after the first create) | ||
| # and pins it for the sandbox's lifetime. |
There was a problem hiding this comment.
Fixed in the description: the first bullet now says only what this diff does there, and the warning is described as the writable-root fallback.
… age The one-minute rule missed a deletion that runs longer: unlinking files deeper in a moved-aside directory doesn't change the directory itself. Each deletion now holds a lock on its directory, and a sweep skips a directory whose lock is held. The lock goes with its process, so what a killed worker left is taken at once, as before the age rule. Signed-off-by: xyuzh <xinyzng@gmail.com>
| # Finish deleting what a killed worker moved aside. One listdir, so a | ||
| # cache hit pays it too, and a node that only ever hits its cache still | ||
| # gets that space back. | ||
| _remove_stale_deletions(images_dir) |
There was a problem hiding this comment.
Update: 25e7de9 replaces the one-minute rule with a lock. A deletion holds a lock on the directory it removes, and a cache hit skips a directory whose lock is held, so it's exact for a deletion of any length.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 25e7de9. Configure here.
The deletion's lock was taken by the sweep that deletes the directory, after the rename had already put it under its .deleting name. A cache hit that swept in between found it unlocked and deleted it itself, which is the wait the lock is there to prevent. The rename now takes the lock first and hands it to the delete, so a directory is never under its new name without a lock while its owner is alive. Signed-off-by: xyuzh <xinyzng@gmail.com>

Description
Each image lookup on a create scans the whole cache for eviction (sizing every cached image and live sandbox marker), then takes the image's exclusive lock, so creates of one image queue. Each bundle also runs
runsc spec, a Go process, for the same default spec.This PR:
Measured on 2026-10-14 against base 00a3adb, before #67025 and the floor check (16 cores, medians of 5 alternating runs; the other images were stand-in cache entries):
Related issues
Split out of #66821.
Additional information
TEST_SANDBOX=1 pytest tests/test_{gvisor_backend,checkpoint_restore,image_manager,image_utils,sandbox_runtime,overlayfs,sandbox_config}.py: 368 passed, 15 skipped (master: 361 passed, 15 skipped). pre-commit passes.