feat: implement line-level profiling agent with ASM instrumentation
This commit is contained in:
parent
39c000c264
commit
e75070abef
9 changed files with 1056 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue