diff --git a/divinemc-server/minecraft-patches/features/0051-Regionized-Chunk-Ticking.patch b/divinemc-server/minecraft-patches/features/0051-Regionized-Chunk-Ticking.patch index 62e2eafa..dbaa7323 100644 --- a/divinemc-server/minecraft-patches/features/0051-Regionized-Chunk-Ticking.patch +++ b/divinemc-server/minecraft-patches/features/0051-Regionized-Chunk-Ticking.patch @@ -525,14 +525,15 @@ index fcc172a2aeee3fe6bfd526411e1e123d0f80c3e3..4c09d75b824293728ccc6367cdcd6b84 try { this.isUpdatingNavigations = true; diff --git a/net/minecraft/server/level/ServerPlayer.java b/net/minecraft/server/level/ServerPlayer.java -index 32a2819db5a929f0a8cde7f77f371bcd7890fd9c..5a9ce8cde62b738ebda64cdf63535299cee1f640 100644 +index 32a2819db5a929f0a8cde7f77f371bcd7890fd9c..fed602181a0013d7f75759da8c697f806bfb0467 100644 --- a/net/minecraft/server/level/ServerPlayer.java +++ b/net/minecraft/server/level/ServerPlayer.java -@@ -437,6 +437,7 @@ public class ServerPlayer extends Player implements ca.spottedleaf.moonrise.patc +@@ -437,6 +437,8 @@ public class ServerPlayer extends Player implements ca.spottedleaf.moonrise.patc private boolean compassBar = false; // Purpur - Add compass command private boolean ramBar = false; // Purpur - Implement rambar commands public boolean hasTickedAtLeastOnceInNewWorld = false; // DivineMC - Parallel world ticking + public final org.bxteam.divinemc.async.rct.RollingLongBuffer avgTickTimeNanos = new org.bxteam.divinemc.async.rct.RollingLongBuffer(100); // DivineMC - Region tick time tracking ++ public int lastRegionChunkSize, lastRegionEntityAmount, regionHash; // DivineMC - Region tracking // Paper start - rewrite chunk system private ca.spottedleaf.moonrise.patches.chunk_system.player.RegionizedPlayerChunkLoader.PlayerChunkLoaderData chunkLoader; diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/AvgTimeLogger.java b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/AvgTimeLogger.java index c9f1e396..e4dcc3ee 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/AvgTimeLogger.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/AvgTimeLogger.java @@ -1,17 +1,18 @@ package org.bxteam.divinemc.async.rct; import com.mojang.logging.LogUtils; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; +import org.slf4j.Logger; + +import java.io.*; import java.time.LocalDate; import java.time.LocalTime; import java.time.format.DateTimeFormatter; -import org.slf4j.Logger; +import java.util.zip.GZIPOutputStream; public final class AvgTimeLogger { private static final Logger LOGGER = LogUtils.getLogger(); private static final String LOG_DIR = "tracking"; + private static final int MAX_LOG_AGE_DAYS = 30; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); private final String levelName; @@ -22,16 +23,65 @@ public AvgTimeLogger(String levelName) { this.levelName = levelName; try { File logDir = new File(LOG_DIR + "/" + levelName); - if (!logDir.exists()) { - logDir.mkdirs(); + if (!logDir.exists() && !logDir.mkdirs()) { + LOGGER.warn("Failed to create log directory {}", logDir.getAbsolutePath()); } currentDate = LocalDate.now(); initializeLogWriter(); + cleanupOldLogs(); } catch (IOException e) { LOGGER.error("Failed to initialize region tick time log file", e); } } + private void cleanupOldLogs() { + File logDir = new File(LOG_DIR + "/" + levelName); + File[] files = logDir.listFiles(); + if (files == null) return; + + LocalDate cutoffDate = LocalDate.now().minusDays(MAX_LOG_AGE_DAYS); + + for (File file : files) { + String name = file.getName(); + if (name.startsWith("region-tick-") && name.endsWith(".log")) { + String dateStr = name.substring("region-tick-".length(), name.length() - ".log".length()); + try { + LocalDate fileDate = LocalDate.parse(dateStr, DATE_FORMATTER); + if (!fileDate.equals(LocalDate.now())) { + compressLogFile(file); + } + } catch (Exception ignored) {} + } else if (name.startsWith("region-tick-") && name.endsWith(".log.gz")) { + String dateStr = name.substring("region-tick-".length(), name.length() - ".log.gz".length()); + try { + LocalDate fileDate = LocalDate.parse(dateStr, DATE_FORMATTER); + if (fileDate.isBefore(cutoffDate) && !file.delete()) { + LOGGER.warn("Failed to delete old log file: {}", file.getName()); + } + } catch (Exception ignored) {} + } + } + } + + private void compressLogFile(File file) { + File gzFile = new File(file.getAbsolutePath() + ".gz"); + try (FileInputStream fis = new FileInputStream(file); + FileOutputStream fos = new FileOutputStream(gzFile); + GZIPOutputStream gzipOS = new GZIPOutputStream(fos)) { + byte[] buffer = new byte[1024]; + int len; + while ((len = fis.read(buffer)) != -1) { + gzipOS.write(buffer, 0, len); + } + } catch (IOException e) { + LOGGER.error("Failed to compress old log: {}", file.getName(), e); + return; + } + if (!file.delete()) { + LOGGER.warn("Failed to delete original log file after compression: {}", file.getName()); + } + } + private void initializeLogWriter() throws IOException { String filename = "region-tick-" + currentDate.format(DATE_FORMATTER) + ".log"; File logFile = new File(LOG_DIR + "/" + levelName, filename); @@ -46,6 +96,7 @@ public void logTickTime(String data) { if (regionTickLogWriter != null) { regionTickLogWriter.close(); } + cleanupOldLogs(); initializeLogWriter(); } diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RegionizedChunkTicking.java b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RegionizedChunkTicking.java index f1fce59d..e3d76117 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RegionizedChunkTicking.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RegionizedChunkTicking.java @@ -4,26 +4,12 @@ import ca.spottedleaf.moonrise.common.util.CoordinateUtils; import ca.spottedleaf.moonrise.common.util.TickThread; import com.mojang.datafixers.DataFixer; -import com.mojang.logging.LogUtils; import io.papermc.paper.entity.activation.ActivationRange; import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; import it.unimi.dsi.fastutil.objects.ObjectArrayList; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.function.Supplier; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -39,13 +25,18 @@ import org.bxteam.divinemc.config.DivineConfig; import org.bxteam.divinemc.util.NamedAgnosticThreadFactory; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Supplier; public final class RegionizedChunkTicking extends ServerChunkCache { public static final Executor REGION_EXECUTOR = Executors.newFixedThreadPool(DivineConfig.AsyncCategory.regionizedChunkTickingExecutorThreadCount, new NamedAgnosticThreadFactory<>("Region Ticking", TickThread::new, DivineConfig.AsyncCategory.regionizedChunkTickingExecutorThreadPriority)); - private static final int LOG_INTERVAL = 18000; private final AvgTimeLogger avgTimeLogger; + public final RollingLongBuffer avgTime = new RollingLongBuffer(100); + private final LongOpenHashSet tickedChunkKeys = new LongOpenHashSet(8192); private int i = 0; public RegionizedChunkTicking( @@ -67,6 +58,7 @@ public RegionizedChunkTicking( @Override protected void iterateTickingChunksFaster(final @NotNull CompletableFuture spawns) { + final long start = System.nanoTime(); final ServerLevel world = this.level; final int randomTickSpeed = world.getGameRules().get(GameRules.RANDOM_TICK_SPEED); final LevelChunk[] raw = world.moonrise$getEntityTickingChunks().toArray(new LevelChunk[0]); @@ -84,6 +76,8 @@ protected void iterateTickingChunksFaster(final @NotNull CompletableFuture finishTicking(futures, randomTickSpeed, raw, tickPair); spawns.join(); + final long end = System.nanoTime(); + avgTime.add(end - start); } private CompletableFuture tick(RegionData region, int randomTickSpeed) { @@ -102,20 +96,34 @@ private CompletableFuture tick(RegionData region, int randomTic tickEntity(entity); } - final long end = System.nanoTime(); - region.players().forEach(player -> player.avgTickTimeNanos.add(end - start)); + final long time = System.nanoTime() - start; + final int regionHash = region.hashCode(); + final int chunks = regionChunksIDs.size(); + final int entities = region.entities().size(); + for (ServerPlayer player : region.players()) { + player.avgTickTimeNanos.add(time); + player.lastRegionChunkSize = chunks; + player.lastRegionEntityAmount = entities; + player.regionHash = regionHash; + } return regionChunksIDs; }, REGION_EXECUTOR); } private void finishTicking(final ObjectArrayList> ticked, final int randomTickSpeed, final LevelChunk[] raw, final TickPair tickPair) { - try { - CompletableFuture.allOf(ticked.toArray(new CompletableFuture[0])).join(); - } catch (CompletionException ex) { - LOGGER.error("Error during region chunk ticking", ex.getCause()); + tickedChunkKeys.clear(); + for (CompletableFuture future : ticked) { + try { + LongOpenHashSet result = future.join(); + if (result != null) { + tickedChunkKeys.addAll(result); + } + } catch (Exception e) { + LOGGER.error("Exception retrieving region ticking result", e); + } } - if (false && i % 100 == 0 && tickPair.regions().length > 0) { + if (i++ % 100 == 0 && tickPair.regions().length > 0) { REGION_EXECUTOR.execute(() -> { StringBuilder sb = new StringBuilder(); for (RegionData regionData : tickPair.regions()) { @@ -132,18 +140,6 @@ private void finishTicking(final ObjectArrayList future : ticked) { - if (!future.isCompletedExceptionally()) { - try { - tickedChunkKeys.addAll(future.join()); - } catch (Exception e) { - LOGGER.error("Exception retrieving region ticking result", e); - } - } - } - for (LevelChunk chunk : raw) { if (!tickedChunkKeys.contains(chunk.coordinateKey)) { level.tickChunk(chunk, randomTickSpeed); @@ -155,6 +151,53 @@ private void finishTicking(final ObjectArrayList> ticked, final int randomTickSpeed, final LevelChunk[] raw, final TickPair tickPair) { + // try { + // CompletableFuture.allOf(ticked.toArray(new CompletableFuture[0])).join(); + // } catch (CompletionException ex) { + // LOGGER.error("Error during region chunk ticking", ex.getCause()); + // } + // + // if (i++ % 100 == 0 && tickPair.regions().length > 0) { + // REGION_EXECUTOR.execute(() -> { + // StringBuilder sb = new StringBuilder(); + // for (RegionData regionData : tickPair.regions()) { + // sb.append("Region with ").append(regionData.chunks().size()).append(" chunks and ").append(regionData.entities().size()).append(" entities ticked for Players:\n"); + // for (ServerPlayer player : regionData.players()) { + // long avgNanos = Math.round(player.avgTickTimeNanos.average().orElse(0)); + // long ms = avgNanos / 1_000_000; + // long us = (avgNanos % 1_000_000) / 1_000; + // long ns = avgNanos % 1_000; + // sb.append("- ").append(player.displayName).append(" avg region tick time: ").append(ms).append(" ms ").append(us).append(" us ").append(ns).append(" ns").append("\n"); + // } + // } + // avgTimeLogger.logTickTime(sb.toString()); + // }); + // } + // + // LongOpenHashSet tickedChunkKeys = new LongOpenHashSet(raw.length); + // + // for (CompletableFuture future : ticked) { + // if (!future.isCompletedExceptionally()) { + // try { + // tickedChunkKeys.addAll(future.join()); + // } catch (Exception e) { + // LOGGER.error("Exception retrieving region ticking result", e); + // } + // } + // } + // + // for (LevelChunk chunk : raw) { + // if (!tickedChunkKeys.contains(chunk.coordinateKey)) { + // level.tickChunk(chunk, randomTickSpeed); + // } + // } + // + // for (Entity entity : tickPair.entities()) { + // tickEntity(entity); + // } + // } + private TickPair computePlayerRegions() { List players = new ArrayList<>(level.players()); final int defaultTickDist = level.moonrise$getViewDistanceHolder().getViewDistances().tickViewDistance(); @@ -237,12 +280,6 @@ private TickPair computePlayerRegions() { } } - i++; - if (false && i % LOG_INTERVAL == 0) { - LOGGER.info("Computed {} regions for {} players", regions.size(), players.size()); - LOGGER.info("region sizes for each region: {}", Arrays.toString(regions.stream().mapToInt(r -> r.chunks().size()).toArray())); - } - final Set firstTick = ConcurrentHashMap.newKeySet(); IteratorSafeOrderedReferenceSet entities; @@ -273,7 +310,7 @@ private TickPair computePlayerRegions() { } } - regions.sort(Comparator.comparingDouble(r -> ((RegionData) r).players().stream().map(p -> p.avgTickTimeNanos.average().orElse(-1)).max(Comparator.naturalOrder()).orElse(-1d)).reversed()); + regions.sort(Comparator.comparingDouble(r -> r.players().stream().map(p -> p.avgTickTimeNanos.average().orElse(-1)).max(Comparator.naturalOrder()).orElse(-1d)).reversed()); return new TickPair(regions.toArray(new RegionData[0]), firstTick); } @@ -313,9 +350,9 @@ public boolean isEmpty() { record Rectangle(int minX, int minZ, int maxX, int maxZ) { boolean intersects(Rectangle other) { return !(this.maxX < other.minX || - this.minX > other.maxX || - this.maxZ < other.minZ || - this.minZ > other.maxZ); + this.minX > other.maxX || + this.maxZ < other.minZ || + this.minZ > other.maxZ); } } diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RollingLongBuffer.java b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RollingLongBuffer.java index d4700355..6ec65a95 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RollingLongBuffer.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/async/rct/RollingLongBuffer.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.OptionalDouble; +import java.util.OptionalLong; import java.util.stream.LongStream; public final class RollingLongBuffer { @@ -70,6 +71,38 @@ public LongStream longStream() { return Arrays.stream(snapshot, 0, snapshot.length); } + /** + * Returns the arithmetic average of the last {@code n} entries as OptionalDouble. + * If the buffer is empty, returns OptionalDouble.empty(). + * If n > size(), averages over the available entries. + * + * Time: O(n) where n is the requested number of entries (bounded by buffer size). + * + * @throws IllegalArgumentException if n <= 0 + */ + public synchronized OptionalDouble averageLast(int n) { + if (n <= 0) throw new IllegalArgumentException("n must be > 0"); + if (count == 0) return OptionalDouble.empty(); + + int use = Math.min(n, count); + // accumulate in double to reduce risk of overflow for large sums + double acc = 0.0; + int start = (head + count - use) % capacity; + for (int i = 0; i < use; i++) { + acc += buffer[(start + i) % capacity]; + } + return OptionalDouble.of(acc / (double) use); + } + + /** + * Returns the last (newest) entry as OptionalLong, or OptionalLong.empty() if buffer is empty. + */ + public synchronized OptionalLong last() { + if (count == 0) return OptionalLong.empty(); + int idx = (head + count - 1) % capacity; + return OptionalLong.of(buffer[idx]); + } + /** * Returns the arithmetic average as OptionalDouble; O(1). */ diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/command/DivineCommand.java b/divinemc-server/src/main/java/org/bxteam/divinemc/command/DivineCommand.java index 86a95b17..f941004d 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/command/DivineCommand.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/command/DivineCommand.java @@ -29,16 +29,18 @@ public final class DivineCommand extends Command { private static final DivineSubCommand MSPT_SUBCOMMAND = new MSPTCommand(); private static final DivineSubCommand RELOAD_SUBCOMMAND = new ReloadCommand(); private static final DivineSubCommand VERSION_SUBCOMMAND = new VersionCommand(); + private static final DivineSubCommand TICK_TIMES_SUBCOMMAND = new TickTimes(); private static final Map SUBCOMMANDS = Util.make(() -> { final Map, DivineSubCommand> commands = new HashMap<>(); commands.put(Set.of(MSPTCommand.LITERAL_ARGUMENT), MSPT_SUBCOMMAND); commands.put(Set.of(ReloadCommand.LITERAL_ARGUMENT), RELOAD_SUBCOMMAND); commands.put(Set.of(VersionCommand.LITERAL_ARGUMENT), VERSION_SUBCOMMAND); + commands.put(Set.of(TickTimes.LITERAL_ARGUMENT), TICK_TIMES_SUBCOMMAND); return commands.entrySet().stream() - .flatMap(entry -> entry.getKey().stream().map(s -> Map.entry(s, entry.getValue()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .flatMap(entry -> entry.getKey().stream().map(s -> Map.entry(s, entry.getValue()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); }); // alias -> subcommand label private static final Map ALIASES = Util.make(() -> { @@ -47,8 +49,8 @@ public final class DivineCommand extends Command { aliases.put(VersionCommand.LITERAL_ARGUMENT, Set.of("ver")); return aliases.entrySet().stream() - .flatMap(entry -> entry.getValue().stream().map(s -> Map.entry(s, entry.getKey()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .flatMap(entry -> entry.getValue().stream().map(s -> Map.entry(s, entry.getKey()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); }); private String createUsageMessage(Collection arguments) { @@ -70,10 +72,10 @@ public DivineCommand() { @Override public List tabComplete( - final CommandSender sender, - final String alias, - final String[] args, - final @Nullable Location location + final CommandSender sender, + final String alias, + final String[] args, + final @Nullable Location location ) throws IllegalArgumentException { if (args.length <= 1) { List subCommandArguments = new ArrayList<>(SUBCOMMANDS.size()); @@ -104,9 +106,9 @@ private boolean testHasOnePermission(CommandSender sender) { @Override public boolean execute( - final CommandSender sender, - final String commandLabel, - final String[] args + final CommandSender sender, + final String commandLabel, + final String[] args ) { // Check if the sender has the base permission and at least one specific permission diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/command/subcommands/TickTimes.java b/divinemc-server/src/main/java/org/bxteam/divinemc/command/subcommands/TickTimes.java new file mode 100644 index 00000000..ca5e7474 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/command/subcommands/TickTimes.java @@ -0,0 +1,179 @@ +package org.bxteam.divinemc.command.subcommands; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.permissions.PermissionDefault; +import org.bxteam.divinemc.async.rct.RegionizedChunkTicking; +import org.bxteam.divinemc.async.rct.RollingLongBuffer; +import org.bxteam.divinemc.command.DivineCommand; +import org.bxteam.divinemc.command.DivineSubCommandPermission; +import org.bxteam.divinemc.config.DivineConfig; +import org.jetbrains.annotations.NotNull; + +import java.text.DecimalFormat; +import java.time.Duration; +import java.util.*; + +import static net.kyori.adventure.text.Component.empty; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.format.NamedTextColor.*; + +public final class TickTimes extends DivineSubCommandPermission { + public static final String LITERAL_ARGUMENT = "ticktimes"; + public static final String PERM = DivineCommand.BASE_PERM + "." + LITERAL_ARGUMENT; + private static final DecimalFormat DF = new DecimalFormat("########0.0"); + private static final Component SLASH = text("/"); + private static final ClickCallback.Options options = ClickCallback.Options.builder().uses(-1).lifetime(Duration.ofMinutes(1)).build(); + + private record RegionStatsKey(int chunkSize, int entityAmount, int regionHash) {} + + public TickTimes() { + super(PERM, PermissionDefault.OP); + } + + @Override + public boolean execute(@NotNull CommandSender sender, @NotNull String subCommand, String @NotNull [] args) { + if (!DivineConfig.AsyncCategory.enableRegionizedChunkTicking) { + sender.sendMessage(Component.text("Per-world tick times tracking is only available when regionized chunk ticking is enabled.", RED)); + sender.sendMessage(Component.text("Please enable it in divinemc.yml to use this command.", GRAY)); + return true; + } + + List onlinePlayers = MinecraftServer.getServer().getPlayerList().players; + + if (onlinePlayers.isEmpty()) { + sender.sendMessage(text("No players online.", NamedTextColor.GRAY)); + return true; + } + + TreeMap> playersByWorld = new TreeMap<>( + Comparator.comparingInt(level -> level.players().size()) + .reversed() + .thenComparing(Comparator.comparingDouble(level -> ((RegionizedChunkTicking) level.chunkSource).avgTime.average().orElse(0)).reversed()) + .thenComparing(level -> level.serverLevelData.getLevelName()) + ); + + for (ServerLevel level : MinecraftServer.getServer().getAllLevels()) { + List players = level.players(); + if (!players.isEmpty()) { + playersByWorld.put(level, new ArrayList<>(players)); + } + } + + sender.sendMessage(text("━━━━━━━━━━━━━ ", GOLD) + .append(text("Player Tick Times ", YELLOW)) + .append(text("(last/1s/5s)", NamedTextColor.GRAY)) + .append(text(" ━━━━━━━━━━━━━", GOLD))); + + int i = 0; + for (Map.Entry> entry : playersByWorld.entrySet()) { + ServerLevel level = entry.getKey(); + String worldName = level.serverLevelData.getLevelName(); + List players = entry.getValue(); + RollingLongBuffer average = ((RegionizedChunkTicking) level.chunkSource).avgTime; + + players.sort((p1, p2) -> Double.compare( + p2.avgTickTimeNanos.average().orElse(0), + p1.avgTickTimeNanos.average().orElse(0) + )); + + Map> playersByRegionStats = new LinkedHashMap<>(); + for (ServerPlayer player : players) { + RegionStatsKey key = new RegionStatsKey(player.lastRegionChunkSize, player.lastRegionEntityAmount, player.regionHash); + playersByRegionStats.computeIfAbsent(key, ignored -> new ArrayList<>()).add(player); + } + + sender.sendMessage(text("➤ ", YELLOW) + .append(text(worldName, GOLD)) + .append(text(" (" + players.size() + " players)", GRAY)) + .append(stats(average)) + .hoverEvent(text("Region amount: " + playersByRegionStats.size(), GREEN))); + + for (List group : playersByRegionStats.values()) { + Component stats; + ServerPlayer firstPlayer = group.getFirst(); + if (group.size() == 1) { + stats = stats(firstPlayer.avgTickTimeNanos); + } else { + double last = 0.0; + double avg1s = 0.0; + double avg5s = 0.0; + + for (ServerPlayer player : group) { + last += player.avgTickTimeNanos.last().orElse(0L); + avg1s += player.avgTickTimeNanos.averageLast(20).orElse(0L); + avg5s += player.avgTickTimeNanos.average().orElse(0L); + } + + last = last / group.size() * 1.0E-6; + avg1s = avg1s / group.size() * 1.0E-6; + avg5s = avg5s / group.size() * 1.0E-6; + stats = stats(last, avg1s, avg5s); + } + + Component hover = text("Last region size: ", GRAY) + .append(text(firstPlayer.lastRegionChunkSize, GREEN)) + .appendNewline() + .append(text("Last region entity amount: ", GRAY)) + .append(text(firstPlayer.lastRegionEntityAmount, GREEN)) + .appendNewline() + .append(text("Grouped players: ", GRAY)) + .append(text(group.size(), GREEN)); + + Component playerLine = text(" - ", GRAY) + .append(group.stream() + .map(player -> text(player.displayName, AQUA) + .hoverEvent(text("Click to teleport", GREEN)) + .clickEvent(ClickEvent.callback(audience -> { + if (audience instanceof CraftPlayer sourcePlayer) { + MinecraftServer.getServer().execute(() -> sourcePlayer.teleport(player.getBukkitEntity().getLocation())); + } + }, options)) + ) + .reduce((left, right) -> left.append(text(", ", GRAY)).append(right)) + .orElseGet(Component::empty) + ) + .append(stats.hoverEvent(hover)); + + sender.sendMessage(playerLine); + } + + if (i < playersByWorld.size() - 1) { + sender.sendMessage(empty()); + } + i++; + } + + return true; + } + + private Component stats(RollingLongBuffer buffer) { + return stats(buffer.last().orElse(0L) * 1.0E-6, buffer.averageLast(20).orElse(0L) * 1.0E-6, buffer.average().orElse(0L) * 1.0E-6); + } + + private Component stats(double last, double avg1s, double avg5s) { + return text(" [", GRAY) + .append(getColoredValue(last)) + .append(SLASH) + .append(getColoredValue(avg1s)) + .append(SLASH) + .append(getColoredValue(avg5s)) + .append(text("]", GRAY)); + } + + private static Component getColoredValue(double value) { + NamedTextColor color = value >= 35 ? RED + : value >= 30 ? YELLOW + : value >= 25 ? GOLD + : value >= 15 ? GREEN + : AQUA; + return text(DF.format(value) + "ms", color); + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java index b1b004c2..421939fc 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java @@ -1,6 +1,17 @@ package org.bxteam.divinemc.config; import com.google.common.base.Throwables; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Random; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.EntityType; @@ -21,18 +32,6 @@ import org.simpleyaml.exceptions.InvalidConfigurationException; import su.plo.matter.HashingVersion; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.Random; - @SuppressWarnings({"SameParameterValue", "DataFlowIssue"}) public class DivineConfig { private static final String HEADER = """ @@ -47,6 +46,8 @@ public class DivineConfig { public static final Logger LOGGER = LogManager.getLogger(DivineConfig.class.getSimpleName()); public static final int CONFIG_VERSION = 7; + private static final int maxThreads = Runtime.getRuntime().availableProcessors(); + private static File configFile; public static final YamlFile config = new YamlFile(); @@ -96,9 +97,8 @@ static void readConfig(Class clazz, Object instance) throws IOException { for (Class innerClass : clazz.getDeclaredClasses()) { if (Modifier.isStatic(innerClass.getModifiers())) { try { - Object innerInstance = null; - - Method loadMethod = null; + Object innerInstance; + Method loadMethod; try { loadMethod = innerClass.getDeclaredMethod("load"); } catch (NoSuchMethodException ignored) { @@ -191,6 +191,8 @@ private static List getStringList(String key, @Nullable String oldKey, L } public static class AsyncCategory { + private static boolean enableAutoAllocation = false; + // Parallel world ticking settings @Experimental("Parallel World Ticking") public static boolean enableParallelWorldTicking = false; @@ -229,12 +231,66 @@ public static class AsyncCategory { public static boolean asyncNaturalSpawn = true; public static void load() { + enableAutoAllocation(); parallelWorldTicking(); regionizedChunkTicking(); asyncPathfinding(); multithreadedTracker(); asyncChunkSending(); asyncMobSpawning(); + autoAllocation(); + } + + private static void enableAutoAllocation() { + enableAutoAllocation = getBoolean(ConfigCategory.ASYNC.key("auto-thread-allocation"), enableAutoAllocation, + "Enables optimal thread allocation for parallel world ticking and regionized chunk ticking.", + "By enabling this, both parallel world ticking and regionized chunk ticking will get enabled and", + "thread count in config will get ignored and automatically allocated based on your system's CPU cores." + ); + if (enableAutoAllocation) { + if (maxThreads < 6) { + LOGGER.warn("Minimum thread count for auto-thread-allocation is 6, disabling auto-thread-allocation."); + enableAutoAllocation = false; + } else { + LOGGER.info("Detected {} CPU threads", maxThreads); + } + } + } + + private static void autoAllocation() { + if (!enableAutoAllocation) { + return; + } + + enableParallelWorldTicking = true; + enableRegionizedChunkTicking = true; + + assert maxThreads >= 6; + int max = maxThreads - 1; + switch (maxThreads) { + case 6, 7, 8 -> { + parallelThreadCount = 2; + max -= 2; + regionizedChunkTickingExecutorThreadCount = max; // 3 - 5 + } + case 9, 10, 11 -> { + parallelThreadCount = 3; + max -= 4; + regionizedChunkTickingExecutorThreadCount = max; // 4 - 6 + } + case 12, 13, 14, 15 -> { + parallelThreadCount = 4; + max -= 5; + regionizedChunkTickingExecutorThreadCount = max; // 6 - 9 + } + default -> { + parallelThreadCount = 5; + max -= 5; + regionizedChunkTickingExecutorThreadCount = Math.min(max, 16); // 10 - 16 + } + } + + LOGGER.info("Auto-thread-allocation enabled, using {} threads for parallel world ticking and {} threads for regionized chunk ticking.", parallelThreadCount, regionizedChunkTickingExecutorThreadCount); } private static void parallelWorldTicking() { @@ -263,7 +319,7 @@ private static void regionizedChunkTicking() { regionizedChunkTickingExecutorThreadPriority = getInt(ConfigCategory.ASYNC.key("regionized-chunk-ticking.executor-thread-priority"), regionizedChunkTickingExecutorThreadPriority, "Configures the thread priority of the executor"); - if (regionizedChunkTickingExecutorThreadCount < 1 || regionizedChunkTickingExecutorThreadCount > 10) { + if (!enableAutoAllocation && (regionizedChunkTickingExecutorThreadCount <= 1 || regionizedChunkTickingExecutorThreadCount > maxThreads)) { LOGGER.warn("Invalid regionized chunk ticking thread count: {}, resetting to default (4)", regionizedChunkTickingExecutorThreadCount); regionizedChunkTickingExecutorThreadCount = 4; } @@ -275,7 +331,6 @@ private static void asyncPathfinding() { asyncPathfindingKeepalive = getInt(ConfigCategory.ASYNC.key("pathfinding.keepalive"), asyncPathfindingKeepalive); asyncPathfindingQueueSize = getInt(ConfigCategory.ASYNC.key("pathfinding.queue-size"), asyncPathfindingQueueSize); - final int maxThreads = Runtime.getRuntime().availableProcessors(); if (asyncPathfindingMaxThreads < 0) { asyncPathfindingMaxThreads = Math.max(maxThreads + asyncPathfindingMaxThreads, 1); } else if (asyncPathfindingMaxThreads == 0) { @@ -318,9 +373,9 @@ private static void multithreadedTracker() { asyncEntityTrackerQueueSize = getInt(ConfigCategory.ASYNC.key("multithreaded-tracker.queue-size"), asyncEntityTrackerQueueSize); if (asyncEntityTrackerMaxThreads < 0) { - asyncEntityTrackerMaxThreads = Math.max(Runtime.getRuntime().availableProcessors() + asyncEntityTrackerMaxThreads, 1); + asyncEntityTrackerMaxThreads = Math.max(maxThreads + asyncEntityTrackerMaxThreads, 1); } else if (asyncEntityTrackerMaxThreads == 0) { - asyncEntityTrackerMaxThreads = Math.max(Runtime.getRuntime().availableProcessors() / 4, 1); + asyncEntityTrackerMaxThreads = Math.max(maxThreads / 4, 1); } if (!multithreadedEnabled) { @@ -338,9 +393,9 @@ private static void asyncChunkSending() { asyncChunkSendingMaxThreads = getInt(ConfigCategory.ASYNC.key("chunk-sending.max-threads"), asyncChunkSendingMaxThreads); if (asyncChunkSendingMaxThreads < 0) { - asyncChunkSendingMaxThreads = Math.max(Runtime.getRuntime().availableProcessors() + asyncChunkSendingMaxThreads, 1); + asyncChunkSendingMaxThreads = Math.max(maxThreads + asyncChunkSendingMaxThreads, 1); } else if (asyncChunkSendingMaxThreads == 0) { - asyncChunkSendingMaxThreads = Math.max(Runtime.getRuntime().availableProcessors() / 4, 1); + asyncChunkSendingMaxThreads = Math.max(maxThreads / 4, 1); } } @@ -399,13 +454,13 @@ public static class PerformanceCategory { )); // Virtual threads - public static boolean virtualThreadsEnabled = false; - public static boolean virtualBukkitScheduler = false; - public static boolean virtualChatScheduler = false; - public static boolean virtualTabCompleteScheduler = false; - public static boolean virtualAsyncExecutor = false; - public static boolean virtualCommandBuilderScheduler = false; - public static boolean virtualServerTextFilterPool = false; + public static boolean virtualThreadsEnabled = true; + public static boolean virtualBukkitScheduler = true; + public static boolean virtualChatScheduler = true; + public static boolean virtualTabCompleteScheduler = true; + public static boolean virtualAsyncExecutor = true; + public static boolean virtualCommandBuilderScheduler = true; + public static boolean virtualServerTextFilterPool = true; public static void load() { chunkSettings();