[joblib] Add opt-in autoscaling to the default 'ray' actor pool - #64957
[joblib] Add opt-in autoscaling to the default 'ray' actor pool#64957OneSizeFitsQuorum wants to merge 19 commits into
Conversation
Add an opt-in joblib backend that runs each batch as a short-lived Ray task instead of a long-lived actor, so pending work surfaces CPU demand to the Ray autoscaler and finished tasks release their CPUs. The existing "ray" backend keeps a fixed-size pool of num_cpus=0 actors (set by ray-project#22048) for the whole Parallel call, which surfaces no resource demand and prevents worker scale-down. The new "ray_tasks" backend submits num_cpus=1 tasks; Ray's scheduler bounds concurrency to the available CPUs, pending tasks drive autoscaling, and tasks release CPUs on completion. n_jobs is a concurrency cap, not a startup requirement: with M < N available CPUs and n_jobs=N, M tasks run and N-M stay pending (and drive autoscaling toward N). n_jobs=-1 keeps joblib's "all cores" convention; max_n_jobs sets an explicit cap. register_ray(register_tasks=True) additionally registers "ray_tasks"; the default is unchanged. Reuses RayBatchedCalls object-store dedup so a shared sklearn dataset is ray.put once across batches. Talks only to Ray Core APIs; does not call the Kubernetes API and does not depend on KubeRay. Documented in doc/source/ray-more-libs/joblib.rst. Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
fb9e1bd to
f32d423
Compare
Address code-style review feedback on the ray_tasks joblib backend:
- Drop review-directed prose from docstrings ("unchanged from prior Ray
releases", joblib version notes, test-leak "(or test)") and trim
over-explained comments to match the terse style of the surrounding joblib
module.
- Strip Sphinx :func:/:class: roles and RST emphasis (*...*) from code
docstrings; the module's existing style is plain text.
- Move local imports (joblib, ThreadingBackend, BatchedCalls, os,
multiprocessing.TimeoutError) to the module top.
- Remove the dead _node_id() helper (closures inline the call).
No behavior change; the 4 affected tests still pass locally.
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Reviewed by Cursor Bugbot for commit 27d7a27. Configure here.
There was a problem hiding this comment.
Code Review
This pull request introduces an elastic Ray task backend ("ray_tasks") for joblib, allowing each batch to run as a short-lived Ray task instead of using a fixed-size actor pool. This enables better integration with the Ray autoscaler and allows idle workers to scale down. The changes include documentation, unit tests, and the backend implementation itself. Feedback focuses on ensuring thread safety when modifying the in-flight task list concurrently from background threads, wrapping the RayBatchedCalls import in a try-except block to handle potential import failures, and making effective_n_jobs more robust when Ray is not yet initialized or when CPU resources are missing from the cluster state.
Address bot review feedback (Cursor Bugbot + gemini-code-assist) on the
ray_tasks joblib backend:
- max_n_jobs is now an upper bound on the resolved concurrency, not an
override of n_jobs: effective = min(resolve(n_jobs), max_n_jobs). So
parallel_backend("ray_tasks", n_jobs=8, max_n_jobs=64) runs 8, not 64.
(Cursor, medium.) To grow the cluster toward a ceiling, set n_jobs
directly (n_jobs=64); the docs now say so.
- Guard self._in_flight with a threading.Lock: the done-callback that
drops a ref runs on a Ray background thread, concurrent with the main
thread's submit/abort/terminate/configure. (gemini, high.)
- n_jobs==1 (no override) now falls back to Sequential BEFORE starting
Ray, matching the actor backend (which never inits Ray for n_jobs==1).
(Cursor, low.)
- Wrap the RayBatchedCalls import in try/except for consistency with the
`if RayBatchedCalls is None:` guard. (gemini, medium.)
Add two pure unit tests (no cluster) guarding the first two changes:
effective_n_jobs capping and n_jobs==1 not starting Ray. 14/14 tests pass
locally (joblib 1.2.0 + scikit-learn 1.5.2).
Skipped: gemini's effective_n_jobs-before-configure hardening, which would
diverge from the existing RayBackend (same cluster_resources() pattern).
Docs (joblib.rst) and PR body updated for the max_n_jobs semantic.
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
| Elastic Ray task backend (``"ray_tasks"``) | ||
| ------------------------------------------ | ||
|
|
||
| The default ``"ray"`` backend keeps a fixed-size pool of long-lived Ray actors | ||
| for the whole ``Parallel`` call. Because those actors do not reserve any CPUs | ||
| and never exit between batches, pending work is invisible to the Ray autoscaler | ||
| and the workers they run on cannot scale back down. This makes it a poor fit for | ||
| elastic clusters that grow and shrink with demand. |
There was a problem hiding this comment.
Instead, could we make the pool of actors autoscale? That would preferable over supporting two backends, and would likely have better and more predictable performance
|
@OneSizeFitsQuorum do you use the joblib backend? |
|
@edoakes Yes, I’m actively using the joblib backend. My goal is to take tasks that are currently parallelized locally with joblib and scale them out to a Kubernetes-backed Ray cluster by allowing the worker pool to grow with demand. I found that the current pool cannot scale elastically on Kubernetes, which led me to investigate how this could be improved. I agree that making the actor pool autoscale would be preferable if we can retain a single backend. However, the current pool uses fixed I’m happy to explore an autoscaling actor pool, potentially as an opt-in mode first. Would you prefer implementing it in |
I would suggest prototyping/validating it in whatever way is easiest. Could be in the multiprocessing pool, actor pool, or just hand-written. Once you have validated the behavior/performance, we can decide the best abstraction & layering. Off the top of my head, I would think it probably makes sense to have the actor pool support autoscaling optionally (and use it for the joblib backend), and then the multiprocessing pool could just be a fixed-size actor pool. |
|
@edoakes Thanks for the idea! I'll test it out and evaluate it. |
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Remove files that do not belong in this PR (preserved locally, not lost): - kuberay_joblib_actor_pool/ (bring-your-own-cluster KubeRay e2e harness: not a bazel target, not pytest-collected, arm64-only) - JOBLIB_ELASTIC_ACTOR_BACKEND_DESIGN.md (1788-line contributor design doc at repo root; Ray does not ship design docs there) - joblib_actor_pool_benchmark.py (manual dev tool, not CI-wired) - joblib_actor_pool_test_requirements.txt + the two joblib_1_5 BUILD.bazel targets (dead CI lane pinning joblib==1.5.3, which conflicts with the repo-wide joblib==1.2.0 the ray/ray_tasks backends require) Kept in PR: the ray_tasks backend, the actor-pool prototype + its unit/controller tests, the effective_n_jobs hardening, and the test_joblib_task_backend CI target. The single-backend direction (fold autoscaling into the default "ray" backend per edoakes' feedback) is planned separately. Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
5a910c4 to
8be4a0e
Compare
… reap) Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
…l overrides Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
…rkers Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>
CI's pytest_checker requires every py_test file to contain an `if __name__ == "__main__":` block. test_joblib_autoscale.py was missing it, failing the lint job. Add it plus the matching `import sys`, following the style of the neighboring test_joblib.py. Signed-off-by: OneSizeFitsQuorum <tanxinyu@apache.org>

Add opt-in autoscaling to the joblib actor pool
Closes #64959.
What
An opt-in
autoscale=Truemode on the existing"ray"joblib backend:Set it once at
register_ray— all subsequentparallel_backend("ray")callsuse autoscaling. Per-call params (
max_size,min_size, etc.) override theregister-time defaults.
Why
Ray maintainer edoakes commented on this PR: "Instead, could we make the pool
of actors autoscale? That would be preferable over supporting two backends."
This PR does exactly that — folds autoscaling into the default
"ray"actorpool (one backend), and withdraws the earlier
ray_taskstask-backend prototype(pre-release, never merged to master).
The #22048 deadlock was caused by the eager create-all +
ray.get(ping)readiness barrier, not by
num_cpusper se. Removing the barrier and creatingactors lazily (capped at
max_size) lets pendingnum_cpus=1actors drive theautoscaler instead of deadlocking construction. The default path
(
autoscale=False) is byte-identical — no behavioral change unless opted in.How
All changes are in
python/ray/util/multiprocessing/pool.py(~130 lines) +python/ray/util/joblib/ray_backend.py(~20 lines) +python/ray/util/joblib/__init__.py(~10 lines):_actor_poolbecomes a fixed-lengthOptionalslot list(
len = max_size, stable) so all existing chunking/fan-out/round-robin codeis unchanged.
_run_batchlazy-createsNoneslots on dispatch._start_actor_poolautoscale branch skips the eagercreate-all + ping barrier; actors are created on demand.
idle_timeout_sdownto
min_size, reusing the existing_stop_actorgraceful-terminate path.autoscale=True→num_cpus=1(drives the autoscaler);autoscale=False→num_cpus=0(back-compat).register_ray(autoscale=True, max_size=64)setsdefaults once via a factory closure; per-call params override.
RayBackendstores the params in
__init__and forwards them toPoolinconfigure.apply_async→AsyncResult→ResultThreadpath. No 1.5 dependency.Tests
python/ray/tests/test_joblib_autoscale.py(5 tests):autoscale=False→len == processes, all slots non-None).initial_size=0→ no actors at startup, lazy-create on dispatch).idle_timeout_s→ actors reaped to 0).@pytest.mark.skip): warm-actorpool.mapvsper-task cold dispatch — asserts the actor pool is faster. Skipped in CI
(timing is environment-dependent); see local validation below.
num_cpus=1actors drive the real Rayautoscaler to add worker nodes (cluster CPU grows 2 → 6 → 10). Uses the
same autoscaler KubeRay uses.
Local validation (not in CI)
Run on py311,
joblib==1.2.0, main-repo clean_raylet.sobuild overlaidwith this PR's three changed files:
pool.mapdispatch is ~5.1× fasterthan per-task cold dispatch (median 0.076s vs 0.388s, N=20, 3 rounds).
Pool and task are measured separately: a fixed pool of
num_cpus=1actorssaturates the cluster's CPUs, so concurrent
num_cpus=1tasks woulddeadlock — hence the separate measurement and the
@skipin CI.test_autoscaling_cluster_e2ePASSED (65s).Autoscaler log: head 2 CPU →
Adding 2 node(s) of type cpu_worker→Resized to 6 CPUs→Resized to 10 CPUs.RayCluster(head 2 CPU, worker groupminReplicas:0 / maxReplicas:3 / 2 CPU each,enableInTreeAutoscaling). The PR's three changed files werepatched into the head pod;
Pool(autoscale=True, max_size=6, idle_timeout_s=10)submittednum_cpus=1actors.worker pods —
kubectl get podsshowed 0 → 2 Running within ~10s;RESULTS_OK: True(50 batches correct),CLUSTER_CPUS: 6(head 2 +2 worker×2).
(
idle_timeout_s=10) reaps idle actors → worker pods go idle → theKubeRay autoscaler scales worker pods 2 → 0 and
CLUSTER_CPUSdrops6 → 2 (head only) within ~100s. Full 0 → 2 → 0 cycle captured.
The joblib backend layer (
RayBackend.configure) is the same pathvalidated in SC3; SC4 focuses on the KubeRay provider (RayCluster CR →
worker pod scale up + down).
Scope
~370 additions across 7 files. The core change is ~130 lines in
pool.py(one file) — no new module, no controller, no adapter, no second
ResultThread. The earlier 1336-line elastic-actor prototype and KubeRayharness were removed (preserved locally, not in the PR).
Not in scope
ray.util.ActorPoolmigration (pull-based contract; separate PR).ray_taskstask backend (withdrawn; pre-release, never merged to master).The
effective_n_jobshardening from that work is retained.Duplicate-work check
No open issue/PR proposes autoscaling the joblib actor pool. Sibling: #31128
(
ray.util.multiprocessing.Poolfixed-size, same root-cause family). #22048set
PoolActortonum_cpus=0(context for the barrier this PR removes).AI assistance
This change was prepared with AI assistance. The implementation, tests, and
this description were reviewed end-to-end before submission.