diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java index dd4e99051..5e5de74de 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java @@ -2,6 +2,7 @@ import com.google.common.collect.Sets; import de.peeeq.wurstscript.attributes.CompileError; +import de.peeeq.wurstscript.ast.GlobalVarDef; import de.peeeq.wurstscript.jassIm.*; import de.peeeq.wurstscript.translation.imtranslation.ImHelper; import de.peeeq.wurstscript.translation.imtranslation.ImTranslator; @@ -9,16 +10,25 @@ import de.peeeq.wurstscript.validation.NamePreservation; import org.jetbrains.annotations.Nullable; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; public class GlobalsInliner implements OptimizerPass { + @Override public int optimize(ImTranslator trans) { int obsoleteCount = 0; ImProg prog = trans.getImProg(); prog.clearAttributes(); // TODO only clear read/write attributes + LiteralConstantAnalysis literalConstants = analyzeLiteralConstants(trans, prog); Set obsoleteVars = Sets.newLinkedHashSet(); for (final ImVar v : prog.getGlobals()) { @@ -52,15 +62,21 @@ public int optimize(ImTranslator trans) { continue; } - if (v.attrWrites().size() == 1) { + boolean literalConstant = literalConstants.safeConstants.contains(v); + if (v.attrWrites().size() == 1 || literalConstant) { ImExpr right = null; ImVarWrite obs = null; - for (ImVarWrite write : v.attrWrites()) { - ImFunction func = write.getNearestFunc(); - if (isInInitGlobals(func)) { - right = write.getRight(); - obs = write; - break; + if (literalConstant) { + obs = literalConstants.replacementWrites.get(v); + right = obs.getRight(); + } else { + for (ImVarWrite write : v.attrWrites()) { + ImFunction func = write.getNearestFunc(); + if (isInInitGlobals(func)) { + right = write.getRight(); + obs = write; + break; + } } } if (obs == null) { @@ -73,7 +89,7 @@ public int optimize(ImTranslator trans) { v3.replaceBy(replacement.copy()); } } - if (replacement != null || v.attrReads().size() == 0) { + if ((replacement != null || v.attrReads().size() == 0) && v.attrWrites().size() == 1) { obsoleteVars.add(v); } } else if (v.attrWrites().size() > 1 && !(v.getType() instanceof ImTupleType)) { @@ -149,6 +165,10 @@ private ImExpr findReplacement(ImExpr right, ImVarWrite obs) { return replacement; } + private static boolean isLiteral(ImExpr expr) { + return expr instanceof ImIntVal || expr instanceof ImRealVal || expr instanceof ImStringVal || expr instanceof ImBoolVal; + } + @Override public String getName() { return "Globals Inlined"; @@ -159,4 +179,272 @@ private static boolean isInInitGlobals(ImFunction func) { return func != null && func.getName().equals("initGlobals"); } + /** + * A package constant is assigned at runtime in a package initializer. Replacing all reads is + * valid only when no startup path can observe the default value before that emitted assignment. + * Analyze the actual IM startup order once, including transitive calls and function references, + * rather than trying to reconstruct translation and dependency order from source positions. + */ + private static LiteralConstantAnalysis analyzeLiteralConstants(ImTranslator trans, ImProg prog) { + List initializationOrder = trans.getInitializationOrder(); + IdentityHashMap statementRanks = new IdentityHashMap<>(); + IdentityHashMap writeRanks = new IdentityHashMap<>(); + for (int functionRank = 0; functionRank < initializationOrder.size(); functionRank++) { + ImFunction initializer = initializationOrder.get(functionRank); + for (int statementRank = 0; statementRank < initializer.getBody().size(); statementRank++) { + statementRanks.put(initializer.getBody().get(statementRank), statementRank); + } + int[] writeRank = {0}; + int currentFunctionRank = functionRank; + initializer.getBody().accept(new ImStmt.DefaultVisitor() { + @Override + public void visit(ImSet write) { + long rank = ((long) currentFunctionRank << 32) | (writeRank[0]++ & 0xffffffffL); + writeRanks.put(write, rank); + super.visit(write); + } + }); + } + + List candidates = new ArrayList<>(); + IdentityHashMap replacementWrites = new IdentityHashMap<>(); + for (ImVar var : prog.getGlobals()) { + if (!isSourceConstant(var)) { + continue; + } + ImVarWrite replacementWrite = null; + ImExpr replacement = null; + long replacementRank = Long.MAX_VALUE; + boolean eligible = !var.attrWrites().isEmpty(); + for (ImVarWrite write : var.attrWrites()) { + ImFunction initializer = write.getNearestFunc(); + ImStmt statement = initializer == null ? null + : topLevelStatement((de.peeeq.wurstscript.jassIm.Element) write, initializer); + Integer statementRank = statementRanks.get(statement); + Long writeRank = writeRanks.get(write); + if (statement == null || statementRank == null + || writeRank == null || !isLiteral(write.getRight())) { + eligible = false; + break; + } + if (replacement == null) { + replacement = write.getRight(); + } else if (!replacement.structuralEquals(write.getRight())) { + eligible = false; + break; + } + if (writeRank < replacementRank) { + replacementRank = writeRank; + replacementWrite = write; + } + } + if (eligible) { + candidates.add(var); + replacementWrites.put(var, replacementWrite); + } + } + if (candidates.isEmpty()) { + return new LiteralConstantAnalysis(identitySet(), replacementWrites); + } + + IdentityHashMap> readsByFunction = new IdentityHashMap<>(); + IdentityHashMap> readsByStatement = new IdentityHashMap<>(); + IdentityHashMap> writesByStatement = new IdentityHashMap<>(); + IdentityHashMap> writesByInitializer = new IdentityHashMap<>(); + BitSet unsafe = new BitSet(candidates.size()); + + for (int i = 0; i < candidates.size(); i++) { + ImVar candidate = candidates.get(i); + for (ImVarRead read : candidate.attrReads()) { + ImFunction function = read.getNearestFunc(); + if (function == null) { + unsafe.set(i); + continue; + } + readsByFunction.computeIfAbsent(function, ignored -> new HashSet<>()).add(i); + ImStmt statement = topLevelStatement((de.peeeq.wurstscript.jassIm.Element) read, function); + if (statement != null) { + readsByStatement.computeIfAbsent(statement, ignored -> new HashSet<>()).add(i); + } + } + for (ImVarWrite write : candidate.attrWrites()) { + ImFunction function = write.getNearestFunc(); + ImStmt statement = function == null ? null + : topLevelStatement((de.peeeq.wurstscript.jassIm.Element) write, function); + if (statement != null) { + writesByStatement.computeIfAbsent(statement, ignored -> new HashSet<>()).add(i); + writesByInitializer.computeIfAbsent(function, ignored -> new HashSet<>()).add(i); + } + } + } + + BitSet pending = new BitSet(candidates.size()); + pending.set(0, candidates.size()); + Set reachableFromStartup = identitySet(); + ImFunction config = trans.getConfFunc(); + if (config != null) { + scanStartupStatements(config.getBody(), pending, unsafe, readsByStatement, writesByStatement, + readsByFunction, reachableFromStartup, null); + } + if (!initializationOrder.isEmpty()) { + scanStartupStatements(initializationOrder.get(0).getBody(), pending, unsafe, readsByStatement, + writesByStatement, readsByFunction, reachableFromStartup, null); + scanMainPrefix(trans, initializationOrder, pending, unsafe, readsByStatement, writesByStatement, + readsByFunction, reachableFromStartup); + } + for (int i = 1; i < initializationOrder.size(); i++) { + ImFunction initializer = initializationOrder.get(i); + scanStartupStatements(initializer.getBody(), pending, unsafe, readsByStatement, writesByStatement, + readsByFunction, reachableFromStartup, writesByInitializer.get(initializer)); + } + unsafe.or(pending); + + Set safeConstants = identitySet(); + for (int i = 0; i < candidates.size(); i++) { + if (!unsafe.get(i)) { + safeConstants.add(candidates.get(i)); + } + } + return new LiteralConstantAnalysis(safeConstants, replacementWrites); + } + + private static void scanMainPrefix(ImTranslator trans, List initializationOrder, + BitSet pending, BitSet unsafe, + Map> readsByStatement, + Map> writesByStatement, + Map> readsByFunction, + Set reachableFromStartup) { + Set packageInitializers = identitySet(); + packageInitializers.addAll(initializationOrder.subList(1, initializationOrder.size())); + for (ImStmt statement : trans.getMainFunc().getBody()) { + Set usedFunctions = directlyUsedFunctions(statement); + if (!Collections.disjoint(usedFunctions, packageInitializers)) { + return; + } + scanStartupStatements(Collections.singleton(statement), pending, unsafe, readsByStatement, + writesByStatement, readsByFunction, reachableFromStartup, null); + } + } + + private static void scanStartupStatements(Collection statements, BitSet pending, BitSet unsafe, + Map> readsByStatement, + Map> writesByStatement, + Map> readsByFunction, + Set reachableFromStartup, + @Nullable Set writesInAbortableInitializer) { + for (ImStmt statement : statements) { + BitSet reads = new BitSet(); + for (int candidate : readsByStatement.getOrDefault(statement, Collections.emptySet())) { + reads.set(candidate); + } + addNewlyReachableReads(statement, reachableFromStartup, readsByFunction, reads); + reads.and(pending); + unsafe.or(reads); + if (writesInAbortableInitializer != null && !isDefinitelyNonAbortingInitializerStatement(statement)) { + for (int candidate : writesInAbortableInitializer) { + if (pending.get(candidate)) { + unsafe.set(candidate); + } + } + } + for (int candidate : writesByStatement.getOrDefault(statement, Collections.emptySet())) { + pending.clear(candidate); + } + } + } + + private static void addNewlyReachableReads(ImStmt statement, Set reachableFromStartup, + Map> readsByFunction, BitSet reads) { + ArrayDeque undiscovered = new ArrayDeque<>(); + for (ImFunction function : directlyUsedFunctions(statement)) { + if (function != null && reachableFromStartup.add(function)) { + undiscovered.addLast(function); + } + } + while (!undiscovered.isEmpty()) { + ImFunction function = undiscovered.removeFirst(); + for (int candidate : readsByFunction.getOrDefault(function, Collections.emptySet())) { + reads.set(candidate); + } + for (ImFunction callee : function.calcUsedFunctions()) { + if (callee != null && reachableFromStartup.add(callee)) { + undiscovered.addLast(callee); + } + } + } + } + + private static boolean isDefinitelyNonAbortingInitializerStatement(ImStmt statement) { + return statement instanceof ImSet set && set.getLeft() instanceof ImVarAccess && isLiteral(set.getRight()); + } + + private static Set directlyUsedFunctions(ImStmt statement) { + Set result = identitySet(); + statement.accept(new ImStmt.DefaultVisitor() { + @Override + public void visit(ImFunctionCall call) { + super.visit(call); + result.add(call.getFunc()); + } + + @Override + public void visit(ImFuncRef ref) { + super.visit(ref); + result.add(ref.getFunc()); + } + + @Override + public void visit(ImMethodCall call) { + super.visit(call); + if (call.getMethod().getImplementation() != null) { + result.add(call.getMethod().getImplementation()); + } + for (ImMethod subMethod : call.getMethod().getSubMethods()) { + if (subMethod.getImplementation() != null) { + result.add(subMethod.getImplementation()); + } + } + } + }); + return result; + } + + @Nullable + @SuppressWarnings("ReferenceEquality") + private static ImStmt topLevelStatement(de.peeeq.wurstscript.jassIm.Element element, ImFunction function) { + de.peeeq.wurstscript.jassIm.Element current = element; + while (current != null && current.getParent() != function.getBody()) { + current = current.getParent(); + } + return current instanceof ImStmt ? (ImStmt) current : null; + } + + private static boolean isSourceConstant(ImVar var) { + if (!(var.getTrace() instanceof GlobalVarDef)) { + return false; + } + if (var.getName().equals("MagicFunctions_compiletime") + || var.getName().equals("MagicFunctions_isLua")) { + // These values depend on compiler execution context/backend and are lowered by their + // dedicated paths. They are not ordinary source literals for package-constant folding. + return false; + } + GlobalVarDef global = (GlobalVarDef) var.getTrace(); + return global.attrIsConstant() && !global.hasAnnotation("@configurable"); + } + + private static Set identitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private static final class LiteralConstantAnalysis { + private final Set safeConstants; + private final Map replacementWrites; + + private LiteralConstantAnalysis(Set safeConstants, Map replacementWrites) { + this.safeConstants = safeConstants; + this.replacementWrites = replacementWrites; + } + } + } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java index 34a6bd684..c59fbd0db 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java @@ -165,6 +165,9 @@ public T canonical(T copy) { public final Map initFuncMap = new Object2ObjectLinkedOpenHashMap<>(); + /** Initializer functions in the exact order emitted by {@link #finishInitFunctions()}. */ + private final List initializationOrder = new ArrayList<>(); + /** * When targeting Lua, package init functions that should be called directly via xpcall * rather than through the JASS TriggerEvaluate thread-isolation pattern. @@ -676,6 +679,8 @@ private void translateCompilationUnit(CompilationUnit cu) { private void finishInitFunctions() { + initializationOrder.clear(); + initializationOrder.add(globalInitFunc); // init globals, at beginning of main func: getMainFunc().getBody().add(0, ImFunctionCall(emptyTrace, globalInitFunc, ImTypeArguments(), ImExprs(), false, CallType.NORMAL)); @@ -744,6 +749,7 @@ private void callInitFunc(Set calledInitializers, WPackage p, @Nullabl if (initFunc.getBody().size() == 0) { return; } + initializationOrder.add(initFunc); if (isLuaTarget()) { // In Lua mode, xpcall replaces TriggerEvaluate for error isolation without WC3 handle overhead. // Record the init function so the Lua translator can wrap it with xpcall. @@ -1531,7 +1537,9 @@ private Multimap getCallRelations() { public ImFunction getMainFunc() { return mainFunc; } public ImFunction getConfFunc() { return configFunc; } - + public List getInitializationOrder() { + return Collections.unmodifiableList(initializationOrder); + } /** * returns a list of classes and functions implementing funcDef diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index e4cb98666..41b300513 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -58,6 +58,47 @@ private String compileOptimizedLua(String testName, String... lines) { return compileLuaWithRunArgs(testName, runArgs, false, lines); } + @Test + public void packageConstantsInlineAndRemoveDeadGuards() { + String compiled = compileOptimizedLuaWithStdLib( + "packageConstantsInlineAndRemoveDeadGuards", + "package Test", + "public constant bool COMPILETIME_DISABLED = compiletime(false)", + "public constant int VALUE = 7", + "public constant bool DISABLED = false", + "public constant bool ENABLED = true", + "@configurable public constant int CONFIGURABLE = 9", + "native consume(int value)", + "bool active", + "function dead()", + " consume(VALUE)", + "function compiletimeDead()", + " consume(VALUE)", + "function guarded()", + " if COMPILETIME_DISABLED and active", + " compiletimeDead()", + " if DISABLED and active", + " dead()", + " if ENABLED and active", + " consume(VALUE)", + " consume(CONFIGURABLE)", + "init", + " guarded()" + ); + + assertFalse("constant uses must be emitted as literals:\n" + compiled, + compiled.contains("consume(Test_VALUE)") || compiled.contains("Test_DISABLED and") + || compiled.contains("Test_ENABLED and") || compiled.contains("Test_COMPILETIME_DISABLED and")); + assertFalse("a false constant guard must remove its unreachable callee:\n" + compiled, + compiled.contains("function dead(") || compiled.contains("function compiletimeDead(")); + assertTrue("a true constant guard must retain its dynamic condition:\n" + compiled, + compiled.contains("if Test_active then")); + assertTrue("constant arithmetic uses must be emitted as literals:\n" + compiled, + compiled.contains("consume(7)")); + assertTrue("configurable constants must remain globals until configuration resolution:\n" + compiled, + compiled.contains("Test_CONFIGURABLE")); + } + private String compileOptimizedLuaWithStdLib(String testName, String... lines) { RunArgs runArgs = new RunArgs().with("-lua", "-inline", "-localOptimizations", "-runcompiletimefunctions", "-lib", StdLib.getLib()); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OptimizerTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OptimizerTests.java index 2875b129b..c6dbd10fb 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OptimizerTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OptimizerTests.java @@ -33,6 +33,137 @@ public class OptimizerTests extends WurstScriptTest { + @Test + public void packageConstantsInlineAndRemoveDeadGuardsInJass() throws IOException { + test().withStdLib().runCompiletimeFunctions(true).lines( + "package Test", + "public constant bool COMPILETIME_DISABLED = compiletime(false)", + "public constant int VALUE = 7", + "public constant bool DISABLED = false", + "public constant bool ENABLED = true", + "@configurable public constant int CONFIGURABLE = 9", + "native consume(int value)", + "bool active", + "function dead()", + " consume(VALUE)", + "function compiletimeDead()", + " consume(VALUE)", + "function guarded()", + " if COMPILETIME_DISABLED and active", + " compiletimeDead()", + " if DISABLED and active", + " dead()", + " if ENABLED and active", + " consume(VALUE)", + " consume(CONFIGURABLE)", + "init", + " guarded()" + ); + + String compiled = Files.toString( + new File("test-output/OptimizerTests_packageConstantsInlineAndRemoveDeadGuardsInJass_inlopt.j"), + Charsets.UTF_8); + assertFalse(compiled.contains("Test_VALUE") || compiled.contains("Test_DISABLED") || compiled.contains("Test_ENABLED") + || compiled.contains("Test_COMPILETIME_DISABLED")); + assertFalse(compiled.contains("function Test_dead takes") || compiled.contains("function Test_compiletimeDead takes")); + assertTrue(compiled.contains("if Test_active then")); + assertTrue(compiled.contains("call consume(7)")); + assertTrue(compiled.contains("Test_CONFIGURABLE")); + } + + @Test + public void laterPackageConstantIsNotInlinedIntoEarlierInitializers() throws IOException { + test().lines( + "package Test", + "native consume(int value)", + "int observed = readLater()", + "init", + " consume(readLater())", + "constant int LATER = 7", + "function readLater() returns int", + " return LATER", + "init", + " consume(observed)" + ); + String compiled = Files.toString( + new File("test-output/OptimizerTests_laterPackageConstantIsNotInlinedIntoEarlierInitializers_inlopt.j"), + Charsets.UTF_8); + assertTrue(compiled.contains("Test_LATER")); + } + + @Test + public void superclassTranslationOrderPreservesLaterConstant() throws IOException { + test().lines( + "package Test", + "native consume(int value)", + "class Child extends Parent", + "constant int LATER = 7", + "class Parent", + " static int observed = readLater()", + "function readLater() returns int", + " return LATER", + "init", + " consume(Parent.observed)" + ); + String compiled = Files.toString( + new File("test-output/OptimizerTests_superclassTranslationOrderPreservesLaterConstant_inlopt.j"), + Charsets.UTF_8); + assertTrue(compiled.contains("Test_LATER")); + } + + @Test + public void abortableInitializerBeforeConstantPreservesLaterWrite() throws IOException { + test().compilationUnits( + compilationUnit("AbortBeforeConstant", + "package AbortBeforeConstant", + "native abortInitialization()", + "init", + " abortInitialization()", + "public constant int LATER = 7"), + compilationUnit("ReadAfterAbort", + "package ReadAfterAbort", + "import AbortBeforeConstant", + "constant int SAFE = 11", + "native consume(int value)", + "init", + " consume(LATER + SAFE)") + ); + String compiled = Files.toString( + new File("test-output/OptimizerTests_abortableInitializerBeforeConstantPreservesLaterWrite_inlopt.j"), + Charsets.UTF_8); + assertTrue(compiled.contains("AbortBeforeConstant_LATER")); + assertFalse(compiled.contains("ReadAfterAbort_SAFE")); + } + + @Test + public void initlaterAnalysisIsCompilationScoped() throws IOException { + compileInitlaterConstantRepro("first", "First"); + compileInitlaterConstantRepro("second", "Second"); + + String compiled = Files.toString( + new File("test-output/OptimizerTests_initlaterAnalysis_second_inlopt.j"), + Charsets.UTF_8); + assertTrue(compiled.contains("SecondValue_VALUE")); + } + + private void compileInitlaterConstantRepro(String testName, String prefix) { + testNamed("initlaterAnalysis_" + testName).compilationUnits( + compilationUnit(prefix + "Value", + "package " + prefix + "Value", + "public constant int VALUE = 7", + "public function readValue() returns int", + " return VALUE"), + compilationUnit(prefix + "Reader", + "package " + prefix + "Reader", + "import initlater " + prefix + "Value", + "native consume(int value)", + "int observed = readValue()", + "init", + " consume(observed)") + ); + } + + @Test public void test_number_shortening() {