Skip to content

[joblib] Add opt-in autoscaling to the default 'ray' actor pool - #64957

Draft
OneSizeFitsQuorum wants to merge 19 commits into
ray-project:masterfrom
OneSizeFitsQuorum:worktree-joblib-task-backend
Draft

[joblib] Add opt-in autoscaling to the default 'ray' actor pool#64957
OneSizeFitsQuorum wants to merge 19 commits into
ray-project:masterfrom
OneSizeFitsQuorum:worktree-joblib-task-backend

Conversation

@OneSizeFitsQuorum

@OneSizeFitsQuorum OneSizeFitsQuorum commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Add opt-in autoscaling to the joblib actor pool

Closes #64959.

What

An opt-in autoscale=True mode on the existing "ray" joblib backend:

from ray.util.joblib import register_ray
register_ray(autoscale=True, max_size=64)

import joblib
with joblib.parallel_backend("ray", n_jobs=8):
    search.fit(X, y)

Set it once at register_ray — all subsequent parallel_backend("ray") calls
use autoscaling. Per-call params (max_size, min_size, etc.) override the
register-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" actor
pool (one backend), and withdraws the earlier ray_tasks task-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_cpus per se. Removing the barrier and creating
actors lazily (capped at max_size) lets pending num_cpus=1 actors drive the
autoscaler 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):

  • Slot model: _actor_pool becomes a fixed-length Optional slot list
    (len = max_size, stable) so all existing chunking/fan-out/round-robin code
    is unchanged. _run_batch lazy-creates None slots on dispatch.
  • Barrier removal: _start_actor_pool autoscale branch skips the eager
    create-all + ping barrier; actors are created on demand.
  • Idle reaper: a daemon thread reaps idle actors past idle_timeout_s down
    to min_size, reusing the existing _stop_actor graceful-terminate path.
  • num_cpus: autoscale=Truenum_cpus=1 (drives the autoscaler);
    autoscale=Falsenum_cpus=0 (back-compat).
  • register_ray factory: register_ray(autoscale=True, max_size=64) sets
    defaults once via a factory closure; per-call params override. RayBackend
    stores the params in __init__ and forwards them to Pool in configure.
  • joblib 1.2.0: uses the existing apply_asyncAsyncResult
    ResultThread path. No 1.5 dependency.

Tests

python/ray/tests/test_joblib_autoscale.py (5 tests):

  1. Default-path byte-identity (autoscale=Falselen == processes, all slots non-None).
  2. Lazy creation (initial_size=0 → no actors at startup, lazy-create on dispatch).
  3. Idle reaping (tiny idle_timeout_s → actors reaped to 0).
  4. Performance benchmark (@pytest.mark.skip): warm-actor pool.map vs
    per-task cold dispatch — asserts the actor pool is faster. Skipped in CI
    (timing is environment-dependent); see local validation below.
  5. AutoscalingCluster e2e: pending num_cpus=1 actors drive the real Ray
    autoscaler 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.so build overlaid
with this PR's three changed files:

  • Perf (criterion 1): warm-actor pool.map dispatch is ~5.1× faster
    than 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=1 actors
    saturates the cluster's CPUs, so concurrent num_cpus=1 tasks would
    deadlock — hence the separate measurement and the @skip in CI.
  • AutoscalingCluster e2e: test_autoscaling_cluster_e2e PASSED (65s).
    Autoscaler log: head 2 CPU → Adding 2 node(s) of type cpu_worker
    Resized to 6 CPUsResized to 10 CPUs.
  • K8s/KubeRay e2e (scale up + scale down): KubeRay operator + a
    RayCluster (head 2 CPU, worker group minReplicas:0 / maxReplicas:3 / 2 CPU each, enableInTreeAutoscaling). The PR's three changed files were
    patched into the head pod; Pool(autoscale=True, max_size=6, idle_timeout_s=10) submitted num_cpus=1 actors.
    • Scale up: pending placements drove the KubeRay autoscaler to add
      worker pods — kubectl get pods showed 0 → 2 Running within ~10s;
      RESULTS_OK: True (50 batches correct), CLUSTER_CPUS: 6 (head 2 +
      2 worker×2).
    • Scale down: after work completes and actors idle, the PR's reaper
      (idle_timeout_s=10) reaps idle actors → worker pods go idle → the
      KubeRay autoscaler scales worker pods 2 → 0 and CLUSTER_CPUS drops
      6 → 2 (head only) within ~100s. Full 0 → 2 → 0 cycle captured.
      The joblib backend layer (RayBackend.configure) is the same path
      validated 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 KubeRay
harness were removed (preserved locally, not in the PR).

Not in scope

  • ray.util.ActorPool migration (pull-based contract; separate PR).
  • KubeRay e2e harness (not a bazel target; external).
  • ray_tasks task backend (withdrawn; pre-release, never merged to master).
    The effective_n_jobs hardening from that work is retained.

Duplicate-work check

No open issue/PR proposes autoscaling the joblib actor pool. Sibling: #31128
(ray.util.multiprocessing.Pool fixed-size, same root-cause family). #22048
set PoolActor to num_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.

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>
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>
@OneSizeFitsQuorum
OneSizeFitsQuorum marked this pull request as ready for review July 23, 2026 11:00
@OneSizeFitsQuorum
OneSizeFitsQuorum requested review from a team as code owners July 23, 2026 11:00

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 27d7a27. Configure here.

Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
Comment thread python/ray/util/joblib/ray_task_backend.py Outdated
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>
@ray-gardener ray-gardener Bot added core Issues that should be addressed in Ray Core community-contribution Contributed by the community labels Jul 23, 2026
Comment thread doc/source/ray-more-libs/joblib.rst Outdated
Comment on lines +84 to +91
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@edoakes edoakes self-assigned this Jul 23, 2026
@edoakes

edoakes commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

@OneSizeFitsQuorum do you use the joblib backend?

@OneSizeFitsQuorum

Copy link
Copy Markdown
Contributor Author

@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 num_cpus=0 actors and follows fixed-size multiprocessing.Pool semantics, so this may affect resource accounting, startup behavior, actor lifetime, and potentially existing default behavior.

I’m happy to explore an autoscaling actor pool, potentially as an opt-in mode first. Would you prefer implementing it in ray.util.multiprocessing.Pool, or keeping it internal to the joblib backend?

@edoakes

edoakes commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

I’m happy to explore an autoscaling actor pool, potentially as an opt-in mode first. Would you prefer implementing it in ray.util.multiprocessing.Pool, or keeping it internal to the joblib backend?

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.

@OneSizeFitsQuorum

Copy link
Copy Markdown
Contributor Author

@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>
@OneSizeFitsQuorum
OneSizeFitsQuorum marked this pull request as draft July 27, 2026 15:16
@OneSizeFitsQuorum OneSizeFitsQuorum changed the title [Core] Add elastic Ray task backend for joblib (ray_tasks) [Core] Prototype an elastic Joblib Actor pool backend Jul 27, 2026
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>
@OneSizeFitsQuorum
OneSizeFitsQuorum force-pushed the worktree-joblib-task-backend branch from 5a910c4 to 8be4a0e Compare July 28, 2026 04:19
… 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>
@OneSizeFitsQuorum OneSizeFitsQuorum changed the title [Core] Prototype an elastic Joblib Actor pool backend [joblib] Add opt-in autoscaling to the default 'ray' actor pool Jul 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community core Issues that should be addressed in Ray Core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[joblib] Add opt-in autoscaling to the default 'ray' actor pool

2 participants