fix(slurm): make SLURM mode work with VRAM-based GPU sizing - #170
Merged
Conversation
SLURM workers started Ray with a hardcoded resource string carrying only the job id, so a SLURM cluster never advertised the VRAM_MB custom resource the AppBuilder uses to pack replicas by real GPU memory. The job script now queries nvidia-smi on the allocated node and appends VRAM_MB to the resource string; single-GPU workers only, matching the head, because a node-level VRAM_MB on a multi-GPU node is a sum Ray can satisfy across devices. A VRAM-packed replica requests its GPU demand as VRAM_MB and only a sub-1 num_gpus to bind a device, so the autoscaler's GPU-demand check saw num_gpus < 1 and never provisioned a GPU node. It now treats a VRAM_MB request as requiring one GPU. The CPU divisor is also guarded against a zero pending-CPU value. close_all() returned early when no Ray node was registered, which left SLURM jobs that were still queued — or started but not yet joined to Ray — running until their time limit after the worker shut down. It now always sweeps SLURM after draining the registered nodes, and awaits the graceful stops so shutdown does not race the process exit.
An autoscaling SLURM cluster can be scaled to zero at deploy time, so the proxy actor reports no GPU and no min_gpu_total_mb. The builder read that as "nothing known" and reserved a whole GPU per replica — a reservation that is never revisited once a worker node joins, so VRAM packing could not take effect on the very mode that needs it most. AppBuilder now takes an elastic_gpu_cluster flag (set by AppsManager for slurm mode). When the cluster is elastic and no GPU is visible yet, the replica requests VRAM_MB up front and stays pending until the autoscaler provides a node that advertises it, instead of locking in a whole-GPU reservation. Clusters with a known GPU are unaffected, and gpu_memory_mb=-1 still books a whole device. _sum_resources truncated num_gpus to an int, so the epsilon reservation used to bind a device for VRAM-packed replicas summed to zero and hid the app's GPU demand from the autoscaler. num_gpus now stays a float. Adds unit tests for the sizing helpers; they are pure and need no cluster.
…droom Deploying to a SLURM cluster scaled to zero could be rejected outright with "Insufficient resources", before the autoscaler had any chance to satisfy the request. The admission check in _check_resources first looks for a registered Ray node with room, then falls back to asking whether another SLURM worker could be spawned. That fallback compared the total number of worker jobs against max_workers. A job that has been submitted but has not yet registered as a Ray node satisfies neither test: it is invisible to the node scan, and it consumes headroom in the job count. That state is the norm rather than an edge case. A deployment on a cold cluster triggers its own scale-up — the app-introspection Ray task creates pending demand before the resource check runs — so with max_workers=1 the check reliably saw one job and zero nodes and refused. Larger max_workers narrows the window but does not close it. Worker jobs with no corresponding Ray node are now treated as capacity in flight, and admit the deployment when the worker defaults fit the request. The replica then waits for the node in the normal pending state.
nilsmechtel
marked this pull request as ready for review
September 5, 2026 07:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
SLURM mode could not run GPU applications that use VRAM-based sizing.
Ray Serve replicas that declare
gpu_memory_mbare packed onto GPUs by real memory: the builder requests aVRAM_MBcustom resource and a tinynum_gpusvalue that only binds a device. Four independent defects broke that path end-to-end on a SLURM cluster.SLURM workers never advertised
VRAM_MB. The worker job started Ray with a hardcoded--resourcesstring carrying only the job id, soray.cluster_resources()on a SLURM cluster contained noVRAM_MBat all. Every GPU app fell back to a whole-GPU reservation, and any app explicitly requestingVRAM_MBstayed pending forever.The autoscaler ignored VRAM-only demand.
_check_scale_upread pending GPU demand fromrequired_resources["GPU"]. A VRAM-packed replica reportsGPU: 0.01, which is< 1, so the autoscaler submitted a CPU-only SLURM job — or nothing — and the replica never scheduled.The builder locked in whole-GPU reservations on a scaled-to-zero cluster. GPU sizing is resolved once at deploy time by asking the proxy actor what GPUs the cluster has. An autoscaling SLURM cluster is normally at zero workers when an app is deployed, so the answer is "no GPU visible", and the builder chose a whole-GPU reservation. That reservation is never revisited after a worker joins, so VRAM packing could not take effect on the mode that needs it most.
The admission gate counted capacity in flight as consumed headroom.
_check_resourcesfirst looks for a registered Ray node with room, then falls back to asking whether another SLURM worker could be spawned — comparing the total number of worker jobs againstmax_workers. A job that has been submitted but has not yet registered as a Ray node satisfies neither test: it is invisible to the node scan and it consumes headroom in the job count. That state is the norm rather than an edge case, because a deployment on a cold cluster triggers its own scale-up: the app-introspection Ray task creates pending demand before the resource check runs. Withmax_workers=1the check therefore saw one job and zero nodes and rejected the deployment with "Insufficient resources" every time. Largermax_workersnarrows the window but does not close it.Separately,
SlurmWorkers.close_all()returned early whenever no Ray node was registered. SLURM jobs that were still queued, or had started but not yet joined Ray, therefore survived the worker's shutdown and burned allocation until they hit their time limit.Solution
Advertise real per-node VRAM. The generated sbatch script queries
nvidia-smion the allocated node and appends"VRAM_MB": <total>to the Ray resource string. Detection happens inside the job because only the allocated node knows its own GPU size. It is gated to single-GPU workers, matching the existing invariant for the head node: on a multi-GPU node the node-levelVRAM_MBis a sum Ray can satisfy across devices, which would let a replica claim more memory than any single GPU has. If detection fails the job logs a notice and starts withoutVRAM_MB, falling back to fractional sizing.Treat a VRAM request as GPU demand.
_check_scale_upnow raisesnum_gpusto 1 when pending resources includeVRAM_MBwithnum_gpus < 1. The CPU divisor is guarded against a zero pending-CPU value that could raiseZeroDivisionError.Request VRAM up front on elastic clusters.
AppBuildertakes a newelastic_gpu_clusterflag, set byAppsManagerforslurmmode. When the cluster is elastic and no GPU is visible yet, the replica requestsVRAM_MBrather than a whole GPU, so it stays pending until the autoscaler provides a node that advertises it. Clusters with a known GPU are unaffected, andgpu_memory_mb=-1still books a whole device.Admit deployments backed by capacity in flight. Worker jobs with no corresponding Ray node are no longer counted against
max_workers; they admit the deployment when the worker defaults fit the request, and the replica then waits for the node in the normal pending state.Keep fractional GPU demand visible.
_sum_resourcestruncatednum_gpusto an int, so the epsilon reservation used to bind a device summed to zero and hid the app's GPU demand entirely.num_gpusnow stays a float.Always sweep SLURM on shutdown.
close_all()drains registered nodes, awaits those graceful stops so shutdown does not race process exit, and then cancels any remaining jobs regardless of whether nodes were found.Behavioural changes
VRAM_MBresource. Existing deployments that reserve whole GPUs are unaffected; apps declaringgpu_memory_mbwill start packing by memory.No configuration or manifest changes are required.
Test plan
Validated on an NSC Berzelius A100 cluster (SLURM, apptainer,
min_workers=0,max_workers=1,-C thin) against the published dev imageghcr.io/aicell-lab/bioengine-worker:0.16.5-dev1, built from this branch. The worker booted from that image with no source bind-mount, so both the head and — viaruntime_env.py_modules— every replica ran the image's ownbioengine. The test application requestsgpu_memory_mband pullstorch==2.5.1throughruntime_env.pip, i.e. the same transport a real BioEngine app uses. Every GPU app shipped in this repo requests a whole device, so a purpose-built app was needed to exercise the VRAM packing path at all.Cold start, from a cluster with zero worker nodes, driven entirely through Hypha (
upload_app->deploy_app-> RPC call):Ray resources: {"slurm_job_id:17464167": 1, "VRAM_MB": 40960}.VRAM_MB: 32960.0still available on the node, i.e. 40960 minus the requested 8000.RUNNINGafter 181 s, and a method call over Hypha RPC ran a real fp16 8192³ matmul on the allocated device:{'gpu': 'NVIDIA A100-SXM4-40GB', 'gpu_total_mb': 40440, 'visible_devices': '0', 'matmul_tflops': 250.2, 'peak_alloc_mb': 520}— confirming the epsilonnum_gpusreservation binds a usable CUDA device.stop_appanddelete_appleft no applications behind. Shutting the worker down cancelled the still-running job (Cancelling 1 job(s): ['17464167']), completed in 73.3 s, and leftsqueueempty.Two further runs over the same branch source covered the remaining paths: an in-place redeploy of the same
application_idatgpu_memory_mb=-1reachedRUNNINGon the whole-GPU path at 236.4 TFLOPS, so both reservation shapes work on one cluster; and a pure-Ray run confirmed scale-down, where deleting the application let the node go idle and the autoscaler cancelled the job after 102.2 s against a 90 s threshold.Generated sbatch scripts were also inspected for
num_gpusof 0, 1 and 2 to confirm the VRAM block is emitted only for single-GPU workers.Unit tests need no cluster.
tests/test_gpu_sizing.py(9 cases) covers the sizing helpers directly: VRAM-advertised packing, the fraction fallback, the elastic-cluster path, the static-cluster whole-GPU fallback, thegpu_memory_mb=-1sentinel,disable_gpu, idempotent re-derivation, the oversized-request error, and fractionalnum_gpussummation.tests/test_slurm_resource_admission.py(4 cases) drives_check_resourcesagainst a stub cluster: empty cluster, in-flight job counted as capacity, registered node with room, and a full cluster correctly rejecting an oversized request. Reverting themanager.pychange makes the in-flight case fail and leaves the other three passing.Files changed
bioengine/cluster/slurm_workers.pyVRAM_MBin the sbatch script; treatVRAM_MBdemand as requiring a GPU in_check_scale_up; guard the CPU divisor; always sweep SLURM jobs inclose_all()and await graceful stopsbioengine/apps/builder.pyelastic_gpu_clusterflag; merge it into the cluster GPU sizing result; requestVRAM_MBwhen an elastic cluster has no GPU visible yet; keepnum_gpusa float in_sum_resourcesbioengine/apps/manager.pyelastic_gpu_cluster=self.ray_cluster.mode == "slurm"toAppBuilder; treat unregistered SLURM worker jobs as capacity in flight in_check_resourcestests/test_gpu_sizing.pytests/test_slurm_resource_admission.pypyproject.toml,bioengine/_version.py