feat: implement line-level profiling agent with ASM instrumentation

This commit is contained in:
HeshamHM28 2026-02-19 06:57:33 +02:00
parent 39c000c264
commit e75070abef
9 changed files with 1056 additions and 0 deletions

View file

@ -48,6 +48,18 @@
<version>3.45.0.0</version>
</dependency>
<!-- ASM for bytecode instrumentation (profiler agent) -->
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.7.1</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>9.7.1</version>
</dependency>
<!-- JUnit 5 for testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
@ -100,9 +112,19 @@
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>org.objectweb.asm</pattern>
<shadedPattern>com.codeflash.asm</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codeflash.Comparator</mainClass>
<manifestEntries>
<Premain-Class>com.codeflash.profiler.ProfilerAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</transformer>
</transformers>
<filters>

View file

@ -0,0 +1,41 @@
package com.codeflash.profiler;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* ASM ClassVisitor that filters methods and wraps target methods with
* {@link LineProfilingMethodVisitor} for line-level profiling.
*/
public class LineProfilingClassVisitor extends ClassVisitor {
private final String internalClassName;
private final ProfilerConfig config;
private String sourceFile;
public LineProfilingClassVisitor(ClassVisitor classVisitor, String internalClassName, ProfilerConfig config) {
super(Opcodes.ASM9, classVisitor);
this.internalClassName = internalClassName;
this.config = config;
}
@Override
public void visitSource(String source, String debug) {
super.visitSource(source, debug);
// Resolve the absolute source file path from the config
this.sourceFile = config.resolveSourceFile(internalClassName);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (config.shouldInstrumentMethod(internalClassName, name)) {
return new LineProfilingMethodVisitor(mv, access, name, descriptor,
internalClassName, sourceFile);
}
return mv;
}
}

View file

@ -0,0 +1,64 @@
package com.codeflash.profiler;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.commons.AdviceAdapter;
/**
* ASM MethodVisitor that injects line-level profiling probes.
*
* <p>At each {@code LineNumber} table entry within the target method:
* <ol>
* <li>Registers the line with {@link ProfilerRegistry} (happens once at class-load time)</li>
* <li>Injects bytecode: {@code LDC globalId; INVOKESTATIC ProfilerData.hit(I)V}</li>
* </ol>
*
* <p>At method entry (first line): injects {@code ProfilerData.enterMethod(entryLineId)}.
* <p>At method exit (every RETURN/ATHROW): injects {@code ProfilerData.exitMethod()}.
*/
public class LineProfilingMethodVisitor extends AdviceAdapter {
private static final String PROFILER_DATA = "com/codeflash/profiler/ProfilerData";
private final String internalClassName;
private final String sourceFile;
private final String methodName;
private boolean firstLineVisited = false;
protected LineProfilingMethodVisitor(
MethodVisitor mv, int access, String name, String descriptor,
String internalClassName, String sourceFile) {
super(Opcodes.ASM9, mv, access, name, descriptor);
this.internalClassName = internalClassName;
this.sourceFile = sourceFile;
this.methodName = name;
}
@Override
public void visitLineNumber(int line, Label start) {
super.visitLineNumber(line, start);
// Register this line and get its global ID (happens once at class-load time)
String dotClassName = internalClassName.replace('/', '.');
int globalId = ProfilerRegistry.register(sourceFile, dotClassName, methodName, line);
if (!firstLineVisited) {
firstLineVisited = true;
// Inject enterMethod call at the first line of the method
mv.visitLdcInsn(globalId);
mv.visitMethodInsn(INVOKESTATIC, PROFILER_DATA, "enterMethod", "(I)V", false);
}
// Inject: ProfilerData.hit(globalId)
mv.visitLdcInsn(globalId);
mv.visitMethodInsn(INVOKESTATIC, PROFILER_DATA, "hit", "(I)V", false);
}
@Override
protected void onMethodExit(int opcode) {
// Before every RETURN or ATHROW, flush timing for the last line
// This fixes the "last line always shows 0ms" bug
mv.visitMethodInsn(INVOKESTATIC, PROFILER_DATA, "exitMethod", "()V", false);
}
}

View file

