diff --git a/shiroha-server/build.gradle.kts.patch b/shiroha-server/build.gradle.kts.patch index bbac92b..d61df98 100644 --- a/shiroha-server/build.gradle.kts.patch +++ b/shiroha-server/build.gradle.kts.patch @@ -48,7 +48,7 @@ } } val log4jPlugins = sourceSets.create("log4jPlugins") { -@@ -134,7 +_,14 @@ +@@ -134,7 +_,16 @@ } dependencies { @@ -60,6 +60,8 @@ + implementation("com.github.luben:zstd-jni:1.5.4-1") + implementation("net.openhft:zero-allocation-hashing:0.16") + implementation("net.objecthunter:exp4j:0.4.8") ++ implementation("tools.profiler:async-profiler:4.5") ++ implementation("tools.profiler:jfr-converter:4.5") + // Shiroha end implementation("ca.spottedleaf:leafpile:1.0.0") implementation("org.jline:jline-terminal-ffm:3.27.1") // use ffm on java 22+ diff --git a/shiroha-server/minecraft-patches/features/0089-Per-region-async-profiler.patch b/shiroha-server/minecraft-patches/features/0089-Per-region-async-profiler.patch new file mode 100644 index 0000000..41d5788 --- /dev/null +++ b/shiroha-server/minecraft-patches/features/0089-Per-region-async-profiler.patch @@ -0,0 +1,83 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: NanaChiyo0721 +Date: Sun, 16 Aug 2026 01:31:31 +0800 +Subject: [PATCH] Per region async profiler + + +diff --git a/io/papermc/paper/threadedregions/TickRegionScheduler.java b/io/papermc/paper/threadedregions/TickRegionScheduler.java +index 724332d074bd7d3dfdcb60e83cf1fd6ce706000e..eb65077d18d86fce3ae1131445ebdcb736f5dc93 100644 +--- a/io/papermc/paper/threadedregions/TickRegionScheduler.java ++++ b/io/papermc/paper/threadedregions/TickRegionScheduler.java +@@ -220,6 +220,7 @@ public final class TickRegionScheduler { + */ + public void scheduleRegion(final RegionScheduleHandle region) { + region.scheduler = this; ++ region.scheduleProfiler = TickRegions.getScheduleProfilerManager().register(region.id); // Shiroha - per region async profiler + this.scheduler.schedule(region); + } + +@@ -230,6 +231,7 @@ public final class TickRegionScheduler { + public void descheduleRegion(final RegionScheduleHandle region) { + // To avoid acquiring any of the locks the scheduler may be using, we + // simply cancel the next action. ++ TickRegions.getScheduleProfilerManager().deRegister(region.id); region.scheduleProfiler = null; // Shiroha - per region async profiler + region.markNonSchedulable(); + } + +@@ -296,6 +298,7 @@ public final class TickRegionScheduler { + } + + public static abstract class RegionScheduleHandle extends SchedulableTick { ++ public io.nanachiyo0721.shiroha.utils.profiling.RegionScheduleProfiler scheduleProfiler = null; // Shiroha - per region async profiler + + protected long currentTick; + protected long lastTickStart; +@@ -391,6 +394,7 @@ public final class TickRegionScheduler { + + final FoliaWatchdogThread.RunningTick runningTick = new FoliaWatchdogThread.RunningTick(tickStart, this, Thread.currentThread()); // Folia - watchdog + WATCHDOG_THREAD.addTick(runningTick); // Folia - watchdog ++ if (this.scheduleProfiler != null) this.scheduleProfiler.taskBegin(); // Shiroha - per region async profiler + try { + this.runRegionTasks(() -> { + return !RegionScheduleHandle.this.cancelled.get() && canContinue.getAsBoolean(); +@@ -400,6 +404,7 @@ public final class TickRegionScheduler { + // don't release region for another tick + return false; + } finally { ++ if (this.scheduleProfiler != null) this.scheduleProfiler.taskEnd(); // Shiroha - per region async profiler + WATCHDOG_THREAD.removeTick(runningTick); // Folia - watchdog + final long tickEnd = System.nanoTime(); + final long cpuEnd = MEASURE_CPU_TIME ? THREAD_MX_BEAN.getCurrentThreadCpuTime() : 0L; +@@ -476,6 +481,7 @@ public final class TickRegionScheduler { + + final FoliaWatchdogThread.RunningTick runningTick = new FoliaWatchdogThread.RunningTick(tickStart, this, Thread.currentThread()); // Folia - region threading + WATCHDOG_THREAD.addTick(runningTick); // Folia - region threading ++ if (this.scheduleProfiler != null) this.scheduleProfiler.tickBegin(); // Shiroha - per region async profiler + try { + // next start isn't updated until the end of this tick + this.tickRegion(tickCount, tickStart, scheduledEnd); +@@ -489,6 +495,7 @@ public final class TickRegionScheduler { + // regionFailed will schedule a shutdown, so we should avoid letting this region tick further + return false; + } finally { ++ if (this.scheduleProfiler != null) this.scheduleProfiler.tickEnd(); // Shiroha - per region async profiler + WATCHDOG_THREAD.removeTick(runningTick); // Folia - region threading + final long tickEnd = System.nanoTime(); + final long cpuEnd = MEASURE_CPU_TIME ? THREAD_MX_BEAN.getCurrentThreadCpuTime() : 0L; +diff --git a/io/papermc/paper/threadedregions/TickRegions.java b/io/papermc/paper/threadedregions/TickRegions.java +index ab94ea7f18799d9dd3cf164a1332db69f40a9278..863f78680150d87c5b82132e471baac527452b6b 100644 +--- a/io/papermc/paper/threadedregions/TickRegions.java ++++ b/io/papermc/paper/threadedregions/TickRegions.java +@@ -36,10 +36,12 @@ public final class TickRegions implements ThreadedRegionizer.RegionCallbacks | " + + "/sprofiler start-at | " + + "/sprofiler stop " + )); + return true; + } + + private static Throwable unwrap(Throwable throwable) { + while (throwable.getCause() != null + && (throwable instanceof java.util.concurrent.CompletionException + || throwable instanceof java.util.concurrent.ExecutionException)) { + throwable = throwable.getCause(); + } + return throwable; + } + + private static void sendMessage(CommandSender sender, Component message) { + RegionizedServer.getInstance().addTask(() -> sender.sendMessage(message)); + } + + private static void sendFailure(CommandSender sender, String operation, Throwable throwable) { + final Throwable cause = unwrap(throwable); + final String detail = cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage(); + sendMessage(sender, Component.text("Failed to " + operation + ": " + detail).color(ERROR)); + } + + private void startProfiling( + CommandSender sender, + String typeInput, + String categoryInput, + long regionId, + int seconds, + String targetDescription + ) { + final EnumProfilingType profilingType; + final EnumProfilingCategory category; + try { + profilingType = EnumProfilingType.valueOf(typeInput.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + sender.sendMessage(Component.text("Unknown profiling type: " + typeInput).color(ERROR)); + return; + } + try { + category = EnumProfilingCategory.valueOf(categoryInput.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + sender.sendMessage(Component.text("Unknown profiling category: " + categoryInput).color(ERROR)); + return; + } + + final long profilingEnd; + try { + final long durationNanos = Math.multiplyExact((long) seconds, 1_000_000_000L); + profilingEnd = Math.addExact(System.nanoTime(), durationNanos); + } catch (ArithmeticException exception) { + sender.sendMessage(Component.text("Profiling duration is too long").color(ERROR)); + return; + } + + this.profilerManager.startProfilingSession(profilingType, category, regionId, profilingEnd) + .whenComplete((session, startFailure) -> { + if (startFailure != null) { + sendFailure(sender, "start profiler", startFailure); + return; + } + + sendMessage(sender, Component.text( + "Started profiler session " + session.sessionId() + + " for " + targetDescription + + " for " + seconds + " seconds" + ).color(SUCCESS)); + + session.output().whenComplete((output, exportFailure) -> { + if (exportFailure != null) { + sendFailure(sender, "export profiler session " + session.sessionId(), exportFailure); + return; + } + if (output == null) { + sendMessage(sender, Component.text( + "Profiler session " + session.sessionId() + " ended without an output file" + ).color(ERROR)); + return; + } + + final Path absoluteOutput = output.toAbsolutePath().normalize(); + sendMessage(sender, Component.text( + "Profiler session " + session.sessionId() + " exported to " + absoluteOutput + ).color(SUCCESS)); + }); + }); + } + + private final class StartCommand extends LiteralNode { + private StartCommand() { + super("start"); + children(new TypeArg()); + } + + @Override + public boolean requires(@NotNull CommandSourceStack source) { + return ProfilerCommand.hasPermission(source.getSender(), this.name); + } + } + + private final class TypeArg extends ArgumentNode { + private TypeArg() { + super("type", StringArgumentType.word()); + children(new CategoryArg()); + } + + @Override + protected CompletableFuture getSuggestions( + @NotNull CommandContext context, + @NotNull SuggestionsBuilder builder + ) { + Arrays.stream(EnumProfilingType.values()) + .map(value -> value.name().toLowerCase(Locale.ROOT)) + .forEach(builder::suggest); + return builder.buildFuture(); + } + } + + private final class CategoryArg extends ArgumentNode { + private CategoryArg() { + super("category", StringArgumentType.word()); + children(new RegionArg()); + } + + @Override + protected CompletableFuture getSuggestions( + @NotNull CommandContext context, + @NotNull SuggestionsBuilder builder + ) { + Arrays.stream(EnumProfilingCategory.values()) + .map(value -> value.name().toLowerCase(Locale.ROOT)) + .forEach(builder::suggest); + return builder.buildFuture(); + } + } + + private final class RegionArg extends ArgumentNode { + private RegionArg() { + super("regionId", LongArgumentType.longArg(0L)); + children(new SecondsArg()); + } + } + + private final class SecondsArg extends ArgumentNode { + private SecondsArg() { + super("seconds", IntegerArgumentType.integer(1)); + } + + @Override + protected boolean execute(@NotNull CommandContext context) { + final CommandSender sender = context.getSender(); + final String typeInput = context.getArgument(TypeArg.class); + final String categoryInput = context.getArgument(CategoryArg.class); + final long regionId = context.getArgument("regionId", Long.class); + final int seconds = context.getArgument(SecondsArg.class); + + startProfiling(sender, typeInput, categoryInput, regionId, seconds, "region " + regionId); + return true; + } + } + + private final class StartAtCommand extends LiteralNode { + private StartAtCommand() { + super("start-at"); + children(new CoordinateTypeArg()); + } + + @Override + public boolean requires(@NotNull CommandSourceStack source) { + return ProfilerCommand.hasPermission(source.getSender(), this.name); + } + } + + private final class CoordinateTypeArg extends ArgumentNode { + private CoordinateTypeArg() { + super("coordinateType", StringArgumentType.word()); + children(new CoordinateCategoryArg()); + } + + @Override + protected CompletableFuture getSuggestions( + @NotNull CommandContext context, + @NotNull SuggestionsBuilder builder + ) { + Arrays.stream(EnumProfilingType.values()) + .map(value -> value.name().toLowerCase(Locale.ROOT)) + .forEach(builder::suggest); + return builder.buildFuture(); + } + } + + private final class CoordinateCategoryArg extends ArgumentNode { + private CoordinateCategoryArg() { + super("coordinateCategory", StringArgumentType.word()); + children(new WorldArg()); + } + + @Override + protected CompletableFuture getSuggestions( + @NotNull CommandContext context, + @NotNull SuggestionsBuilder builder + ) { + Arrays.stream(EnumProfilingCategory.values()) + .map(value -> value.name().toLowerCase(Locale.ROOT)) + .forEach(builder::suggest); + return builder.buildFuture(); + } + } + + private final class WorldArg extends ArgumentNode { + private WorldArg() { + super("world", StringArgumentType.word()); + children(new BlockXArg()); + } + + @Override + protected CompletableFuture getSuggestions( + @NotNull CommandContext context, + @NotNull SuggestionsBuilder builder + ) { + Bukkit.getWorlds().stream().map(World::getName).forEach(builder::suggest); + return builder.buildFuture(); + } + } + + private final class BlockXArg extends ArgumentNode { + private BlockXArg() { + super("blockX", IntegerArgumentType.integer()); + children(new BlockZArg()); + } + } + + private final class BlockZArg extends ArgumentNode { + private BlockZArg() { + super("blockZ", IntegerArgumentType.integer()); + children(new CoordinateSecondsArg()); + } + } + + private final class CoordinateSecondsArg extends ArgumentNode { + private CoordinateSecondsArg() { + super("coordinateSeconds", IntegerArgumentType.integer(1)); + } + + @Override + protected boolean execute(@NotNull CommandContext context) { + final CommandSender sender = context.getSender(); + final String worldName = context.getArgument(WorldArg.class); + final int blockX = context.getArgument(BlockXArg.class); + final int blockZ = context.getArgument(BlockZArg.class); + final World world = Bukkit.getWorld(worldName); + + if (world == null) { + sender.sendMessage(Component.text("Unknown world: " + worldName).color(ERROR)); + return true; + } + + final var region = ((CraftWorld) world).getHandle().regioniser + .getRegionAtSynchronised(blockX >> 4, blockZ >> 4); + if (region == null) { + sender.sendMessage(Component.text( + "No active region at " + worldName + " " + blockX + " " + blockZ + ).color(ERROR)); + return true; + } + + final long regionId = region.getData().getRegionSchedulingHandle().id; + final int seconds = context.getArgument(CoordinateSecondsArg.class); + startProfiling( + sender, + context.getArgument(CoordinateTypeArg.class), + context.getArgument(CoordinateCategoryArg.class), + regionId, + seconds, + "region " + regionId + " at " + worldName + " " + blockX + " " + blockZ + ); + return true; + } + } + + private final class StopCommand extends LiteralNode { + private StopCommand() { + super("stop"); + children(new SessionArg()); + } + + @Override + public boolean requires(@NotNull CommandSourceStack source) { + return ProfilerCommand.hasPermission(source.getSender(), this.name); + } + } + + private final class SessionArg extends ArgumentNode { + private SessionArg() { + super("sessionId", IntegerArgumentType.integer(1)); + } + + @Override + protected boolean execute(@NotNull CommandContext context) { + final CommandSender sender = context.getSender(); + final int sessionId = context.getInteger("sessionId"); + + profilerManager.endProfilingAsync(sessionId).whenComplete((stopped, failure) -> { + if (failure != null) { + sendFailure(sender, "stop profiler session " + sessionId, failure); + } else if (stopped) { + sendMessage(sender, Component.text( + "Stopped profiler session " + sessionId + "; exporting the result" + ).color(SUCCESS)); + } else { + sendMessage(sender, Component.text( + "Profiler session " + sessionId + " is not active" + ).color(ERROR)); + } + }); + return true; + } + } +} diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/config/modules/function/ProfilerConfig.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/config/modules/function/ProfilerConfig.java new file mode 100644 index 0000000..f7f7f00 --- /dev/null +++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/config/modules/function/ProfilerConfig.java @@ -0,0 +1,34 @@ +package io.nanachiyo0721.shiroha.config.modules.function; + +import com.electronwill.nightconfig.core.file.CommentedFileConfig; +import io.nanachiyo0721.shiroha.config.IConfigModule; +import io.nanachiyo0721.shiroha.config.flags.ConfigClassInfo; +import io.nanachiyo0721.shiroha.config.flags.ConfigInfo; +import io.nanachiyo0721.shiroha.config.flags.DoNotLoad; +import io.nanachiyo0721.shiroha.config.flags.HotReloadUnsupported; +import io.nanachiyo0721.shiroha.enums.EnumConfigCategory; +import io.papermc.paper.threadedregions.TickRegions; +import org.jetbrains.annotations.Nullable; + +import java.util.Set; + +@ConfigClassInfo(name = "shiroha_profiler", category = EnumConfigCategory.FUNCTION) +public class ProfilerConfig implements IConfigModule { + @ConfigInfo(name = "enabled") + @HotReloadUnsupported + public static boolean enabled = false; + + @DoNotLoad + private static boolean shutdownHookAttached = false; + + @Override + public void onLoaded(CommentedFileConfig configInstance, @Nullable Set e) { + if (enabled) { + if (!shutdownHookAttached) { + shutdownHookAttached = true; + + Runtime.getRuntime().addShutdownHook(new Thread(() -> TickRegions.getScheduleProfilerManager().shutdown())); + } + } + } +} diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingCategory.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingCategory.java new file mode 100644 index 0000000..3456b71 --- /dev/null +++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingCategory.java @@ -0,0 +1,16 @@ +package io.nanachiyo0721.shiroha.enums; + +public enum EnumProfilingCategory { + TICK("tick"), + TASK("task"); + + private final String name; + + EnumProfilingCategory(String name) { + this.name = name; + } + + public String getTagCategory() { + return this.name; + } +} diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingType.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingType.java new file mode 100644 index 0000000..030a28c --- /dev/null +++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/enums/EnumProfilingType.java @@ -0,0 +1,17 @@ +package io.nanachiyo0721.shiroha.enums; + +public enum EnumProfilingType { + ALLOC("alloc"), + CPU("cpu"), + WALL("wall"); + + private final String eventName; + + EnumProfilingType(String eventName) { + this.eventName = eventName; + } + + public String getEventName() { + return this.eventName; + } +} diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfiler.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfiler.java new file mode 100644 index 0000000..d6fca6a --- /dev/null +++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfiler.java @@ -0,0 +1,117 @@ +package io.nanachiyo0721.shiroha.utils.profiling; + +import io.nanachiyo0721.shiroha.enums.EnumProfilingCategory; +import one.profiler.Span; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NonNull; + +/** + * note: 1.a single instance should be called single threadedly(except method "toggle") + * 2.tickEnd, tickBegin, taskEnd, taskBegin are called serially not concurrently, if not, exception or unexpected behavior would be + * surely happened + */ +public class RegionScheduleProfiler { + private final long id; + private final String tagNameTick; + private final String tagNameTask; + + private ExecutionRecord executionState; + + private volatile boolean enabled = false; + private volatile long lastExecutionTime = Long.MIN_VALUE; + + public RegionScheduleProfiler(long id) { + this.id = id; + + this.tagNameTick = "region-" + EnumProfilingCategory.TICK.getTagCategory() + "-" + this.id; + this.tagNameTask = "region-" + EnumProfilingCategory.TASK.getTagCategory() + "-" + this.id; + } + + public void toggle(boolean enabled) { + synchronized (this) { + this.enabled = enabled; + } + } + + public boolean tryToggle(boolean enabled) { + boolean oldVal = this.enabled; + + if (oldVal == enabled) { + return false; + } + + synchronized (this) { + oldVal = this.enabled; + + if (oldVal == enabled) { + return false; + } + + this.enabled = enabled; + return true; + } + } + + public void tickBegin() { + if (!this.enabled) { + return; + } + + this.executionState = ExecutionRecord.of(Span.start(), System.nanoTime()); + } + + public void tickEnd() { + final long end = System.nanoTime(); + + if (this.executionState != null) { + Span.end(this.executionState.span, this.tagNameTick); + + this.lastExecutionTime = end - this.executionState.start; + this.executionState = null; + } + } + + public void taskBegin() { + if (!this.enabled) { + return; + } + + this.executionState = ExecutionRecord.of(Span.start(), System.nanoTime()); + } + + public void taskEnd() { + final long end = System.nanoTime(); + + if (this.executionState != null) { + Span.end(this.executionState.span, this.tagNameTask); + + this.lastExecutionTime = end - this.executionState.start; + this.executionState = null; + } + } + + /** + * Gets the execution time of last operation + * @return -1 -> no execution history yet, otherwise the execution time of last operation + */ + public long lastExecutionTime() { + long executionTime = this.lastExecutionTime; + + if (executionTime == Long.MIN_VALUE) { + return -1; + } + + return executionTime; + } + + public long profilerId() { + return this.id; + } + + private record ExecutionRecord(long span, long start) { + @Contract("_,_ -> new") + public static @NonNull ExecutionRecord of(long span, long start) { + return new ExecutionRecord(span, start); + } + } +} diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfilerManager.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfilerManager.java new file mode 100644 index 0000000..23e4199 --- /dev/null +++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/utils/profiling/RegionScheduleProfilerManager.java @@ -0,0 +1,497 @@ +package io.nanachiyo0721.shiroha.utils.profiling; + +import ca.spottedleaf.concurrentutil.collection.MultiThreadedQueue; +import com.mojang.logging.LogUtils; +import io.nanachiyo0721.shiroha.enums.EnumProfilingCategory; +import io.nanachiyo0721.shiroha.enums.EnumProfilingType; +import it.unimi.dsi.fastutil.Pair; +import one.convert.Arguments; +import one.convert.Main; +import one.profiler.AsyncProfiler; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class RegionScheduleProfilerManager { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Path PROFILER_FOLDER = Path.of("./shiroha_profiler"); + private static final Pattern SESSION_FOLDER_PATTERN = Pattern.compile("session-([1-9]\\d*)"); + + private final Map registereddProfilers = new ConcurrentHashMap<>(); + private final OpThread opThread = new OpThread(); + + // note: following fields are managed by the OpThread + private boolean profiling = false; + private int activeSessionId = -1; + private long profilingStartNs = Long.MIN_VALUE; + private String profilingOutput = ""; + private List lastProfilingIds = Collections.emptyList(); + private Consumer lastOnEnd; + // end + + public record ProfilingSession(int sessionId, CompletableFuture output) { + public ProfilingSession { + Objects.requireNonNull(output, "output"); + } + } + + // we do not rely on the serialized operation to be executed on the global region + // as we are also profiling that + private static final class OpThread extends Thread { + private volatile boolean shutdown = false; + + private final MultiThreadedQueue ops = new MultiThreadedQueue<>(); + private final MultiThreadedQueue afterShutdown = new MultiThreadedQueue<>(); + + public void signalShutdown() { + this.shutdown = true; + LockSupport.unpark(this); + } + + public void shutdownAndAwait() { + this.signalShutdown(); + + final Thread toUnpark = Thread.currentThread(); + if (!afterShutdown.offer(() -> LockSupport.unpark(toUnpark))) { + return; + } + + LockSupport.park(); + } + + public boolean postOpIfNotOnOpThread(Runnable op) { + if (Thread.currentThread() == this) { + op.run(); + return true; + } + + return this.postOp(op); + } + + public boolean postOp(Runnable op) { + final boolean posted = this.ops.offer(op); + + if (posted) { + LockSupport.unpark(this); + } + + return posted; + } + + private void postShutdown() { + Runnable op; + while ((op = this.afterShutdown.pollOrBlockAdds()) != null) { + try { + op.run(); + } catch (Throwable ex) { + LOGGER.warn("Failed to execute operation!", ex); + } + } + } + + private void flushOps() { + Runnable op; + while ((op = (this.shutdown ? this.ops.pollOrBlockAdds() : this.ops.poll())) != null) { + try { + op.run(); + } catch (Throwable ex) { + LOGGER.warn("Failed to execute operation!", ex); + } + } + } + + @Override + public void run() { + while (true) { + this.flushOps(); + + if (this.shutdown) { + // final flush + this.flushOps(); + break; + } + + // idle + LockSupport.park(); + } + + this.postShutdown(); + } + } + + public void init() { + // call for a simple bootstrap + AsyncProfiler.getInstance(); + + // kick off op thread + this.opThread.start(); + } + + public void shutdown() { + this.opThread.postOpIfNotOnOpThread(this::haltAllProfiler); + this.opThread.shutdownAndAwait(); + } + + private void haltAllProfiler() { + if (this.profiling) { + this.stopActiveProfiling(); + return; + } + + for (RegionScheduleProfiler profiler : this.registereddProfilers.values()) { + profiler.toggle(false); + } + } + + public void endProfiling(int sessionId) { + this.endProfilingAsync(sessionId); + } + + public CompletableFuture endProfilingAsync(int sessionId) { + final CompletableFuture callback = new CompletableFuture<>(); + + if (!this.opThread.postOpIfNotOnOpThread(() -> { + if (!this.profiling || sessionId != this.activeSessionId) { + callback.complete(false); + return; + } + + final Throwable stopFailure = this.stopActiveProfiling(); + if (stopFailure == null) { + callback.complete(true); + } else { + callback.completeExceptionally(stopFailure); + } + })) { + callback.completeExceptionally(new IllegalStateException("Profiler manager has been shut down")); + } + + return callback; + } + + private Throwable stopActiveProfiling() { + for (long id : this.lastProfilingIds) { + final RegionScheduleProfiler profiler = this.registereddProfilers.get(id); + + // may be it was death, so no need to warn + if (profiler == null) { + continue; + } + + final boolean success = profiler.tryToggle(false); + if (!success) { + LOGGER.warn("Failed to deactivate async profiler for region of id {}!", id); + } + } + + final long profiledTime = System.nanoTime() - this.profilingStartNs; + final long profiledTimeSeconds = profiledTime / 1_000_000_000L; + + Throwable stopFailure = null; + String commandResult = ""; + try { + commandResult = AsyncProfiler.getInstance().execute("stop"); + } catch (Throwable throwable) { + stopFailure = throwable; + LOGGER.warn("Failed to stop async profiler!", throwable); + } + + LOGGER.info( + "Profiler stopped with ret: {}, executed for {} seconds, saved to {}", + commandResult, + profiledTimeSeconds, + this.profilingOutput + ); + + final Consumer onEnd = this.lastOnEnd; + + this.profiling = false; + this.activeSessionId = -1; + this.profilingStartNs = Long.MIN_VALUE; + this.profilingOutput = ""; + this.lastProfilingIds = Collections.emptyList(); + this.lastOnEnd = null; + + if (onEnd != null) { + try { + onEnd.accept(stopFailure); + } catch (Throwable throwable) { + LOGGER.warn("Failed to retire profiling callback!", throwable); + } + } + + return stopFailure; + } + + public CompletableFuture> startProfiling( + EnumProfilingType profilingType, + EnumProfilingCategory category, + long regionId, + long profilingEnd + ) { + final CompletableFuture> callback = new CompletableFuture<>(); + + try { + this.startProfilingSession(profilingType, category, regionId, profilingEnd).whenComplete((session, startFailure) -> { + if (startFailure != null) { + callback.completeExceptionally(startFailure); + return; + } + + session.output().whenComplete((output, exportFailure) -> { + if (exportFailure != null) { + LOGGER.warn("Error occurred while profiling!", exportFailure); + callback.complete(Pair.of(session.sessionId(), null)); + } else { + callback.complete(Pair.of(session.sessionId(), output)); + } + }); + }); + } catch (Throwable throwable) { + callback.completeExceptionally(throwable); + } + + return callback; + } + + public CompletableFuture startProfilingSession( + EnumProfilingType profilingType, + EnumProfilingCategory category, + long regionId, + long profilingEnd + ) { + Objects.requireNonNull(profilingType, "profilingType"); + Objects.requireNonNull(category, "category"); + + final CompletableFuture callback = new CompletableFuture<>(); + + // prevent scheduling ahead + if (profilingEnd <= System.nanoTime()) { + callback.completeExceptionally(new IllegalArgumentException("profilingEnd must be in the future")); + return callback; + } + + if (!this.opThread.postOpIfNotOnOpThread(() -> { + // prevent duplicated profiling + if (this.profiling) { + callback.completeExceptionally(new IllegalStateException("Another profiler session is already active")); + return; + } + + // check for existence + final RegionScheduleProfiler profiler = this.registereddProfilers.get(regionId); + if (profiler == null) { + callback.completeExceptionally(new IllegalArgumentException("No registered region profiler with id " + regionId)); + return; + } + + final AllocatedSession allocatedSession; + try { + allocatedSession = this.reserveSessionDirectory(); + } catch (IOException exception) { + LOGGER.warn("Failed to reserve profiler session directory", exception); + callback.completeExceptionally(exception); + return; + } + + final int sessionId = allocatedSession.sessionId(); + final Path outputFolder = allocatedSession.folder(); + final Path jfrOutput = outputFolder.resolve(regionId + ".jfr"); + + final CompletableFuture convertCallback = new CompletableFuture<>(); + + if (!profiler.tryToggle(true)) { + LOGGER.warn("Failed to activate async profiler for region of id {}!", regionId); + } + + final String builtCommand = "start,event=" + profilingType.getEventName() + ",jfr,file=" + jfrOutput; + String commandResult; + try { + commandResult = AsyncProfiler.getInstance().execute(builtCommand); + } catch (Throwable throwable) { + profiler.toggle(false); + LOGGER.error("Failed to start async profiler!", throwable); + callback.completeExceptionally(throwable); + return; + } + + // my shit() + this.profiling = true; + this.activeSessionId = sessionId; + this.profilingStartNs = System.nanoTime(); + this.profilingOutput = jfrOutput.toString(); + this.lastProfilingIds = List.of(regionId); + + this.lastOnEnd = stopFailure -> { + if (stopFailure != null) { + convertCallback.completeExceptionally(stopFailure); + return; + } + + this.export(jfrOutput, outputFolder, regionId, profilingType, category).whenComplete((path, exportFailure) -> { + if (exportFailure == null) { + convertCallback.complete(path); + } else { + convertCallback.completeExceptionally(exportFailure); + } + }); + }; + + LOGGER.info( + "Started profiling for region with id {} at {} system ns, output: {}, profiling event: {}, return message: {}", + regionId, + this.profilingStartNs, + jfrOutput, + profilingType, + commandResult + ); + + callback.complete(new ProfilingSession(sessionId, convertCallback)); + + if (this.profiling && this.activeSessionId == sessionId) { + final long delay = profilingEnd - System.nanoTime(); + + if (delay <= 0) { + this.endProfiling(sessionId); + return; + } + + CompletableFuture.delayedExecutor(delay, TimeUnit.NANOSECONDS) + .execute(() -> this.endProfiling(sessionId)); + } + })) { + callback.completeExceptionally(new IllegalStateException("Profiler manager has been shut down")); + } + + return callback; + } + + private record AllocatedSession(int sessionId, Path folder) { + } + + @Contract(" -> new") + private RegionScheduleProfilerManager.@NonNull AllocatedSession reserveSessionDirectory() throws IOException { + Files.createDirectories(PROFILER_FOLDER); + + int maxSessionId = 0; + try (Stream children = Files.list(PROFILER_FOLDER)) { + for (Path child : children.toList()) { + if (!Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + + final Matcher matcher = SESSION_FOLDER_PATTERN.matcher(child.getFileName().toString()); + if (!matcher.matches()) { + continue; + } + + try { + final int sessionId = Integer.parseInt(matcher.group(1)); + if (sessionId > maxSessionId) { + maxSessionId = sessionId; + } + } catch (NumberFormatException ignored) { + } + } + } + + while (maxSessionId < Integer.MAX_VALUE) { + final int sessionId = maxSessionId + 1; + final Path sessionPath = PROFILER_FOLDER.resolve("session-" + sessionId); + + try { + Files.createDirectory(sessionPath); + return new AllocatedSession(sessionId, sessionPath); + } catch (FileAlreadyExistsException ignored) { + maxSessionId = sessionId; + } + } + + throw new IOException("Profiler session IDs are exhausted"); + } + + public RegionScheduleProfiler register(long id) { + RegionScheduleProfiler ret = new RegionScheduleProfiler(id); + + if (this.registereddProfilers.putIfAbsent(id, ret) != null) { + throw new IllegalStateException("Already registed profiler for region of id " + id + "!"); + } + + return ret; + } + + public void deRegister(long id) { + this.registereddProfilers.remove(id); + } + + public CompletableFuture export( + Path jfrDataFile, + Path outputFolder, + long regionId, + EnumProfilingType exportType, + EnumProfilingCategory profilingCategory + ) { + return CompletableFuture.supplyAsync(() -> { + try { + Files.createDirectories(outputFolder); + + final String tag = "region-" + + profilingCategory.getTagCategory() + + "-" + + regionId; + + final String outputName = + "region-" + regionId + + "-" + profilingCategory.getTagCategory() + + "-" + exportType.name().toLowerCase(Locale.ROOT) + + ".html"; + + final Path output = outputFolder.resolve(outputName); + + final Arguments args = new Arguments(); + + args.output = "html"; + args.tag = tag; + + switch (exportType) { + case CPU -> args.cpu = true; + case ALLOC -> args.alloc = true; + case WALL -> args.wall = true; + default -> throw new IllegalArgumentException( + "Unsupported export type: " + exportType + ); + } + + Main.convert( + jfrDataFile.toString(), + output.toString(), + args + ); + + return output; + } catch (IOException e) { + throw new CompletionException(e); + } + }); + } +}