Skip to content

perf(pool): size the auto pool by physical cores, not logical CPUs - #609

Closed
singaraiona wants to merge 2 commits into
devfrom
perf/pool-physical-cores-606
Closed

singaraiona wants to merge 2 commits into
devfrom
perf/pool-physical-cores-606

Conversation

@singaraiona

Copy link
Copy Markdown
Collaborator

What & why

The pool recruited one participant per online logical CPU. Two SMT siblings
split one core's issue bandwidth, so the second thread re-runs the same
instruction stream at roughly half the IPC rather than adding throughput.

ray_physical_core_count() already existed for exactly this reason — its own
comment records the suite running ~11% slower with 32 SMT threads than with 16
physical cores on a 5950X — and was used only to estimate a cache size, never to
size the pool.

Measurements

Full ClickBench, 10M rows, 43 queries × 3, three repetitions of the whole suite
per configuration, same build and same parted store on both sides. Two of the
three hosts were measured by @vbmithr on #606.

host cycles instructions sum of hot times
Ryzen AI 7 350 (8 physical / 16 logical) −31.6% −0.4% −1.6%
i7-10700K (8 physical / 16 logical) −34.4% −0.3% −0.8%

Instructions being flat is the point — the saving is stall cycles, not work
removed. Cycle counts varied under 0.5% across repetitions. The hot-time column
is inside the run-to-run spread on both hosts (5.7% and 1.8% at -c 16), so the
claim is no measurable wall-time regression, not a speedup. Physical cores were
also the more stable configuration on both.

On the join shape from #599 the mechanism shows directly:

instructions cycles IPC wall
logical (8 workers) 22.74 G 15.11 G 1.51 0.571 s
physical (4 workers) 22.71 G 7.50 G 3.20 0.559 s

Where it is not free

A 4-physical/8-logical i7-6700 pays +9.4% on hot times for the same ~50%
cycle saving — it surrenders half its threads and has only four cores to fall
back on. Measured with interleaved A/B runs to cancel machine drift: +9.6 / +9.1
/ +9.5% across three reps, baseline stable to 0.4 ms. Latency-bound deployments
on low core-count SMT hosts should set -c explicitly, which is the existing
knob for it.

The one consistent per-query regression

q20 — (count (select {from: hits where: (like URL "*google*")})) — regresses
+16% to +21% on all three machines, and it is the only query that does.

ClickBench's URL is 2.62M distinct values in a ~303 MB symbol heap, so that
query is millions of random dereferences into a heap far larger than cache
followed by short branchy substring scans: latency-bound, which is the one shape
a sibling thread genuinely fills. like itself is not at fault — its cost tracks
distinct symbols rather than rows, so the SYM optimisation is already there — and
the SMT advantage appears only once the heap stops fitting in cache (+9.8% at
2.62M distinct, none at 100k).

A carve-out keeping such scans wide would rest on a real property, but it is one
query against a whole-suite effect that is inside the noise. Left as a possible
follow-up rather than bundled here.

Compatibility

RAYFORCE_CORES and -c are unaffected. ray_physical_core_count falls back to
the logical count when the topology cannot be read, so systems whose topology is
unreadable keep the old behaviour. test_pool.c's policy test is updated from
auto_all_logical_cpus to auto_physical_cores.

Closes #606

Checklist

  • PR targets dev (not master)
  • Commits follow Conventional Commits (feat: / fix: / perf: / docs: / …)
  • make builds cleanly (no new warnings)
  • make test passes; tests added/updated for behaviour changes

The pool recruited one participant per online logical CPU.  Two SMT siblings
split one core's issue bandwidth, so the second thread re-runs the same
instruction stream at roughly half the IPC rather than adding throughput.
ray_physical_core_count already existed for exactly this reason — its own
comment records the full suite running ~11% slower with 32 SMT threads than
with 16 physical cores on a 5950X — and was used only to estimate a cache
size, never to size the pool.

