Skip to content

Execution pool recruits every core regardless of work size: 2.7x the CPU for 1.2x the speed #599

Description

@vbmithr

Summary

The execution pool splits a query across every available worker regardless of
how much work there is. Past a handful of workers the coordination cost grows
faster than the saving, so total CPU rises steeply while wall time barely
improves. A service that is not latency-bound pays several times the CPU it
needs and has no way to ask for the cheaper trade.

Reproducer

200 inner-joins of a 3-row key set against a 130,000-row table, the shape a
market-data service runs continuously:

(set N 130000)
(set syms (map (fn [i] (parse (format "INST%" i))) (til 773)))
(set t (table [venue instrument session message_id px]
         (list (take ['BFU] N) (take syms N) (take ['S1] N) (til N) (til N))))
(set ks (select {from: t by: [venue instrument] n: (count px)}))
(set k3 (select {from: ks take: 3}))
(set n 0)
(times 200 (set n (+ n (count (inner-join [venue instrument] t k3)))))

/usr/bin/time on a 16-core machine, 2.6.2.r229.ge7770412:

cores user CPU wall speedup CPU vs -c 1
-c 1 0.71 s 0.71 s 1.0x 1.00x
-c 2 0.71 s 0.36 s 2.0x 1.00x
-c 4 0.80 s 0.22 s 3.2x 1.13x
-c 16 1.93 s 0.18 s 3.9x 2.72x

-c 2 is free: half the wall time for the same CPU. -c 4 costs 13% for a
3.2x speedup. -c 16 costs 172% more CPU to go 1.2x faster than -c 4.

Small queries are unaffected — 20,000 joins over a 10-row table cost 0.05 s at
every core count, so nothing is dispatched for those. The effect starts where
the pool starts splitting.

Impact in production

A depth-of-book service of ours, default pool on a 16-core host, against an
identical instance on the same live feed with -c 2, in lock-step on every
state counter throughout:

default (16c)   50%  13%  23%  44%  57%  64%
-c 2            25%  10%  15%  23%  27%  31%

Half the CPU, identical throughput, identical progress. A second run comparing
-c 1 with -c 4 was monotonic in the same direction. The service's own batch
takes 2.5-5.7 ms against a ~26 ms inter-batch gap, so the latency the pool buys
is worth nothing to it — and .sys.querylog reports effective parallelism of
~0.0002 for a typical batch, while perf put 73% of samples in worker_loop.

Notably our other Rayforce services on the same host and the same feed sit at
1-2% CPU: they do not run joins large enough to dispatch, so they never meet
this. It is specific to whoever does the table-sized work.

What we are asking for

  1. Scale the worker count to the work, not to the machine — a morsel-count
    or row-count heuristic, so a 130k-row join does not recruit 16 threads for a
    1.2x gain over 4. The measurements above suggest the knee is low.
  2. Failing that, a throughput mode — a way to say "minimise CPU, I am not
    latency-bound". -c is that knob today, but it is a blunt global: it also
    caps the genuinely parallel work a process may want elsewhere.

We have set -c 2 and consider the immediate problem solved for us; this is
filed because the default costs every non-latency-bound service the same
multiple, silently.


Measured on rayforce 2.6.2.r229.ge7770412, 16-core host.

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