Skip to content

Add stickyAffinity() to Thread.Builder.OfVirtual#226

Open
franz1981 wants to merge 3 commits into
openjdk:fibersfrom
franz1981:sticky_affinity
Open

Add stickyAffinity() to Thread.Builder.OfVirtual#226
franz1981 wants to merge 3 commits into
openjdk:fibersfrom
franz1981:sticky_affinity

Conversation

@franz1981

@franz1981 franz1981 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Virtual threads with sticky affinity preserve carrier locality:

  • start/unpark from a sticky VT uses lazy submission (local queue, no signal)
  • yield skips externalSubmit, staying on the current carrier
  • sub-pollers (mode 2 and 3) are made sticky, simplifying Poller.polled()

Lazily submitted tasks remain fully stealable (although they lose the wakeup/signaling): stickiness is a locality hint, not a hard pin.

This change to be relevant would need a configuration parameter to allow the built-in scheduler to use the same timer mechanism of the custom scheduler.
All the numbers collected below are NOT using timers on purpose as they change by far the performance results (negatively).



Progress

  • Change must not contain extraneous whitespace

Reviewing

Using git

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

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

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 226

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

Using diff file

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

Using Webrev

Link to Webrev Comment

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()
@bridgekeeper

bridgekeeper Bot commented Jun 29, 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 Jun 29, 2026

Copy link
Copy Markdown

@franz1981 This change now passes all automated pre-integration checks.

ℹ️ This project also has non-automated pre-integration requirements. Please see the file CONTRIBUTING.md for details.

After integration, the commit message for the final commit will be:

Add stickyAffinity() to Thread.Builder.OfVirtual

You can use pull request commands such as /summary, /contributor and /issue to adjust it as needed.

At the time when this comment was updated there had been 1 new commit pushed to the fibers branch:

  • cb844da: Skip preemptable init marking on platform threads

Please see this link for an up-to-date comparison between the source branch of this pull request and the fibers branch.
As there are no conflicts, your changes will automatically be rebased on top of these commits when integrating. If you prefer to avoid this automatic rebasing, please check the documentation for the /integrate command for further details.

As you do not have Committer status in this project an existing Committer must agree to sponsor your change.

➡️ To flag this PR as ready for integration with the above commit message, type /integrate in a new comment. (Afterwards, your sponsor types /sponsor in a new comment to perform the integration).

@openjdk openjdk Bot added ready Ready to be integrated rfr Ready for review labels Jun 29, 2026
@mlbridge

mlbridge Bot commented Jun 29, 2026

Copy link
Copy Markdown

Webrevs

@franz1981

franz1981 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Using https://github.com/franz1981/Netty-VirtualThread-Scheduler/blob/master/PERFORMANCE.md#io-bound-configuration-max-tps-8-cores with pollerMode=3

  • baseline/sticky: netty running as normal VirtualThread - 8 FJP carriers
  • FJP: 8 netty event loop platform threads + 8 FJP carriers
  • custom: custom scheduler with NIO, 8 carriers

Performance (I/O-bound: 8 cores, 30ms mock, 10K connections, NIO, per-request VT):

  ┌───────────────────────────────────┬───────┬─────────────┐
  │              Changes              │  TPS  │ vs baseline │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ Baseline (no sticky)              │ ~135K │ —           │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ Commit 1 only (start/unpark lazy) │ 143K  │ +6%         │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ Commit 2 only (afterYield)        │ 146K  │ +8%         │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ Both commits                      │ 155K  │ +15%        │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ custom no WS                      │ 157K  │ +16%        │
  ├───────────────────────────────────┼───────┼─────────────┤
  │ FJP reference                     │ 139K  │ —           │
  └───────────────────────────────────┴───────┴─────────────┘

and per-op perf stat counters:

  ┌──────────────────┬────────────┬──────────────────┬───────────────┬─────────────────────┐
  │   Per request    │ FJP (139K) │ no-sticky (133K) │ sticky (155K) │ custom no WS (157K) │
  ├──────────────────┼────────────┼──────────────────┼───────────────┼─────────────────────┤
  │ Cycles           │ 2.39M      │ 2.48M            │ 2.18M         │ 2.16M               │
  ├──────────────────┼────────────┼──────────────────┼───────────────┼─────────────────────┤
  │ Instructions     │ 2.23M      │ 2.34M            │ 2.28M         │ 2.35M               │
  ├──────────────────┼────────────┼──────────────────┼───────────────┼─────────────────────┤
  │ Context switches │ 1.61       │ 1.93             │ 0.085         │ 0.017               │
  ├──────────────────┼────────────┼──────────────────┼───────────────┼─────────────────────┤
  │ CPU migrations   │ 0.025      │ 0.010            │ 0.007         │ 0.004               │
  ├──────────────────┼────────────┼──────────────────┼───────────────┼─────────────────────┤
  │ IPC              │ 0.93       │ 0.94             │ 1.05          │ 1.09                │
  └──────────────────┴────────────┴──────────────────┴───────────────┴─────────────────────┘

Which shows that there's a massive reduction in context switches and better IPC.

At 120K tps instead, way below saturation:

  ┌──────────────┬─────────┬──────────────┬────────────────┬───────────────┐
  │    Config    │   p50   │ p99 (median) │ p99.9 (median) │ CPUs (median) │
  ├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
  │ FJP          │ 30.54ms │ 98.04ms      │ 158.33ms       │ 6.87          │
  ├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
  │ no-sticky    │ 30.28ms │ 115.87ms     │ 173.02ms       │ 7.13          │
  ├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
  │ sticky       │ 30.28ms │ 84.93ms      │ 157.29ms       │ 6.85          │
  ├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
  │ custom no WS │ 30.80ms │ 156.24ms     │ 214.96ms       │ 6.45          │
  └──────────────┴─────────┴──────────────┴────────────────┴───────────────┘

@AlanBateman

Copy link
Copy Markdown
Collaborator

Can you confirm that "FJP reference" is the current scheduler, it's just that Thread.yield is changed to use lazySubmit unconditionally when scheduling the tasks for the poller threads.

Is "Both commits" your custom scheduler that it tied to Netty?

@franz1981

