Skip to content

Mpsc scheduler#227

Draft
franz1981 wants to merge 19 commits into
openjdk:fibersfrom
franz1981:mpsc-scheduler
Draft

Mpsc scheduler#227
franz1981 wants to merge 19 commits into
openjdk:fibersfrom
franz1981:mpsc-scheduler

Conversation

@franz1981

@franz1981 franz1981 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This is a first PoC for an alternative scheduler targeting low number of cores.
It has no work-stealing implemented (so it would be better to have VT non-voluntary preemption, but can still suffer from unbalanced work-loads (obviously!) - but to keep latencies and cpu usage tight, it collapse the subpollers as carrier-confined in the carrier loop.
I've introduced a pollerMode 4 for this.

In term of peak tps it is in between the #226 performance and the custom scheduler with native transport i.e. the number of hops from I/O polling is the same.

Architecture

  • Single MPSC queue per carrier, no work stealing
  • Carrier affinity via affinityHint, set once at onStart, onContinue always routes home
  • roundRobinAffinity() for initial VT distribution across carriers
  • Poller Mode 4: each carrier owns its own epoll fd, VT fds register directly: no sub-pollers, no master poller thread. Plain HashMap<Integer, Thread> for fd→VT mapping (carrier-local, single-thread access)
  • Event loop interleaves task drain (configurable 50µs budget) with non-blocking epoll_wait(0), blocks only when genuinely idle
  • @Contended on queue producer/consumer fields to avoid false sharing
  • Activated with -Djdk.virtualThreadScheduler.useMpsc=true -Djdk.pollerMode=4

If pwait2 would be available it could collapse into it a timer wheel structure (or just a priority q, since there's no sharing) for timers.
I have not performed any specific optimization on wakeups; eventFd is knew to be terrible and in Netty indeed we shield concurrent eventFd writes with a slightly more complex state machine. Here I made it simple as the most of the interaction(s) are local.

Sustained Load Results

Benchmark: same as #226

MPSC (Mode 4) : this new scheduler
FJP (sticky): the FJP scheduler

Both are running Netty event loops as "sticky" VirtualThreads, assigned (if the scheduler allow it) round robin to each carrier.

CPU-bound (50K req/s, 2 server CPUs, 1ms mock delay, 100 connections)

Metric MPSC (Mode 4) FJP (sticky)
p50 1.15ms 1.20ms
p75 1.23ms 1.61ms
p90 4.98ms 2.95ms
p99 33.82ms 44.56ms
CPU utilization 1.71 CPUs 1.82 CPUs
Context switches/sec 8.7K 36.3K
Page faults 5.4K 218

MPSC matches or beats FJP at p50-p75 and p99 with 6% less CPU. Context switches are 4x lower. p90 is wider.

I/O-bound (120K req/s, 12 server CPUs, 30ms mock delay, 7000 connections)

Metric MPSC (Mode 4) FJP
p50 30.28ms 30.28ms
p90 32.24ms 30.54ms
p99 182.45ms 56.10ms
CPU utilization 8.61 CPUs 10.0 CPUs
Context switches/sec 54.6K 106K

Same throughput. MPSC uses 14% less CPU with half the context switches. Tail latency (p99) is worse under heavy I/O.



Progress

  • Change must not contain extraneous whitespace

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.org/loom.git pull/227/head:pull/227
$ git checkout pull/227

Update a local copy of the PR:
$ git checkout pull/227
$ git pull https://git.openjdk.org/loom.git pull/227/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 227

View PR using the GUI difftool:
$ git pr show -t 227

Using diff file

Download this PR as a diff file:
https://git.openjdk.org/loom/pull/227.diff

franz1981 added 18 commits June 29, 2026 14:37
Virtual threads with sticky affinity preserve carrier locality:
- start/unpark from a sticky VT uses lazy submission (local queue, no signal)
- sub-pollers (mode 2 and 3) are made sticky, simplifying Poller.polled()
…llers

Mode 2 sub-pollers no longer use stickyAffinity — they revert to the
original useLazyUnpark dispatch in polled(). Only mode 3 (per-carrier)
sub-pollers are sticky.
Alternative scheduler using dual MPSC queues per carrier (sticky + external),
ticket-lock work stealing, FJP-style probing, and alternating drain.

Adds roundRobinAffinity() and stickyAffinity() support via preferredCarrier
and affinityHint routing. Poller Mode 4: each carrier is its own master
poller — sub-pollers register with carrier-local epoll fd, no global master
poller thread. Activated with -Djdk.virtualThreadScheduler.useMpsc=true
and -Djdk.pollerMode=4.
Carrier count now matches parallelism exactly. Steal scan uses
precomputed int[] siblings and skips parked carriers. @contended
on carrierState and ticket lock fields to avoid false sharing.
Mode 4 (carrier-master-poller) added epoll_wait syscall overhead
that reduced peak throughput. Reverted to plain carrier loop with
LockSupport.park/unpark and Mode 3 (global master poller + per-carrier
sub-pollers). Also fixed setVolatile for PARKED state (StoreLoad barrier)
and added isEmpty guard on external queue poll.
Single queue, no ticket lock, no alternation, no steal scan.
The carrier loop is just poll → run → poll. Carrier affinity via
attachment handles all routing after first run.
Single object per carrier: CarrierThread holds queue, state, id
directly. No indirection, no shutdown check. Plain volatile for
carrierState. Skip attach when attachment is already current carrier.
Task attachment set once at onStart, never in the carrier loop.
Brief spin (16 iterations with onSpinWait) before parking to
catch imminent work without OS scheduler round-trip.
Each carrier owns its own epoll fd with direct VT fd registration.
No sub-pollers, no master poller, no ConcurrentHashMap — plain HashMap
since only the carrier accesses it. Event loop interleaves task drain
(50us budget) with non-blocking I/O poll. Blocks in epoll_wait only
when both queue and I/O are idle. Uses VThreadTask (lightweight) with
affinityHint for carrier routing instead of CustomVThreadTask.
Continuation.pin() was held through LockSupport.park(), blocking the
carrier. Fixed by unpinning after the poller lookup but before parking.
Replaced ThreadLocal with ConcurrentHashMap keyed by carrier thread id
since VTs can't see carrier thread-locals.
Add @contended groups to MpscUnboundedQueue to separate producer
fields (producerIndex, producerLimit) from consumer fields
(consumerIndex) on different cache lines. Add Thread.onSpinWait()
in the poll spin loop for lagging producer stores. Defer
System.nanoTime() in drain budget to after first task polled.
@bridgekeeper

bridgekeeper Bot commented Jul 1, 2026

Copy link
Copy Markdown

👋 Welcome back franz1981! A progress list of the required criteria for merging this PR into fibers will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk

openjdk Bot commented Jul 1, 2026

Copy link
Copy Markdown

❗ This change is not yet ready to be integrated.
See the Progress checklist in the description for automated requirements.

@franz1981

franz1981 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

As a design point, given the low core assumption, this scheduler could use a mpmc shared queue where to spill work which exceed a threshold for each carrier (I would make the mpsc mailboxes soft bounded) - in case the VT to spill has not sticky affinity.
If sticky, will end up in the mpsc queue.
The VT started or continued from a sticky one instead, could spill into the mpmc shared queue.
Than, each carrier, can use a prime-based interval while draining it, no matter what.
This should keep a locality- first design with some form of redistribution under load.
BTW this is similar to what go does, although it uses a more "reactive" approach using nmspinner to search for work.

@He-Pin

He-Pin commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

this will be faster for the eventloop think, in pekko stream, the mailbox is a mpsc queue now and faster than it was

@franz1981

franz1981 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Some more benchmarking data from the CPU bound test:

image

30K tps:
image

40K tps:

image

50K tps:

image

60K tps:

image

and p50/p99 vs load:

image

And at 30K tps load:

  ┌──────────────────────┬────────────┬────────────┬────────────┐
  │        Metric        │    MPSC    │    FJP     │   Ratio    │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ Context switches/sec │ 17,412     │ 32,942     │ 1.9x fewer │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ CPU migrations/sec   │ 50         │ 1,881      │ 37x fewer  │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ Page faults/sec      │ 730        │ 1,093      │ 1.5x fewer │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ CPU utilization      │ 1.34 cores │ 1.57 cores │ 15% less   │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ Instructions         │ 70.0B      │ 72.7B      │ 4% fewer   │
  ├──────────────────────┼────────────┼────────────┼────────────┤
  │ IPC                  │ 1.30       │ 1.22       │ 7% higher  │
  └──────────────────────┴────────────┴────────────┴────────────┘

@franz1981

franz1981 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@AlanBateman By moving the poller to be per carrier (as this pr), un-shared, would allow me to more easily use edge-triggered poll, further reducing the difference between Netty event loops/async I/O frameworks - and what Loom I/O does, saving one syscall per blocking read.
That said, is not guaranteed to be a win as with edge-triggered poll if a socket is used by different carrier in its life-cycle, its fd will end up awaking N different carriers pollers, with only one of them which have a parked VT, so probably ONESHOT is the best possible (and cleanest) choice, for this case.
Netty event loops physically "event loop confined" a socket so this won't EVER happen.
The different ownership model w Loom is what would drive this specific optimization to be appliable.

At this point a better name for this scheduler would be "event loop scheduler"

@franz1981

franz1981 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@AlanBateman IIRC the way eventFd is setup to not accumulate writes and a single poll would drain them all: so I've pushed be4dd8f as an additional optimization, although rare, in case on a cycle of VT draining, none register for some I/O events; if that happen, the per carrier local poller just skip the non blocking poll.

@franz1981

franz1981 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

As a side note for Netty uring integration: in modern Linux kernel versions the uring ring buffer can provide its own (pollable) fd which can be registered for POLLIN ONE SHOT against the per carrier poller allowing Netty VT to safely park/unpack.
Clearly this need OpenJDK to expose "something" to allow registering such fd, somehow.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants