Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,103 @@
ProfilerFiller profiler = Profiler.get();
profiler.push("pathfind");
BlockPos fromPos = above ? this.mob.blockPosition().above() : this.mob.blockPosition();
@@ -172,6 +_,11 @@
@@ -172,17 +_,116 @@
return path;
}

+ // Paper start - Perf: Optimise pathfinding
+ private int lastFailure = 0;
+ private int pathfindFailures = 0;
+ // Paper end - Perf: Optimise pathfinding
+
+ // Paper start - async pathfinding
+ private volatile boolean processingPath = false;
+
+ private boolean asyncPathfindingEnabled() {
+ return this.level instanceof ServerLevel && this.level.paperConfig().entities.behavior.asyncPathfinding;
+ }
+
+ // Prepares the request on the main thread (validation, EntityPathfindEvent, region snapshot),
+ // offloads only the expensive A* search to a worker, then applies the finished path back on
+ // the main thread. Returns true optimistically so callers treat the move as accepted while the
+ // path is being computed - mobs already tolerate a null/stale path and simply recompute.
+ private boolean createPathAsyncAndMove(Set<BlockPos> targets, @Nullable Entity target, final int radiusOffset, final boolean above, final int reachRange, final double speedModifier) {
+ if (this.processingPath) {
+ // A search for this mob is already in flight; keep following the current path.
+ return true;
+ }
+ if (targets.isEmpty() || this.mob.getY() < this.level.getMinY() || !this.canUpdatePath()) {
+ return false;
+ }
+ if (this.path != null && !this.path.isDone() && targets.contains(this.targetPos)) {
+ return this.moveTo(this.path, speedModifier);
+ }
+
+ // EntityPathfindEvent + world border filtering (must run on the main thread).
+ boolean copiedSet = false;
+ for (final BlockPos possibleTarget : targets) {
+ if (!this.mob.level().getWorldBorder().isWithinBounds(possibleTarget) || !new com.destroystokyo.paper.event.entity.EntityPathfindEvent(this.mob.getBukkitEntity(),
+ org.bukkit.craftbukkit.util.CraftLocation.toBukkit(possibleTarget, this.mob.level()), target == null ? null : target.getBukkitEntity()).callEvent()) {
+ if (!copiedSet) {
+ copiedSet = true;
+ targets = new java.util.HashSet<>(targets);
+ }
+ targets.remove(possibleTarget);
+ if (targets.isEmpty()) {
+ return false;
+ }
+ }
+ }
+
+ final float maxPathLength = this.getMaxPathLength();
+ final BlockPos fromPos = above ? this.mob.blockPosition().above() : this.mob.blockPosition();
+ final int radius = (int) (maxPathLength + radiusOffset);
+ final PathNavigationRegion region = new PathNavigationRegion(this.level, fromPos.offset(-radius, -radius, -radius), fromPos.offset(radius, radius, radius));
+ // Thread-confined finder: never share this.pathFinder (mutable node evaluator) across threads.
+ final PathFinder finder = this.createPathFinder(Mth.floor(maxPathLength * 16.0F));
+ final Set<BlockPos> finalTargets = targets;
+ final float visitedMultiplier = this.maxVisitedNodesMultiplier;
+ final net.minecraft.server.MinecraftServer server = ((ServerLevel) this.level).getServer();
+
+ this.processingPath = true;
+ io.papermc.paper.entity.AsyncPathProcessor.execute(() -> {
+ Path computed;
+ try {
+ computed = finder.findPath(region, this.mob, finalTargets, maxPathLength, reachRange, visitedMultiplier);
+ } catch (final Throwable throwable) {
+ // Reading a concurrently-mutated region/mob failed; drop this cycle, the mob recomputes next time.
+ computed = null;
+ }
+ final Path result = computed;
+ server.execute(() -> {
+ this.processingPath = false;
+ if (this.mob.isRemoved()) {
+ return;
+ }
+ if (result != null && result.getTarget() != null) {
+ this.targetPos = result.getTarget();
+ this.reachRange = reachRange;
+ this.resetStuckTimeout();
+ }
+ this.moveTo(result, speedModifier);
+ });
+ });
+ return true;
+ }
+ // Paper end - async pathfinding
+
public boolean moveTo(final double x, final double y, final double z, final double speedModifier) {
+ if (this.asyncPathfindingEnabled()) return this.createPathAsyncAndMove(ImmutableSet.of(BlockPos.containing(x, y, z)), null, 8, false, 1, speedModifier); // Paper - async pathfinding
return this.moveTo(this.createPath(x, y, z, 1), speedModifier);
}
@@ -181,8 +_,23 @@