franz1981 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author
  • "FJP reference" = builtin FJP scheduler with Netty event loops on platform threads (separated). It runs on a jdk before this PR changes (I've tried with these, without any effect, positive or negative)

  • "Both commits" = also the builtin FJP scheduler. Event loops run as "sticky VTs" on FJP workers, not on the custom scheduler. Commit 1 and 2 are the 2 commits of this PR. Both means both commits

So both are FJP.
The difference is: platform-thread event loops vs sticky-VT event loops on the same builtin scheduler.

@AlanBateman

Copy link
Copy Markdown
Collaborator

Okay, would it be possible to clarify this in the first table? I think it would help to collapse the rows for the commits into one so that we have a simple picture of what is bring compared.

(I think the main point Thread.yield using lazySubmit is the difference and that this can be achieved with a small API change, and without any interface for custom schedulers, at least that what I take from the experiment).

@franz1981

franz1981 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

it would help to collapse the rows for the commits into one so that we have a simple picture of what is bring compared

I have run performance tests on both commits, what's why I reported the effect in term of peak TPS for each of them, as both help to reduce the number of context switches.

I think the main point Thread.yield using lazySubmit is the difference

Both changes are needed, that's why I reported the numbers for each, singularly:

  1. sticky threads started/unparked other VTs locally (as subpollers does) -> + 6%
  2. sticky threads yield keep them more local -> + 8%
  3. both changes -> +15% (it is nearly an additive improvement)

this can be achieved with a small API change, and without any interface for custom schedulers

There are important gaps on CPU usage, native transport, topology awareness, but as you rightly said, this one is a way to improve what we got by narrowing what the custom scheduler would have achieved.

The last meeting your words and Ron ones kept rounding in my head "enable the Virtual Thread where Netty fat poller run to behave like the JDK subpoller" - so I'm trying to not modify the FJP and see at which extent is achievable.

That said, let me write down the simplified tables:

Using https://github.com/franz1981/Netty-VirtualThread-Scheduler/blob/master/PERFORMANCE.md#io-bound-configuration-max-tps-8-cores with pollerMode=3

Configs:

  • baseline: Netty event loops as VTs on FJP, before this PR changes. 8 FJP carriers. Builtin scheduler.
  • sticky: same as baseline but event loop VTs use stickyAffinity(). This PR changes. Builtin scheduler.
  • FJP: Netty event loops on platform threads + VTs on FJP. 8 event loop threads + 8 FJP carriers, before this PR changes. Builtin scheduler.
  • custom no WS: custom scheduler (NettyScheduler) with NIO, 8 carriers, no work stealing.

Peak TPS (I/O-bound: 8 cores, 30ms mock, 10K connections, NIO, per-request VT)

┌──────────────────┬───────┬──────────────┐
│      Config      │  TPS  │ vs baseline  │
├──────────────────┼───────┼──────────────┤
│ baseline         │ ~135K │ —            │
├──────────────────┼───────┼──────────────┤
│ sticky           │ 155K  │ +15%         │
├──────────────────┼───────┼──────────────┤
│ FJP              │ 139K  │ —            │
├──────────────────┼───────┼──────────────┤
│ custom no WS     │ 157K  │ —            │
└──────────────────┴───────┴──────────────┘

Per-op perf stat counters

┌──────────────────┬────────────┬────────────────────┬───────────────┬─────────────────────┐
│   Per request    │ FJP (139K) │ baseline (133K)    │ sticky (155K) │ custom no WS (157K) │
├──────────────────┼────────────┼────────────────────┼───────────────┼─────────────────────┤
│ Cycles           │ 2.39M      │ 2.48M              │ 2.18M         │ 2.16M               │
├──────────────────┼────────────┼────────────────────┼───────────────┼─────────────────────┤
│ Instructions     │ 2.23M      │ 2.34M              │ 2.28M         │ 2.35M               │
├──────────────────┼────────────┼────────────────────┼───────────────┼─────────────────────┤
│ Context switches │ 1.61       │ 1.93               │ 0.085         │ 0.017               │
├──────────────────┼────────────┼────────────────────┼───────────────┼─────────────────────┤
│ CPU migrations   │ 0.025      │ 0.010              │ 0.007         │ 0.004               │
├──────────────────┼────────────┼────────────────────┼───────────────┼─────────────────────┤
│ IPC              │ 0.93       │ 0.94               │ 1.05          │ 1.09                │
└──────────────────┴────────────┴────────────────────┴───────────────┴─────────────────────┘

Massive reduction in context switches (19x vs FJP) and better IPC (+13%).

Latency @ 120K req/s (median of 3 runs)

┌──────────────┬─────────┬──────────────┬────────────────┬───────────────┐
│    Config    │   p50   │ p99 (median) │ p99.9 (median) │ CPUs (median) │
├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
│ FJP          │ 30.54ms │ 98.04ms      │ 158.33ms       │ 6.87          │
├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
│ baseline     │ 30.28ms │ 115.87ms     │ 173.02ms       │ 7.13          │
├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
│ sticky       │ 30.28ms │ 84.93ms      │ 157.29ms       │ 6.85          │
├──────────────┼─────────┼──────────────┼────────────────┼───────────────┤
│ custom no WS │ 30.80ms │ 156.24ms     │ 214.96ms       │ 6.45          │
└──────────────┴─────────┴──────────────┴────────────────┴───────────────┘

Sticky matches FJP on p50, beats it on p99 (85ms vs 98ms), and uses 2% less CPU.

@franz1981

franz1981 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

NOTE @AlanBateman these changes work only with pollerMode=3 (with pollerMode=2 the benefits are way way less).
With pollerMode=2, they won't deliver the same improvement as it can cause too many VT migrations.

@franz1981

Copy link
Copy Markdown
Contributor Author

I'm running the same changes in different boxes and configuration to better understand exactly how each config affect the results

…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.
@openjdk

openjdk Bot commented Jun 30, 2026

Copy link
Copy Markdown

@franz1981 Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. See OpenJDK Developers’ Guide for more information.

@franz1981

franz1981 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

@AlanBateman @viktorklang-ora I was reading the FJP expected behavior for the sticky case and found semething...weird to me, which I'm still dissecting.

with -XX:ActiveProcessorCount=1 (on my 32 cores machine).

Running this before my changes hangs forever

public class StickenssProgress {

    @Test
    @Timeout(10)
    public void testProgress() throws InterruptedException {
        testProgress(false);
    }

    private static void testProgress(boolean sticky) throws InterruptedException {
        Assertions.assertTrue(!Thread.currentThread().isVirtual());
        // just use booleans to save start/onContinue from sticky threads to be relevant
        AtomicBoolean firstStarted = new AtomicBoolean();
        AtomicBoolean progressed = new AtomicBoolean();
        var factory = Thread.ofVirtual();
        if (sticky) {
            factory.stickyAffinity();
        }
        factory.start(() -> {
            Thread.yield();
            firstStarted.set(true);
            while (!progressed.get()) {
                Thread.yield();
            }
        });
        while (!firstStarted.get()) {
            Thread.onSpinWait();
        }
        System.out.println("first is started: starting the second one");
        Thread secondThread = Thread.ofVirtual().start(() -> {
            progressed.set(true);
        });
        secondThread.join();
    }

}

I would have expected Thread.yield to submit to the external queue eventually after the second thread, completing both.
But even without stickyness it doesn't make progress.
I have verified it happens on JDk 25 and 26 as well...

My first analysis shows that the single worker is trapped in one external queue slot, perpetually draining and refilling the first VT, never scanning the other slots where the second VT has been submitted.

FEW NOTES:
It seems that with 1 core we still have 16 worker queue slots available but the test will lazily allocates just 2 (1 internal 1 external) + 1 worker thread (parallism is 1)

&& ct.getQueuedTaskCount() == 0) {
externalSubmitRunContinuation();
} else {
submitRunContinuation();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC this can still signal for work despite it would resolve into an internal submission

@viktorklang-ora

Copy link
Copy Markdown
Contributor

@franz1981 FJP is biased to keep taking work where work is found. In a scenario with a single worker, there's still multiple queues (as you noted). It would be interesting to see how your change behaves under openjdk/jdk#28797 / tagging @DougLea in case he has additional details/thoughts

@franz1981

franz1981 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @viktorklang-ora

In a scenario with a single worker, there's still multiple queues (as you noted)

Yep, I see - indeed that's what I'm getting so far; the yield is causing the worker to keep polling against a different "external" worker q from the one where the new VT is submitted, causing the latter to starve.
Do we need to fill a JDK bug w the reproducer I made (could be made better by ensuring the probe to end up on different external queues TBH ^^)?
I know 1 core to be an "exceptional" case but it looks to me (reading the code) that it can still happen in other cases too with more cores, just not sure if I can reproduce it that easily e,g, with N (== number of cores) spinning Thread::yield VTs and some unlucky probe assignment for the other external work.

e.g.

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

public class StickinessProgress2 {
    public static void main(String[] args) throws InterruptedException {
        int carriers = Runtime.getRuntime().availableProcessors();
        System.out.println("Carriers: " + carriers);

        AtomicInteger started = new AtomicInteger();
        AtomicBoolean progressed = new AtomicBoolean();

        // Start N yielding VTs to trap all carriers
        for (int i = 0; i < carriers; i++) {
            Thread.ofVirtual().start(() -> {
                Thread.yield();
                started.incrementAndGet();
                while (!progressed.get()) {
                    Thread.yield();
                }
            });
        }

        while (started.get() < carriers) {
            Thread.onSpinWait();
        }
        System.out.println("All " + carriers + " yielders started, starting rescue VT...");
        Thread rescue = Thread.ofVirtual().start(() -> {
            progressed.set(true);
        });
        rescue.join();
        System.out.println("Done — progressed");
    }
}

This seems to hang with 2 and 4 cores but pass with 8, likely because of the number of external queue slots vs carriers.
The starvation depends on whether any carrier's probe maps to the same external queue slot as the main thread's probe; it means it can really happen with ANY number of cores, depending on the unlucky probe distribution.

That said, i still see a large amount of unpredictable miss while the worker scan for work with few cores, as it doesn't know which of the 16 slots is available. Probably for FJP we can expect most of the submissions to happen from the delay scheduler/scheduler tp or the master poller - having 16 available slots is quite large for a 2 o 4 cores machine as there's much less contention to spread with so few cores.

It would be interesting to see how your change behaves under openjdk/jdk#28797

I will cherry-pick them and run some tests to see if the benefits I expect are still there 🙏

@AlanBateman

Copy link
Copy Markdown
Collaborator

FJP is biased to keep taking work where work is found. In a scenario with a single worker, there's still multiple queues (as you noted).

and just to add that this just goes with have no fairness guarantee.

@franz1981

franz1981 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

In this case it looks more like a liveness guarantee; as there's someone which cannot ever make any progress (starting too) despite the yield enter back into the runtime

@AlanBateman

Copy link
Copy Markdown
Collaborator

In this case it looks more like a liveness guarantee; as there's someone which cannot ever make any progress (starting too) despite the yield enter back into the runtime

I have a prototype from some time ago that pushed to a random external queue for the same reason.

@DougLea

DougLea commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

I sent Alan a version of lazySubmit with more randomization to check.

But to answer other concerns: Unless there is only one core, polling/scanning is almost always better than using a single contended queue, even if some metrics suggest otherwise.

* @return this builder
* @since 99
*/
OfVirtual stickyAffinity();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just affinity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm bad with names...but sticky is because each VT which start or continue through it is locally submitted

@franz1981

franz1981 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

WDYT about this change @AlanBateman @DougLea ?
It is an attempt to preserve some bursty locality - and with user-made I/O pollers (and our subpollers too) it is quite effective both with low and medium number of cores (1-16).
Framework writers can even decide to pass sticky factory to structured scope issued from these I/O pollers and would benefit of it.

@AlanBateman

AlanBateman commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

WDYT about this change @AlanBateman @DougLea ?

I'll look at it this week. If we expose a method in the prototype for this this then it will require some cleanup and working through some issues with naming and API docs.

@franz1981 franz1981 mentioned this pull request Jul 1, 2026
2 tasks
@AlanBateman

Copy link
Copy Markdown
Collaborator

Thanks for the updated table. It was initially hard to see how "baseline/sticky", "FJP" and "custom" mapped to the rows "Baseline (no sticky)", "Both commits", "custom no WS" and " FJP reference".

If I read the update correctly then changing Thread.yield to use lazySubmit when virtual threads have the "stickyAffinity" bit improves the peak by 15% on this benchmark. The baseline that is the Netty event loops running on virtual threads.

I don't mind if we bring this prototype into the fibers branch and probably do some cleanup (do we still need PollerGroup.useLazyUnpark, which was the previous prototype in this area), and think about naming. I think this direction leads to seeing how FJP could handle it if the special bit is in the ForkJoinTask, and maybe see how it could be used to close the gap on MPSC scheduler.

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

Labels

ready Ready to be integrated rfr Ready for review

Development

Successfully merging this pull request may close these issues.

5 participants