Skip to content

Scheduler optimization#13705

Open
Intybyte wants to merge 11 commits intoPaperMC:mainfrom
Intybyte:opt/timing-wheel
Open

Scheduler optimization#13705
Intybyte wants to merge 11 commits intoPaperMC:mainfrom
Intybyte:opt/timing-wheel

Conversation

@Intybyte
Copy link
Contributor

An optimization for the task scheduler, instead of using PriorityQueue (which has O(log n) operations), use a Timing Wheel (which has O(1) within one round execution).

I tested this in one of the plugins I work with and had good improvements from it.

I tested and spawned 100_000 tasks using a plugin, the server has 8GB max ram and /spark profiler start --timeout 60 --thread *

1.21.11 paper build 127 [PriorityQueue]
https://spark.lucko.me/ghhGHA8Pq7
CraftScheduler#mainThreadHeartbeat() took 23.49ms of which
18.14ms for removal and 1.33ms for insertion

1.21.11 paper opt/timing-wheel branch [TimingWheel]
https://spark.lucko.me/HUNomOcLXC
CraftScheduler#mainThreadHeartbeat() took 9.49ms of which
2.64ms for removal and 2.12ms for insertion

Plugin's testing code:

public final class TaskSpawner extends JavaPlugin {

    @Override
    public void onEnable() {
        var schdl = Bukkit.getScheduler();

        schdl.runTaskLater(
            this,
            () -> {
                Random rndDelay = new Random();
                for (int i = 0; i < 100_000; i++) {
                    schdl.runTaskTimer(
                        this,
                        new Runnable() {
                            long x = new Random().nextLong();

                            @Override
                            public void run() {
                                if (x % 2 == 0) {
                                    x /= 2;
                                } else {
                                    x *= 3;
                                    x++;
                                }
                            }
                        }, rndDelay.nextInt(1, 5000), 1L
                    );
                }

                schdl.runTaskLater(this, () -> Bukkit.broadcast(Component.text("Completed")), 5000L);
            }, 40L
        );
    }
}

@Intybyte Intybyte requested a review from a team as a code owner March 14, 2026 20:14
@github-project-automation github-project-automation bot moved this to Awaiting review in Paper PR Queue Mar 14, 2026
@github-project-automation github-project-automation bot moved this from Awaiting review to Changes required in Paper PR Queue Mar 14, 2026
@electronicboy
Copy link
Member

electronicboy commented Mar 15, 2026

This looks like this breaks same tick task execution, and does so in a manner that would create even weirder unexpected behavior to plugins; as well as the order promised by the impl of FIFO which was pointed out to me

@Intybyte
Copy link
Contributor Author

Timing wheel with sorted linked list based on task creation, there is a small additional overhead for the add, but the performance is about the same
https://spark.lucko.me/kVbOfEi7NM

@masmc05
Copy link
Contributor

masmc05 commented Mar 15, 2026

I feel like after latest changes (especially the part of inserting the tasks in a sorted manner) makes the worst case of this optimisation way worse than the single sorted queue, with a huge weakly related changes to such core components that it makes it dangerous on review. Maybe consider making the wheel based on sorted queues and minimising the diff in CraftScheduler, while also making the exponent configurable (exponent of 0 would mean a single sorted queue so basically old behaviour)

@Intybyte
Copy link
Contributor Author

Intybyte commented Mar 15, 2026

I feel like after latest changes (especially the part of inserting the tasks in a sorted manner) makes the worst case of this optimisation way worse than the single sorted queue, with a huge weakly related changes to such core components that it makes it dangerous on review.

Worst case you mean a ton of tasks scheduled in the same tick?

Maybe consider making the wheel based on sorted queues and minimising the diff in CraftScheduler

I mean sure, I did minize the diff in the CraftScheduler, most diff is just tabbing because there is a new while cycle and cycling the task obtained through the timing wheel, but I didn't change anything else, beside exposing some methods

while also making the exponent configurable (exponent of 0 would mean a single sorted queue so basically old behaviour)

I can do it, but what would the benefit be of adding a exponent 0 case? If it is only used in that section of the code, it would be better to just throw (with proper documentation ofc)

@masmc05
Copy link
Contributor

masmc05 commented Mar 15, 2026

Worst case you mean a ton of tasks scheduled in the same tick?

A lot of tasks scheduled on same tick, then adding a hundred tasks that were scheduled on every 1/2/5 ticks (so they're most probably the oldest)

I can do it, but what would the benefit be of adding a exponent 0 case?

In combination with redoing to be based on priority queues, the benefit is complete old behaviour

@Intybyte
Copy link
Contributor Author

Worst case you mean a ton of tasks scheduled in the same tick?

A lot of tasks scheduled on same tick, then adding a hundred tasks that were scheduled on every 1/2/5 ticks (so they're most probably the oldest)

I can do it, but what would the benefit be of adding a exponent 0 case?

In combination with redoing to be based on priority queues, the benefit is complete old behaviour

After some trial and errors, priority queues are not the way to go for any kind of optimization, using them together with the TimingWheel gives worse results than just using a single priority queue

Instead of sorting at add (and making the worst case scenario worse), I am now sorting on pop using List#sort, and got the best result even with a lot of tasks in the same bucket http://spark.lucko.me/LHPX8wcT80

getCommand("task_same_bucket").setExecutor(
            (sender, command, label, args) -> {
                for (int i = 0; i < 100_000; i++) {
                    schdl.runTaskTimer(
                        TaskSpawner.this,
                        new Runnable() {
                            long x = new Random().nextLong();

                            @Override
                            public void run() {
                                if (x % 2 == 0) {
                                    x /= 2;
                                } else {
                                    x *= 3;
                                    x++;
                                }
                            }
                        }, 1L, 1L
                    );
                }

                Bukkit.broadcast(Component.text("Completed"));
                return true;
            }

@Intybyte
Copy link
Contributor Author

I can make a static method that given an int returns a scheduler using priorityQueue when 0 or less is supplied and a timing wheel otherwise if you still want

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

Labels

None yet

Projects

Status: Changes required

Development

Successfully merging this pull request may close these issues.

4 participants