Mpsc scheduler - #227
Conversation
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.
|
👋 Welcome back franz1981! A progress list of the required criteria for merging this PR into |
|
❗ This change is not yet ready to be integrated. |
|
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. |
|
this will be faster for the eventloop think, in pekko stream, the mailbox is a mpsc queue now and faster than it was |
|
@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. At this point a better name for this scheduler would be "event loop scheduler" |
|
@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. |
|
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/sub-poller allowing Netty VT to safely park/unpack. |
Upstream removed lazyUnpark/lazyStart. stickyAffinity now uses currentThreadIsSticky() in the start() path to trigger lazySubmit for FJP locality. The polled() callback uses plain LockSupport.unpark since the caller's stickiness already triggers lazySubmit in the VirtualThread.unpark() path. Removed lazyStart() from OfVirtual. Use VThreadScheduler.currentCarrierThread() test utility instead of direct field reflection in StickyAffinityTest.
Single MPSC queue per carrier, affinityHint routing, roundRobinAffinity support, preferredCarrier, drain budget, plainLoop fallback. Includes MpscUnboundedQueue with @contended fields and onSpinWait spin loop, lazyUnpark on BaseVirtualThread, ROUND_ROBIN_AFFINITY characteristic on Thread.OfVirtual, and useMpsc activation in VirtualThread.
Each carrier is its own master poller with EPOLLONESHOT, HashMap fd tracking, isEmpty() skip on non-blocking poll, eventfd wakeup. Adds CARRIER_LOCAL_POLLER mode enum and CarrierLocalPollerGroup in Poller. JLA bridge (System.java + JavaLangAccess.java) for carrierLocalPoller() lookup from NIO layer.
be4dd8f to
081d380
Compare
| private final int epfd; | ||
| private final long pollAddress; | ||
| private final EventFD eventfd; | ||
| private final HashMap<Integer, Thread> fdToThread = new HashMap<>(); |
There was a problem hiding this comment.
Can we have an IntMap here?
There was a problem hiding this comment.
Yeah it was my first choice tbh but thanks to:
- Netty selectors to be very few (the poller register the selector FD and not each connection)
- FD reuse
The numbers were low enough that boxing was using the int cache.
But yes, in a final version would be better to use a primitive map
Check actual scheduler instance via JLA.isMpscScheduler() instead of re-reading system property. Warns on fallback. Fixes POLLIN routing to write poller when carrier-local poller is unavailable.
|
I've noticed that I forgot to re-add In addition to that I've performed few perf tests in a I/O bound scenario and the custom scheduler I built, which have a different prioritization for the Netty event loop, still beat MPSC with poller mode 4. |






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
affinityHint, set once atonStart,onContinuealways routes homeroundRobinAffinity()for initial VT distribution across carriersHashMap<Integer, Thread>for fd→VT mapping (carrier-local, single-thread access)epoll_wait(0), blocks only when genuinely idle@Contendedon queue producer/consumer fields to avoid false sharing-Djdk.virtualThreadScheduler.useMpsc=true -Djdk.pollerMode=4If 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 schedulerFJP (sticky): the FJP schedulerBoth 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)
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)
Same throughput. MPSC uses 14% less CPU with half the context switches. Tail latency (p99) is worse under heavy I/O.
Progress
Reviewing
Using
gitCheckout this PR locally:
$ git fetch https://git.openjdk.org/loom.git pull/227/head:pull/227$ git checkout pull/227Update a local copy of the PR:
$ git checkout pull/227$ git pull https://git.openjdk.org/loom.git pull/227/headUsing Skara CLI tools
Checkout this PR locally:
$ git pr checkout 227View PR using the GUI difftool:
$ git pr show -t 227Using diff file
Download this PR as a diff file:
https://git.openjdk.org/loom/pull/227.diff