@ -0,0 +1,46 @@
package com.codeflash.profiler;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
/**
* {@link ClassFileTransformer} that instruments target classes with line profiling.
*
* <p>When a class matches the profiler configuration, it is run through ASM
* to inject {@link ProfilerData#hit(int)} calls at each line number.
*/
public class LineProfilingTransformer implements ClassFileTransformer {
private final ProfilerConfig config;
public LineProfilingTransformer(ProfilerConfig config) {
this.config = config;
}
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
if (className == null || !config.shouldInstrumentClass(className)) {
return null; // null = don't transform
}
try {
return instrumentClass(className, classfileBuffer);
} catch (Exception e) {
System.err.println("[codeflash-profiler] Failed to instrument " + className + ": " + e.getMessage());
return null;
}
}
private byte[] instrumentClass(String internalClassName, byte[] bytecode) {
ClassReader cr = new ClassReader(bytecode);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
LineProfilingClassVisitor cv = new LineProfilingClassVisitor(cw, internalClassName, config);
cr.accept(cv, ClassReader.EXPAND_FRAMES);
return cw.toByteArray();
}
}

View file

@ -0,0 +1,48 @@
package com.codeflash.profiler;
import java.lang.instrument.Instrumentation;
/**
* Java agent entry point for the CodeFlash line profiler.
*
* <p>Loaded via {@code -javaagent:codeflash-profiler-agent.jar=config=/path/to/config.json}.
*
* <p>The agent:
* <ol>
* <li>Parses the config file specifying which classes/methods to profile</li>
* <li>Registers a {@link LineProfilingTransformer} to instrument target classes at load time</li>
* <li>Registers a shutdown hook to write profiling results to JSON</li>
* </ol>
*/
public class ProfilerAgent {
/**
* Called by the JVM before {@code main()} when the agent is loaded.
*
* @param agentArgs comma-separated key=value pairs (e.g., {@code config=/path/to/config.json})
* @param inst the JVM instrumentation interface
*/
public static void premain(String agentArgs, Instrumentation inst) {
ProfilerConfig config = ProfilerConfig.parse(agentArgs);
if (config.getTargetClasses().isEmpty()) {
System.err.println("[codeflash-profiler] No target classes configured, profiler inactive");
return;
}
// Pre-allocate registry with estimated capacity
ProfilerRegistry.initialize(config.getExpectedLineCount());
// Register the bytecode transformer
inst.addTransformer(new LineProfilingTransformer(config), true);
// Register shutdown hook to write results on JVM exit
String outputFile = config.getOutputFile();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
ProfilerReporter.writeResults(outputFile, config);
}, "codeflash-profiler-shutdown"));
System.err.println("[codeflash-profiler] Agent loaded, profiling "
+ config.getTargetClasses().size() + " class(es)");
}
}

View file