public boolean moveTo(final double x, final double y, final double z, final int reachRange, final double speedModifier) {
+ if (this.asyncPathfindingEnabled()) return this.createPathAsyncAndMove(ImmutableSet.of(BlockPos.containing(x, y, z)), null, 8, false, reachRange, speedModifier); // Paper - async pathfinding
return this.moveTo(this.createPath(x, y, z, reachRange), speedModifier);
}

public boolean moveTo(final Entity target, final double speedModifier) {
+ if (this.asyncPathfindingEnabled()) return this.createPathAsyncAndMove(ImmutableSet.of(target.blockPosition()), target, 16, true, 1, speedModifier); // Paper - async pathfinding
+ // Paper start - Perf: Optimise pathfinding
+ if (this.pathfindFailures > 10 && this.path == null && net.minecraft.server.MinecraftServer.currentTick < this.lastFailure + 40) {
+ return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ public class MobsCanAlwaysPickUpLoot extends ConfigurationPart {
}

public boolean disablePlayerCrits = false;
@Comment("Runs mob A* pathfinding off the main thread (experimental). Reduces main-thread MSPT on mob-heavy servers. Paths may lag by a few ticks; disable if you see mob movement issues.")
public boolean asyncPathfinding = false;
public boolean nerfPigmenFromNetherPortals = false;
@Comment("Prevents merging items that are not on the same y level, preventing potential visual artifacts.")
public boolean onlyMergeItemsHorizontally = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.papermc.paper.entity;

import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.jetbrains.annotations.NotNull;

/**
* Worker pool that runs the expensive A* portion of mob pathfinding
* ({@link net.minecraft.world.level.pathfinder.PathFinder#findPath}) off the
* main server thread.
*
* <p>This is opt-in per world via {@code entities.behavior.async-pathfinding} and
* is disabled by default. Only the {@code findPath} computation is offloaded; the
* request is prepared (validation, {@code EntityPathfindEvent}, region snapshot) on
* the main thread and the finished {@link net.minecraft.world.level.pathfinder.Path}
* is applied back on the main thread, so the world's mutation model is unchanged.</p>
*
* <p>Backpressure: the queue is bounded and uses a caller-runs policy, so when the
* pool is saturated the pathfinding simply runs synchronously on the main thread as
* it would in vanilla — latency never regresses past the vanilla baseline.</p>
*/
public final class AsyncPathProcessor {

// Reserve most cores for the main tick + chunk workers; pathfinding gets a small slice.
private static final int THREADS = Math.max(1, Runtime.getRuntime().availableProcessors() / 4);

private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
THREADS, THREADS,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(256),
new ThreadFactoryBuilder()
.setNameFormat("Paper Async Pathfinding Thread #%d")
.setDaemon(true)
.setPriority(Thread.NORM_PRIORITY - 2)
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // saturated -> run on caller (main) = safe vanilla fallback
);

static {
EXECUTOR.allowCoreThreadTimeOut(true);
}

private AsyncPathProcessor() {
}

public static void execute(final @NotNull Runnable task) {
EXECUTOR.execute(task);
}

public static int threadCount() {
return THREADS;
}
}