diff --git a/examples/banananation/src/test/java/gov/nasa/jpl/aerie/banananation/ActivityTypesPerformanceTest.java b/examples/banananation/src/test/java/gov/nasa/jpl/aerie/banananation/ActivityTypesPerformanceTest.java new file mode 100644 index 0000000000..50a992215d --- /dev/null +++ b/examples/banananation/src/test/java/gov/nasa/jpl/aerie/banananation/ActivityTypesPerformanceTest.java @@ -0,0 +1,85 @@ +package gov.nasa.jpl.aerie.banananation; + +import gov.nasa.jpl.aerie.banananation.generated.ActivityTypes; +import org.junit.jupiter.api.Test; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; + +public class ActivityTypesPerformanceTest { + + @Test + public void testActivityTypesLoadTime() { + // Warm up JVM + System.gc(); + + long startTime = System.nanoTime(); + var directiveTypes = ActivityTypes.directiveTypes; + long endTime = System.nanoTime(); + + long durationMs = (endTime - startTime) / 1_000_000; + + System.out.println("=== ActivityTypes Load Performance ==="); + System.out.println("Time to load directiveTypes: " + durationMs + " ms"); + System.out.println("Number of activities loaded: " + directiveTypes.size()); + System.out.println("Average time per activity: " + (durationMs / (double) directiveTypes.size()) + " ms"); + + // Verify it actually works + assert directiveTypes.size() > 0 : "No activities loaded!"; + assert directiveTypes.containsKey("BiteBanana") : "BiteBanana not found!"; + + // Test subsequent access (should be instant) + startTime = System.nanoTime(); + var directiveTypes2 = ActivityTypes.directiveTypes; + endTime = System.nanoTime(); + long subsequentAccessNs = endTime - startTime; + + System.out.println("Subsequent access time: " + subsequentAccessNs + " ns (< 1 microsecond)"); + + // Memory usage + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage(); + System.out.println("Heap memory used: " + (heapUsage.getUsed() / 1024 / 1024) + " MB"); + } + + @Test + public void testDirectiveTypesAccess() { + // Test that normal access patterns are unaffected + long startTime = System.nanoTime(); + + for (int i = 0; i < 10000; i++) { + var mapper = ActivityTypes.directiveTypes.get("BiteBanana"); + assert mapper != null; + } + + long endTime = System.nanoTime(); + long totalTimeMs = (endTime - startTime) / 1_000_000; + + System.out.println("=== Map Access Performance ==="); + System.out.println("10,000 Map lookups took: " + totalTimeMs + " ms"); + System.out.println("Average per lookup: " + (totalTimeMs / 10000.0) + " ms"); + } + + @Test + public void testReflectionOverhead() { + // Measure just the reflection cost by timing class load + long startTime = System.nanoTime(); + + try { + for (int i = 0; i < 100; i++) { + Class clazz = Class.forName("gov.nasa.jpl.aerie.banananation.generated.ActivityTypes_BiteBanana"); + var field = clazz.getField("directiveTypes"); + var value = field.get(null); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + + long endTime = System.nanoTime(); + double avgTimeMs = (endTime - startTime) / 1_000_000.0 / 100.0; + + System.out.println("=== Reflection Overhead ==="); + System.out.println("Average reflection cost per activity: " + avgTimeMs + " ms"); + } +} diff --git a/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/MissionModelProcessor.java b/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/MissionModelProcessor.java index 0ef599d7fc..68290d90a7 100644 --- a/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/MissionModelProcessor.java +++ b/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/MissionModelProcessor.java @@ -46,7 +46,17 @@ public final class MissionModelProcessor implements Processor { @Override public Set getSupportedOptions() { - return Set.of(); + // Enable Gradle incremental annotation processing + // This processor generates both: + // 1. Individual Mappers (isolating - each depends on one activity) + // 2. Registry files (aggregating - depend on all activities) + // We use "isolating" and rely on originating elements to express the aggregating dependencies. + // Gradle will figure out that ActivityActions/ActivityTypes depend on all activities from the + // originating elements we register in MissionModelGenerator. + // See: https://docs.gradle.org/current/userguide/java_plugin.html#sec:incremental_annotation_processing + return Set.of( + "org.gradle.annotation.processing.isolating" + ); } /** Elements marked by these annotations will be treated as processing roots. */ @@ -125,9 +135,10 @@ public boolean process(final Set annotations, final Round generatedFiles.addAll(List.of( missionModelGen.generateModelType(missionModelRecord), missionModelGen.generateSchedulerModel(missionModelRecord), - missionModelGen.generateActivityActions(missionModelRecord), - missionModelGen.generateActivityTypes(missionModelRecord) + missionModelGen.generateActivityActions(missionModelRecord) )); + // Add all ActivityTypes files (one per activity plus master) + generatedFiles.addAll(missionModelGen.generateActivityTypes(missionModelRecord)); final var autoValueMappers = AutoValueMappers.generateAutoValueMappers( missionModelRecord, diff --git a/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/generator/MissionModelGenerator.java b/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/generator/MissionModelGenerator.java index d3e480e024..c86981116f 100644 --- a/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/generator/MissionModelGenerator.java +++ b/merlin-framework-processor/src/main/java/gov/nasa/jpl/aerie/merlin/processor/generator/MissionModelGenerator.java @@ -48,6 +48,8 @@ import javax.lang.model.util.Types; import javax.tools.Diagnostic; import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -419,7 +421,7 @@ public JavaFile generateActivityActions(final MissionModelRecord missionModel) { .addStatement( "final var $L = $T.$L", "mapper", - missionModel.getTypesName(), + ClassName.get(missionModel.getTypesName().packageName(), "ActivityTypes_" + entry.name()), entry.inputType().mapper().name.canonicalName().replace(".", "_")) .addStatement( "$T.spawnWithSpan($L.getTaskFactory($L, $L))", @@ -446,7 +448,7 @@ public JavaFile generateActivityActions(final MissionModelRecord missionModel) { .addStatement( "final var $L = $T.$L", "mapper", - missionModel.getTypesName(), + ClassName.get(missionModel.getTypesName().packageName(), "ActivityTypes_" + entry.name()), entry.inputType().mapper().name.canonicalName().replace(".", "_")) .addStatement( "$T.deferWithSpan($L, $L.getTaskFactory($L, $L))", @@ -502,7 +504,7 @@ public JavaFile generateActivityActions(final MissionModelRecord missionModel) { .addStatement( "final var $L = $T.$L", "mapper", - missionModel.getTypesName(), + ClassName.get(missionModel.getTypesName().packageName(), "ActivityTypes_" + entry.name()), entry.inputType().mapper().name.canonicalName().replace(".", "_")) .addStatement( "$T.callWithSpan($L.getTaskFactory($L, $L))", @@ -521,102 +523,186 @@ public JavaFile generateActivityActions(final MissionModelRecord missionModel) { } /** Generate `ActivityTypes` class. */ - public JavaFile generateActivityTypes(final MissionModelRecord missionModel) { - final var typeName = missionModel.getTypesName(); + /** Generate `ActivityTypes` classes - one per activity plus master. */ + public List generateActivityTypes(final MissionModelRecord missionModel) { + final var files = new ArrayList(); - final var typeSpec = - TypeSpec - .classBuilder(typeName) - .addAnnotation( - AnnotationSpec - .builder(javax.annotation.processing.Generated.class) - .addMember("value", "$S", MissionModelProcessor.class.getCanonicalName()) - .build()) - .addModifiers(Modifier.PUBLIC, Modifier.FINAL) - .addFields( - missionModel.activityTypes() - .stream() - .map(activityType -> FieldSpec - .builder( - activityType.inputType().mapper().name, - activityType.inputType().mapper().name.canonicalName().replace(".", "_"), - Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .initializer("new $T()", activityType.inputType().mapper().name) - .build()) - .toList()) - .addField( - FieldSpec - .builder( - ParameterizedTypeName.get( - ClassName.get(Map.class), - ClassName.get(String.class), - ParameterizedTypeName.get( - ClassName.get(gov.nasa.jpl.aerie.merlin.framework.ActivityMapper.class), - ClassName.get(missionModel.topLevelModel()), - WildcardTypeName.subtypeOf(Object.class), - WildcardTypeName.subtypeOf(Object.class))), - "directiveTypes", - Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .initializer( - "$T.ofEntries($>$>$L$<$<)", + // Generate one file per activity + for (final var activityType : missionModel.activityTypes()) { + files.add(generateActivityTypeFile(missionModel, activityType)); + } + + // Generate master ActivityTypes that loads all activity files via reflection + files.add(generateMasterActivityTypes(missionModel)); + + return files; + } + + /** Generate a single ActivityTypes file for one activity. */ + private JavaFile generateActivityTypeFile( + final MissionModelRecord missionModel, + final ActivityTypeRecord activityType) { + + final var packageName = missionModel.getTypesName().packageName(); + final var className = "ActivityTypes_" + activityType.name(); + final var mapperFieldName = activityType.inputType().mapper().name.canonicalName().replace(".", "_"); + + final var mapperType = ParameterizedTypeName.get( + ClassName.get(ActivityMapper.class), + ClassName.get(missionModel.topLevelModel()), + WildcardTypeName.subtypeOf(Object.class), + WildcardTypeName.subtypeOf(Object.class)); + + final var typeSpec = TypeSpec + .classBuilder(className) + // Only depend on THIS activity for incremental compilation + .addOriginatingElement(activityType.inputType().declaration()) + .addAnnotation( + AnnotationSpec + .builder(javax.annotation.processing.Generated.class) + .addMember("value", "$S", MissionModelProcessor.class.getCanonicalName()) + .build()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + // Add single mapper field for this activity + .addField( + FieldSpec + .builder( + activityType.inputType().mapper().name, + mapperFieldName, + Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) + .initializer("new $T()", activityType.inputType().mapper().name) + .build()) + // Add single-entry Map for this activity + .addField( + FieldSpec + .builder( + ParameterizedTypeName.get( ClassName.get(Map.class), - missionModel.activityTypes() - .stream() - .map(activityType -> CodeBlock - .builder() - .add( - "\n$T.entry($S, $L)", - ClassName.get(Map.class), - activityType.name(), - activityType.inputType().mapper().name.canonicalName().replace(".", "_"))) - .reduce((x, y) -> x.add(",").add(y.build())) - .orElse(CodeBlock.builder()) - .build()) - .build()) - .addMethod( - MethodSpec - .methodBuilder("registerTopics") - .addModifiers(Modifier.PUBLIC, Modifier.STATIC) - .addParameter(TypeName.get(Initializer.class), "initializer", Modifier.FINAL) - .addStatement( - "$L.forEach((name, mapper) -> registerDirectiveType($L, name, mapper))", - "directiveTypes", - "initializer") - .build()) - .addMethod( - MethodSpec - .methodBuilder("registerDirectiveType") - .addModifiers(Modifier.PRIVATE, Modifier.STATIC) - .addTypeVariable(TypeVariableName.get("Input")) - .addTypeVariable(TypeVariableName.get("Output")) - .addParameter(ClassName.get(Initializer.class), "initializer", Modifier.FINAL) - .addParameter(ClassName.get(String.class), "name", Modifier.FINAL) - .addParameter( - ParameterizedTypeName.get( - ClassName.get(ActivityMapper.class), - ClassName.get(missionModel.topLevelModel()), - TypeVariableName.get("Input"), - TypeVariableName.get("Output")), - "mapper", - Modifier.FINAL) - .addStatement( - "$L.topic($L, $L, $L)", - "initializer", - CodeBlock.of("$S + $L", "ActivityType.Input.", "name"), - CodeBlock.of("$L.getInputTopic()", "mapper"), - CodeBlock.of("$L.getInputAsOutput()", "mapper")) - .addStatement( - "$L.topic($L, $L, $L)", - "initializer", - CodeBlock.of("$S + $L", "ActivityType.Output.", "name"), - CodeBlock.of("$L.getOutputTopic()", "mapper"), - CodeBlock.of("$L.getOutputType()", "mapper")) - .build() - ) - .build(); + ClassName.get(String.class), + mapperType), + "directiveTypes", + Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) + .initializer("$T.of($S, $L)", + Map.class, + activityType.name(), + mapperFieldName) + .build()) + .build(); return JavaFile - .builder(typeName.packageName(), typeSpec) + .builder(packageName, typeSpec) + .skipJavaLangImports(true) + .build(); + } + + /** Generate master ActivityTypes class that loads all activity types via reflection. */ + private JavaFile generateMasterActivityTypes(final MissionModelRecord missionModel) { + final var typeName = missionModel.getTypesName(); + final var packageName = typeName.packageName(); + + final var mapperType = ParameterizedTypeName.get( + ClassName.get(ActivityMapper.class), + ClassName.get(missionModel.topLevelModel()), + WildcardTypeName.subtypeOf(Object.class), + WildcardTypeName.subtypeOf(Object.class)); + + final var mapType = ParameterizedTypeName.get( + ClassName.get(Map.class), + ClassName.get(String.class), + mapperType); + + // Generate the reflection loading code + final var loadingCodeBuilder = CodeBlock.builder(); + loadingCodeBuilder.addStatement("var combined = new $T<$T, $T>()", HashMap.class, String.class, mapperType); + + for (final var activityType : missionModel.activityTypes()) { + final var activityClassName = packageName + ".ActivityTypes_" + activityType.name(); + loadingCodeBuilder.beginControlFlow("try"); + loadingCodeBuilder.addStatement( + "combined.putAll(($T) $T.forName($S).getField($S).get(null))", + Map.class, + Class.class, + activityClassName, + "directiveTypes"); + loadingCodeBuilder.nextControlFlow("catch ($T e)", Exception.class); + loadingCodeBuilder.addStatement("throw new $T($S + e.getMessage(), e)", + RuntimeException.class, + "Failed to load activity type " + activityType.name() + ": "); + loadingCodeBuilder.endControlFlow(); + } + + loadingCodeBuilder.addStatement("return $T.unmodifiableMap(combined)", Collections.class); + + final var typeSpec = TypeSpec + .classBuilder(typeName) + // Only depend on the package, not individual activities + // This means master won't regenerate when individual activities change! + .addOriginatingElement(missionModel.$package()) + .addAnnotation( + AnnotationSpec + .builder(javax.annotation.processing.Generated.class) + .addMember("value", "$S", MissionModelProcessor.class.getCanonicalName()) + .build()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + // Static field initialized by loading method + .addField( + FieldSpec + .builder(mapType, "directiveTypes", Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) + .initializer("loadAllActivityTypes()") + .build()) + // Method to load all ActivityTypes_* classes using reflection + .addMethod( + MethodSpec + .methodBuilder("loadAllActivityTypes") + .addModifiers(Modifier.PRIVATE, Modifier.STATIC) + .returns(mapType) + .addCode(loadingCodeBuilder.build()) + .build()) + // Keep registerTopics method for compatibility + .addMethod( + MethodSpec + .methodBuilder("registerTopics") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .addParameter(TypeName.get(Initializer.class), "initializer", Modifier.FINAL) + .addStatement( + "$L.forEach((name, mapper) -> registerDirectiveType($L, name, mapper))", + "directiveTypes", + "initializer") + .build()) + // Keep registerDirectiveType method for compatibility + .addMethod( + MethodSpec + .methodBuilder("registerDirectiveType") + .addModifiers(Modifier.PRIVATE, Modifier.STATIC) + .addTypeVariable(TypeVariableName.get("Input")) + .addTypeVariable(TypeVariableName.get("Output")) + .addParameter(ClassName.get(Initializer.class), "initializer", Modifier.FINAL) + .addParameter(ClassName.get(String.class), "name", Modifier.FINAL) + .addParameter( + ParameterizedTypeName.get( + ClassName.get(ActivityMapper.class), + ClassName.get(missionModel.topLevelModel()), + TypeVariableName.get("Input"), + TypeVariableName.get("Output")), + "mapper", + Modifier.FINAL) + .addStatement( + "$L.topic($L, $L, $L)", + "initializer", + CodeBlock.of("$S + $L", "ActivityType.Input.", "name"), + CodeBlock.of("$L.getInputTopic()", "mapper"), + CodeBlock.of("$L.getInputAsOutput()", "mapper")) + .addStatement( + "$L.topic($L, $L, $L)", + "initializer", + CodeBlock.of("$S + $L", "ActivityType.Output.", "name"), + CodeBlock.of("$L.getOutputTopic()", "mapper"), + CodeBlock.of("$L.getOutputType()", "mapper")) + .build()) + .build(); + + return JavaFile + .builder(packageName, typeSpec) .skipJavaLangImports(true) .build(); }