Skip to content

Worker pool ignores CPU affinity and cgroup quota: a container limited to 1 CPU still starts a full-sized pool #610

Description

@singaraiona

Summary

The worker pool sizes itself from sysconf(_SC_NPROCESSORS_ONLN), which reports
the host's online CPUs. It ignores both mechanisms an operator uses to
restrict a process: CPU affinity (taskset, docker --cpuset-cpus, systemd
AllowedCPUs) and CFS quota (docker --cpus, Kubernetes CPU limits). A
container given one CPU still builds a pool sized for the whole machine.

src/core/platform.c:

uint32_t ray_thread_count(void) {
    long n = sysconf(_SC_NPROCESSORS_ONLN);
    return (n > 0) ? (uint32_t)n : 1;
}

ray_physical_core_count() is affected the same way — it counts unique
(package, core) pairs from sysfs, which is also a host-wide view.

Measurement

Pinning the process and counting the OS threads it actually creates, on a
4-physical / 8-logical host (/proc/<pid>/task, 2M-row group-by in a loop):

affinity CPUs the process may use OS threads created
unrestricted 8 4
taskset -c 0,1 2 4
taskset -c 0 1 4

The pool is identical in all three cases. Pinned to a single CPU it still starts
four threads, which then time-slice one core: context switches and scheduler
latency with no parallelism available to recover them.

For contrast, nproc gets this right because it uses sched_getaffinity:

$ taskset -c 0,1 nproc
2
$ taskset -c 0,1 getconf _NPROCESSORS_ONLN
8            # what ray_thread_count() reads

Nothing in src/ calls sched_getaffinity today.

Why it matters now

Two reasons this is worth more than it was a week ago.

  1. Pool width just became deliberate policy. #606 / perf(pool): size the auto pool by physical cores, not logical CPUs #609 changed the default
    from logical CPUs to physical cores precisely because oversizing the pool
    costs cycles for no throughput. Being wrong about the denominator undoes
    that reasoning in exactly the deployments where it matters most.
  2. It is invisible. An operator who sets --cpus=2 has every reason to
    believe they constrained the process. Nothing in the banner or .sys.info
    contradicts them: .sys.info's cores reports the host count too, so the
    diagnostic agrees with the bug.

The failure mode is worst under CFS quota rather than cpuset: with
--cpus=1.5 the threads are not pinned, they are throttled as a group, so N
runnable threads share 1.5 CPUs of budget and each one's tail latency grows with
N. That is the shape a latency-sensitive service in Kubernetes would hit, and it
is silent.

What a fix needs to read

Three sources, taking the minimum of whichever are present:

  • Affinity — sched_getaffinity(0, ...) + CPU_COUNT. Covers taskset,
    --cpuset-cpus, systemd AllowedCPUs. This alone fixes the measured case
    above.
  • cgroup v2 quota — /sys/fs/cgroup/cpu.max, "$QUOTA $PERIOD" or "max $PERIOD"; effective CPUs = ceil(QUOTA / PERIOD). This host is cgroup2fs.
  • cgroup v1 quota — cpu.cfs_quota_us / cpu.cfs_period_us, quota -1
    meaning unlimited.

All three are Linux-specific and should fall back to the current behaviour
elsewhere, in the same shape as ray_physical_core_count's existing fallback.
The clamp belongs at the auto-size path in pool.c rather than inside
ray_thread_count, since that function is also used to report the hardware fact
and some callers legitimately want the host view — deciding which callers want
which is part of the work.

Worth pairing with a way to see the answer: the REPL banner and .sys.info
should be able to show the pool width against the CPUs the process may actually
use, so the next person can tell these apart without /proc/<pid>/task.

Not urgent

No correctness impact, -c N is an effective workaround for anyone who knows to
use it, and the oversizing is bounded by the host's CPU count rather than
unbounded. Filing it because it is silent, because containerized deployment is
the normal case for a service, and because it makes the #606 default less
effective exactly where that default was aimed.

Related: #606 (physical-core default), #609 (the PR), #599 (the report that
started this).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions