feat(perf): add stack fallback sampling

This commit is contained in:
MoePus committed 2026-08-18 13:50:28 +09:00
1 parent 4a107b4f20
commit 03bf028855
10 files changed
+303 -16

No files matched your search

@@ -10,6 +10,7 @@ import java.util.Objects;
public record PerfReportData(long reportId,
double serverMspt,
long lastTickGcMillis,
long elapsedMillis,
long generatedAtEpochMillis,
long tickCount,
@@ -27,6 +28,9 @@ public record PerfReportData(long reportId,
if (reportId < 0L) {
throw new IllegalArgumentException("Report ID must be non-negative");
}
if (lastTickGcMillis < 0L) {
throw new IllegalArgumentException("Last tick GC time must be non-negative");
}
if (omittedChunks < 0 || omittedTickables < 0
|| omittedOwnerSources < 0 || omittedLoaderSources < 0) {
throw new IllegalArgumentException("Omitted report counts must be non-negative");
@@ -51,7 +55,27 @@ public record PerfReportData(long reportId,
List<TickableEntry> tickables,
List<OwnerEntry> owners,
List<LoaderSourceEntry> loaderSources) {
this(0L, serverMspt, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples,
this(0L, serverMspt, 0L, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples,
intervalMicros, omittedChunks, omittedTickables, omittedOwnerSources,
omittedLoaderSources, chunks, tickables, owners, loaderSources);
}
public PerfReportData(long reportId,
double serverMspt,
long elapsedMillis,
long generatedAtEpochMillis,
long tickCount,
long totalSamples,
int intervalMicros,
int omittedChunks,
int omittedTickables,
int omittedOwnerSources,
int omittedLoaderSources,
List<ChunkEntry> chunks,
List<TickableEntry> tickables,
List<OwnerEntry> owners,
List<LoaderSourceEntry> loaderSources) {
this(reportId, serverMspt, 0L, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples,
intervalMicros, omittedChunks, omittedTickables, omittedOwnerSources,
omittedLoaderSources, chunks, tickables, owners, loaderSources);
}
@@ -68,19 +92,20 @@ public record PerfReportData(long reportId,
List<ChunkEntry> chunks,
List<TickableEntry> tickables,
List<OwnerEntry> owners) {
this(0L, serverMspt, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples, intervalMicros,
this(0L, serverMspt, 0L, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples, intervalMicros,
omittedChunks, omittedTickables, omittedOwnerSources, 0,
chunks, tickables, owners, List.of());
}
public static PerfReportData empty(int intervalMicros) {
return new PerfReportData(0L, 0.0D, 0L, 0L, 0L, 0L, intervalMicros,
return new PerfReportData(0L, 0.0D, 0L, 0L, 0L, 0L, 0L, intervalMicros,
0, 0, 0, 0, List.of(), List.of(), List.of(), List.of());
}
public PerfReportData withLoaderSources(List<LoaderSourceEntry> nextLoaderSources) {
return loaderSources.equals(nextLoaderSources) ? this : new PerfReportData(
reportId, serverMspt, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples,
reportId, serverMspt, lastTickGcMillis, elapsedMillis, generatedAtEpochMillis,
tickCount, totalSamples,
intervalMicros, omittedChunks, omittedTickables, omittedOwnerSources,
omittedLoaderSources, chunks, tickables, owners, nextLoaderSources);
}
@@ -66,7 +66,10 @@ public final class PerfReportTextRenderer {
id += "#" + entry.entityId();
}
if (entry.type() == TickType.TASK) {
return id + " @ " + entry.eventLoop();
return entry.eventLoop().isEmpty() ? id : id + " @ " + entry.eventLoop();
}
if (entry.type() == TickType.HANDLER) {
return entry.type() + " " + entry.eventLoop();
}
if (!entry.positioned()) {
return id + " @ " + entry.dimension();
@@ -34,6 +34,7 @@ final class PerfSampleAggregator {
private int chunkDropsUntilReplacement = REPLACEMENT_INTERVAL;
private long tickCount;
private long totalTickNanos;
private long lastTickGcMillis;
PerfSampleAggregator(long seed) {
reportId = seed;
@@ -50,6 +51,7 @@ final class PerfSampleAggregator {
void accept(TickSamples tick) {
tickCount++;
totalTickNanos += tick.durationNanos;
lastTickGcMillis = tick.gcTimeMillis;
if (tick.totalSamples == 0) {
ownerAggregator.updateMetadata(tick.attribution);
} else {
@@ -72,7 +74,8 @@ final class PerfSampleAggregator {
PerfReportData.TickableEntry::metric);
PerfOwnerAggregator.Build ownerBuild = ownerAggregator.build(tickCount, nanosPerSample);
double serverMspt = tickCount == 0 ? 0.0D : totalTickNanos / 1_000_000.0D / tickCount;
return new PerfReportData(reportId, serverMspt, elapsedNanos / 1_000_000L, System.currentTimeMillis(),
return new PerfReportData(reportId, serverMspt, lastTickGcMillis,
elapsedNanos / 1_000_000L, System.currentTimeMillis(),
tickCount, totalSamples, intervalMicros,
omitted(limitedChunks.omitted(), droppedChunkHashes),
omitted((long) sampledTickables.omitted() + limitedTickables.omitted(), droppedTickableHashes),
@@ -261,7 +264,8 @@ final class PerfSampleAggregator {
TickSamples.TickableKey key,
long ticks,
double nanosPerSample) {
return new PerfReportData.TickableEntry(key.type(), key.dimension(), className(key.workClass()),
String displayName = key.displayName().isEmpty() ? className(key.workClass()) : key.displayName();
return new PerfReportData.TickableEntry(key.type(), key.dimension(), displayName,
nullableId(key.detailId()), key.detailKind(), key.entityId(), positioned,
x, y, z, chunkPosition, key.eventLoop(),
stats.metric(ticks, nanosPerSample));
@@ -24,10 +24,12 @@ public final class PerfSamplerSession {
private final long durationNanos;
private final SplittableRandom intervalRandom;
private final AnalysisClock analysisClock;
private final TickGcTimeTracker gcTimeTracker = new TickGcTimeTracker();
private final Thread samplerThread;
private final WindowsTimerResolution timerResolution = new WindowsTimerResolution();
private volatile PerfReportData latestReport;
private volatile TickSamples currentTick;
private TickSamples betweenTicks = new TickSamples(null);
private long sampleAttempts;
private long acceptedTicks;
private int publishedEntryKinds;
@@ -125,7 +127,11 @@ public final class PerfSamplerSession {
if (analysisClock.state() != AnalysisClock.State.RUNNING) {
throw new IllegalStateException("Cannot begin a tick while sampling is not running");
}
currentTick = new TickSamples(attribution);
TickSamples tick = new TickSamples(attribution);
tick.mergeSamples(betweenTicks);
betweenTicks = new TickSamples(null);
tick.gcStartMillis = gcTimeTracker.snapshot();
currentTick = tick;
}
}
@@ -146,6 +152,7 @@ public final class PerfSamplerSession {
return;
}
tick.durationNanos = durationNanos;
tick.gcTimeMillis = gcTimeTracker.elapsedSince(tick.gcStartMillis);
currentTick = null;
closedTicks.add(tick);
}
@@ -159,6 +166,7 @@ public final class PerfSamplerSession {
Measurement measurement;
synchronized (stateLock) {
currentTick = null;
betweenTicks = new TickSamples(null);
analysisClock.stop();
measurement = measurementLocked();
}
@@ -216,6 +224,15 @@ public final class PerfSamplerSession {
}
TickSamples expected = currentTick;
PerfWorkTracker.Snapshot snapshot = PerfWorkTracker.snapshot();
if (snapshot == null) {
snapshot = PerfStackAttributor.capture(PerfWorkTracker.profiledThread());
} else if (snapshot.type() == TickType.ENTITY) {
PerfWorkTracker.Snapshot handler =
PerfStackAttributor.captureTickHandler(PerfWorkTracker.profiledThread());
if (handler != null) {
snapshot = handler;
}
}
synchronized (stateLock) {
SamplerStatus status = samplerStatusLocked();
if (status != SamplerStatus.RUNNING) {
@@ -224,6 +241,8 @@ public final class PerfSamplerSession {
sampleAttempts++;
if (expected != null && currentTick == expected) {
expected.sample(snapshot);
} else if (expected == null && currentTick == null && snapshot != null) {
betweenTicks.sample(snapshot);
}
return SamplerStatus.RUNNING;
}
@@ -0,0 +1,154 @@
package com.moepus.serverwarashi.common.modules.performance.sampling;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Map;
final class PerfStackAttributor {
private static final int MAX_STACK_DEPTH = 96;
private static final String EVENT_BUS_PACKAGE = "net.neoforged.bus.";
private static final String EVENT_BUS_CLASS = "net.neoforged.bus.EventBus";
private static final String EVENT_HOOKS_CLASS = "net.neoforged.neoforge.event.EventHooks";
private static final String MINECRAFT_SERVER_CLASS = "net.minecraft.server.MinecraftServer";
private static final String UNKNOWN_NAME = "Unknown";
private static final ThreadMXBean THREADS = ManagementFactory.getThreadMXBean();
private static final Map<String, TickEvent> TICK_EVENTS = Map.of(
"fireServerTickPre", tickEvent("ServerTickEvent.Pre"),
"fireServerTickPost", tickEvent("ServerTickEvent.Post"),
"fireLevelTickPre", tickEvent("LevelTickEvent.Pre"),
"fireLevelTickPost", tickEvent("LevelTickEvent.Post"),
"firePlayerTickPre", tickEvent("PlayerTickEvent.Pre"),
"firePlayerTickPost", tickEvent("PlayerTickEvent.Post"),
"fireEntityTickPre", tickEvent("EntityTickEvent.Pre"),
"fireEntityTickPost", tickEvent("EntityTickEvent.Post")
);
private PerfStackAttributor() {
}
static PerfWorkTracker.Snapshot capture(Thread thread) {
StackTraceElement[] stack = captureStack(thread);
return stack == null ? null : classify(stack);
}
static PerfWorkTracker.Snapshot captureTickHandler(Thread thread) {
StackTraceElement[] stack = captureStack(thread);
return stack == null ? null : findTickHandler(stack);
}
private static StackTraceElement[] captureStack(Thread thread) {
if (thread == null || !thread.isAlive()) {
return null;
}
ThreadInfo info = THREADS.getThreadInfo(thread.threadId(), MAX_STACK_DEPTH);
return info == null ? null : info.getStackTrace();
}
static PerfWorkTracker.Snapshot classify(StackTraceElement[] stack) {
PerfWorkTracker.Snapshot handler = findTickHandler(stack);
if (handler != null) {
return handler;
}
return isNormalTickIdle(stack) ? null : unknown();
}
private static PerfWorkTracker.Snapshot findTickHandler(StackTraceElement[] stack) {
for (int hookIndex = 0; hookIndex < stack.length; hookIndex++) {
StackTraceElement hook = stack[hookIndex];
TickEvent event = tickEvent(hook);
if (event == null) {
continue;
}
StackTraceElement handler = handlerAboveDispatch(stack, hookIndex);
if (handler != null) {
return handler(handler, event);
}
}
return null;
}
private static TickEvent tickEvent(StackTraceElement frame) {
return EVENT_HOOKS_CLASS.equals(frame.getClassName())
? TICK_EVENTS.get(frame.getMethodName()) : null;
}
private static StackTraceElement handlerAboveDispatch(StackTraceElement[] stack, int hookIndex) {
boolean foundPost = false;
for (int index = hookIndex - 1; index >= 0; index--) {
StackTraceElement frame = stack[index];
if (isDispatchInfrastructure(frame)) {
foundPost |= isEventBusPost(frame);
continue;
}
return foundPost ? frame : null;
}
return null;
}
private static boolean isDispatchInfrastructure(StackTraceElement frame) {
String className = frame.getClassName();
return className.startsWith(EVENT_BUS_PACKAGE)
|| className.startsWith("java.lang.invoke.")
|| className.startsWith("java.lang.reflect.")
|| className.startsWith("jdk.internal.reflect.");
}
private static boolean isEventBusPost(StackTraceElement frame) {
return EVENT_BUS_CLASS.equals(frame.getClassName()) && "post".equals(frame.getMethodName());
}
private static boolean isNormalTickIdle(StackTraceElement[] stack) {
boolean waiting = false;
boolean betweenTicks = false;
for (StackTraceElement frame : stack) {
waiting |= "waitForTasks".equals(frame.getMethodName());
betweenTicks |= MINECRAFT_SERVER_CLASS.equals(frame.getClassName())
&& "waitUntilNextTick".equals(frame.getMethodName());
}
return waiting && betweenTicks;
}
private static PerfWorkTracker.Snapshot handler(StackTraceElement frame, TickEvent event) {
String fullClass = readableClassName(frame.getClassName());
String shortClass = shortClassName(frame.getClassName());
String fullName = fullClass + "#" + frame.getMethodName() + "(" + event.fullName() + ")";
String shortName = shortClass + "#" + frame.getMethodName() + "(" + event.shortName() + ")";
return snapshot(TickType.HANDLER, HandlerWork.class, shortName, fullName);
}
private static PerfWorkTracker.Snapshot unknown() {
return snapshot(TickType.TASK, UnknownWork.class, UNKNOWN_NAME, "");
}
private static PerfWorkTracker.Snapshot snapshot(
TickType type, Class<?> workClass, String displayName, String fullName) {
return new PerfWorkTracker.Snapshot(type, null, workClass, null, TickDetailKind.NONE,
-1, false, 0, 0, 0, false, fullName, displayName);
}
private static TickEvent tickEvent(String nestedName) {
String packageName = "net.neoforged.neoforge.event.tick.";
return new TickEvent(nestedName, packageName + nestedName);
}
private static String readableClassName(String binaryName) {
int hiddenSuffix = binaryName.indexOf('/');
String visible = hiddenSuffix < 0 ? binaryName : binaryName.substring(0, hiddenSuffix);
return visible.replace('$', '.');
}
private static String shortClassName(String binaryName) {
int packageEnd = binaryName.lastIndexOf('.');
return readableClassName(binaryName.substring(packageEnd + 1));
}
private record TickEvent(String shortName, String fullName) {
}
private static final class HandlerWork {
}
private static final class UnknownWork {
}
}
@@ -17,7 +17,7 @@ public final class PerfWorkTracker {
public static volatile boolean isProfiling;
private static volatile Slot current;
private static Thread serverThread;
private static volatile Thread serverThread;
private static int depth;
private static int overflowDepth;
@@ -123,6 +123,10 @@ public final class PerfWorkTracker {
return null;
}
static Thread profiledThread() {
return isProfiling ? serverThread : null;
}
private static boolean canPublish() {
return isProfiling && Thread.currentThread() == serverThread;
}
@@ -169,7 +173,23 @@ public final class PerfWorkTracker {
int y,
int z,
boolean chunkPosition,
String eventLoop) {
String eventLoop,
String displayName) {
public Snapshot(TickType type,
ResourceLocation dimension,
Class<?> workClass,
ResourceLocation detailId,
TickDetailKind detailKind,
int entityId,
boolean positioned,
int x,
int y,
int z,
boolean chunkPosition,
String eventLoop) {
this(type, dimension, workClass, detailId, detailKind, entityId,
positioned, x, y, z, chunkPosition, eventLoop, "");
}
}
private static final class Slot {
@@ -187,6 +207,7 @@ public final class PerfWorkTracker {
private int z;
private boolean chunkPosition;
private String eventLoop;
private String displayName;
void publishChunk(ResourceLocation dimension, LevelChunk chunk) {
var pos = chunk.getPos();
@@ -259,6 +280,7 @@ public final class PerfWorkTracker {
version++;
this.type = type;
this.workClass = workClass;
displayName = "";
}
private void finishPublish() {
@@ -279,7 +301,7 @@ public final class PerfWorkTracker {
}
Snapshot result = new Snapshot(type, dimension, workClass, detailId, detailKind,
entityId, positioned,
x, y, z, chunkPosition, eventLoop);
x, y, z, chunkPosition, eventLoop, displayName);
return before == version ? result : null;
}
}
@@ -0,0 +1,38 @@
package com.moepus.serverwarashi.common.modules.performance.sampling;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
import java.util.function.LongSupplier;
final class TickGcTimeTracker {
private final LongSupplier totalCollectionMillis;
TickGcTimeTracker() {
List<GarbageCollectorMXBean> collectors = List.copyOf(ManagementFactory.getGarbageCollectorMXBeans());
totalCollectionMillis = () -> totalCollectionMillis(collectors);
}
TickGcTimeTracker(LongSupplier totalCollectionMillis) {
this.totalCollectionMillis = totalCollectionMillis;
}
long snapshot() {
return totalCollectionMillis.getAsLong();
}
long elapsedSince(long snapshot) {
return Math.max(0L, snapshot() - snapshot);
}
private static long totalCollectionMillis(List<GarbageCollectorMXBean> collectors) {
long total = 0L;
for (GarbageCollectorMXBean collector : collectors) {
long collectionTime = collector.getCollectionTime();
if (collectionTime > 0L) {
total += collectionTime;
}
}
return total;
}
}
@@ -13,6 +13,8 @@ final class TickSamples {
final Map<ChunkKey, Integer> chunks = new HashMap<>();
PerfAttributionSnapshot attribution;
long durationNanos;
long gcStartMillis;
long gcTimeMillis;
int totalSamples;
boolean refreshReport;
@@ -33,6 +35,13 @@ final class TickSamples {
}
}
void mergeSamples(TickSamples source) {
totalSamples += source.totalSamples;
source.tickables.forEach((key, count) ->
tickables.computeIfAbsent(key, ignored -> new TickCount()).merge(count));
source.chunks.forEach((key, count) -> chunks.merge(key, count, Integer::sum));
}
record ChunkKey(String dimension, long position) {
static ChunkKey from(PerfWorkTracker.Snapshot snapshot) {
if (!snapshot.positioned() || snapshot.dimension() == null) {
@@ -52,17 +61,18 @@ final class TickSamples {
TickDetailKind detailKind,
int entityId,
long fixedPosition,
String eventLoop) {
String eventLoop,
String displayName) {
static TickableKey from(PerfWorkTracker.Snapshot snapshot) {
String dimension = snapshot.dimension() == null ? "" : snapshot.dimension().toString();
long position = switch (snapshot.type()) {
case CHUNK -> ChunkPos.asLong(snapshot.x(), snapshot.z());
case BLOCK_ENTITY -> BlockPos.asLong(snapshot.x(), snapshot.y(), snapshot.z());
case ENTITY, TASK -> 0L;
case ENTITY, TASK, HANDLER -> 0L;
};
return new TickableKey(snapshot.type(), dimension, snapshot.workClass(), snapshot.detailId(),
snapshot.detailKind(),
snapshot.entityId(), position, snapshot.eventLoop());
snapshot.entityId(), position, snapshot.eventLoop(), snapshot.displayName());
}
}
@@ -82,5 +92,14 @@ final class TickSamples {
y = snapshot.y();
z = snapshot.z();
}
void merge(TickCount source) {
count += source.count;
positioned = source.positioned;
chunkPosition = source.chunkPosition;
x = source.x;
y = source.y;
z = source.z;
}
}
}
@@ -4,5 +4,6 @@ public enum TickType {
CHUNK,
ENTITY,
BLOCK_ENTITY,
TASK
TASK,
HANDLER
}
@@ -21,6 +21,7 @@ public final class PerfReportCodec {
EntryBudget budget = new EntryBudget();
buffer.writeVarLong(report.reportId());
buffer.writeDouble(report.serverMspt());
buffer.writeVarLong(report.lastTickGcMillis());
buffer.writeVarLong(report.elapsedMillis());
buffer.writeVarLong(report.generatedAtEpochMillis());
buffer.writeVarLong(report.tickCount());
@@ -40,6 +41,7 @@ public final class PerfReportCodec {
EntryBudget budget = new EntryBudget();
long reportId = buffer.readVarLong();
double serverMspt = buffer.readDouble();
long lastTickGcMillis = buffer.readVarLong();
long elapsedMillis = buffer.readVarLong();
long generatedAtEpochMillis = buffer.readVarLong();
long tickCount = buffer.readVarLong();
@@ -53,7 +55,7 @@ public final class PerfReportCodec {
List<PerfReportData.TickableEntry> tickables = readList(buffer, PerfReportCodec::readTickable, budget);
List<PerfReportData.OwnerEntry> owners = readList(buffer, PerfReportCodec::readOwner, budget);
List<PerfReportData.LoaderSourceEntry> loaders = PerfLoaderReportCodec.read(buffer, budget);
return new PerfReportData(reportId, serverMspt, elapsedMillis, generatedAtEpochMillis,
return new PerfReportData(reportId, serverMspt, lastTickGcMillis, elapsedMillis, generatedAtEpochMillis,
tickCount, totalSamples, intervalMicros, omittedChunks, omittedTickables,
omittedOwnerSources, omittedLoaderSources, chunks, tickables, owners, loaders);
}