Full ClickBench (10M rows, 43 queries x 3, three repetitions of the whole
suite per configuration, same build and same parted store on both sides):

  host                       cycles    instructions   sum of hot times
  Ryzen AI 7 350  (8/16)     -31.6%    -0.4%          -1.6%
  i7-10700K       (8/16)     -34.4%    -0.3%          -0.8%

Instructions being flat is the point: the saving is stall cycles, not work
removed.  Cycle counts varied under 0.5% across repetitions; the hot-time
column is inside the run-to-run spread on both hosts (5.7% and 1.8% at -c 16),
so the claim is no measurable wall-time regression rather than a speedup.
Physical cores were also the more stable configuration on both.

On the reported join shape the same mechanism shows directly: 22.74 G vs
22.71 G instructions — identical — at IPC 1.51 vs 3.20, for 15.11 G vs
7.50 G cycles and no wall-clock difference.

Not free everywhere.  A 4-physical/8-logical i7-6700 pays ~9.4% on hot times
for the same ~50% cycle saving, because it surrenders half its threads and has
only four cores to fall back on; measured with interleaved A/B runs, +9.6 /
+9.1 / +9.5% across three reps.  Latency-bound deployments on low core-count
SMT hosts should set -c explicitly, which is the existing knob for it.

One query regresses consistently across all three machines: q20,
(count (select {from: hits where: (like URL "*google*")})), +16% to +21%.
ClickBench's URL column is 2.62M distinct values in a ~303 MB symbol heap, so
that query is millions of random dereferences into a heap far larger than
cache followed by short branchy scans — latency-bound, the one shape a sibling
thread genuinely fills.  `like` itself is not at fault: its cost tracks
distinct symbols rather than rows, so the SYM optimisation is already there,
and the SMT advantage appears only once the heap stops fitting in cache.  A
carve-out keeping such scans wide would rest on a real property, but it is one
query against a whole-suite effect that is inside the noise, so it is left as a
possible follow-up rather than bundled here.

RAYFORCE_CORES and -c are unaffected, and ray_physical_core_count falls back to
the logical count when the topology cannot be read, so systems whose topology
is unreadable keep the old behaviour.

Closes #606
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown

Rayforce targeted audit passed

The required Rayforce audit gate passed on the latest run.

Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35633156797

Two defects the audit caught in the previous commit, both introduced by it.

platform.h declares ray_physical_core_count unconditionally, but platform.c
defined it only in the POSIX and Windows arms.  pool.c compiles on every
platform, so moving the auto-size path onto it left the WASM arm referencing a
symbol that does not exist — the line previously called ray_thread_count, which
that arm does provide.  Nothing in the repo builds WASM today, so CI could not
have caught it; it is a maintained portability arm regressed by a diff that
never looked at it.  Defined the same way the Windows arm does, and the WASM
threading comment no longer justifies its single-threaded invariant in terms of
a function the pool has stopped calling.

docs/docs/language/repl.md stated "By default the pool includes every online
logical CPU, including SMT threads" — the exact default the previous commit
inverts, and the only prose a user has for this behaviour.  It now describes the
physical-core default, says why, and keeps the -c guidance for a latency-bound
service on a low core-count SMT host, which is where the wider pool still wins
wall-clock time.  docs/grouping-engine-scaling-plan.md carried the same stale
assumption in a checklist item.

.sys.info's `cores` is left alone: it reports _SC_NPROCESSORS_ONLN, a hardware
fact rather than the pool width, and docs/docs/guides/memory.md describes it
correctly.

Also strengthened pool/auto_physical_cores, which the audit fairly called close
to a tautology: it asserts against the same function pool.c calls, so on a
non-SMT runner — physical == logical, the common CI case — it cannot distinguish
the new policy from the old.  It now also pins physical <= logical and
total <= logical, so on an SMT host a revert to the logical count fails here
instead of passing quietly.  The limitation is written down next to it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worker pool defaults to logical CPUs, not physical cores: ~2x the CPU on SMT hosts

1 participant