@ -0,0 +1,416 @@
package com.codeflash.profiler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Configuration for the profiler agent, parsed from a JSON file.
*
* <p>The JSON is generated by Python ({@code JavaLineProfiler.generate_agent_config()}).
* Uses a hand-rolled JSON parser to avoid external dependencies (keeps the agent JAR small).
*/
public final class ProfilerConfig {
private String outputFile = "";
private final Map<String, List<MethodTarget>> targets = new HashMap<>();
private final Map<String, String> lineContents = new HashMap<>();
private final Set<String> targetClassNames = new HashSet<>();
public static class MethodTarget {
public final String name;
public final int startLine;
public final int endLine;
public final String sourceFile;
public MethodTarget(String name, int startLine, int endLine, String sourceFile) {
this.name = name;
this.startLine = startLine;
this.endLine = endLine;
this.sourceFile = sourceFile;
}
}
/**
* Parse agent arguments and load the config file.
*
* <p>Expected format: {@code config=/path/to/config.json}
*/
public static ProfilerConfig parse(String agentArgs) {
ProfilerConfig config = new ProfilerConfig();
if (agentArgs == null || agentArgs.isEmpty()) {
return config;
}
String configPath = null;
for (String part : agentArgs.split(",")) {
String trimmed = part.trim();
if (trimmed.startsWith("config=")) {
configPath = trimmed.substring("config=".length());
}
}
if (configPath == null) {
System.err.println("[codeflash-profiler] No config= in agent args: " + agentArgs);
return config;
}
try {
String json = new String(Files.readAllBytes(Paths.get(configPath)), StandardCharsets.UTF_8);
config.parseJson(json);
} catch (IOException e) {
System.err.println("[codeflash-profiler] Failed to read config: " + e.getMessage());
}
return config;
}
public String getOutputFile() {
return outputFile;
}
public Set<String> getTargetClasses() {
return Collections.unmodifiableSet(targetClassNames);
}
public List<MethodTarget> getMethodsForClass(String internalClassName) {
return targets.getOrDefault(internalClassName, Collections.emptyList());
}
public Map<String, String> getLineContents() {
return Collections.unmodifiableMap(lineContents);
}
public int getExpectedLineCount() {
int count = 0;
for (List<MethodTarget> methods : targets.values()) {
for (MethodTarget m : methods) {
count += Math.max(m.endLine - m.startLine + 1, 1);
}
}
return Math.max(count, 256);
}
/**
* Check if a class should be instrumented. Uses JVM internal names (slash-separated).
*/
public boolean shouldInstrumentClass(String internalClassName) {
return targetClassNames.contains(internalClassName);
}
/**
* Check if a specific method in a class should be instrumented.
*/
public boolean shouldInstrumentMethod(String internalClassName, String methodName) {
List<MethodTarget> methods = targets.get(internalClassName);
if (methods == null) return false;
for (MethodTarget m : methods) {
if (m.name.equals(methodName)) {
return true;
}
}
return false;
}
/**
* Resolve the absolute source file path for a given class and its source file attribute.
*/
public String resolveSourceFile(String internalClassName) {
List<MethodTarget> methods = targets.get(internalClassName);
if (methods != null && !methods.isEmpty()) {
return methods.get(0).sourceFile;
}
return internalClassName.replace('/', '.') + ".java";
}
// ---- Minimal JSON parser ----
private void parseJson(String json) {
json = json.trim();
if (!json.startsWith("{") || !json.endsWith("}")) return;
int[] pos = {1}; // mutable position cursor
skipWhitespace(json, pos);
while (pos[0] < json.length() - 1) {
String key = readString(json, pos);
skipWhitespace(json, pos);
expect(json, pos, ':');
skipWhitespace(json, pos);
switch (key) {
case "outputFile":
this.outputFile = readString(json, pos);
break;
case "targets":
parseTargets(json, pos);
break;
case "lineContents":
parseLineContents(json, pos);
break;
default:
skipValue(json, pos);
break;
}
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
}
private void parseTargets(String json, int[] pos) {
expect(json, pos, '[');
skipWhitespace(json, pos);
while (pos[0] < json.length() && json.charAt(pos[0]) != ']') {
parseTargetObject(json, pos);
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
pos[0]++; // skip ']'
}
private void parseTargetObject(String json, int[] pos) {
expect(json, pos, '{');
skipWhitespace(json, pos);
String className = "";
List<MethodTarget> methods = new ArrayList<>();
while (pos[0] < json.length() && json.charAt(pos[0]) != '}') {
String key = readString(json, pos);
skipWhitespace(json, pos);
expect(json, pos, ':');
skipWhitespace(json, pos);
switch (key) {
case "className":
className = readString(json, pos);
break;
case "methods":
methods = parseMethodsArray(json, pos);
break;
default:
skipValue(json, pos);
break;
}
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
pos[0]++; // skip '}'
if (!className.isEmpty()) {
targets.put(className, methods);
targetClassNames.add(className);
}
}
private List<MethodTarget> parseMethodsArray(String json, int[] pos) {
List<MethodTarget> methods = new ArrayList<>();
expect(json, pos, '[');
skipWhitespace(json, pos);
while (pos[0] < json.length() && json.charAt(pos[0]) != ']') {
methods.add(parseMethodTarget(json, pos));
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
pos[0]++; // skip ']'
return methods;
}
private MethodTarget parseMethodTarget(String json, int[] pos) {
expect(json, pos, '{');
skipWhitespace(json, pos);
String name = "";
int startLine = 0;
int endLine = 0;
String sourceFile = "";
while (pos[0] < json.length() && json.charAt(pos[0]) != '}') {
String key = readString(json, pos);
skipWhitespace(json, pos);
expect(json, pos, ':');
skipWhitespace(json, pos);
switch (key) {
case "name":
name = readString(json, pos);
break;
case "startLine":
startLine = readInt(json, pos);
break;
case "endLine":
endLine = readInt(json, pos);
break;
case "sourceFile":
sourceFile = readString(json, pos);
break;
default:
skipValue(json, pos);
break;
}
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
pos[0]++; // skip '}'
return new MethodTarget(name, startLine, endLine, sourceFile);
}
private void parseLineContents(String json, int[] pos) {
expect(json, pos, '{');
skipWhitespace(json, pos);
while (pos[0] < json.length() && json.charAt(pos[0]) != '}') {
String key = readString(json, pos);
skipWhitespace(json, pos);
expect(json, pos, ':');
skipWhitespace(json, pos);
String value = readString(json, pos);
lineContents.put(key, value);
skipWhitespace(json, pos);
if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
pos[0]++;
skipWhitespace(json, pos);
}
}
pos[0]++; // skip '}'
}
private static String readString(String json, int[] pos) {
if (pos[0] >= json.length() || json.charAt(pos[0]) != '"') return "";
pos[0]++; // skip opening quote
StringBuilder sb = new StringBuilder();
while (pos[0] < json.length()) {
char c = json.charAt(pos[0]);
if (c == '\\' && pos[0] + 1 < json.length()) {
pos[0]++;
char escaped = json.charAt(pos[0]);
switch (escaped) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case '/': sb.append('/'); break;
case 'n': sb.append('\n'); break;
case 't': sb.append('\t'); break;
case 'r': sb.append('\r'); break;
default: sb.append('\\').append(escaped); break;
}
} else if (c == '"') {
pos[0]++; // skip closing quote
return sb.toString();
} else {
sb.append(c);
}
pos[0]++;
}
return sb.toString();
}
private static int readInt(String json, int[] pos) {
int start = pos[0];
boolean negative = false;
if (pos[0] < json.length() && json.charAt(pos[0]) == '-') {
negative = true;
pos[0]++;
}
while (pos[0] < json.length() && Character.isDigit(json.charAt(pos[0]))) {
pos[0]++;
}
String numStr = json.substring(start, pos[0]);
try {
return Integer.parseInt(numStr);
} catch (NumberFormatException e) {
return 0;
}
}
private static void skipValue(String json, int[] pos) {
if (pos[0] >= json.length()) return;
char c = json.charAt(pos[0]);
if (c == '"') {
readString(json, pos);
} else if (c == '{') {
skipBraced(json, pos, '{', '}');
} else if (c == '[') {
skipBraced(json, pos, '[', ']');
} else if (c == 'n' && json.startsWith("null", pos[0])) {
pos[0] += 4;
} else if (c == 't' && json.startsWith("true", pos[0])) {
pos[0] += 4;
} else if (c == 'f' && json.startsWith("false", pos[0])) {
pos[0] += 5;
} else {
// number
while (pos[0] < json.length() && "0123456789.eE+-".indexOf(json.charAt(pos[0])) >= 0) {
pos[0]++;
}
}
}
private static void skipBraced(String json, int[] pos, char open, char close) {
int depth = 0;
boolean inString = false;
while (pos[0] < json.length()) {
char c = json.charAt(pos[0]);
if (inString) {
if (c == '\\') {
pos[0]++; // skip escaped char
} else if (c == '"') {
inString = false;
}
} else {
if (c == '"') inString = true;
else if (c == open) depth++;
else if (c == close) {
depth--;
if (depth == 0) {
pos[0]++;
return;
}
}
}
pos[0]++;
}
}
private static void skipWhitespace(String json, int[] pos) {
while (pos[0] < json.length() && Character.isWhitespace(json.charAt(pos[0]))) {
pos[0]++;
}
}
private static void expect(String json, int[] pos, char expected) {
if (pos[0] < json.length() && json.charAt(pos[0]) == expected) {
pos[0]++;
}
}
}

View file

@ -0,0 +1,207 @@
package com.codeflash.profiler;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Zero-allocation, zero-contention per-line profiling data storage.
*
* <p>Each thread gets its own primitive {@code long[]} arrays for hit counts and self-time.
* The hot path ({@link #hit(int)}) performs only an array-index increment and a single
* {@link System#nanoTime()} call no object allocations, no locks, no shared-state contention.
*
* <p>A per-thread call stack tracks method entry/exit to:
* <ul>
* <li>Attribute time to the last line of a function (fixes the "last line 0ms" bug)</li>
* <li>Pause parent-line timing during callee execution (fixes cross-function timing)</li>
* <li>Handle recursion correctly (each stack frame is independent)</li>
* </ul>
*/
public final class ProfilerData {
private static final int INITIAL_CAPACITY = 4096;
private static final int MAX_CALL_DEPTH = 256;
// Thread-local arrays each thread gets its own, no contention
private static final ThreadLocal<long[]> hitCounts =
ThreadLocal.withInitial(() -> registerArray(new long[INITIAL_CAPACITY]));
private static final ThreadLocal<long[]> selfTimeNs =
ThreadLocal.withInitial(() -> registerTimeArray(new long[INITIAL_CAPACITY]));
// Per-thread "last line" tracking for time attribution
// Using int[1] and long[1] to avoid boxing
private static final ThreadLocal<int[]> lastLineId =
ThreadLocal.withInitial(() -> new int[]{-1});
private static final ThreadLocal<long[]> lastLineTime =
ThreadLocal.withInitial(() -> new long[]{0L});
// Per-thread call stack for method entry/exit
private static final ThreadLocal<int[]> callStackLineIds =
ThreadLocal.withInitial(() -> new int[MAX_CALL_DEPTH]);
private static final ThreadLocal<int[]> callStackDepth =
ThreadLocal.withInitial(() -> new int[]{0});
// Global references to all thread-local arrays for harvesting at shutdown
private static final List<long[]> allHitArrays = new CopyOnWriteArrayList<>();
private static final List<long[]> allTimeArrays = new CopyOnWriteArrayList<>();
private ProfilerData() {}
private static long[] registerArray(long[] arr) {
allHitArrays.add(arr);
return arr;
}
private static long[] registerTimeArray(long[] arr) {
allTimeArrays.add(arr);
return arr;
}
/**
* Record a hit on a profiled line. This is the HOT PATH.
*
* <p>Called at every instrumented line number. Must not allocate after the initial
* thread-local array expansion.
*
* @param globalId the line's registered ID from {@link ProfilerRegistry}
*/
public static void hit(int globalId) {
long now = System.nanoTime();
long[] hits = hitCounts.get();
if (globalId >= hits.length) {
hits = ensureCapacity(hitCounts, allHitArrays, globalId);
}
hits[globalId]++;
// Attribute elapsed time to the PREVIOUS line (the one that was executing)
int[] lastId = lastLineId.get();
long[] lastTime = lastLineTime.get();
if (lastId[0] >= 0) {
long[] times = selfTimeNs.get();
if (lastId[0] >= times.length) {
times = ensureCapacity(selfTimeNs, allTimeArrays, lastId[0]);
}
times[lastId[0]] += now - lastTime[0];
}
lastId[0] = globalId;
lastTime[0] = now;
}
/**
* Called at method entry to push a call-stack frame.
*
* <p>Attributes any pending time to the previous line (the call site), then
* saves the caller's line state onto the stack so it can be restored in
* {@link #exitMethod()}.
*
* @param entryLineId the globalId of the first line in the entering method (unused for stack,
* but may be used for future total-time tracking)
*/
public static void enterMethod(int entryLineId) {
long now = System.nanoTime();
// Flush pending time to the line that made the call
int[] lastId = lastLineId.get();
long[] lastTime = lastLineTime.get();
if (lastId[0] >= 0) {
long[] times = selfTimeNs.get();
if (lastId[0] >= times.length) {
times = ensureCapacity(selfTimeNs, allTimeArrays, lastId[0]);
}
times[lastId[0]] += now - lastTime[0];
}
// Push caller's line ID onto the stack
int[] depth = callStackDepth.get();
int[] stack = callStackLineIds.get();
if (depth[0] < stack.length) {
stack[depth[0]] = lastId[0];
}
depth[0]++;
// Reset for the new method scope
lastId[0] = -1;
lastTime[0] = now;
}
/**
* Called at method exit (before RETURN or ATHROW) to pop the call stack.
*
* <p>Attributes remaining time to the last line of the exiting method (fixes the
* "last line always 0ms" bug), then restores the caller's timing state.
*/
public static void exitMethod() {
long now = System.nanoTime();
// Attribute remaining time to the last line of the exiting method
int[] lastId = lastLineId.get();
long[] lastTime = lastLineTime.get();
if (lastId[0] >= 0) {
long[] times = selfTimeNs.get();
if (lastId[0] >= times.length) {
times = ensureCapacity(selfTimeNs, allTimeArrays, lastId[0]);
}
times[lastId[0]] += now - lastTime[0];
}
// Pop the call stack and restore parent's timing state
int[] depth = callStackDepth.get();
if (depth[0] > 0) {
depth[0]--;
int[] stack = callStackLineIds.get();
int parentLineId = stack[depth[0]];
lastId[0] = parentLineId;
lastTime[0] = now; // Self-time: exclude callee duration
} else {
lastId[0] = -1;
lastTime[0] = 0L;
}
}
/**
* Sum hit counts across all threads. Called once at shutdown for reporting.
*/
public static long[] getGlobalHitCounts() {
int maxId = ProfilerRegistry.getMaxId();
long[] global = new long[maxId];
for (long[] threadHits : allHitArrays) {
int limit = Math.min(threadHits.length, maxId);
for (int i = 0; i < limit; i++) {
global[i] += threadHits[i];
}
}
return global;
}
/**
* Sum self-time across all threads. Called once at shutdown for reporting.
*/
public static long[] getGlobalSelfTimeNs() {
int maxId = ProfilerRegistry.getMaxId();
long[] global = new long[maxId];
for (long[] threadTimes : allTimeArrays) {
int limit = Math.min(threadTimes.length, maxId);
for (int i = 0; i < limit; i++) {
global[i] += threadTimes[i];
}
}
return global;
}
private static long[] ensureCapacity(ThreadLocal<long[]> tl, List<long[]> registry, int minIndex) {
long[] old = tl.get();
int newSize = Math.max((minIndex + 1) * 2, INITIAL_CAPACITY);
long[] expanded = new long[newSize];
System.arraycopy(old, 0, expanded, 0, old.length);
// Update the registry: remove old, add new
registry.remove(old);
registry.add(expanded);
tl.set(expanded);
return expanded;
}
}

View file

@ -0,0 +1,120 @@
package com.codeflash.profiler;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Maps (sourceFile, lineNumber) pairs to compact integer IDs at class-load time.
*
* <p>Registration happens once per unique line during class transformation (not on the hot path).
* The integer IDs are used as direct array indices in {@link ProfilerData} for zero-allocation
* hit recording at runtime.
*/
public final class ProfilerRegistry {
private static final AtomicInteger nextId = new AtomicInteger(0);
private static final ConcurrentHashMap<Long, Integer> lineToId = new ConcurrentHashMap<>();
private static volatile String[] idToFile;
private static volatile int[] idToLine;
private static volatile String[] idToClassName;
private static volatile String[] idToMethodName;
private static int capacity;
private static final Object growLock = new Object();
private ProfilerRegistry() {}
/**
* Pre-allocate reverse-lookup arrays with the given capacity.
* Called once from {@link ProfilerAgent#premain} before any classes are loaded.
*/
public static void initialize(int expectedLines) {
capacity = Math.max(expectedLines * 2, 4096);
idToFile = new String[capacity];
idToLine = new int[capacity];
idToClassName = new String[capacity];
idToMethodName = new String[capacity];
}
/**
* Register a source line and return its global ID.
*
* <p>Thread-safe. Called during class loading by the ASM visitor. If the same
* (className, lineNumber) pair has already been registered, returns the existing ID.
*
* @param sourceFile absolute path of the source file
* @param className dot-separated class name (e.g. "com.example.Calculator")
* @param methodName method name
* @param lineNumber 1-indexed line number in the source file
* @return compact integer ID usable as an array index
*/
public static int register(String sourceFile, String className, String methodName, int lineNumber) {
// Pack className hash + lineNumber into a 64-bit key for fast lookup
long key = ((long) className.hashCode() << 32) | (lineNumber & 0xFFFFFFFFL);
Integer existing = lineToId.get(key);
if (existing != null) {
return existing;
}
int id = nextId.getAndIncrement();
if (id >= capacity) {
grow(id + 1);
}
Integer winner = lineToId.putIfAbsent(key, id);
if (winner != null) {
// Another thread registered first use its ID
return winner;
}
idToFile[id] = sourceFile;
idToLine[id] = lineNumber;
idToClassName[id] = className;
idToMethodName[id] = methodName;
return id;
}
private static void grow(int minCapacity) {
synchronized (growLock) {
if (minCapacity <= capacity) return;
int newCapacity = Math.max(minCapacity * 2, capacity * 2);
String[] newFiles = new String[newCapacity];
int[] newLines = new int[newCapacity];
String[] newClasses = new String[newCapacity];
String[] newMethods = new String[newCapacity];
System.arraycopy(idToFile, 0, newFiles, 0, capacity);
System.arraycopy(idToLine, 0, newLines, 0, capacity);
System.arraycopy(idToClassName, 0, newClasses, 0, capacity);
System.arraycopy(idToMethodName, 0, newMethods, 0, capacity);
idToFile = newFiles;
idToLine = newLines;
idToClassName = newClasses;
idToMethodName = newMethods;
capacity = newCapacity;
}
}
public static int getMaxId() {
return nextId.get();
}
public static String getFile(int id) {
return idToFile[id];
}
public static int getLine(int id) {
return idToLine[id];
}
public static String getClassName(int id) {
return idToClassName[id];
}
public static String getMethodName(int id) {
return idToMethodName[id];
}
}

View file

@ -0,0 +1,92 @@
package com.codeflash.profiler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
/**
* Writes profiling results to a JSON file in the same format as the old source-injected profiler.
*
* <p>Output format (consumed by {@code JavaLineProfiler.parse_results()} in Python):
* <pre>
* {
* "/path/to/File.java:10": {
* "hits": 100,
* "time": 5000000,
* "file": "/path/to/File.java",
* "line": 10,
* "content": "int x = compute();"
* },
* ...
* }
* </pre>
*/
public final class ProfilerReporter {
private ProfilerReporter() {}
/**
* Write profiling results to the output file. Called once from a JVM shutdown hook.
*/
public static void writeResults(String outputFile, ProfilerConfig config) {
if (outputFile == null || outputFile.isEmpty()) return;
long[] globalHits = ProfilerData.getGlobalHitCounts();
long[] globalTimes = ProfilerData.getGlobalSelfTimeNs();
int maxId = ProfilerRegistry.getMaxId();
Map<String, String> lineContents = config.getLineContents();
StringBuilder json = new StringBuilder(Math.max(maxId * 128, 256));
json.append("{\n");
boolean first = true;
for (int id = 0; id < maxId; id++) {
long hits = (id < globalHits.length) ? globalHits[id] : 0;
long timeNs = (id < globalTimes.length) ? globalTimes[id] : 0;
if (hits == 0 && timeNs == 0) continue;
String file = ProfilerRegistry.getFile(id);
int line = ProfilerRegistry.getLine(id);
if (file == null) continue;
String key = file + ":" + line;
String content = lineContents.getOrDefault(key, "");
if (!first) json.append(",\n");
first = false;
json.append(" \"").append(escapeJson(key)).append("\": {\n");
json.append(" \"hits\": ").append(hits).append(",\n");
json.append(" \"time\": ").append(timeNs).append(",\n");
json.append(" \"file\": \"").append(escapeJson(file)).append("\",\n");
json.append(" \"line\": ").append(line).append(",\n");
json.append(" \"content\": \"").append(escapeJson(content)).append("\"\n");
json.append(" }");
}
json.append("\n}");
try {
Path path = Paths.get(outputFile);
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.write(path, json.toString().getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
System.err.println("[codeflash-profiler] Failed to write results: " + e.getMessage());
}
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}