diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da83f128246..3db5df41d6e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,7 +78,7 @@ jobs: strategy: fail-fast: false matrix: - test: [ testProveRules, testRunAllFunProofs, testRunAllInfProofs, testRunAllWdProofs ] + test: [ testProveRules, testRunAllFunProofs, testRunAllInfProofs, testRunAllWdProofs, testMtStress ] os: [ ubuntu-latest ] java: [ 21 ] runs-on: ${{ matrix.os }} diff --git a/build.gradle b/build.gradle index 19e67c33877..e5586e9685c 100644 --- a/build.gradle +++ b/build.gradle @@ -123,6 +123,11 @@ subprojects { systemProperty "RUNALLPROOFS_DIR", layout.buildDirectory.dir("report/runallproves").get().toString() systemProperty "key.disregardSettings", "true" + // Pin the whole test suite to the single-threaded prover, so no test silently runs on the + // multi-core prover (e.g. merge-rule, slicing and symbolic-execution tests rely on single + // core). Opt-in parallel tests (equivalence, stress, benchmark) set this property at runtime, + // which overrides the baseline within their fork. + systemProperty "key.prover.parallel", "false" maxHeapSize = "4g" forkEvery = 0 //default diff --git a/key.core.infflow/src/main/java/de/uka/ilkd/key/informationflow/JavaInfFlowProfile.java b/key.core.infflow/src/main/java/de/uka/ilkd/key/informationflow/JavaInfFlowProfile.java index aff32e44ce0..17b50c69ac8 100644 --- a/key.core.infflow/src/main/java/de/uka/ilkd/key/informationflow/JavaInfFlowProfile.java +++ b/key.core.infflow/src/main/java/de/uka/ilkd/key/informationflow/JavaInfFlowProfile.java @@ -18,6 +18,13 @@ public class JavaInfFlowProfile extends JavaProfile { public static final String NAME = "Java InfFlow Profile"; public static final String PROFILE_ID = "java-infflow"; + @Override + public boolean supportsParallelAutomode() { + // Information-flow proofs use side-proof machinery that is not thread-safe: keep + // single-core. + return false; + } + @Override public String ident() { return PROFILE_ID; diff --git a/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SimplifyTermProfile.java b/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SimplifyTermProfile.java index 29ba6cf2c68..29f7ef752b5 100644 --- a/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SimplifyTermProfile.java +++ b/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SimplifyTermProfile.java @@ -57,6 +57,12 @@ public SimplifyTermProfile() { /** * {@inheritDoc} */ + @Override + public boolean supportsParallelAutomode() { + // Symbolic-execution term-simplification profile: keep single-core (not audited). + return false; + } + @Override protected ImmutableList computeTermLabelConfiguration() { ImmutableList result = super.computeTermLabelConfiguration(); diff --git a/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SymbolicExecutionJavaProfile.java b/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SymbolicExecutionJavaProfile.java index e56d8566542..a54c440d9f0 100644 --- a/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SymbolicExecutionJavaProfile.java +++ b/key.core.symbolic_execution/src/main/java/de/uka/ilkd/key/symbolic_execution/profile/SymbolicExecutionJavaProfile.java @@ -111,6 +111,13 @@ protected ImmutableSet> computeSupportedGoalChoo .add(new SymbolicExecutionGoalChooserFactory()); } + @Override + public boolean supportsParallelAutomode() { + // The symbolic-execution debugger profile builds a shared SE tree during search and is not + // audited for thread-safety: keep single-core. + return false; + } + /** * {@inheritDoc} */ diff --git a/key.core.wd/src/main/java/de/uka/ilkd/key/wd/WdProfile.java b/key.core.wd/src/main/java/de/uka/ilkd/key/wd/WdProfile.java index bc8ab3570ec..ef81ba30a46 100644 --- a/key.core.wd/src/main/java/de/uka/ilkd/key/wd/WdProfile.java +++ b/key.core.wd/src/main/java/de/uka/ilkd/key/wd/WdProfile.java @@ -61,6 +61,12 @@ public WdProfile() { defRules.standardBuiltInRules()); } + @Override + public boolean supportsParallelAutomode() { + // Well-definedness checks are not yet audited for thread-safety: keep single-core. + return false; + } + @Override public String ident() { return PROFILE_ID; diff --git a/key.core/build.gradle b/key.core/build.gradle index a0a0a14cc34..4dd2eca4b46 100644 --- a/key.core/build.gradle +++ b/key.core/build.gradle @@ -10,6 +10,7 @@ dependencies { api project(':key.util') api project(':key.ncore') api project(':key.ncore.calculus') + api project(':key.ncore.compiler') antlr4 "org.antlr:antlr4:4.13.2" api "org.antlr:antlr4-runtime:4.13.2" @@ -97,6 +98,30 @@ classes.dependsOn << generateSolverPropsList tasks.withType(Test) { enableAssertions = true + // The whole suite is pinned to the single-threaded prover in the root build (key.prover.parallel + // = false); opt-in parallel tests override it at runtime. The forwarding loop below still lets + // the Gradle command line override it (e.g. -Dkey.prover.parallel=true). + // Forward the multithreading / parallel-prover properties from the Gradle command line to the + // test JVM (Gradle does not propagate -D automatically). Lets one run e.g. + // ./gradlew :key.core:test --tests '*MtSpeedupBenchmark' -Dkey.mt.benchmark=true + // or toggle the parallel prover for any test via -Dkey.prover.parallel(.threads). + ["key.mt.benchmark", "key.mt.benchmark.threads", "key.mt.benchmark.proofs", + "key.mt.benchmark.maxsteps", + "key.mt.synth.splits", "key.mt.synth.worksplits", "key.mt.synth.work", + "key.mt.synth.linear", "key.mt.synth.loop", + "key.mt.jfr.probe", "key.mt.jfr.proof", "key.mt.jfr.workers", "key.mt.jfr.reps", + "key.prover.parallel", "key.prover.parallel.threads"].each { p -> + if (System.getProperty(p) != null) { + systemProperty(p, System.getProperty(p)) + } + } + // Record a Flight Recording of the test JVM when -Dkey.mt.jfr= is given. Used to profile + // the parallel prover's lock contention and hot paths (see MtJfrProbe). + if (System.getProperty("key.mt.jfr") != null) { + jvmArgs += ["-XX:StartFlightRecording=filename=${System.getProperty('key.mt.jfr')}," + + "settings=profile,dumponexit=true,disk=true", + "-XX:FlightRecorderOptions=stackdepth=128"] + } // Forward retention-probe knobs (see MtRetentionProbe) to the test JVM. systemProperties += System.properties.findAll { it.key.toString().startsWith("key.mt.retention") } if (project.hasProperty("stressHeap")) { @@ -149,6 +174,26 @@ tasks.register('testStrictSMT', Test) { } } +// Stress test for the goal-level parallel prover: run a few large splitting proofs many times at a +// high (over-subscribed) worker count and assert each one closes. Slow (minutes), so it is gated out +// of the normal unit-test suite and only runs here. Guards against reintroducing a concurrency race +// that makes proofs nondeterministically fail to close. See MtStressTest. +tasks.register('testMtStress', Test) { + description = 'Stress the parallel prover at high worker counts to catch non-closure races' + group = 'verification' + systemProperty("key.mt.stress", "true") + maxParallelForks = 1 + // Each class proves many full proofs; give every test class a fresh JVM so global KeY state + // (settings, static caches/interners, the script parser) cannot leak between classes -- e.g. + // MtScriptStressTest's position-sensitive script must not inherit state from earlier classes. + forkEvery = 1 + filter { + includeTestsMatching "MtStressTest" + includeTestsMatching "MtMacroStressTest" + includeTestsMatching "MtScriptStressTest" + } +} + //Generation of the three version files within the resources by executing `git'. tasks.register('generateVersionFiles') { def outputFolder = file("build/resources/main/de/uka/ilkd/key/util") @@ -250,14 +295,21 @@ tasks.register("testRAP", Test) { dependsOn('generateRAPUnitTests', 'testClasses') forkEvery = 1 - maxParallelForks = 2 + // run the regression proofs on up to 10 parallel JVMs (overridable with -PrapForks=N); + // for clean perfTest timing reproduction use -PrapForks=1 + maxParallelForks = (project.findProperty('rapForks') ?: '10') as int useJUnitPlatform() it.filter { it.includeTestsMatching "de.uka.ilkd.key.proof.runallproofs.gen.*" } - // set heap size for the test JVM(s) + // forward the compiled-matcher switch to the (in-process) proof runs in each fork; default + // off, enable with -Pmatcher.compiled=true (or -Dkey.matcher.compiled=true) + systemProperty 'key.matcher.compiled', + (project.findProperty('matcher.compiled') + ?: System.getProperty('key.matcher.compiled', 'false')) + // set heap size for the test JVM(s) (overridable with -PrapHeap=8g for the large examples) minHeapSize = "1g" - maxHeapSize = "3g" + maxHeapSize = (project.findProperty('rapHeap') ?: '3g') // set JVM arguments for the test JVM(s) //jvmArgs('-XX:MaxPermSize=1g') diff --git a/key.core/src/main/java/de/uka/ilkd/key/java/JavaInfo.java b/key.core/src/main/java/de/uka/ilkd/key/java/JavaInfo.java index 4e780b7ca34..906b0ba6713 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/java/JavaInfo.java +++ b/key.core/src/main/java/de/uka/ilkd/key/java/JavaInfo.java @@ -4,6 +4,7 @@ package de.uka.ilkd.key.java; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import de.uka.ilkd.key.java.ast.ProgramElement; import de.uka.ilkd.key.java.ast.abstraction.*; @@ -25,7 +26,7 @@ import org.key_project.logic.Name; import org.key_project.logic.sort.Sort; -import org.key_project.util.LRUCache; +import org.key_project.util.ConcurrentLruCache; import org.key_project.util.collection.ImmutableArray; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -53,17 +54,27 @@ public final class JavaInfo { */ private KeYJavaType nullType = null; - // some caches for the getKeYJavaType methods. - private HashMap> sort2KJTCache = null; - private HashMap type2KJTCache = null; - private HashMap name2KJTCache = null; + // Caches for the getKeYJavaType methods. A single JavaInfo is shared across all parallel-prover + // workers of a proof, so every access below is made thread-safe: + // * sort2KJTCache / name2KJTCache are built-then-read. Each (re)build constructs a fresh map + // locally and publishes it via the volatile field in one assignment, so a concurrent reader + // never observes a half-populated map. Builds are serialized by cacheBuildLock. + // * type2KJTCache also receives entries on the fly (getPrimitiveKeYJavaType), so it is a + // ConcurrentHashMap; its lazy creation is serialized by cacheBuildLock. + // * commonSubtypeCache is an access-ordered LRU (get() mutates), so all access synchronizes on + // the cache instance. + private volatile HashMap> sort2KJTCache = null; + private volatile ConcurrentHashMap type2KJTCache = null; + private volatile HashMap name2KJTCache = null; + /** serializes (re)builds of the lazily populated caches above */ + private final Object cacheBuildLock = new Object(); - private final LRUCache, ImmutableList> commonSubtypeCache = - new LRUCache<>(200); + private final ConcurrentLruCache, ImmutableList> commonSubtypeCache = + new ConcurrentLruCache<>(200); - private int nameCachedSize = 0; - private int sortCachedSize = 0; + private volatile int nameCachedSize = 0; + private volatile int sortCachedSize = 0; /** * The default execution context is for the case of program statements on the top level. It is @@ -158,11 +169,13 @@ public TypeReference createTypeReference(KeYJavaType kjt) { } public void resetCaches() { - sort2KJTCache = null; - type2KJTCache = null; - name2KJTCache = null; - nameCachedSize = 0; - sortCachedSize = 0; + synchronized (cacheBuildLock) { + sort2KJTCache = null; + type2KJTCache = null; + name2KJTCache = null; + nameCachedSize = 0; + sortCachedSize = 0; + } } /** @@ -174,31 +187,42 @@ public void resetCaches() { */ public KeYJavaType getTypeByName(String fullName) { fullName = translateArrayType(fullName); - if (name2KJTCache == null || kpmi.rec2key().keYTypes().size() > nameCachedSize) { - buildNameCache(); + HashMap cache = name2KJTCache; + if (cache == null || kpmi.rec2key().keYTypes().size() > nameCachedSize) { + cache = buildNameCache(); } - return name2KJTCache.get(fullName); + return cache.get(fullName); } /** * caches all known types using their qualified name as retrieval key + * + * @return the (re)built cache, published to {@link #name2KJTCache} */ - private void buildNameCache() { - var types = kpmi.rec2key().keYTypes(); - nameCachedSize = types.size(); - name2KJTCache = new LinkedHashMap<>(); - for (final KeYJavaType type : types) { - if (type.getJavaType() instanceof ArrayType) { - final ArrayType at = (ArrayType) type.getJavaType(); - var old = name2KJTCache.put(at.getFullName(), type); - assert old == null; - old = name2KJTCache.put(at.getAlternativeNameRepresentation(), type); - assert old == null; - } else { - var name = getFullName(type); - var old = name2KJTCache.put(name, type); - assert old == null : "Already had KeYJavaType for name '" + name + "'"; + private HashMap buildNameCache() { + synchronized (cacheBuildLock) { + // another worker may have (re)built the cache while we waited for the lock + if (name2KJTCache != null && kpmi.rec2key().keYTypes().size() <= nameCachedSize) { + return name2KJTCache; } + var types = kpmi.rec2key().keYTypes(); + var cache = new LinkedHashMap(); + for (final KeYJavaType type : types) { + if (type.getJavaType() instanceof ArrayType) { + final ArrayType at = (ArrayType) type.getJavaType(); + var old = cache.put(at.getFullName(), type); + assert old == null; + old = cache.put(at.getAlternativeNameRepresentation(), type); + assert old == null; + } else { + var name = getFullName(type); + var old = cache.put(name, type); + assert old == null : "Already had KeYJavaType for name '" + name + "'"; + } + } + nameCachedSize = types.size(); + name2KJTCache = cache; // publish fully built map in a single volatile write + return cache; } } @@ -276,12 +300,17 @@ public KeYJavaType getPrimitiveKeYJavaType(PrimitiveType type) { } - if (type2KJTCache != null && type2KJTCache.containsKey(type)) { - return type2KJTCache.get(type); + final ConcurrentHashMap typeCache = type2KJTCache; + if (typeCache != null) { + final KeYJavaType cached = typeCache.get(type); + if (cached != null) { + return cached; + } } - if (name2KJTCache != null && name2KJTCache.containsKey(type.getName())) { - return name2KJTCache.get(type.getName()); + final HashMap nameCache = name2KJTCache; + if (nameCache != null && nameCache.containsKey(type.getName())) { + return nameCache.get(type.getName()); } Name ldtName; @@ -299,8 +328,9 @@ public KeYJavaType getPrimitiveKeYJavaType(PrimitiveType type) { } KeYJavaType result = new KeYJavaType(type, sort); - if (type2KJTCache != null) { - type2KJTCache.put(type, result); + final ConcurrentHashMap cacheForPut = type2KJTCache; + if (cacheForPut != null) { + cacheForPut.put(type, result); } return result; @@ -412,39 +442,70 @@ public KeYJavaType getKeYJavaType(Sort sort) { return null; } - private void updateSort2KJTCache() { - if (sort2KJTCache == null || kpmi.rec2key().size() > sortCachedSize) { - sortCachedSize = kpmi.rec2key().size(); - sort2KJTCache = new HashMap<>(); + private HashMap> updateSort2KJTCache() { + HashMap> cache = sort2KJTCache; + if (cache != null && kpmi.rec2key().size() <= sortCachedSize) { + return cache; + } + synchronized (cacheBuildLock) { + // another worker may have (re)built the cache while we waited for the lock + if (sort2KJTCache != null && kpmi.rec2key().size() <= sortCachedSize) { + return sort2KJTCache; + } + var newCache = new HashMap>(); for (final KeYJavaType oKJT : kpmi.allTypes()) { Sort s = oKJT.getSort(); - List l = sort2KJTCache.computeIfAbsent(s, k -> new LinkedList<>()); + List l = newCache.computeIfAbsent(s, k -> new LinkedList<>()); if (!l.contains(oKJT)) { l.add(oKJT); } } + sortCachedSize = kpmi.rec2key().size(); + sort2KJTCache = newCache; // publish fully built map in a single volatile write + return newCache; } } public List lookupSort2KJTCache(Sort sort) { - updateSort2KJTCache(); - return sort2KJTCache.get(sort); + return updateSort2KJTCache().get(sort); } /** * returns the KeYJavaType belonging to the given Type t */ public KeYJavaType getKeYJavaType(Type t) { - if (type2KJTCache == null) { - type2KJTCache = new LinkedHashMap<>(); - for (final KeYJavaType type : kpmi.allTypes()) { - type2KJTCache.put(type.getJavaType(), type); - } + ConcurrentHashMap cache = type2KJTCache; + if (cache == null) { + cache = buildType2KJTCache(); } if (t instanceof PrimitiveType) { return getPrimitiveKeYJavaType((PrimitiveType) t); } else { - return type2KJTCache.get(t); + return cache.get(t); + } + } + + /** + * lazily builds the {@link Type} to {@link KeYJavaType} cache + * + * @return the built cache, published to {@link #type2KJTCache} + */ + private ConcurrentHashMap buildType2KJTCache() { + synchronized (cacheBuildLock) { + if (type2KJTCache != null) { + return type2KJTCache; + } + var cache = new ConcurrentHashMap(); + for (final KeYJavaType type : kpmi.allTypes()) { + final Type javaType = type.getJavaType(); + // a ConcurrentHashMap rejects null keys; such entries were never retrievable by a + // (non-null) Type lookup anyway, so skipping them preserves behaviour + if (javaType != null) { + cache.put(javaType, type); + } + } + type2KJTCache = cache; // publish fully built map in a single volatile write + return cache; } } @@ -1148,6 +1209,9 @@ public ProgramVariable lookupVisibleAttribute(String programName, KeYJavaType cl */ public ImmutableList getCommonSubtypes(KeYJavaType k1, KeYJavaType k2) { final Pair ck = new Pair<>(k1, k2); + // commonSubtypeCache is a ConcurrentLruCache: get/put are internally synchronized (the + // underlying access-ordered LRU reorders even on get). The value is a pure function of the + // key, so a recomputed-after-eviction entry is always identical -- no proof dependence. ImmutableList result = commonSubtypeCache.get(ck); if (result != null) { diff --git a/key.core/src/main/java/de/uka/ilkd/key/java/ServiceCaches.java b/key.core/src/main/java/de/uka/ilkd/key/java/ServiceCaches.java index d8e7739c803..1a7f15fbd85 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/java/ServiceCaches.java +++ b/key.core/src/main/java/de/uka/ilkd/key/java/ServiceCaches.java @@ -3,6 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.java; +import java.util.Collections; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; @@ -29,7 +30,7 @@ import org.key_project.prover.rules.instantiation.caches.AssumesFormulaInstantiationCache; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.RuleAppCost; -import org.key_project.util.LRUCache; +import org.key_project.util.ConcurrentLruCache; import org.key_project.util.collection.ImmutableSet; import org.key_project.util.collection.Pair; @@ -82,67 +83,80 @@ public class ServiceCaches implements SessionCaches { * The cache used by {@link TermTacletAppIndexCacheSet} instances. */ private final Map termTacletAppIndexCache = - new LRUCache<>(MAX_TERM_TACLET_APP_INDEX_ENTRIES); + new ConcurrentLruCache<>(MAX_TERM_TACLET_APP_INDEX_ENTRIES); /* * Table of formulas which could be splitted using the beta rule This is the cache the method * "isBetaCandidate" uses * * keys: Term values: TermInfo + * + * NOTE (multithreading effort, branch bubel/mt-goals): the LRU caches below use + * ConcurrentLruCache (exact, single-lock) so they are safe under concurrent matching while + * preserving EXACT LRU eviction -- behaviour-preserving. The exact flavour is mandatory here + * because some of these caches are eviction/history-sensitive (e.g. introductionTimeCache, + * whose + * value reflects the goal history at first-cache time, and the term interning cache): + * approximate/striped eviction was shown to change proofs. The Weak caches stay wrapped in + * Collections.synchronizedMap. */ - private final LRUCache betaCandidates = new LRUCache<>(1000); + private final Map betaCandidates = + new ConcurrentLruCache<>(1000); - private final LRUCache ifThenElseMalusCache = - new LRUCache<>(1000); + private final Map ifThenElseMalusCache = + new ConcurrentLruCache<>(1000); - private final LRUCache introductionTimeCache = - new LRUCache<>(10000); + private final Map introductionTimeCache = + new ConcurrentLruCache<>(10000); - private final LRUCache monomialCache = - new LRUCache<>(2000); + private final Map monomialCache = + new ConcurrentLruCache<>(2000); - private final LRUCache polynomialCache = - new LRUCache<>(2000); + private final Map polynomialCache = + new ConcurrentLruCache<>(2000); /** * a HashMap from Term to TriggersSet uses to cache all * created TriggersSets */ private final Map triggerSetCache = - new LRUCache<>(1000); + new ConcurrentLruCache<>(1000); /** * Map from Term(allTerm) to ClausesGraph */ - private final Map graphCache = new LRUCache<>(1000); + private final Map graphCache = + new ConcurrentLruCache<>(1000); /** * Cache used by the TermFactory to avoid unnecessary creation of terms */ - private final Map termCache = new LRUCache<>(20000); + private final Map termCache = new ConcurrentLruCache<>(20000); /** * Cache used by TypeComparisonCondition */ private final Map> disjointnessCache = - new WeakHashMap<>(); + Collections.synchronizedMap(new WeakHashMap<>()); /** * Cache used by HandleArith for caching formatted terms */ - private final LRUCache formattedTermCache = new LRUCache<>(5000); + private final Map formattedTermCache = + new ConcurrentLruCache<>(5000); /** * Caches used bu HandleArith to cache proof results */ - private final LRUCache provedByArithFstCache = new LRUCache<>(5000); + private final Map provedByArithFstCache = + new ConcurrentLruCache<>(5000); - private final LRUCache, JTerm> provedByArithSndCache = - new LRUCache<>(5000); + private final Map, JTerm> provedByArithSndCache = + new ConcurrentLruCache<>(5000); /** Cache used by the exhaustive macro */ private final Map exhaustiveMacroCache = - new WeakHashMap<>(); + Collections.synchronizedMap(new WeakHashMap<>()); /** Cache used by the ifinstantiator */ private final IfInstantiationCachePool ifInstantiationCache = new IfInstantiationCachePool(); @@ -156,15 +170,16 @@ public class ServiceCaches implements SessionCaches { new AppliedRuleAppsNameCache(); /** Cache used by EqualityConstraint to speed up meta variable search */ - private final LRUCache> mvCache = - new LRUCache<>(2000); + private final Map> mvCache = + new ConcurrentLruCache<>(2000); /** * Cache used by {@link de.uka.ilkd.key.rule.label.OriginTermLabelRefactoring}: the * origins of a term and all its subterms. Terms are immutable, so the set never * changes for a given term. */ - private final Map> subtermOriginsCache = new LRUCache<>(20000); + private final Map> subtermOriginsCache = + new ConcurrentLruCache<>(20000); /** @@ -186,23 +201,23 @@ public final Map> getSubtermOriginsCache() { return subtermOriginsCache; } - public final LRUCache getBetaCandidates() { + public final Map getBetaCandidates() { return betaCandidates; } - public final LRUCache getIfThenElseMalusCache() { + public final Map getIfThenElseMalusCache() { return ifThenElseMalusCache; } - public final LRUCache getIntroductionTimeCache() { + public final Map getIntroductionTimeCache() { return introductionTimeCache; } - public final LRUCache getMonomialCache() { + public final Map getMonomialCache() { return monomialCache; } - public final LRUCache getPolynomialCache() { + public final Map getPolynomialCache() { return polynomialCache; } @@ -222,15 +237,15 @@ public final Map> getDisjointnessCache() { return disjointnessCache; } - public final LRUCache getFormattedTermCache() { + public final Map getFormattedTermCache() { return formattedTermCache; } - public final LRUCache getProvedByArithFstCache() { + public final Map getProvedByArithFstCache() { return provedByArithFstCache; } - public final LRUCache, JTerm> getProvedByArithSndCache() { + public final Map, JTerm> getProvedByArithSndCache() { return provedByArithSndCache; } @@ -250,7 +265,7 @@ public AppliedRuleAppsNameCache getAppliedRuleAppsNameCache() { return appliedRuleAppsNameCache; } - public LRUCache> getMVCache() { + public Map> getMVCache() { return mvCache; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/java/Services.java b/key.core/src/main/java/de/uka/ilkd/key/java/Services.java index b4a0970e06c..49355b19ad8 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/java/Services.java +++ b/key.core/src/main/java/de/uka/ilkd/key/java/Services.java @@ -18,6 +18,8 @@ import de.uka.ilkd.key.proof.init.Profile; import de.uka.ilkd.key.proof.io.consistency.FileRepo; import de.uka.ilkd.key.proof.mgt.SpecificationRepository; +import de.uka.ilkd.key.prover.impl.ParallelNameAllocator; +import de.uka.ilkd.key.prover.impl.ParallelProver; import de.uka.ilkd.key.util.KeYResourceManager; import org.key_project.logic.LogicServices; @@ -88,6 +90,15 @@ public class Services implements TermServices, LogicServices, ProofServices { private NameRecorder nameRecorder; + /** + * Per-proof allocator of fresh names that are globally unique across worker threads (shared + * across this proof's overlay/copy {@link Services}). Used by {@link VariableNameProposer} + * while + * a multi-threaded run is active so that names stay disjoint without consulting the shared + * namespace. See the multithreading effort (branch {@code bubel/mt-goals}). + */ + private final ParallelNameAllocator nameAllocator; + private ITermProgramVariableCollectorFactory factory = TermProgramVariableCollector::new; @@ -135,6 +146,7 @@ private Services(Profile profile, @Nullable JavaService javaService, this.javaInfo = new JavaInfo(new KeYProgModelInfo(this.javaService), this); } nameRecorder = new NameRecorder(); + this.nameAllocator = new ParallelNameAllocator(); } private Services(Services s) { @@ -154,6 +166,16 @@ private Services(Services s) { this.termBuilder = new TermBuilder(new TermFactory(caches.getTermFactoryCache()), this); this.termBuilderWithoutCache = new TermBuilder(new TermFactory(), this); this.originFactory = s.originFactory; + // Share the allocator across all overlay/copy Services of the same proof so that + // per-(worker,base) counters stay coherent. + this.nameAllocator = s.nameAllocator; + } + + /** + * @return this proof's fresh-name allocator (shared across overlay/copy {@link Services}) + */ + public ParallelNameAllocator getNameAllocator() { + return nameAllocator; } public Services getOverlay(NamespaceSet namespaces) { @@ -195,19 +217,49 @@ public JavaInfo getJavaInfo() { } + /** + * Per-worker name recorder, used while a goal is being applied off the calling thread (parallel + * prover, branch {@code bubel/mt-goals}). The rule executor records fresh-name proposals during + * the compute phase, which runs concurrently outside the commit lock; routing those through a + * thread-local recorder keeps them off the shared {@link #nameRecorder} field. Bound per worker + * by {@link #bindWorkerNameRecorder()} and consulted only while a multi-threaded run is active. + */ + private static final ThreadLocal WORKER_NAME_RECORDER = new ThreadLocal<>(); + + /** Binds a fresh per-worker name recorder to the calling thread. */ + public static void bindWorkerNameRecorder() { + WORKER_NAME_RECORDER.set(new NameRecorder()); + } + + /** Removes the per-worker name recorder from the calling thread. */ + public static void unbindWorkerNameRecorder() { + WORKER_NAME_RECORDER.remove(); + } + + private @Nullable NameRecorder activeWorkerNameRecorder() { + return ParallelProver.isMultiThreadedRunActive() ? WORKER_NAME_RECORDER.get() : null; + } + public NameRecorder getNameRecorder() { - return nameRecorder; + NameRecorder worker = activeWorkerNameRecorder(); + return worker != null ? worker : nameRecorder; } public void saveNameRecorder(Node n) { - n.setNameRecorder(nameRecorder); - nameRecorder = new NameRecorder(); + NameRecorder worker = activeWorkerNameRecorder(); + if (worker != null) { + n.setNameRecorder(worker); + WORKER_NAME_RECORDER.set(new NameRecorder()); + } else { + n.setNameRecorder(nameRecorder); + nameRecorder = new NameRecorder(); + } } public void addNameProposal(Name proposal) { - nameRecorder.addProposal(proposal); + getNameRecorder().addProposal(proposal); } @@ -307,8 +359,12 @@ public void setProof(Proof proof) { /* * returns an existing named counter, creates a new one otherwise + * + * Synchronized so that concurrent workers (multithreading effort) cannot lose a counter + * through a racing check-then-put on the shared counters map. The per-counter increment is + * atomic (see Counter); this only guards lookup/creation. */ - public Counter getCounter(String name) { + public synchronized Counter getCounter(String name) { Counter c = counters.get(name); if (c != null) { return c; diff --git a/key.core/src/main/java/de/uka/ilkd/key/java/ast/ContextStatementBlock.java b/key.core/src/main/java/de/uka/ilkd/key/java/ast/ContextStatementBlock.java index 304e085d9f5..2b3b5c8030d 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/java/ast/ContextStatementBlock.java +++ b/key.core/src/main/java/de/uka/ilkd/key/java/ast/ContextStatementBlock.java @@ -16,10 +16,12 @@ import de.uka.ilkd.key.rule.MatchConditions; import de.uka.ilkd.key.rule.inst.SVInstantiations; -import org.key_project.logic.IntIterator; +import org.key_project.prover.rules.matcher.vm.ProgramChildrenMatcher; import org.key_project.util.collection.ImmutableArray; import org.key_project.util.collection.ImmutableSLList; +import org.jspecify.annotations.Nullable; + /** * In the DL-formulae description of Taclets the program part can have the following form * {@code < pi @@ -130,6 +132,28 @@ public boolean compatibleBlockSize(int pos, int max) { } public MatchConditions match(SourceData source, MatchConditions matchCond) { + return match(source, matchCond, null); + } + + /** + * Matches this context block against the given source. Phases (1) prefix descent to the active + * statement, (2) inner execution context matching and (4) completion of the context + * instantiation (prefix/suffix positions) are always performed here. Phase (3), the matching of + * the active statements (this block's children from the active offset), is delegated to the + * supplied {@code activeStatements} matcher when one is given (and the located source position + * is a regular child offset); otherwise the built-in {@link #matchChildren} is used. All three + * yield identical results; the {@code activeStatements} matcher (a VM sub-program or a compiled + * matcher) simply matches the active-statement subtree by direct navigation instead of the + * monolithic AST matcher. + * + * @param source the source to match against + * @param matchCond the match conditions found so far + * @param activeStatements a matcher for the active statements, or {@code null} to use the + * built-in {@link #matchChildren} for phase (3) + * @return the resulting match conditions, or {@code null} if matching fails + */ + public MatchConditions match(SourceData source, MatchConditions matchCond, + @Nullable ProgramChildrenMatcher activeStatements) { assert getPrefixLength() > 0; SourceData newSource = source; @@ -192,8 +216,13 @@ public MatchConditions match(SourceData source, MatchConditions matchCond) { return null; } - // matching children - matchCond = matchChildren(newSource, matchCond, executionContext == null ? 0 : 1); + // matching children (the active statements) -- phase (3) + final int offset = executionContext == null ? 0 : 1; + if (activeStatements != null && newSource.getChildPos() >= 0) { + matchCond = matchActiveStatements(newSource, matchCond, activeStatements, offset); + } else { + matchCond = matchChildren(newSource, matchCond, offset); + } if (matchCond == null) { return null; @@ -205,6 +234,37 @@ public MatchConditions match(SourceData source, MatchConditions matchCond) { return matchCond; } + /** + * Phase (3) via a supplied matcher (VM sub-program or compiled): matches the active statements + * of this context block (its children from index {@code offset}) against the children of + * {@code newSource.getElement()} starting at {@code newSource.getChildPos()}. This mirrors + * {@link #matchChildren(SourceData, MatchConditions, int)} for the case where every active + * statement consumes exactly one source child (the only case the generator converts -- list + * schema variables and other variable-arity constructs keep the interpreter). On success the + * source position is advanced exactly as {@code matchChildren} would, so the subsequent + * {@link #makeContextInfoComplete} computes the same suffix start. + */ + private @Nullable MatchConditions matchActiveStatements(SourceData newSource, + MatchConditions matchCond, ProgramChildrenMatcher activeStatements, int offset) { + final int startPos = newSource.getChildPos(); + // number of active statements to match (each consumes exactly one source child) + final int n = getChildCount() - offset; + final ProgramElement parent = newSource.getElement(); + if (!(parent instanceof NonTerminalProgramElement ntParent) + || ntParent.getChildCount() < startPos + n) { + // not enough source children -> matchChildren would also fail (null source child) + return null; + } + final MatchConditions result = (MatchConditions) activeStatements.matchChildrenFrom( + parent, startPos, matchCond, newSource.getServices()); + if (result == null) { + return null; + } + // advance the source position past the matched children, as matchChildren would + newSource.setChildPos(startPos + n); + return result; + } + /** * completes match of context block by adding the prefix end position and the suffix start * position @@ -295,10 +355,10 @@ private PosInProgram matchPrefixEnd(final ProgramPrefix prefix, int pos, PosInPr ProgramPrefix currentPrefix = prefix; int i = 0; while (i <= pos) { - final IntIterator it = currentPrefix.getFirstActiveChildPos().iterator(); - while (it.hasNext()) { - prefixEnd = prefixEnd.down(it.next()); - } + // concatenate this prefix element's active-child position in one step instead of + // iterating + a fresh PosInProgram per position (matchPrefixEnd is the dominant + // cost of a context match; this avoids an IntIterator and intermediate copies) + prefixEnd = prefixEnd.append(currentPrefix.getFirstActiveChildPos()); i++; if (i <= pos) { // as fail-fast measure I do not test here using diff --git a/key.core/src/main/java/de/uka/ilkd/key/java/ast/statement/JmlAssert.java b/key.core/src/main/java/de/uka/ilkd/key/java/ast/statement/JmlAssert.java index dd5c3a67f0a..c6e769ad703 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/java/ast/statement/JmlAssert.java +++ b/key.core/src/main/java/de/uka/ilkd/key/java/ast/statement/JmlAssert.java @@ -32,7 +32,7 @@ public class JmlAssert extends JavaStatement { /** * the condition in parse tree form */ - private KeyAst.Expression condition; + private final KeyAst.Expression condition; /** * @param kind diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/JTerm.java b/key.core/src/main/java/de/uka/ilkd/key/logic/JTerm.java index dc84ea2b302..834f47ef7bb 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/JTerm.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/JTerm.java @@ -119,6 +119,16 @@ public interface JTerm */ boolean containsJavaBlockRecursive(); + /** + * Checks if this {@link JTerm} or one of its direct or indirect children has a + * {@link de.uka.ilkd.key.logic.op.Transformer} operator. Cached; used by + * {@link de.uka.ilkd.key.rule.RewriteTaclet#checkPrefix} to skip the prefix walk in the common + * transformer-free case. + * + * @return {@code true} iff a transformer occurs anywhere in the term tree + */ + boolean containsTransformerRecursive(); + /** * Returns a human-readable source of this term. For example the filename with line and offset. */ diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/TermImpl.java b/key.core/src/main/java/de/uka/ilkd/key/logic/TermImpl.java index 42063167176..505b7a872a5 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/TermImpl.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/TermImpl.java @@ -81,6 +81,9 @@ private enum ThreeValuedTruth { */ private ThreeValuedTruth containsJavaBlockRecursive = ThreeValuedTruth.UNKNOWN; + /** caches whether this term or a (direct/indirect) child has a {@link Transformer} operator. */ + private ThreeValuedTruth containsTransformerRecursive = ThreeValuedTruth.UNKNOWN; + // ------------------------------------------------------------------------- // constructors // ------------------------------------------------------------------------- @@ -441,5 +444,24 @@ public boolean containsJavaBlockRecursive() { return containsJavaBlockRecursive == ThreeValuedTruth.TRUE; } + @Override + public boolean containsTransformerRecursive() { + if (containsTransformerRecursive == ThreeValuedTruth.UNKNOWN) { + ThreeValuedTruth result = ThreeValuedTruth.FALSE; + if (op instanceof Transformer) { + result = ThreeValuedTruth.TRUE; + } else { + for (int i = 0, arity = subs.size(); i < arity; i++) { + if (subs.get(i).containsTransformerRecursive()) { + result = ThreeValuedTruth.TRUE; + break; + } + } + } + this.containsTransformerRecursive = result; + } + return containsTransformerRecursive == ThreeValuedTruth.TRUE; + } + } diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/label/TermLabelManager.java b/key.core/src/main/java/de/uka/ilkd/key/logic/label/TermLabelManager.java index f08bb194f98..b04df10fb86 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/label/TermLabelManager.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/label/TermLabelManager.java @@ -5,7 +5,6 @@ import java.util.*; import java.util.Map.Entry; -import java.util.stream.Collectors; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.logic.*; @@ -2156,10 +2155,55 @@ public static JTerm removeIrrelevantLabels(JTerm term, Services services) { * @see TermLabel#isProofRelevant() */ public static JTerm removeIrrelevantLabels(JTerm term, TermFactory tf) { + // Identity-preserving rebuild: only allocate along paths that actually carry an irrelevant + // label, and return the original term unchanged otherwise. The previous implementation + // rebuilt the whole term tree on every call (stream().map()/filter().collect() per node), + // which was the single biggest allocator during proof search (~20%) even though the vast + // majority of subterms have no irrelevant labels and need not change. + final ImmutableArray subs = term.subs(); + final int n = subs.size(); + JTerm[] newSubs = null; // allocated lazily, only once a sub actually changes + for (int i = 0; i < n; i++) { + final JTerm oldSub = subs.get(i); + final JTerm newSub = removeIrrelevantLabels(oldSub, tf); + if (newSub != oldSub && newSubs == null) { + newSubs = new JTerm[n]; + for (int j = 0; j < i; j++) { + newSubs[j] = subs.get(j); + } + } + if (newSubs != null) { + newSubs[i] = newSub; + } + } + + final ImmutableArray labels = term.getLabels(); + ImmutableArray newLabels = labels; + if (!labels.isEmpty()) { + int relevant = 0; + for (int i = 0, sz = labels.size(); i < sz; i++) { + if (labels.get(i).isProofRelevant()) { + relevant++; + } + } + if (relevant != labels.size()) { + final TermLabel[] kept = new TermLabel[relevant]; + int k = 0; + for (int i = 0, sz = labels.size(); i < sz; i++) { + final TermLabel l = labels.get(i); + if (l.isProofRelevant()) { + kept[k++] = l; + } + } + newLabels = new ImmutableArray<>(kept); + } + } + + if (newSubs == null && newLabels == labels) { + return term; // no irrelevant label anywhere in this subtree -> no allocation + } return tf.createTerm(term.op(), - new ImmutableArray<>(term.subs().stream().map(t -> removeIrrelevantLabels(t, tf)) - .collect(Collectors.toList())), - term.boundVars(), new ImmutableArray<>(term.getLabels().stream() - .filter(TermLabel::isProofRelevant).collect(Collectors.toList()))); + newSubs == null ? subs : new ImmutableArray<>(newSubs), + term.boundVars(), newLabels); } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/op/ElementaryUpdate.java b/key.core/src/main/java/de/uka/ilkd/key/logic/op/ElementaryUpdate.java index 1c632ce9a8e..430045412ee 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/op/ElementaryUpdate.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/op/ElementaryUpdate.java @@ -3,15 +3,13 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.logic.op; -import java.lang.ref.WeakReference; -import java.util.WeakHashMap; - import de.uka.ilkd.key.ldt.JavaDLTheory; import org.key_project.logic.Name; import org.key_project.logic.SyntaxElement; import org.key_project.logic.op.UpdateableOperator; import org.key_project.logic.sort.Sort; +import org.key_project.util.collection.WeakValueInterner; /** @@ -21,8 +19,14 @@ */ public final class ElementaryUpdate extends JAbstractSortedOperator { - private static final WeakHashMap> instances = - new WeakHashMap<>(); + /** + * Interns elementary updates so that the same left-hand side yields the same operator object + * (the rest of the system relies on this identity). Thread-safe: two concurrent workers must + * not + * end up with two distinct operators for the same left-hand side. + */ + private static final WeakValueInterner instances = + new WeakValueInterner<>(); private final UpdateableOperator lhs; @@ -40,17 +44,7 @@ private ElementaryUpdate(UpdateableOperator lhs) { * Returns the elementary update operator for the passed left hand side. */ public static ElementaryUpdate getInstance(UpdateableOperator lhs) { - WeakReference ref = instances.get(lhs); - ElementaryUpdate result = null; - if (ref != null) { - result = ref.get(); - } - if (result == null) { - result = new ElementaryUpdate(lhs); - ref = new WeakReference<>(result); - instances.put(lhs, ref); - } - return result; + return instances.intern(lhs, ElementaryUpdate::new); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/op/JModality.java b/key.core/src/main/java/de/uka/ilkd/key/logic/op/JModality.java index b05101f182e..91c49b927c0 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/op/JModality.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/op/JModality.java @@ -3,8 +3,8 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.logic.op; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import de.uka.ilkd.key.java.ast.JavaProgramElement; import de.uka.ilkd.key.ldt.JavaDLTheory; @@ -133,7 +133,14 @@ public String toString() { } public static class JavaModalityKind extends Kind { - private static final Map kinds = new HashMap<>(); + // Written only via this class's (public) constructor: the built-in kinds below at + // class-init, and ModalOperatorSV (schema modalities) at taclet-parse time -- both + // single-threaded -- and read via getKind only during proof replay. The goal-parallel + // prover's workers never touch it. A ConcurrentHashMap is used as cheap defensive + // hardening, + // since the constructor is public and the map is a global static (point lookups, non-null + // keys/values, never iterated -> a behaviour-neutral swap). + private static final Map kinds = new ConcurrentHashMap<>(); /** * The diamond operator of dynamic logic. A formula {@code Phi} can be read as after * processing diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/op/ParametricFunctionInstance.java b/key.core/src/main/java/de/uka/ilkd/key/logic/op/ParametricFunctionInstance.java index b9a7337535c..5642e8b0155 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/op/ParametricFunctionInstance.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/op/ParametricFunctionInstance.java @@ -6,7 +6,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.WeakHashMap; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.logic.GenericArgument; @@ -18,13 +17,21 @@ import org.key_project.logic.sort.Sort; import org.key_project.util.collection.ImmutableArray; import org.key_project.util.collection.ImmutableList; +import org.key_project.util.collection.WeakValueInterner; import org.jspecify.annotations.NonNull; /// A concrete instance of a [ParametricFunctionDecl]. public class ParametricFunctionInstance extends JFunction { - private static final Map CACHE = - new WeakHashMap<>(); + /** + * Interns parametric function instances so that equal instances are the same object. + * Thread-safe + * (the previous {@code WeakHashMap} + check-then-put could hand two distinct-but-equal + * instances + * to concurrent workers, breaking that identity). + */ + private static final WeakValueInterner CACHE = + new WeakValueInterner<>(); private final ImmutableList args; private final ParametricFunctionDecl base; @@ -38,12 +45,7 @@ public static ParametricFunctionInstance get(ParametricFunctionDecl decl, var argSorts = instantiate(decl, instMap, services); var sort = ParametricSortInstance.instantiate(decl.sort(), instMap, services); var fn = new ParametricFunctionInstance(decl, args, argSorts, sort); - var cached = CACHE.get(fn); - if (cached != null) { - return cached; - } - CACHE.put(fn, fn); - return fn; + return CACHE.intern(fn, candidate -> candidate); } private ParametricFunctionInstance(ParametricFunctionDecl base, diff --git a/key.core/src/main/java/de/uka/ilkd/key/logic/sort/ParametricSortInstance.java b/key.core/src/main/java/de/uka/ilkd/key/logic/sort/ParametricSortInstance.java index 8a1ea48acfa..58ef1ece118 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/logic/sort/ParametricSortInstance.java +++ b/key.core/src/main/java/de/uka/ilkd/key/logic/sort/ParametricSortInstance.java @@ -5,7 +5,6 @@ import java.util.Map; import java.util.Objects; -import java.util.WeakHashMap; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.ldt.JavaDLTheory; @@ -18,13 +17,23 @@ import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; import org.key_project.util.collection.ImmutableSet; +import org.key_project.util.collection.WeakValueInterner; import org.jspecify.annotations.NonNull; /// Concrete sort of a parametric sort. public final class ParametricSortInstance extends AbstractSort { - private static final Map CACHE = - new WeakHashMap<>(); + /** + * Interns parametric sort instances so that equal instances are the same object. Thread-safe + * (the previous {@code WeakHashMap} + check-then-put could hand two distinct-but-equal + * instances + * to concurrent workers, breaking that identity). The miss action -- registering a genuinely + * new + * sort in the namespace -- runs inside {@link WeakValueInterner#intern}, so it happens exactly + * once per new sort and is serialized with the interning. + */ + private static final WeakValueInterner CACHE = + new WeakValueInterner<>(); private final ImmutableList args; private final ParametricSortDecl base; @@ -35,17 +44,13 @@ public final class ParametricSortInstance extends AbstractSort { public static ParametricSortInstance get(ParametricSortDecl base, ImmutableList args, Services services) { assert args.size() == base.getParameters().size(); - ParametricSortInstance sort = - new ParametricSortInstance(base, args); - ParametricSortInstance cached = CACHE.get(sort); - if (cached != null) { - return cached; - } else { - CACHE.put(sort, sort); - if (!sort.containsGenericSort()) - services.getNamespaces().sorts().addSafely(sort); - return sort; - } + ParametricSortInstance sort = new ParametricSortInstance(base, args); + return CACHE.intern(sort, candidate -> { + if (!candidate.containsGenericSort()) { + services.getNamespaces().sorts().addSafely(candidate); + } + return candidate; + }); } /// This must only be called in [ParametricSortInstance#get], which ensures that the cache is diff --git a/key.core/src/main/java/de/uka/ilkd/key/macros/StrategyProofMacro.java b/key.core/src/main/java/de/uka/ilkd/key/macros/StrategyProofMacro.java index 2975e0c6344..7a17a8bb300 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/macros/StrategyProofMacro.java +++ b/key.core/src/main/java/de/uka/ilkd/key/macros/StrategyProofMacro.java @@ -10,7 +10,7 @@ import de.uka.ilkd.key.proof.Goal; import de.uka.ilkd.key.proof.Node; import de.uka.ilkd.key.proof.Proof; -import de.uka.ilkd.key.prover.impl.ApplyStrategy; +import de.uka.ilkd.key.prover.impl.AutoProvers; import de.uka.ilkd.key.strategy.FocussedRuleApplicationManager; import de.uka.ilkd.key.strategy.Strategy; @@ -83,7 +83,11 @@ public ProofMacroFinishedInfo applyTo(UserInterfaceControl uic, Proof proof, final GoalChooser goalChooser = proof.getInitConfig().getProfile().getSelectedGoalChooserBuilder().create(); - final ProverCore applyStrategy = new ApplyStrategy(goalChooser); + // Route through the selection seam so the macro runs on the multi-core prover when it is + // enabled and the proof's profile supports it (otherwise the single-threaded + // ApplyStrategy). + final ProverCore applyStrategy = + AutoProvers.create(goalChooser, proof.getInitConfig().getProfile()); final ImmutableList ignoredOpenGoals = setDifference(proof.openGoals(), goals); // diff --git a/key.core/src/main/java/de/uka/ilkd/key/macros/TryCloseMacro.java b/key.core/src/main/java/de/uka/ilkd/key/macros/TryCloseMacro.java index 753f6f2a894..418d49f13cf 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/macros/TryCloseMacro.java +++ b/key.core/src/main/java/de/uka/ilkd/key/macros/TryCloseMacro.java @@ -8,7 +8,8 @@ import de.uka.ilkd.key.proof.Goal; import de.uka.ilkd.key.proof.Node; import de.uka.ilkd.key.proof.Proof; -import de.uka.ilkd.key.prover.impl.ApplyStrategy; +import de.uka.ilkd.key.proof.init.Profile; +import de.uka.ilkd.key.prover.impl.AutoProvers; import org.key_project.prover.engine.ProofSearchInformation; import org.key_project.prover.engine.ProverCore; @@ -148,9 +149,17 @@ public ProofMacroFinishedInfo applyTo(UserInterfaceControl uic, Proof proof, } // - // create the rule application engine - final ProverCore applyStrategy = new ApplyStrategy( - proof.getServices().getProfile().getSelectedGoalChooserBuilder().create()); + // create the rule application engine. This macro closes one goal at a time under a tight + // per-goal step budget (NUMBER_OF_TRY_STEPS), so it is pinned to the single-threaded + // prover: + // a single goal offers no parallelism, and several workers exploring its subtree apply + // rules + // in a less step-efficient order than the single-threaded prover, which can exhaust the + // budget before the goal closes (leaving a closable goal pruned). Wide, generously-budgeted + // runs keep using the multi-core prover. + final Profile profile = proof.getServices().getProfile(); + final ProverCore applyStrategy = AutoProvers.create( + profile.getSelectedGoalChooserBuilder().create(), profile, false); // assert: all goals have the same proof // diff --git a/key.core/src/main/java/de/uka/ilkd/key/nparser/ParsingFacade.java b/key.core/src/main/java/de/uka/ilkd/key/nparser/ParsingFacade.java index b86472b9215..f5249b0f4a3 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/nparser/ParsingFacade.java +++ b/key.core/src/main/java/de/uka/ilkd/key/nparser/ParsingFacade.java @@ -112,6 +112,24 @@ private static JavaKeYParser createParser(CharStream stream) { return createParser(createLexer(stream)); } + /** + * Releases the ANTLR prediction (DFA) cache of the KeY parser. + *

+ * This cache is built lazily while parsing and held on the generated parser's {@code static} + * fields, so it stays resident for the whole JVM -- including during proof search, where it is + * not needed (on a large proof it retains ~15-20 MB). It is a pure cache: ANTLR rebuilds it + * transparently on the next parse, so dropping it is correctness-safe (one-time re-warm on a + * subsequent parse). Intended to be called once a problem/proof has finished loading. + */ + public static void clearParserCaches() { + try { + new JavaKeYParser(new CommonTokenStream(createLexer(CharStreams.fromString("")))) + .getInterpreter().clearDFA(); + } catch (RuntimeException e) { + LOGGER.warn("Could not clear parser DFA caches", e); + } + } + public static JavaKeYLexer createLexer(Path file) throws IOException { return createLexer(CharStreams.fromPath(file)); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/Counter.java b/key.core/src/main/java/de/uka/ilkd/key/proof/Counter.java index b52df2e0996..dc45e2f0a1d 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/Counter.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/Counter.java @@ -4,34 +4,45 @@ package de.uka.ilkd.key.proof; -/** Proof-specific counter object: taclet names, var names, node numbers, etc */ +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Proof-specific counter object: taclet names, var names, node numbers, etc. + * + *

+ * A single {@link de.uka.ilkd.key.java.Services} (and thus its counters) is shared by all goals of + * a proof. To make counter-based fresh-name minting safe once goals are processed on worker threads + * (multithreading effort, branch {@code bubel/mt-goals}), the increment is atomic. This is + * behaviour-preserving for single-threaded use: {@link #getCountPlusPlus()} still returns the + * pre-increment value and {@link #copy()} still snapshots the current value. + */ public class Counter { private final String name; - private int count; + private final AtomicInteger count; public Counter(String name) { - this.name = name; + this(name, 0); } private Counter(String name, int count) { - this(name); - this.count = count; + this.name = name; + this.count = new AtomicInteger(count); } public int getCount() { - return count; + return count.get(); } public int getCountPlusPlus() { - return count++; + return count.getAndIncrement(); } public String toString() { - return "Counter " + name + ": " + count; + return "Counter " + name + ": " + count.get(); } public Counter copy() { - return new Counter(name, count); + return new Counter(name, count.get()); } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/EssentialProofListener.java b/key.core/src/main/java/de/uka/ilkd/key/proof/EssentialProofListener.java new file mode 100644 index 00000000000..6a1747406ab --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/EssentialProofListener.java @@ -0,0 +1,27 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.proof; + +/** + * Marker for proof listeners that are part of the proving machinery itself and must keep receiving + * events even while non-essential observers are suspended. + * + *

+ * Background (multithreading effort, branch {@code bubel/mt-goals}): during automatic proof search + * — especially once goals are processed on worker threads — we want to detach + * everything that merely observes the proof (caching, slicing, origin labels, GUI tree + * models, …) so that nothing unrelated to proving runs on a worker thread or mutates shared + * state concurrently. {@link Proof#suspendNonEssentialListeners()} removes exactly those listeners + * that are not tagged with this marker, and re-attaches them when the run + * finishes. + * + *

+ * Apply this marker only to listeners that maintain state the prover itself relies on (e.g. proof + * correctness / contract-dependency bookkeeping). Pure observers must not implement it. + * + * @author Claude (KeY multithreading effort) + * @see Proof#suspendNonEssentialListeners() + */ +public interface EssentialProofListener { +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/Goal.java b/key.core/src/main/java/de/uka/ilkd/key/proof/Goal.java index 2e12543c5f1..7aaa7d6a09f 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/Goal.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/Goal.java @@ -15,6 +15,7 @@ import de.uka.ilkd.key.pp.NotationInfo; import de.uka.ilkd.key.proof.proofevent.NodeChangeJournal; import de.uka.ilkd.key.proof.proofevent.RuleAppInfo; +import de.uka.ilkd.key.prover.impl.ParallelProver; import de.uka.ilkd.key.rule.AbstractExternalSolverRuleApp; import de.uka.ilkd.key.rule.IBuiltInRuleApp; import de.uka.ilkd.key.rule.NoPosTacletApp; @@ -39,6 +40,7 @@ import org.key_project.prover.sequent.Sequent; import org.key_project.prover.sequent.SequentChangeInfo; import org.key_project.prover.sequent.SequentFormula; +import org.key_project.prover.strategy.DelegationBasedRuleApplicationManager; import org.key_project.prover.strategy.RuleApplicationManager; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -232,6 +234,18 @@ public void removeGoalListener(GoalListener l) { listeners.remove(l); } + /** + * Returns a snapshot of the goal listeners currently registered on this goal. Used by + * {@link Proof#suspendNonEssentialListeners()} to detach non-essential (e.g. GUI) goal + * listeners + * for the duration of a parallel run, so they do not fire on worker threads. + * + * @return a copy of this goal's registered {@link GoalListener}s + */ + public List getGoalListeners() { + return new ArrayList<>(listeners); + } + /** * informs all goal listeners about a change of the sequent to reduce unnecessary object * creation the necessary information is passed to the listener as parameters and not through an @@ -243,6 +257,15 @@ private void fireSequentChanged(SequentChangeInfo sci) { var time1 = System.nanoTime(); PERF_UPDATE_TAG_MANAGER.getAndAdd(time1 - time); ruleAppIndex.sequentChanged(sci); + // Feed the change to the (possibly delegation-wrapped) queue manager so it can wake parked + // assumes-bases on their matching round (see QueueRuleApplicationManager#parkedByOp). + RuleApplicationManager m = ruleAppManager; + while (m instanceof DelegationBasedRuleApplicationManager d) { + m = d.getDelegate(); + } + if (m instanceof QueueRuleApplicationManager qm) { + qm.sequentChanged(sci); + } var time2 = System.nanoTime(); PERF_UPDATE_RULE_APP_INDEX.getAndAdd(time2 - time1); for (GoalListener listener : listeners) { @@ -628,6 +651,48 @@ private void resetLocalSymbols() { */ @Override public ImmutableList apply(final RuleApp ruleApp) { + final PendingRuleApp pending = computeRuleApp(ruleApp); + if (pending == null) { + return null; + } + return commitRuleApp(pending); + } + + /** + * The result of {@link #computeRuleApp(RuleApp)}: everything a rule application produced that + * has + * not yet been committed to the (shared) proof. Created on the worker thread, consumed by + * {@link #commitRuleApp(PendingRuleApp)}. + */ + public static final class PendingRuleApp { + private final RuleApp ruleApp; + private final NodeChangeJournal journal; + private final Node originalNode; + private final ImmutableList goalList; + + private PendingRuleApp(RuleApp ruleApp, NodeChangeJournal journal, Node originalNode, + ImmutableList goalList) { + this.ruleApp = ruleApp; + this.journal = journal; + this.originalNode = originalNode; + this.goalList = goalList; + } + } + + /** + * Compute phase of a rule application: run the rule executor (term construction, node + * splitting) + * without touching shared proof state. Safe to run concurrently for distinct goals — it + * mutates only this goal's own node subtree, the atomic node counter, the thread-safe strategy + * caches, the worker-disjoint name allocator and a per-worker name recorder. The + * {@link #commitRuleApp(PendingRuleApp)} step then performs the shared mutation under the + * prover's commit lock. The plain {@link #apply(RuleApp)} runs the two back to back, so the + * single-threaded behaviour is unchanged. + * + * @param ruleApp the rule application to perform + * @return the pending application to be committed, or {@code null} if the rule aborted + */ + public @Nullable PendingRuleApp computeRuleApp(final RuleApp ruleApp) { final Proof proof = proof(); final NodeChangeJournal journal = new NodeChangeJournal(proof, this); @@ -635,10 +700,6 @@ public ImmutableList apply(final RuleApp ruleApp) { final Node n = node; - /* - * wrap the services object into an overlay such that any addition to local symbols is - * caught. - */ final ImmutableList goalList; var time = System.nanoTime(); ruleApp.checkApplicability(); @@ -658,21 +719,37 @@ public ImmutableList apply(final RuleApp ruleApp) { PERF_APP_EXECUTE.getAndAdd(System.nanoTime() - time); } - proof.getServices().saveNameRecorder(n); + return new PendingRuleApp(ruleApp, journal, n, goalList); + } + + /** + * Commit phase of a rule application: the shared-state mutation (proof-tree update, + * name-recorder + * stashing and the {@code ruleApplied} event). The parallel prover runs this under its commit + * lock; the order of operations is identical to the original monolithic {@code apply}. + * + * @param pending the result of {@link #computeRuleApp(RuleApp)} + * @return the new goals + */ + public ImmutableList commitRuleApp(final PendingRuleApp pending) { + final Proof proof = proof(); + final ImmutableList goalList = pending.goalList; + + proof.getServices().saveNameRecorder(pending.originalNode); if (goalList.isEmpty()) { proof.closeGoal(this); } else { proof.replace(this, goalList); - if (ruleApp instanceof TacletApp tacletApp && tacletApp.taclet().closeGoal() - || ruleApp instanceof AbstractExternalSolverRuleApp) { + if (pending.ruleApp instanceof TacletApp tacletApp && tacletApp.taclet().closeGoal() + || pending.ruleApp instanceof AbstractExternalSolverRuleApp) { // the first new goal is the one to be closed proof.closeGoal(goalList.head()); } } adaptNamespacesNewGoals(goalList); - final RuleAppInfo ruleAppInfo = journal.getRuleAppInfo(ruleApp); + final RuleAppInfo ruleAppInfo = pending.journal.getRuleAppInfo(pending.ruleApp); proof.fireRuleApplied(new ProofEvent(proof, ruleAppInfo, goalList)); return goalList; } @@ -693,6 +770,25 @@ private void adaptNamespacesNewGoals(final ImmutableList goalList) { Collection newProgVars = localNamespaces.programVariables().elements(); Collection newFunctions = localNamespaces.functions().elements(); + if (ParallelProver.isMultiThreadedRunActive()) { + // Multithreaded run: do NOT flush new symbols into the shared proof namespace, so it + // stays immutable and concurrent matching can read it lock-free. The symbols live in + // node-local storage (which accumulates down the branch, see Node), and each goal's + // local namespaces are rebuilt from the immutable shared base plus that node-local + // storage -- exactly the reconstruction resetLocalSymbols already performs on pruning. + // No global reconciliation is needed afterwards: single-threaded proving also keeps + // these symbols in the local namespace layers (the flush targets a local copy, not the + // global Services namespace), so deferral leaves the global namespace identical. Fresh + // names are kept globally unique by ParallelNameAllocator, so no flush is needed for + // uniqueness either. + for (Goal goal : goalList) { + goal.node().addLocalProgVars(newProgVars); + goal.node().addLocalFunctions(newFunctions); + goal.resetLocalSymbols(); + } + return; + } + localNamespaces.flushToParent(); boolean first = true; diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/NodeInfo.java b/key.core/src/main/java/de/uka/ilkd/key/proof/NodeInfo.java index 81f41480bb1..c6ce077742b 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/NodeInfo.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/NodeInfo.java @@ -113,9 +113,22 @@ public void copyFrom(Node node) { /** - * determines the first and active statement if the applied taclet worked on a modality + * Determines the first and active statement if the applied taclet worked on a modality. + * + *

+ * Computed lazily on demand and cached. The first/active statement is a display + * concern + * (GUI, proof-reference and symbolic-execution analyses); it is intentionally not needed for, + * and + * not computed during, proof search — see {@link #updateNoteInfo()}. {@code synchronized} + * because the lazy computation mutates the cache fields and may be triggered concurrently after + * a + * (parallel) run, e.g. by the GUI. The pure, stateless variants + * {@link #computeActiveStatement(RuleApp)} / {@link #computeFirstStatement(RuleApp)} derive the + * same information from a rule app alone and are the thread-safe choice for any proving-time + * use. */ - private void determineFirstAndActiveStatement() { + private synchronized void determineFirstAndActiveStatement() { if (determinedFstAndActiveStatement) { return; } @@ -200,12 +213,18 @@ public static SourceElement computeActiveStatement(SourceElement firstStatement) return activeStatement; } - void updateNoteInfo() { + synchronized void updateNoteInfo() { + // Only invalidate the cached first/active statement; do NOT recompute eagerly. + // Recomputation + // happens lazily on demand (display / post-run analyses). Computing it here would (a) pull + // this display computation onto the proof-search path -- a thread-safety hazard for the + // parallel prover, NodeInfo must not be used for proving -- and (b) be wrong anyway, since + // this runs from Node#setAppliedRuleApp *before* the new applied rule app is stored, so an + // eager compute would use the stale (previous) rule app. determinedFstAndActiveStatement = false; firstStatement = null; firstStatementString = null; activeStatement = null; - determineFirstAndActiveStatement(); } /** @@ -255,7 +274,7 @@ public static boolean isSymbolicExecution(Taclet t) { * * @return active statement as described above */ - public SourceElement getActiveStatement() { + public synchronized SourceElement getActiveStatement() { determineFirstAndActiveStatement(); return activeStatement; } @@ -274,7 +293,7 @@ public String getBranchLabel() { * * @return statement position as described above */ - public Position getExecStatementPosition() { + public synchronized Position getExecStatementPosition() { determineFirstAndActiveStatement(); return (activeStatement == null) ? Position.UNDEFINED : activeStatement.getStartPosition(); } @@ -284,7 +303,7 @@ public Position getExecStatementPosition() { * * @return string representation of first statement as described above */ - public String getFirstStatementString() { + public synchronized String getFirstStatementString() { determineFirstAndActiveStatement(); if (firstStatement != null) { if (firstStatementString == null) { @@ -303,7 +322,12 @@ public String getFirstStatementString() { * @param s the String to be set */ public void setBranchLabel(String s) { - determineFirstAndActiveStatement(); + // NB: deliberately does NOT compute the first/active statement -- this method only sets the + // (display) branch label and never reads those fields. The previous eager computation here + // pulled NodeInfo's lazy statement computation onto the proof-search path (setBranchLabel + // is + // called during rule application for named branches), which is both wasteful and a + // thread-safety hazard for the parallel prover. It is computed lazily on demand instead. if (s == null) { return; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/PrefixTermTacletAppIndexCacheImpl.java b/key.core/src/main/java/de/uka/ilkd/key/proof/PrefixTermTacletAppIndexCacheImpl.java index c563566ddec..0c8681c898b 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/PrefixTermTacletAppIndexCacheImpl.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/PrefixTermTacletAppIndexCacheImpl.java @@ -36,7 +36,7 @@ protected PrefixTermTacletAppIndexCacheImpl(ImmutableList @Override public TermTacletAppIndex getIndexForTerm(Term t) { - return cache.get(getQueryKey(t)); + return cache.get(new CacheKey(this, t)); } private int hits = 0; @@ -56,7 +56,7 @@ private void countAccess(boolean hit) { @Override public void putIndexForTerm(Term t, TermTacletAppIndex index) { - cache.put(getNewKey(t), index); + cache.put(new CacheKey(this, t), index); } /** @@ -65,42 +65,39 @@ public void putIndexForTerm(Term t, TermTacletAppIndex index) { protected abstract String name(); /** - * @return a freshly created key for the term t that can be stored in the - * cache + * Key into the shared backend index cache, made instance-specific by {@code parent} so that the + * single backend map can be shared across many index-cache instances (different proofs, + * branches + * and locations) without them interfering. + * + *

+ * Immutable on purpose. An earlier version reused one mutable key per cache instance to save + * the + * per-lookup allocation, but that key was shared across the parallel-prover workers (sibling + * goals share the {@link TermTacletAppIndexCacheSet}) and they raced on it — one worker + * overwriting the term while another did its {@code cache.get} returned the index for the wrong + * term, surfacing as an {@code IndexOutOfBoundsException} in the index update (or, worse, a + * silently wrong match). A fresh immutable key per call has no shared mutable state to get + * wrong. */ - private CacheKey getNewKey(Term t) { - return new CacheKey(this, t); - } - - /** - * @return a key for the term t that can be used for cache queries. Calling this - * method twice will return the same object (with different attribute values), i.e., the - * result is not supposed to be stored anywhere - */ - private CacheKey getQueryKey(Term t) { - queryCacheKey.analysedTerm = t; - return queryCacheKey; - } - - private final CacheKey queryCacheKey = new CacheKey(this, null); - public static final class CacheKey { private final PrefixTermTacletAppIndexCacheImpl parent; - public Term analysedTerm; + private final Term analysedTerm; public CacheKey(PrefixTermTacletAppIndexCacheImpl parent, Term analysedTerm) { this.parent = parent; this.analysedTerm = analysedTerm; } + @Override public boolean equals(Object obj) { if (!(obj instanceof CacheKey objKey)) { return false; } - return parent == objKey.parent && analysedTerm.equals(objKey.analysedTerm); } + @Override public int hashCode() { return parent.hashCode() * 3784831 + analysedTerm.hashCode(); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/Proof.java b/key.core/src/main/java/de/uka/ilkd/key/proof/Proof.java index 3e504851cf8..fd191371425 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/Proof.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/Proof.java @@ -1170,6 +1170,137 @@ public void removeRuleAppListener(RuleAppListener p) { } } + /** + * Detaches every registered {@link ProofTreeListener} and {@link RuleAppListener} that is not + * tagged {@link EssentialProofListener}, and returns a handle that re-attaches them when + * closed. + * + *

+ * This is the foundation for safe automatic (and, later, multithreaded) proof search: pure + * observers — caching, slicing, origin labels, GUI tree models, … — are + * suspended for the duration of a run so that nothing unrelated to proving fires on a worker + * thread or mutates shared state concurrently. Essential bookkeeping listeners keep running. + * {@link ProofDisposedListener}s are untouched, as disposal is not part of a run. + * + *

+ * Intended use is a try-with-resources scope around the prover's run: + * + *

+     * try (var ignored = proof.suspendNonEssentialListeners()) {
+     *     prover.start(proof, goals, ...);
+     * } // observers re-attached here; the caller then refreshes them from the final state
+     * 
+ * + * Suspended listeners are re-attached on {@link ListenerSuspension#close()}; their relative + * firing order is not guaranteed to be preserved, which is sound because proof listeners are + * mutually independent. + * + * @return a handle whose {@link ListenerSuspension#close()} restores the suspended listeners; + * restoration is idempotent + */ + public ListenerSuspension suspendNonEssentialListeners() { + return new ListenerSuspension(); + } + + /** + * Handle returned by {@link Proof#suspendNonEssentialListeners()}. Closing it (idempotently) + * re-attaches the listeners that were suspended. + */ + public final class ListenerSuspension implements AutoCloseable { + private final List suspendedTreeListeners = new ArrayList<>(); + private final List suspendedRuleAppListeners = new ArrayList<>(); + /** + * Distinct non-essential per-goal listeners (e.g. the GUI proof-tree model's goal listener, + * which is attached to every open goal). Unlike the proof-level listener lists, these are + * registered per goal and would otherwise fire on the worker threads during a parallel run + * -- and touch EDT-only state. They are detached for the run's duration and re-attached on + * {@link #close()}, to the current open goals (the goal set changes during the + * run). + */ + private final Set suspendedGoalListeners = + Collections.newSetFromMap(new IdentityHashMap<>()); + private boolean restored = false; + /** + * Whether the proof was already closed when the listeners were suspended. Used to decide on + * {@link #close()} whether a {@code proofClosed} event was missed by the suspended + * listeners. + */ + private final boolean closedAtSuspension = closed(); + + private ListenerSuspension() { + synchronized (listenerList) { + for (ProofTreeListener l : listenerList) { + if (!(l instanceof EssentialProofListener)) { + suspendedTreeListeners.add(l); + } + } + listenerList.removeAll(suspendedTreeListeners); + } + synchronized (ruleAppListenerList) { + for (RuleAppListener l : ruleAppListenerList) { + if (!(l instanceof EssentialProofListener)) { + suspendedRuleAppListeners.add(l); + } + } + ruleAppListenerList.removeAll(suspendedRuleAppListeners); + } + // Detach non-essential goal listeners from every open goal. (Construction runs before + // any + // worker starts, so the open-goal set and the goals' listener lists are stable here.) + for (Goal g : openGoals()) { + for (GoalListener l : g.getGoalListeners()) { + if (!(l instanceof EssentialProofListener)) { + suspendedGoalListeners.add(l); + } + } + } + for (Goal g : openGoals()) { + for (GoalListener l : suspendedGoalListeners) { + g.removeGoalListener(l); + } + } + } + + /** Re-attaches the suspended listeners. Idempotent: a second call does nothing. */ + @Override + public void close() { + if (restored) { + return; + } + restored = true; + synchronized (listenerList) { + listenerList.addAll(suspendedTreeListeners); + } + synchronized (ruleAppListenerList) { + ruleAppListenerList.addAll(suspendedRuleAppListeners); + } + // Re-attach to the goals that are open now (the run may have closed some and created + // others); remove-then-add keeps it idempotent and avoids duplicates. The view itself + // is + // refreshed from the final state by the caller (the GUI's autoModeStopped handler + // rebuilds + // the modified subtrees on the EDT). + for (Goal g : openGoals()) { + for (GoalListener l : suspendedGoalListeners) { + g.removeGoalListener(l); + g.addGoalListener(l); + } + } + // If the proof closed while its non-essential listeners were suspended, the proofClosed + // event reached only the essential listeners; the suspended ones (e.g. the GUI's + // "proof closed" notification, the task and goal views) missed it. Re-deliver it to + // them now. This runs after the parallel run has joined all workers, so it is a single + // delivery on one thread -- the same context in which proofClosed fires for the + // single-threaded prover. + if (!closedAtSuspension && !mutedProofCloseEvents && closed()) { + ProofTreeEvent event = new ProofTreeEvent(Proof.this); + for (ProofTreeListener l : suspendedTreeListeners) { + l.proofClosed(event); + } + } + } + } + /** * Registers the given {@link ProofDisposedListener}. * diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/TermTacletAppIndexCacheSet.java b/key.core/src/main/java/de/uka/ilkd/key/proof/TermTacletAppIndexCacheSet.java index 356604b8044..7e1e957cfbb 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/TermTacletAppIndexCacheSet.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/TermTacletAppIndexCacheSet.java @@ -15,7 +15,7 @@ import org.key_project.logic.op.Operator; import org.key_project.logic.op.QuantifiableVariable; import org.key_project.prover.rules.Taclet; -import org.key_project.util.LRUCache; +import org.key_project.util.ConcurrentLruCache; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -82,10 +82,19 @@ public void putIndexForTerm(Term t, TermTacletAppIndex index) {} /** * caches for locations that are not below updates or programs, but in the scope of binders. * this is a mapping from IList to TopLevelCache + * + *

+ * One cache set is shared across the sibling goals of a proof branch (TacletAppIndex.copyWith + * hands on the same instance), so these prefix caches are read and written concurrently on the + * parallel matching path -- hence ConcurrentLruCache. The exact (not striped) flavour is used + * to + * preserve the original global LRU eviction: re-creating an evicted sub-cache orphans its + * instance-specific entries in the shared backend, and the number of distinct quantifier + * prefixes is small (eviction rarely fires), so exact eviction avoids extra backend churn at + * negligible lock cost. */ - private final LRUCache, ITermTacletAppIndexCache> topLevelCaches = - new LRUCache<>( - MAX_CACHE_ENTRIES); + private final ConcurrentLruCache, ITermTacletAppIndexCache> topLevelCaches = + new ConcurrentLruCache<>(MAX_CACHE_ENTRIES); /** * cache for locations that are below updates, but not below programs or in the scope of binders @@ -102,9 +111,8 @@ public void putIndexForTerm(Term t, TermTacletAppIndex index) {} * caches for locations that are both below programs and in the scope of binders. this is a * mapping from IList to BelowProgCache */ - private final LRUCache, ITermTacletAppIndexCache> belowProgCaches = - new LRUCache<>( - MAX_CACHE_ENTRIES); + private final ConcurrentLruCache, ITermTacletAppIndexCache> belowProgCaches = + new ConcurrentLruCache<>(MAX_CACHE_ENTRIES); private final Map cache; diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/VariableNameProposer.java b/key.core/src/main/java/de/uka/ilkd/key/proof/VariableNameProposer.java index c23d9770d32..d9188a34c84 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/VariableNameProposer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/VariableNameProposer.java @@ -18,6 +18,7 @@ import de.uka.ilkd.key.logic.op.SkolemTermSV; import de.uka.ilkd.key.logic.op.VariableSV; import de.uka.ilkd.key.logic.sort.ProgramSVSort; +import de.uka.ilkd.key.prover.impl.ParallelProver; import de.uka.ilkd.key.rule.TacletApp; import org.key_project.logic.Name; @@ -71,12 +72,18 @@ public Name getNewName(Services services, Name baseName) { Name name = services.getNameRecorder().getProposal(); if (name == null || namespaces.lookup(name) != null) { - int i = 0; - - do { - name = new Name(baseName + "_" + i++); - } while (namespaces.lookup(name) != null); - + if (ParallelProver.isMultiThreadedRunActive()) { + // Mint via the per-proof allocator: globally unique across workers without a + // namespace-search race. Re-mint on the rare clash with a pre-existing symbol. + do { + name = new Name(services.getNameAllocator().freshName(baseName.toString())); + } while (namespaces.lookup(name) != null); + } else { + int i = 0; + do { + name = new Name(baseName + "_" + i++); + } while (namespaces.lookup(name) != null); + } } return name; @@ -133,13 +140,21 @@ private String getNameProposalForSkolemTermVariable(String name, Services servic final NamespaceSet nss = services.getNamespaces(); Name l_name; final String basename = name + SKOLEMTERM_VARIABLE_NAME_POSTFIX; - int cnt = 0; - do { - name = basename + cnt; - l_name = new Name(name); - cnt++; - } while (nss.lookup(l_name) != null || previousProposals.contains(name)); - + if (ParallelProver.isMultiThreadedRunActive()) { + // Allocator-minted skolem names are globally unique across workers without searching + // the shared namespace; still honour pre-existing symbols and recorded proposals. + do { + name = services.getNameAllocator().freshName(basename); + l_name = new Name(name); + } while (nss.lookup(l_name) != null || previousProposals.contains(name)); + } else { + int cnt = 0; + do { + name = basename + cnt; + l_name = new Name(name); + cnt++; + } while (nss.lookup(l_name) != null || previousProposals.contains(name)); + } return name; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/init/JavaProfile.java b/key.core/src/main/java/de/uka/ilkd/key/proof/init/JavaProfile.java index 267f4176035..4d0678ff55e 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/init/JavaProfile.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/init/JavaProfile.java @@ -46,6 +46,15 @@ public String ident() { return permissions ? NAME_WITH_PERMISSIONS : PROFILE_ID; } + /** + * The standard Java DL profile is the one validated for concurrent goal processing, so it opts + * into the multi-core prover. Specialised subprofiles override this back to {@code false}. + */ + @Override + public boolean supportsParallelAutomode() { + return true; + } + @Override public String displayName() { return permissions ? NAME_WITH_PERMISSIONS : (PROFILE_ID + " (Default)"); diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/init/Profile.java b/key.core/src/main/java/de/uka/ilkd/key/proof/init/Profile.java index 7f19e4b2b93..3a6b90860ca 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/init/Profile.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/init/Profile.java @@ -105,6 +105,22 @@ default String description() { */ boolean supportsStrategyFactory(Name strategyName); + /** + * Whether automatic proof search for this profile may run on the multi-core (parallel) prover. + * + *

+ * Conservatively {@code false} by default: a profile opts in only once its rules, strategy and + * any side-proof machinery have been confirmed thread-safe under concurrent goal processing. + * The + * standard {@code JavaProfile} opts in; the specialised profiles (well-definedness, information + * flow, symbolic-execution debugger) keep the safe single-core fallback for now. + * + * @return {@code true} if the parallel prover may be used for this profile + */ + default boolean supportsParallelAutomode() { + return false; + } + /** * returns the StrategyFactory for strategy strategyName * diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/io/AbstractProblemLoader.java b/key.core/src/main/java/de/uka/ilkd/key/proof/io/AbstractProblemLoader.java index e4462551b58..9d311ee8108 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/io/AbstractProblemLoader.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/io/AbstractProblemLoader.java @@ -14,6 +14,7 @@ import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.nparser.JavaKeYLexer; import de.uka.ilkd.key.nparser.KeyAst.ProofScript; +import de.uka.ilkd.key.nparser.ParsingFacade; import de.uka.ilkd.key.nparser.ProofScriptEntry; import de.uka.ilkd.key.proof.Node; import de.uka.ilkd.key.proof.Proof; @@ -30,6 +31,7 @@ import de.uka.ilkd.key.settings.ProofIndependentSettings; import de.uka.ilkd.key.speclang.Contract; import de.uka.ilkd.key.speclang.SLEnvInput; +import de.uka.ilkd.key.speclang.njml.JmlFacade; import de.uka.ilkd.key.strategy.Strategy; import de.uka.ilkd.key.strategy.StrategyProperties; @@ -339,6 +341,11 @@ public final void load(Consumer callbackProofLoaded) throws Exception { } } finally { control.loadingFinished(this, poContainer, proofList, result); + // parsing is done; release the ANTLR DFA caches so they are not retained during the + // (long) proof search. They are a pure cache and rebuild transparently on the next + // parse. + ParsingFacade.clearParserCaches(); + JmlFacade.clearCaches(); } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/ProofCorrectnessMgt.java b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/ProofCorrectnessMgt.java index 7c30b3c697f..74373301198 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/ProofCorrectnessMgt.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/ProofCorrectnessMgt.java @@ -9,6 +9,7 @@ import de.uka.ilkd.key.java.ast.abstraction.KeYJavaType; import de.uka.ilkd.key.logic.op.IObserverFunction; +import de.uka.ilkd.key.proof.EssentialProofListener; import de.uka.ilkd.key.proof.Proof; import de.uka.ilkd.key.proof.ProofEvent; import de.uka.ilkd.key.proof.ProofTreeAdapter; @@ -270,7 +271,12 @@ public ProofStatus getStatus() { // inner classes // ------------------------------------------------------------------------- - private class DefaultMgtProofListener implements RuleAppListener { + // These two listeners maintain contract-dependency bookkeeping the prover relies on, so they + // are marked EssentialProofListener and keep firing while pure observers are suspended during + // a run (see Proof#suspendNonEssentialListeners). NOTE (mt-goals): cachedRuleApps is a plain + // LinkedHashSet; once rule application moves onto worker threads, its updates here must be made + // thread-safe (or folded into the serialized tree-commit step). + private class DefaultMgtProofListener implements RuleAppListener, EssentialProofListener { @Override public void ruleApplied(ProofEvent e) { ProofCorrectnessMgt.this.ruleApplied(e.getRuleAppInfo().getRuleApp()); @@ -278,7 +284,8 @@ public void ruleApplied(ProofEvent e) { } - private class DefaultMgtProofTreeListener extends ProofTreeAdapter { + private class DefaultMgtProofTreeListener extends ProofTreeAdapter + implements EssentialProofListener { @Override public void proofClosed(ProofTreeEvent e) { updateProofStatus(); diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/RuleJustificationInfo.java b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/RuleJustificationInfo.java index 6008e87d1c3..4bf11a193d6 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/RuleJustificationInfo.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/RuleJustificationInfo.java @@ -17,9 +17,17 @@ public class RuleJustificationInfo { + /** + * Maps rules to their justifications. All access is {@code synchronized} on this object: the + * rule executor registers justifications (via {@code Goal.addTaclet} -> + * {@code InitConfig.registerRuleIntroducedAtNode}) whenever a rule introduces a taclet, which + * happens concurrently under the goal-level parallel prover (branch {@code bubel/mt-goals}); a + * plain {@link LinkedHashMap} would corrupt under concurrent {@code addJustification}. The + * {@code addJustification} compound (contains-then-iterate-then-put) must also stay atomic. + */ private final Map rule2Justification = new LinkedHashMap<>(); - public void addJustification(Rule r, RuleJustification j) { + public synchronized void addJustification(Rule r, RuleJustification j) { final RuleKey ruleKey = new RuleKey(r); if (rule2Justification.containsKey(ruleKey)) { // TODO: avoid double registration of certain class axioms and remove then the below @@ -36,11 +44,12 @@ public void addJustification(Rule r, RuleJustification j) { } } - public @Nullable RuleJustification getJustification(Rule r) { + public synchronized @Nullable RuleJustification getJustification(Rule r) { return rule2Justification.get(new RuleKey(r)); } - public @Nullable RuleJustification getJustification(RuleApp r, LogicServices services) { + public synchronized @Nullable RuleJustification getJustification(RuleApp r, + LogicServices services) { RuleJustification just = getJustification(r.rule()); if (just instanceof ComplexRuleJustification) { return ((ComplexRuleJustification) just).getSpecificJustification(r, services); @@ -49,11 +58,11 @@ public void addJustification(Rule r, RuleJustification j) { } } - public void removeJustificationFor(Rule rule) { + public synchronized void removeJustificationFor(Rule rule) { rule2Justification.remove(new RuleKey(rule)); } - public RuleJustificationInfo copy() { + public synchronized RuleJustificationInfo copy() { RuleJustificationInfo info = new RuleJustificationInfo(); info.rule2Justification.putAll(rule2Justification); return info; diff --git a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/SpecificationRepository.java b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/SpecificationRepository.java index 455e5a2a48f..7fdd1fc5433 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/SpecificationRepository.java +++ b/key.core/src/main/java/de/uka/ilkd/key/proof/mgt/SpecificationRepository.java @@ -530,6 +530,18 @@ private void mapValues(Map map, // public interface // ------------------------------------------------------------------------- + // Thread-safety: a single SpecificationRepository is shared by all parallel-prover workers of a + // proof. Most maps above are populated once during problem loading and then only read, so + // concurrent reads of them are safe. The maps that are still *mutated during proving* -- + // loopInvs + // (loop-invariant rules and the loop-elimination transforms register specs), the lazy + // allClassAxiomsCache, the limited/unlimited observer maps, and the block/loop-contract maps -- + // are guarded by this object's monitor: every method that reads or writes one of them is + // synchronized (a coarse lock). The monitor is reentrant, which is required because several of + // these methods call one another (e.g. getClassAxioms recurses into enclosing classes, + // addBlockContract reads getBlockContracts, copyLoopInvariant reads then writes). Lock ordering + // is one-way (this monitor -> JavaInfo's cache lock via the services), so no deadlock arises. + /** * Applies the specified operator to every contract in this repository. * @@ -537,7 +549,7 @@ private void mapValues(Map map, * @param services services. * @see SpecificationElement#map(UnaryOperator, Services) */ - public void map(UnaryOperator op, Services services) { + public synchronized void map(UnaryOperator op, Services services) { mapValueSets(contracts, op, services); mapValueSets(operationContracts, op, services); mapValueSets(invs, op, services); @@ -832,7 +844,7 @@ public void addInitiallyClauses(ImmutableSet toAdd) { * Returns all class axioms visible in the passed class, including the axioms induced by * invariant declarations. */ - public ImmutableSet getClassAxioms(KeYJavaType selfKjt) { + public synchronized ImmutableSet getClassAxioms(KeYJavaType selfKjt) { ImmutableSet result = allClassAxiomsCache.get(selfKjt); if (result == null) { // get visible registered axioms of other classes @@ -1243,7 +1255,7 @@ public void removeProof(Proof proof) { /** * Returns the registered loop invariant for the passed loop, or null. */ - public LoopSpecification getLoopSpec(LoopStatement loop) { + public synchronized LoopSpecification getLoopSpec(LoopStatement loop) { final int line = loop.getStartPosition().line(); Pair l = new Pair<>(loop, line); LoopSpecification inv = loopInvs.get(l); @@ -1262,7 +1274,7 @@ public LoopSpecification getLoopSpec(LoopStatement loop) { * @param from the loop with the original contract * @param to the loop for which the contract is to be copied */ - public void copyLoopInvariant(LoopStatement from, LoopStatement to) { + public synchronized void copyLoopInvariant(LoopStatement from, LoopStatement to) { LoopSpecification inv = getLoopSpec(from); if (inv != null) { inv = inv.setLoop(to); @@ -1274,7 +1286,7 @@ public void copyLoopInvariant(LoopStatement from, LoopStatement to) { * Registers the passed loop invariant, possibly overwriting an older registration for the same * loop. */ - public void addLoopInvariant(final LoopSpecification inv) { + public synchronized void addLoopInvariant(final LoopSpecification inv) { final LoopStatement loop = inv.getLoop(); final int line = loop.getStartPosition().line(); Pair l = new Pair<>(loop, line); @@ -1291,7 +1303,7 @@ public void addLoopInvariant(final LoopSpecification inv) { * @param block a block. * @return all block contracts for the specified block. */ - public ImmutableSet getBlockContracts(StatementBlock block) { + public synchronized ImmutableSet getBlockContracts(StatementBlock block) { var b = new BlockContractKey(block, block.getParentClass(), block.getStartPosition().line()); final ImmutableSet contracts = blockContracts.get(b); @@ -1308,7 +1320,7 @@ public ImmutableSet getBlockContracts(StatementBlock block) { * @param block a block. * @return all loop contracts for the specified block. */ - public ImmutableSet getLoopContracts(StatementBlock block) { + public synchronized ImmutableSet getLoopContracts(StatementBlock block) { var b = new LoopContractKey(block, block.getParentClass(), block.getStartPosition().line()); final ImmutableSet contracts = loopContracts.get(b); if (contracts == null) { @@ -1324,7 +1336,7 @@ public ImmutableSet getLoopContracts(StatementBlock block) { * @param loop a loop. * @return all loop contracts for the specified loop. */ - public ImmutableSet getLoopContracts(LoopStatement loop) { + public synchronized ImmutableSet getLoopContracts(LoopStatement loop) { final Pair b = new Pair<>(loop, loop.getStartPosition().line()); final ImmutableSet contracts = loopContractsOnLoops.get(b); if (contracts == null) { @@ -1411,7 +1423,8 @@ public void addBlockContract(final BlockContract contract) { * @param addFunctionalContract whether or not to add a new {@link FunctionalBlockContract} * based on {@code contract}. */ - public void addBlockContract(final BlockContract contract, boolean addFunctionalContract) { + public synchronized void addBlockContract(final BlockContract contract, + boolean addFunctionalContract) { final StatementBlock block = contract.getBlock(); var b = new BlockContractKey(block, block.getParentClass(), block.getStartPosition().line()); @@ -1433,7 +1446,7 @@ public void addBlockContract(final BlockContract contract, boolean addFunctional * * @param contract the {@code BlockContract} to remove. */ - public void removeBlockContract(final BlockContract contract) { + public synchronized void removeBlockContract(final BlockContract contract) { final StatementBlock block = contract.getBlock(); var b = new BlockContractKey(block, block.getParentClass(), block.getStartPosition().line()); @@ -1457,7 +1470,8 @@ public void addLoopContract(final LoopContract contract) { * @param addFunctionalContract whether or not to add a new {@link FunctionalLoopContract} based * on {@code contract}. */ - public void addLoopContract(final LoopContract contract, boolean addFunctionalContract) { + public synchronized void addLoopContract(final LoopContract contract, + boolean addFunctionalContract) { if (contract.isOnBlock()) { final StatementBlock block = contract.getBlock(); var b = @@ -1490,7 +1504,7 @@ public void addLoopContract(final LoopContract contract, boolean addFunctionalCo * * @param contract the {@code LoopContract} to remove. */ - public void removeLoopContract(final LoopContract contract) { + public synchronized void removeLoopContract(final LoopContract contract) { if (contract.isOnBlock()) { final StatementBlock block = contract.getBlock(); var b = @@ -1549,7 +1563,8 @@ public void addSpecs(ImmutableSet specs) { } } - public Pair> limitObs(IObserverFunction obs) { + public synchronized Pair> limitObs( + IObserverFunction obs) { assert limitedToUnlimited.get(obs) == null : " observer is already limited: " + obs; // TODO Was the exact class match "obs.getClass() != // ObserverFunction.class" correctly converted into IProtramMethod? @@ -1584,7 +1599,7 @@ public Pair> limitObs(IObserverFunction return new Pair<>(Objects.requireNonNull(limited), Objects.requireNonNull(taclets)); } - public IObserverFunction unlimitObs(IObserverFunction obs) { + public synchronized IObserverFunction unlimitObs(IObserverFunction obs) { IObserverFunction result = limitedToUnlimited.get(obs); if (result == null) { result = obs; diff --git a/key.core/src/main/java/de/uka/ilkd/key/prover/impl/AutoProvers.java b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/AutoProvers.java new file mode 100644 index 00000000000..70718d5a38a --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/AutoProvers.java @@ -0,0 +1,68 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.impl; + +import de.uka.ilkd.key.proof.Goal; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.init.Profile; + +import org.key_project.prover.engine.GoalChooser; +import org.key_project.prover.engine.ProverCore; + +/** + * Selection seam for the prover used by automatic proof search. + * + *

+ * Returns the {@link ParallelProver} when the multi-core prover is enabled and the proof's + * {@link Profile#supportsParallelAutomode() profile supports it}, and the standard single-threaded + * {@link ApplyStrategy} otherwise. Construction sites that drive automode should go through here + * instead of constructing {@link ApplyStrategy} directly, so the parallel path can be toggled + * centrally and the profile guard cannot be bypassed. + * + * @author Claude (KeY multithreading effort) + */ +public final class AutoProvers { + + private AutoProvers() {} + + /** + * Creates the auto-prover selected by the current configuration for the given profile. + * + * @param goalChooser the goal chooser to use + * @param profile the profile of the proof to be processed; the parallel prover is used only if + * it {@link Profile#supportsParallelAutomode() supports parallel automode} + * @return a {@link ParallelProver} if enabled and supported, otherwise an {@link ApplyStrategy} + */ + public static ProverCore create(GoalChooser goalChooser, + Profile profile) { + return create(goalChooser, profile, true); + } + + /** + * Creates the auto-prover selected by the current configuration, optionally forcing the + * single-threaded prover. + * + *

+ * {@code allowParallel == false} pins the run to {@link ApplyStrategy} even when the multi-core + * prover is enabled. This is for drivers that close one goal at a time under a tight per-goal + * step budget (notably {@link de.uka.ilkd.key.macros.TryCloseMacro}): there is no parallelism + * to + * gain from a single goal, and several workers exploring one goal's subtree apply rules in a + * different, less step-efficient order than the single-threaded prover -- which can exhaust the + * budget before the goal closes. The wide, generously-budgeted runs (interactive automode and + * most macros) keep using the parallel prover, where it pays off. + * + * @param goalChooser the goal chooser to use + * @param profile the profile of the proof to be processed + * @param allowParallel whether the parallel prover may be used at all for this run + * @return a {@link ParallelProver} if allowed, enabled and supported, otherwise an + * {@link ApplyStrategy} + */ + public static ProverCore create(GoalChooser goalChooser, + Profile profile, boolean allowParallel) { + boolean parallel = + allowParallel && ParallelProver.isEnabled() && profile.supportsParallelAutomode(); + return parallel ? new ParallelProver(goalChooser) : new ApplyStrategy(goalChooser); + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/prover/impl/GoalScheduler.java b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/GoalScheduler.java new file mode 100644 index 00000000000..5a5b2316887 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/GoalScheduler.java @@ -0,0 +1,240 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.impl; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +/** + * Thread-safe work queue that hands out open goals to worker threads, the scheduling core of the + * goal-level parallel prover (multithreading effort, branch {@code bubel/mt-goals}, Phase 3). + * + *

+ * This is a clean reimplementation of the 2018 {@code MultiCoreChooser} scheduling logic, decoupled + * from the prover and using a single monitor instead of the original nested locks + * — deliberately avoiding the lock-ordering fragility that made the earlier attempt + * unmaintainable. It is parameterized over the goal type so it can be exercised in isolation. + * + *

+ * Lifecycle of an item: + *

    + *
  • {@link #offer}/{@link #offerAll} make a goal available (deduplicated by identity + * against goals already available or in flight); + *
  • {@link #claimNext} atomically moves an available goal to in flight and returns it; + *
  • {@link #complete} marks an in-flight goal done (e.g. closed, or it produced new goals which + * were offered); + *
  • the queue is quiescent when nothing is available and nothing is in flight — + * the signal that proof search has finished. + *
+ * + *

+ * {@link #claimOrAwait} blocks while no goal is available but work is still in flight (more goals + * may yet appear), and returns {@code null} only once the queue is quiescent — the natural + * termination condition for a worker loop. + * + * @param the goal type (the prover uses {@code GoalScheduler}; tests use tokens) + * @author Claude (KeY multithreading effort) + */ +public final class GoalScheduler { + + private final Deque available = new ArrayDeque<>(); + /** + * Identity-set mirror of {@link #available}, so the duplicate check in {@link #offer} is O(1) + * instead of a linear scan of the deque. This matters a lot: with a wide fan-out (and + * especially + * with few workers draining the queue) the available frontier grows large, and an O(n) scan per + * offer made the whole run quadratic in the frontier width. + */ + private final Set availableSet = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set inFlight = Collections.newSetFromMap(new IdentityHashMap<>()); + + /** + * Goals for which the strategy currently offers no rule application ({@link #stall}ed). They + * are + * not abandoned: KeY's rule-application cost is age-dependent, so a goal with no + * applicable rule now may gain one once other goals advance the proof (and the shared state it + * keys on). They are reactivated by {@link #claimOrAwait} once progress has been made, + * mirroring + * the single-threaded chooser, which retries goals across passes rather than dropping them. + */ + private final Set stalled = Collections.newSetFromMap(new IdentityHashMap<>()); + /** Whether a rule has been applied since the stalled goals were last reactivated. */ + private boolean progressMade; + + /** Makes {@code goal} available unless it is already available or in flight (by identity). */ + public synchronized void offer(T goal) { + if (inFlight.contains(goal) || !availableSet.add(goal)) { + return; + } + available.addLast(goal); + notifyAll(); + } + + /** Offers each goal in {@code goals} (see {@link #offer}). */ + public synchronized void offerAll(Iterable goals) { + for (T goal : goals) { + offer(goal); + } + } + + /** + * Atomically claims the next available goal, moving it to the in-flight set. + * + *

+ * Goals are handed out last-in-first-out: {@link #offer} appends and this polls from + * the + * same end, so each worker dives depth-first down a branch and a split's siblings sit on the + * stack for other workers to pick up. This keeps the set of simultaneously-open goals small + * (roughly the proof depth per worker) instead of the whole breadth of the proof tree, which a + * FIFO order would hold open at once — a large, eviction-heavy working set that dominated + * memory/GC (and ran several times slower) on wide, splitting proofs. Depth-first is also what + * the single-threaded chooser does, keeping the two comparable. + * + * @return the claimed goal, or {@code null} if none is currently available + */ + public synchronized @Nullable T claimNext() { + T goal = available.pollLast(); + if (goal != null) { + availableSet.remove(goal); + inFlight.add(goal); + } + return goal; + } + + /** + * Claims the next goal to process, blocking while work may still appear. + * + *

+ * When no goal is immediately available it reactivates the {@link #stalled} goals — but + * only once a rule has been applied since they stalled ({@link #progressMade}), so they are + * retried in progress-gated batches rather than spun on. It returns {@code null} only when + * nothing is available, nothing is in flight, and the stalled goals (if any) cannot be + * reactivated because no progress was made — i.e. a full cycle yielded no rule for any + * goal, the genuine "no more rules applicable to any goal" termination. + * + * @return the next goal to process, or {@code null} once the search is finished + * @throws InterruptedException if the waiting thread is interrupted + */ + public synchronized @Nullable T claimOrAwait() throws InterruptedException { + while (true) { + T goal = claimNext(); + if (goal != null) { + return goal; + } + if (!stalled.isEmpty() && progressMade) { + reactivateStalled(); + continue; // the reactivated goals are now available; claim one + } + if (inFlight.isEmpty()) { + // nothing available, nothing reactivatable, nothing in flight: finished + return null; + } + wait(); // an in-flight worker may yet apply a rule (progress) or stall its goal + } + } + + /** + * Moves all stalled goals back to the available queue for another attempt (caller holds lock). + */ + private void reactivateStalled() { + for (T g : stalled) { + if (availableSet.add(g)) { + available.addLast(g); + } + } + stalled.clear(); + progressMade = false; + } + + /** + * Records that the strategy currently offers no rule for {@code goal}: it is set aside + * (stalled) + * rather than abandoned, to be retried by {@link #claimOrAwait} once other goals make progress. + * + * @param goal the in-flight goal that yielded no rule application + */ + public synchronized void stall(T goal) { + inFlight.remove(goal); + stalled.add(goal); + notifyAll(); + } + + /** + * Returns an in-flight goal to the available queue for immediate re-processing. Used when a + * rule + * application aborted but the goal still has further candidate rules to try: dropping it (via + * {@link #complete}) would lose the goal and leave the proof open, while {@link #stall}ing + * would + * defer it to a progress-gated retry. Re-offering makes it available again right away, + * mirroring + * the single-threaded prover, which simply retries the goal after an aborted application. + * + * @param goal the in-flight goal whose rule application aborted + */ + public synchronized void reoffer(T goal) { + inFlight.remove(goal); + if (availableSet.add(goal)) { + available.addLast(goal); + } + notifyAll(); + } + + /** Marks an in-flight goal as done and wakes any threads waiting in {@link #claimOrAwait}. */ + public synchronized void complete(T goal) { + inFlight.remove(goal); + notifyAll(); + } + + /** + * Atomically completes {@code goal} and offers its successors, as a single step under the + * monitor. + * + *

+ * This must be atomic: if completing the goal and offering its successors were two separate + * monitor sections, another worker could observe the queue between them — goal no longer + * in flight, successors not yet available — and, finding nothing available and nothing in + * flight, conclude (wrongly) that the search is quiescent and terminate, leaving the proof + * open. + * That gap is hit often under depth-first hand-out, where the frontier is small. Keeping the + * two + * together means a goal stays in flight until its successors are visible, so quiescence is only + * ever observed when the search is genuinely finished. + * + * @param goal the just-processed goal to remove from the in-flight set + * @param successors the open subgoals to make available (may be {@code null} or empty) + */ + public synchronized void completeAndOffer(T goal, @Nullable Iterable successors) { + inFlight.remove(goal); + if (successors != null) { + for (T s : successors) { + if (!inFlight.contains(s) && availableSet.add(s)) { + available.addLast(s); + } + } + } + // A rule was applied, which may have made the stalled goals applicable again. + progressMade = true; + notifyAll(); + } + + /** Whether nothing is available, in flight, or stalled, i.e. proof search has finished. */ + public synchronized boolean isQuiescent() { + return available.isEmpty() && inFlight.isEmpty() && stalled.isEmpty(); + } + + /** Number of goals currently available to claim. */ + public synchronized int availableCount() { + return available.size(); + } + + /** Number of goals currently being processed. */ + public synchronized int inFlightCount() { + return inFlight.size(); + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelNameAllocator.java b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelNameAllocator.java new file mode 100644 index 00000000000..eb480499264 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelNameAllocator.java @@ -0,0 +1,105 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.impl; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Allocator of fresh symbol names that are globally unique by construction across worker + * threads, without consulting the shared {@code Namespace} on the hot path. + * + *

+ * Background (multithreading effort, branch {@code bubel/mt-goals}, Phase 2). When goals are + * processed concurrently, the classic mint pattern + * {@code do { name = base + cnt++ } while (namespaces.lookup(name) != null)} races: two workers can + * both observe {@code base_0} as free and both claim it (a TOCTOU on the shared namespace), so on + * commit two different symbols end up sharing one name — unsound. Locking the namespace would + * fix it but the namespace lookup/add path is performance-critical and must stay lock-free. + * + *

+ * This allocator removes the race by partitioning: each worker draws fresh names + * from a per-worker, per-base counter and tags names belonging to worker {@code w > 0} with a + * {@code __t} segment. Thus two workers can never produce the same name even while racing, and + * no shared namespace read is needed to guarantee that. Worker {@code 0} keeps the legacy + * (untagged) form so the single-threaded path is unchanged. + * + *

+ * Contract: names returned by one allocator instance are pairwise distinct. Uniqueness + * against names that pre-exist in a proof's namespace (e.g. from loading) is not this + * class's job; the Phase-3 integration composes this allocator with a lock-free lookup in the + * worker's local namespace for that. The proof fingerprint hashes rule names, not symbol + * names, so worker-tagged names do not affect the equivalence gate. + * + *

+ * The current worker is bound per thread via {@link #runAsWorker(int, Runnable)} / + * {@link #bindWorker(int)}; unbound threads act as worker {@code 0}. + * + * @author Claude (KeY multithreading effort) + */ +public final class ParallelNameAllocator { + + /** The worker id bound to the current thread; absent means worker 0 (single-threaded). */ + private static final ThreadLocal CURRENT_WORKER = ThreadLocal.withInitial(() -> 0); + + /** Per-{@code (worker,base)} monotonic counters, keyed by {@code worker + "#" + base}. */ + private final ConcurrentMap counters = new ConcurrentHashMap<>(); + + /** + * Binds the calling thread to a worker id for subsequent {@link #freshName(String)} calls. + * Prefer {@link #runAsWorker(int, Runnable)} which restores the previous binding. + * + * @param workerId a non-negative worker id; {@code 0} is the single-threaded/legacy worker + */ + public static void bindWorker(int workerId) { + if (workerId < 0) { + throw new IllegalArgumentException("worker id must be non-negative: " + workerId); + } + CURRENT_WORKER.set(workerId); + } + + /** Resets the calling thread to the default worker ({@code 0}). */ + public static void unbind() { + CURRENT_WORKER.remove(); + } + + /** The worker id currently bound to this thread ({@code 0} if none). */ + public static int currentWorker() { + return CURRENT_WORKER.get(); + } + + /** + * Runs {@code body} with the calling thread bound to {@code workerId}, restoring the previous + * binding afterwards. + * + * @param workerId the worker id to bind for the duration of {@code body} + * @param body the work to run + */ + public static void runAsWorker(int workerId, Runnable body) { + int previous = CURRENT_WORKER.get(); + bindWorker(workerId); + try { + body.run(); + } finally { + CURRENT_WORKER.set(previous); + } + } + + /** + * Returns a fresh name based on {@code base}, unique among all names produced by this + * allocator. + * + * @param base the base name (e.g. a Skolem symbol stem) + * @return a unique fresh name, tagged with the current worker unless it is worker 0 + */ + public String freshName(String base) { + int worker = currentWorker(); + String key = worker + "#" + base; + long n = counters.computeIfAbsent(key, k -> new AtomicLong()).getAndIncrement(); + // Worker 0 keeps the legacy form (base_n); other workers add a disjoint __t segment so + // their names can never coincide with another worker's, regardless of n. + return worker == 0 ? base + "_" + n : base + "_" + n + "__t" + worker; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelProver.java b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelProver.java new file mode 100644 index 00000000000..dc35f375982 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/prover/impl/ParallelProver.java @@ -0,0 +1,512 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.impl; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import de.uka.ilkd.key.java.Services; +import de.uka.ilkd.key.proof.Goal; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; +import de.uka.ilkd.key.settings.ProofSettings; +import de.uka.ilkd.key.settings.StrategySettings; +import de.uka.ilkd.key.strategy.StrategyProperties; + +import org.key_project.prover.engine.GoalChooser; +import org.key_project.prover.engine.TaskStartedInfo; +import org.key_project.prover.engine.impl.ApplyStrategyInfo; +import org.key_project.prover.engine.impl.DefaultProver; +import org.key_project.prover.rules.RuleApp; +import org.key_project.util.collection.ImmutableList; +import org.key_project.util.collection.ImmutableSLList; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Experimental goal-level parallel prover, gated behind {@code -Dkey.prover.parallel}. + * + *

+ * One worker thread per available goal: a {@link GoalScheduler} hands out open goals to a pool of + * workers; each worker applies rules to its goal and offers the resulting subgoals back to the + * scheduler. The run finishes when the scheduler becomes quiescent (no goal available and none in + * flight) or a stop condition fires. Non-proving listeners are suspended for the whole run + * (Phase 1), and each worker is bound to a {@link ParallelNameAllocator} worker id (Phase 2) so + * fresh names stay disjoint across workers. + * + *

+ * Concurrency model. The per-goal rule step splits into a concurrent part and a serialized + * part. Rule selection (matching + cost) and the rule executor ({@link Goal#computeRuleApp}) run in + * parallel across workers; only the proof-tree commit ({@link Goal#commitRuleApp}) runs under a + * single {@code commitLock}, so tree mutation stays mutually exclusive. The scheduler hand-off and + * the run counters (which are atomic) stay outside the lock. Running the executor outside the lock + * is what makes this faster than single-threaded; it is sound because the shared structures the + * executor reaches through the {@link org.key_project.logic.Services} were made thread-safe by the + * shared-state audit (lazy type caches, the specification repository, operator/parametric-function + * interning, built-in-rule instantiation caches, NodeInfo/RuleJustificationInfo, the + * OneStepSimplifier, the shared taclet-index cache), and fresh-name minting is routed through + * {@link ParallelNameAllocator} so the shared namespace stays disjoint across workers. The + * equivalence gate ({@code ProofEquivalenceTest}) and the real-proof gate guard the whole design. + * + *

+ * Worker count comes from {@code -Dkey.prover.parallel.threads} (default 1). + * + * @author Claude (KeY multithreading effort) + */ +public final class ParallelProver extends DefaultProver { + + private static final Logger LOGGER = LoggerFactory.getLogger(ParallelProver.class); + + /** When {@code true}, the auto-prover selection seam builds a {@link ParallelProver}. */ + public static final String PARALLEL_PROPERTY = "key.prover.parallel"; + /** Number of worker threads. */ + public static final String THREADS_PROPERTY = "key.prover.parallel.threads"; + + /** Names worker thread pools uniquely for debugging/profiling. */ + private static final AtomicInteger POOL_COUNTER = new AtomicInteger(); + + /** Number of parallel runs with more than one worker currently in progress (global). */ + private static final AtomicInteger ACTIVE_MULTI_WORKER_RUNS = new AtomicInteger(); + + /** + * Whether a goal-level parallel run with more than one worker is currently in progress. Rules + * that are not yet safe under concurrent goal processing consult this to disable themselves for + * the duration; single-threaded proving (and the old main-thread prover) is unaffected. + * + * @return {@code true} iff at least one multi-worker parallel run is active + * @see de.uka.ilkd.key.rule.merge.MergeRule + */ + public static boolean isMultiThreadedRunActive() { + return ACTIVE_MULTI_WORKER_RUNS.get() > 0; + } + + /** A closeable scope whose {@link #close()} does not throw a checked exception. */ + public interface RunScope extends AutoCloseable { + @Override + void close(); + } + + /** + * Marks a multi-worker run as active until the returned handle is closed. Exposed for the + * prover's own run scope and for tests of the rules that gate on + * {@link #isMultiThreadedRunActive()}. + * + * @return a {@link RunScope} that clears the marker on close + */ + public static RunScope enterMultiThreadedRun() { + ACTIVE_MULTI_WORKER_RUNS.incrementAndGet(); + return ACTIVE_MULTI_WORKER_RUNS::decrementAndGet; + } + + private final int workerCount; + + /** Set once a stop condition fires; the first message wins. */ + private volatile @Nullable String stopMessage; + private volatile boolean stopRequested; + + /** + * How long {@link #awaitWorkerTermination} waits for the workers to wind down after a stop. The + * stop is cooperative (workers leave their claim loop after the current step), so this is only + * a + * safety cap to avoid blocking the caller forever should a worker get genuinely stuck. + */ + private static final long WORKER_SHUTDOWN_TIMEOUT_SECONDS = 60; + private volatile @Nullable Goal nonCloseableGoal; + + /** + * Applied-rule and closed-goal counters maintained lock-free during the run, so the per-step + * bookkeeping does not need the commit lock. They are mirrored into the inherited + * {@code countApplied}/{@code closedGoals} fields when the run finishes (for the result). + */ + private final AtomicInteger appliedSteps = new AtomicInteger(); + private final AtomicInteger closedCount = new AtomicInteger(); + + /** + * @param goalChooser kept for API symmetry with {@link ApplyStrategy}; the parallel engine uses + * a {@link GoalScheduler} for goal selection rather than this chooser + */ + public ParallelProver(GoalChooser goalChooser) { + this.goalChooser = goalChooser; + this.workerCount = resolveWorkerCount(); + } + + /** + * Whether the parallel prover is selected. The system property {@link #PARALLEL_PROPERTY}, when + * set, overrides the user setting (so tests, benchmarks and the CLI can pin a mode regardless + * of + * the persisted preference); otherwise the {@link GeneralSettings#isParallelProverEnabled() + * setting} decides. + */ + public static boolean isEnabled() { + String property = System.getProperty(PARALLEL_PROPERTY); + if (property != null) { + return Boolean.parseBoolean(property); + } + return generalSettings().isParallelProverEnabled(); + } + + /** + * The effective worker count. As with {@link #isEnabled()}, the {@link #THREADS_PROPERTY} + * overrides the setting (and is honoured exactly, allowing the over-subscription the stress + * tests + * rely on); the setting-derived count is clamped to the available processors. + */ + private static int resolveWorkerCount() { + String property = System.getProperty(THREADS_PROPERTY); + if (property != null) { + try { + return Math.max(1, Integer.parseInt(property)); + } catch (NumberFormatException e) { + return 1; + } + } + int configured = generalSettings().getParallelProverThreadCount(); + return Math.max(1, Math.min(configured, Runtime.getRuntime().availableProcessors())); + } + + private static GeneralSettings generalSettings() { + return ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings(); + } + + /** The number of worker threads this prover uses. */ + public int getWorkerCount() { + return workerCount; + } + + @Override + protected final @Nullable RuleApp updateBuiltInRuleIndex(Goal goal, @Nullable RuleApp app) { + // Hack mirrored from ApplyStrategy: built-in rules may become applicable without the + // BuiltInRuleAppIndex noticing. + if (app == null) { + goal.ruleAppIndex().scanBuiltInRules(goal); + app = goal.getRuleAppManager().next(); + } + return app; + } + + @Override + public ApplyStrategyInfo start(Proof proof, Goal goal) { + return start(proof, ImmutableSLList.nil().prepend(goal)); + } + + @Override + public ApplyStrategyInfo start(Proof proof, ImmutableList goals) { + ProofSettings settings = proof.getSettings(); + return start(proof, goals, settings.getStrategySettings()); + } + + @Override + public ApplyStrategyInfo start(Proof proof, ImmutableList goals, + Object strategySettings) { + final StrategySettings stratSet = (StrategySettings) strategySettings; + int maxSteps = stratSet.getMaxSteps(); + long timeout = stratSet.getTimeout(); + boolean stopAtFirstNonCloseableGoal = proof.getSettings().getStrategySettings() + .getActiveStrategyProperties().getProperty(StrategyProperties.STOPMODE_OPTIONS_KEY) + .equals(StrategyProperties.STOPMODE_NONCLOSE); + return start(proof, goals, maxSteps, timeout, stopAtFirstNonCloseableGoal); + } + + @Override + public ApplyStrategyInfo start(Proof proof, ImmutableList goals, + int maxSteps, + long timeout, boolean stopAtFirstNonCloseableGoal) { + assert proof != null; + this.proof = proof; + this.stopAtFirstNonClosableGoal = stopAtFirstNonCloseableGoal; + this.stopCondition = + proof.getSettings().getStrategySettings().getApplyStrategyStopCondition(); + this.maxApplications = stopCondition.getMaximalWork(maxSteps, timeout); + this.timeout = timeout; + this.countApplied = 0; + this.closedGoals = 0; + this.appliedSteps.set(0); + this.closedCount.set(0); + this.cancelled = false; + this.stopRequested = false; + this.stopMessage = null; + this.nonCloseableGoal = null; + + fireTaskStarted(new DefaultTaskStartedInfo(TaskStartedInfo.TaskKind.Strategy, + PROCESSING_STRATEGY, maxApplications)); + + ApplyStrategyInfo result = runParallel(goals); + + proof.addAutoModeTime(result.getTime()); + fireTaskFinished(new DefaultTaskFinishedInfo(this, result, proof, result.getTime(), + result.getNumberOfAppliedRuleApps(), result.getNumberOfClosedGoals())); + return result; + } + + private ApplyStrategyInfo runParallel(ImmutableList goals) { + final long startTime = System.currentTimeMillis(); + final GoalScheduler scheduler = new GoalScheduler<>(); + scheduler.offerAll(goals); + final Object commitLock = new Object(); + + final int poolId = POOL_COUNTER.incrementAndGet(); + final ExecutorService pool = Executors.newFixedThreadPool(workerCount, r -> { + Thread t = new Thread(r, "key-prover-" + poolId); + t.setDaemon(true); + return t; + }); + + @Nullable + Throwable error = null; + // While more than one worker runs, advertise that a multi-threaded run is active so that + // rules not yet safe under concurrency (e.g. MergeRule) disable themselves. + final RunScope mtScope = workerCount > 1 ? enterMultiThreadedRun() : () -> { + }; + try (var ignored = proof.suspendNonEssentialListeners(); mtScope) { + List> futures = new ArrayList<>(workerCount); + try { + for (int w = 0; w < workerCount; w++) { + final int workerId = w; + futures.add( + pool.submit(() -> workerLoop(workerId, scheduler, commitLock, startTime))); + } + for (Future f : futures) { + f.get(); + } + } catch (ExecutionException e) { + error = e.getCause() != null ? e.getCause() : e; + LOGGER.warn("parallel proof run failed", error); + } catch (InterruptedException e) { + cancelled = true; + } finally { + // Stop the workers and WAIT for them to actually finish while the non-essential + // (GUI) proof-tree listeners are STILL suspended by the enclosing + // try-with-resources -- the inner finally runs before the resource is closed. If we + // returned (and let the listeners be re-attached) while a worker was still + // mid-step, + // that worker would deliver a proofExpanded event into the live Swing proof-tree + // model off the EDT and deadlock against it: the EDT holds the AWT tree lock and + // wants the GUIProofTreeModel monitor, while the worker holds that monitor and + // wants + // the AWT tree lock. Setting stopRequested makes the workers leave their claim + // loop; + // shutdownNow unblocks any parked in the scheduler. + stopRequested = true; + pool.shutdownNow(); + awaitWorkerTermination(pool); + } + } + + // Publish the lock-free counters into the inherited fields for the result. + countApplied = appliedSteps.get(); + closedGoals = closedCount.get(); + + long time = System.currentTimeMillis() - startTime; + final String message; + if (error != null) { + message = "Error."; + } else if (cancelled) { + message = "Interrupted."; + } else if (stopMessage != null) { + message = stopMessage; + } else { + message = "No more rules automatically applicable to any goal."; + } + return new ApplyStrategyInfo<>(message, proof, error, nonCloseableGoal, time, countApplied, + closedGoals); + } + + /** + * Waits for the worker pool to terminate after {@link ExecutorService#shutdownNow()}. The wait + * runs with the calling thread's interrupt status cleared (the run is often stopped by + * interrupting this very thread, which would otherwise make {@code awaitTermination} return + * immediately and defeat the purpose) and restores the status afterwards. The generous timeout + * accommodates a worker that is mid-step (e.g. a large taclet match) when asked to stop; the + * stop is cooperative, so it returns as soon as the last worker leaves its step. + */ + private void awaitWorkerTermination(ExecutorService pool) { + boolean wasInterrupted = Thread.interrupted(); + try { + if (!pool.awaitTermination(WORKER_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + LOGGER.warn("parallel prover workers did not stop within {}s of a stop request", + WORKER_SHUTDOWN_TIMEOUT_SECONDS); + } + } catch (InterruptedException e) { + wasInterrupted = true; + } + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + + /** A single worker: claim goals and process them until the queue is quiescent or stopped. */ + private void workerLoop(int workerId, GoalScheduler scheduler, Object commitLock, + long startTime) { + // Bind a per-worker name recorder so the rule executor's fresh-name proposals (made in the + // lock-free compute phase) do not race on the shared one. Only relevant at >1 worker. + Services.bindWorkerNameRecorder(); + try { + ParallelNameAllocator.runAsWorker(workerId, () -> { + try { + Goal goal; + while (!stopRequested && (goal = scheduler.claimOrAwait()) != null) { + processStep(goal, scheduler, commitLock, startTime); + } + } catch (InterruptedException e) { + cancelled = true; + Thread.currentThread().interrupt(); + } + }); + } finally { + Services.unbindWorkerNameRecorder(); + } + } + + /** + * Performs one rule step on {@code goal}: select a rule, apply it, and offer the resulting open + * subgoals back to the scheduler. + * + *

+ * Rule selection runs outside the commit lock — this is where the time goes (taclet + * matching + strategy cost), and it is safe to run concurrently: the goal is owned exclusively + * by this worker (the scheduler hands each goal to one worker), the shared proof namespace is + * immutable during the run (see {@code Goal#adaptNamespacesNewGoals}), fresh names are + * worker-disjoint ({@link ParallelNameAllocator}), and the strategy caches are thread-safe + * (shared exact-LRU). The commit lock wraps only {@code Goal#commitRuleApp} — the + * proof-tree mutation, the one genuinely shared non-thread-safe step. The scheduler hand-off, + * counters, progress and stop check run outside it (the scheduler has its own monitor and the + * counters are atomic), so workers serialize on nothing but the tree mutation itself. + */ + private void processStep(Goal goal, GoalScheduler scheduler, Object commitLock, + long startTime) { + // Whether the goal was handed back to the scheduler in this step (stalled, or completed and + // its successors offered). If it leaves the step any other way -- stop requested, or the + // rule aborted -- the finally below drops it from the in-flight set. Exactly one of these + // happens per goal, which is what keeps the scheduler's accounting (and termination) sound. + boolean handled = false; + try { + if (stopRequested) { + return; + } + // --- selection: concurrent across workers, no commit lock held --- + // countApplied is read without the lock; a slightly stale value only affects the + // step-limit heuristic (at worst a tiny overshoot), never soundness. + if (!stopCondition.isGoalAllowed(goal, maxApplications, timeout, startTime, + appliedSteps.get())) { + requestStop(stopCondition.getGoalNotAllowedMessage(goal, maxApplications, + timeout, startTime, appliedSteps.get()), null); + return; + } + + RuleApp app = goal.getRuleAppManager().next(); + app = updateBuiltInRuleIndex(goal, app); + + if (app == null) { + // The strategy currently offers no rule for this goal. With STOPMODE_NONCLOSE that + // is a hard stop; otherwise the goal is *stalled*, not dropped: KeY's cost is + // age-dependent, so the goal may gain an applicable rule once other goals advance + // the + // proof. The scheduler retries stalled goals after progress and only abandons them + // once a whole cycle makes none -- mirroring the single-threaded chooser, which + // retries goals across passes instead of discarding them on the first empty pass. + if (stopAtFirstNonClosableGoal) { + requestStop("Could not close goal.", goal); + } else { + scheduler.stall(goal); + handled = true; + } + return; + } + + // --- apply: the executor runs concurrently; only the tree commit is serialized --- + // computeRuleApp (rule execution / term construction) is safe to run in parallel: the + // shared structures it reaches through the Services were made thread-safe by the + // shared-state audit. Only commitRuleApp -- the proof-tree mutation -- needs the commit + // lock; the scheduler hand-off, the atomic counters and the stop check stay outside it. + Goal.PendingRuleApp pending = goal.computeRuleApp(app); + if (pending == null) { + // The rule application aborted. The candidate rule was already removed from the + // goal's queue by next() above, so re-offer the goal to retry with its next + // candidate instead of dropping it -- dropping loses the goal and can leave the + // proof open. This mirrors the single-threaded prover, which retries the goal after + // an aborted application rather than discarding it. + scheduler.reoffer(goal); + handled = true; + return; + } + + final ImmutableList goalList; + synchronized (commitLock) { + goalList = goal.commitRuleApp(pending); + } + + final int applied = appliedSteps.incrementAndGet(); + fireTaskProgress(); + + // Partition the successors into closed (just counted) and open (to be rescheduled). + int closedNow = 0; + List open = null; + if (goalList != null) { + closedNow = goalList.isEmpty() ? 1 : 0; + for (Goal g : goalList) { + if (g.node().isClosed()) { + closedNow++; + } else { + if (open == null) { + open = new ArrayList<>(2); + } + open.add(g); + } + } + } + // Complete this goal and offer its open successors as one atomic scheduler step (see + // GoalScheduler#completeAndOffer): a separate complete-then-offer would let another + // worker observe the queue momentarily empty in between and stop the search early. + scheduler.completeAndOffer(goal, open); + handled = true; + if (closedNow > 0) { + closedCount.addAndGet(closedNow); + } + + if (stopCondition.shouldStop(maxApplications, timeout, startTime, applied, null)) { + requestStop(stopCondition.getStopMessage(maxApplications, timeout, startTime, + applied, null), null); + } + } finally { + // Backstop for goals that left the step early (stop requested, or rule aborted): + // release + // them from the in-flight set so the scheduler can still reach quiescence. The handled + // paths above already accounted for the goal, so this runs only when none of them did. + if (!handled) { + scheduler.complete(goal); + } + } + } + + /** Records the first stop reason and signals all workers to finish. */ + private void requestStop(String message, @Nullable Goal goal) { + if (!stopRequested) { + stopMessage = message; + nonCloseableGoal = goal; + stopRequested = true; + } + } + + @Override + public synchronized void clear() { + proof = null; + if (goalChooser != null) { + goalChooser.init(null, ImmutableSLList.nil()); + } + } + + @Override + public boolean hasBeenInterrupted() { + return cancelled; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/AbstractLoopInvariantRule.java b/key.core/src/main/java/de/uka/ilkd/key/rule/AbstractLoopInvariantRule.java index 126ef455d22..dae0f3dc016 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/AbstractLoopInvariantRule.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/AbstractLoopInvariantRule.java @@ -47,17 +47,6 @@ */ public abstract class AbstractLoopInvariantRule implements BuiltInRule { - /** - * The last formula the loop invariant rule was applied to. Used for checking whether - * {@link #lastInstantiation} can be used instead of doing a new instantiation. - */ - private static JTerm lastFocusTerm; - - /** - * A simple cache which ensures that we don't instantiate the rule multiple times for the same - * loop. - */ - private static Instantiation lastInstantiation; /** * @return The number of generated goals by this invariant rule. @@ -373,13 +362,17 @@ protected static JTerm and(TermBuilder tb, JTerm t1, JTerm t2) { */ protected static Instantiation instantiate(final LoopInvariantBuiltInRuleApp app, Services services) throws RuleAbortException { - final JTerm focusTerm = (JTerm) app.posInOccurrence().subTerm(); - - if (focusTerm == lastFocusTerm && lastInstantiation.inv == services - .getSpecificationRepository().getLoopSpec(lastInstantiation.loop)) { - return lastInstantiation; + // Use the instantiation cached on this (thread-confined) rule app if it is still valid, + // i.e. + // the loop spec has not changed since it was computed. + final Instantiation cached = app.getInstantiation(); + if (cached != null && cached.inv() == services.getSpecificationRepository() + .getLoopSpec(cached.loop())) { + return cached; } + final JTerm focusTerm = (JTerm) app.posInOccurrence().subTerm(); + // leading update? final Pair update = splitUpdates(focusTerm, services); final JTerm u = update.first; @@ -419,8 +412,7 @@ protected static Instantiation instantiate(final LoopInvariantBuiltInRuleApp app final Instantiation result = new Instantiation( // u, progPost, loop, spec, selfTerm, innermostExecutionContext); - lastFocusTerm = focusTerm; - lastInstantiation = result; + app.setInstantiation(result); return result; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/LoopInvariantBuiltInRuleApp.java b/key.core/src/main/java/de/uka/ilkd/key/rule/LoopInvariantBuiltInRuleApp.java index 602651e29aa..3138932e0b0 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/LoopInvariantBuiltInRuleApp.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/LoopInvariantBuiltInRuleApp.java @@ -53,6 +53,24 @@ public class LoopInvariantBuiltInRuleApp protected final TermServices services; + /** + * Cached instantiation of the loop-invariant rule for this application. Stored here (on the + * thread-confined rule app) rather than in a static field on the rule, so that the cache is + * safe + * under the parallel prover and properly scoped to the application it belongs to. + */ + private AbstractLoopInvariantRule.@Nullable Instantiation instantiation; + + /** @return the cached {@link AbstractLoopInvariantRule.Instantiation}, or {@code null} */ + AbstractLoopInvariantRule.@Nullable Instantiation getInstantiation() { + return instantiation; + } + + /** Caches the {@link AbstractLoopInvariantRule.Instantiation} for this application. */ + void setInstantiation(AbstractLoopInvariantRule.Instantiation instantiation) { + this.instantiation = instantiation; + } + public LoopInvariantBuiltInRuleApp(T rule, PosInOccurrence pos, TermServices services) { this(rule, pos, null, null, null, services); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/ObserverToUpdateRule.java b/key.core/src/main/java/de/uka/ilkd/key/rule/ObserverToUpdateRule.java index f5ad34e4c84..fb6322916c6 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/ObserverToUpdateRule.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/ObserverToUpdateRule.java @@ -67,10 +67,15 @@ public final class ObserverToUpdateRule implements BuiltInRule { private static final Name NAME = new Name("Observer to update"); /** - * caching matching results + * caching matching results; thread-local because {@code instantiate} is called from the + * applicability check (no rule app available) and this rule INSTANCE is shared via the taclet + * base -- a plain static cache raced across concurrent workers. Confining it to the worker + * thread + * keeps the optimization (applicability + apply run on the same worker). */ - private static JTerm lastFocusTerm; - private static Union lastInstantiation; + private static final ThreadLocal lastFocusTerm = new ThreadLocal<>(); + private static final ThreadLocal> lastInstantiation = + new ThreadLocal<>(); // ------------------------------------------------------------------------- // constructors @@ -402,27 +407,25 @@ private static ModelFieldInstantiation matchModelField(JTerm focusTerm, Services private static Union instantiate(JTerm focusTerm, Services services) { - // result cached? - if (focusTerm == lastFocusTerm) { - return lastInstantiation; + // result cached (per worker thread)? + if (focusTerm == lastFocusTerm.get()) { + return lastInstantiation.get(); } // compute + final Union result; Instantiation inst = UseOperationContractRule.computeInstantiation(focusTerm, services); if (inst != null) { - lastInstantiation = Union.fromFirst(inst); + result = Union.fromFirst(inst); } else { ModelFieldInstantiation mfInst = matchModelField(focusTerm, services); - if (mfInst != null) { - lastInstantiation = Union.fromSecond(mfInst); - } else { - lastInstantiation = null; - } + result = mfInst != null ? Union.fromSecond(mfInst) : null; } // cache and return - lastFocusTerm = focusTerm; - return lastInstantiation; + lastFocusTerm.set(focusTerm); + lastInstantiation.set(result); + return result; } // endregion diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/OneStepSimplifier.java b/key.core/src/main/java/de/uka/ilkd/key/rule/OneStepSimplifier.java index c04e03ef185..8591d56a9d9 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/OneStepSimplifier.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/OneStepSimplifier.java @@ -42,7 +42,7 @@ import org.key_project.prover.rules.instantiation.AssumesFormulaInstDirect; import org.key_project.prover.rules.instantiation.AssumesFormulaInstantiation; import org.key_project.prover.sequent.*; -import org.key_project.util.LRUCache; +import org.key_project.util.StripedLruCache; import org.key_project.util.collection.ImmutableArray; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -65,6 +65,10 @@ public final class OneStepSimplifier implements BuiltInRule { private static final int APPLICABILITY_CACHE_SIZE = 1000; private static final int DEFAULT_CACHE_SIZE = 10000; + /** + * Lock stripes for the OSS caches; OSS runs concurrently on every worker, so split the lock. + */ + private static final int OSS_CACHE_STRIPES = 16; /** * Represents a list of rule applications performed in one OSS step. @@ -86,14 +90,28 @@ public static final class Protocol extends ArrayList { .append("update_apply").append("update_join").append("elimQuantifier"); private static final boolean[] bottomUp = { false, false, false, true, true, true, false }; - private final Map applicabilityCache = - new LRUCache<>(APPLICABILITY_CACHE_SIZE); + // OSS is shared per proof and runs concurrently on every parallel-prover worker. Its two caches + // are PURE (the value is a function of the key: "does this formula simplify?" / "this term is + // irreducible"), so eviction order is irrelevant to the result and a striped (lower-contention) + // cache is sound. They are therefore the only state the hot path (isApplicable/apply) touches + // besides the goal it owns, so that path needs no lock. + private final StripedLruCache applicabilityCache = + new StripedLruCache<>(APPLICABILITY_CACHE_SIZE, OSS_CACHE_STRIPES); + + /** + * Guards the (re)build/teardown of the per-proof state below (refresh/initIndices/ + * shutdownIndices). That family runs at proof setup / settings changes, never concurrently with + * proving (e.g. ProofStarter calls refreshOSS just before starting the prover), so the hot path + * may read {@link #indices}/{@link #active} lock-free; the volatile modifiers and the + * publish-after-build in initIndices give the necessary visibility. + */ + private final Object refreshLock = new Object(); private Proof lastProof; private ImmutableList appsTakenOver; - private TacletIndex[] indices; - private Map[] notSimplifiableCaches; - private boolean active; + private volatile TacletIndex[] indices; + private volatile StripedLruCache[] notSimplifiableCaches; + private volatile boolean active; // ------------------------------------------------------------------------- // constructors @@ -191,17 +209,22 @@ private void initIndices(Proof proof) { shutdownIndices(); lastProof = proof; appsTakenOver = ImmutableSLList.nil(); - indices = new TacletIndex[ruleSets.size()]; - notSimplifiableCaches = (Map[]) new LRUCache[indices.length]; + // Build into locals, then publish to the volatile fields in one write each, so a + // (hypothetical) concurrent reader never sees a half-filled array. + final TacletIndex[] newIndices = new TacletIndex[ruleSets.size()]; + final StripedLruCache[] newCaches = + (StripedLruCache[]) new StripedLruCache[newIndices.length]; int i = 0; ImmutableList done = ImmutableSLList.nil(); for (String ruleSet : ruleSets) { ImmutableList taclets = tacletsForRuleSet(proof, ruleSet, done); - indices[i] = TacletIndexKit.getKit().createTacletIndex(taclets); - notSimplifiableCaches[i] = new LRUCache<>(DEFAULT_CACHE_SIZE); + newIndices[i] = TacletIndexKit.getKit().createTacletIndex(taclets); + newCaches[i] = new StripedLruCache<>(DEFAULT_CACHE_SIZE, OSS_CACHE_STRIPES); i++; done = done.prepend(ruleSet); } + indices = newIndices; + notSimplifiableCaches = newCaches; } } @@ -210,23 +233,25 @@ private void initIndices(Proof proof) { * Deactivate one-step simplification: clear caches, restore taclets to the goals' taclet * indices. */ - public synchronized void shutdownIndices() { - if (lastProof != null) { - if (!lastProof.isDisposed()) { - // We need to treat all goals here instead of just open goals; - // otherwise pruning a (partially) closed proof leads to errors where - // some rule applications are missing. - for (Goal g : lastProof.allGoals()) { - g.ruleAppIndex().addNoPosTacletApp(appsTakenOver); - g.getRuleAppManager().clearCache(); - g.ruleAppIndex().clearIndexes(); + public void shutdownIndices() { + synchronized (refreshLock) { + if (lastProof != null) { + if (!lastProof.isDisposed()) { + // We need to treat all goals here instead of just open goals; + // otherwise pruning a (partially) closed proof leads to errors where + // some rule applications are missing. + for (Goal g : lastProof.allGoals()) { + g.ruleAppIndex().addNoPosTacletApp(appsTakenOver); + g.getRuleAppManager().clearCache(); + g.ruleAppIndex().clearIndexes(); + } } + applicabilityCache.clear(); + lastProof = null; + appsTakenOver = null; + indices = null; + notSimplifiableCaches = null; } - applicabilityCache.clear(); - lastProof = null; - appsTakenOver = null; - indices = null; - notSimplifiableCaches = null; } } @@ -521,7 +546,7 @@ private Instantiation computeInstantiation(PosInOccurrence ossPIO, /** * Tells whether the passed formula can be simplified */ - private synchronized boolean applicableTo(Services services, + private boolean applicableTo(Services services, SequentFormula cf, boolean inAntecedent, Goal goal, RuleApp ruleApp) { final Boolean b = applicabilityCache.get(cf); @@ -538,22 +563,24 @@ private synchronized boolean applicableTo(Services services, } } - private synchronized void refresh(Proof proof) { - ProofSettings settings = proof.getSettings(); - if (settings == null) { - settings = ProofSettings.DEFAULT_SETTINGS; - } + private void refresh(Proof proof) { + synchronized (refreshLock) { + ProofSettings settings = proof.getSettings(); + if (settings == null) { + settings = ProofSettings.DEFAULT_SETTINGS; + } - final boolean newActive = settings.getStrategySettings().getActiveStrategyProperties() - .get(StrategyProperties.OSS_OPTIONS_KEY).equals(StrategyProperties.OSS_ON); + final boolean newActive = settings.getStrategySettings().getActiveStrategyProperties() + .get(StrategyProperties.OSS_OPTIONS_KEY).equals(StrategyProperties.OSS_ON); - if (active != newActive || lastProof != proof || // The setting or proof has changed. - (isShutdown() && !proof.closed())) { // A closed proof was pruned. - active = newActive; - if (active && proof != null && !proof.closed()) { - initIndices(proof); - } else { - shutdownIndices(); + if (active != newActive || lastProof != proof || // The setting or proof has changed. + (isShutdown() && !proof.closed())) { // A closed proof was pruned. + active = newActive; + if (active && proof != null && !proof.closed()) { + initIndices(proof); + } else { + shutdownIndices(); + } } } } @@ -601,7 +628,7 @@ public boolean isApplicable(Goal goal, PosInOccurrence pio) { } @Override - public synchronized @NonNull ImmutableList apply(Goal goal, RuleApp ruleApp) { + public @NonNull ImmutableList apply(Goal goal, RuleApp ruleApp) { assert ruleApp instanceof OneStepSimplifierRuleApp : "The rule app must be suitable for OSS"; @@ -681,9 +708,13 @@ public String toString() { */ public Set getCapturedTaclets() { Set result = new LinkedHashSet<>(); - synchronized (this) { - for (TacletIndex index : indices) { - result.addAll(index.allNoPosTacletApps()); + // Guard against a concurrent refresh/shutdown nulling or rebuilding the index array. + synchronized (refreshLock) { + final TacletIndex[] currentIndices = indices; + if (currentIndices != null) { + for (TacletIndex index : currentIndices) { + result.addAll(index.allNoPosTacletApps()); + } } } return result; diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/RewriteTaclet.java b/key.core/src/main/java/de/uka/ilkd/key/rule/RewriteTaclet.java index 95de43b22f2..7d1cd26f955 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/RewriteTaclet.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/RewriteTaclet.java @@ -37,6 +37,7 @@ * structure described by the term of the find-part. */ public class RewriteTaclet extends FindTaclet { + /** * creates a Schematic Theory Specific Rule (Taclet) with the given parameters that represents a * rewrite rule. @@ -108,6 +109,13 @@ private boolean veto(JTerm t) { public MatchConditions checkPrefix( PosInOccurrence p_pos, MatchConditions p_mc) { + // Fast path: for an unrestricted taclet the loop below only vetoes on a Transformer on the + // path; if the formula has none at all (cached), neither can the prefix, so the O(depth) + // walk is skipped. + if (applicationRestriction().equals(ApplicationRestriction.NONE) + && !((JTerm) p_pos.sequentFormula().formula()).containsTransformerRecursive()) { + return p_mc; + } int polarity = p_pos.isInAntec() ? -1 : 1; // init polarity SVInstantiations svi = p_mc.getInstantiations(); // this is assumed to hold diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/UseOperationContractRule.java b/key.core/src/main/java/de/uka/ilkd/key/rule/UseOperationContractRule.java index 3780c359933..4ca181f6762 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/UseOperationContractRule.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/UseOperationContractRule.java @@ -61,8 +61,16 @@ public class UseOperationContractRule implements BuiltInRule, ComplexJustificati private static final Name NAME = new Name("Use Operation Contract"); - private static JTerm lastFocusTerm; - private static Instantiation lastInstantiation; + /** + * Single-entry memo to avoid recomputing the instantiation between the applicability check and + * the application of the same focus term. Thread-local because {@code instantiate} is called + * from + * {@code isApplicable} (which has no rule app to attach to) and the rule INSTANCE lives in the + * shared taclet base: a plain static cache raced across concurrent workers. Confined to the + * worker thread it preserves the optimization (applicability + apply run on the same worker). + */ + private static final ThreadLocal lastFocusTerm = new ThreadLocal<>(); + private static final ThreadLocal lastInstantiation = new ThreadLocal<>(); // ------------------------------------------------------------------------- // constructors @@ -358,17 +366,17 @@ public static StatementBlock replaceStatement(JavaBlock jb, StatementBlock repla } private static Instantiation instantiate(JTerm focusTerm, Services services) { - // result cached? - if (focusTerm == lastFocusTerm) { - return lastInstantiation; + // result cached (per worker thread)? + if (focusTerm == lastFocusTerm.get()) { + return lastInstantiation.get(); } // compute final Instantiation result = computeInstantiation(focusTerm, services); // cache and return - lastFocusTerm = focusTerm; - lastInstantiation = result; + lastFocusTerm.set(focusTerm); + lastInstantiation.set(result); return result; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/executor/javadl/RewriteTacletExecutor.java b/key.core/src/main/java/de/uka/ilkd/key/rule/executor/javadl/RewriteTacletExecutor.java index 9f5ffd143d1..76a204ec8aa 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/executor/javadl/RewriteTacletExecutor.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/executor/javadl/RewriteTacletExecutor.java @@ -15,7 +15,7 @@ import de.uka.ilkd.key.rule.TacletApp; import de.uka.ilkd.key.rule.tacletbuilder.RewriteTacletGoalTemplate; -import org.key_project.logic.IntIterator; +import org.key_project.logic.PosInTerm; import org.key_project.logic.sort.Sort; import org.key_project.prover.rules.ApplicationRestriction; import org.key_project.prover.rules.instantiation.MatchResultInfo; @@ -36,18 +36,20 @@ public RewriteTacletExecutor(RewriteTaclet taclet) { */ private JTerm replace(JTerm term, JTerm with, TermLabelState termLabelState, TacletLabelHint labelHint, PosInOccurrence posOfFind, - IntIterator it, + PosInTerm pit, int depthIdx, MatchResultInfo mc, Sort maxSort, Goal goal, Services services, TacletApp ruleApp) { - if (it.hasNext()) { - final int indexOfNextSubTerm = it.next(); + // walk the find-position by index instead of via PosInTerm.iterator(), to avoid allocating + // a PiTIterator per rule application (same indices/order as the forward iterator). + if (depthIdx < pit.depth()) { + final int indexOfNextSubTerm = pit.getIndexAt(depthIdx); final JTerm[] subs = new JTerm[term.arity()]; term.subs().arraycopy(0, subs, 0, term.arity()); final Sort newMaxSort = TermHelper.getMaxSort(term, indexOfNextSubTerm); subs[indexOfNextSubTerm] = replace(term.sub(indexOfNextSubTerm), with, termLabelState, - labelHint, posOfFind, it, mc, newMaxSort, goal, services, ruleApp); + labelHint, posOfFind, pit, depthIdx + 1, mc, newMaxSort, goal, services, ruleApp); return services.getTermFactory().createTerm(term.op(), subs, term.boundVars(), term.getLabels()); @@ -71,11 +73,10 @@ private SequentFormula applyReplacewithHelper(Goal goal, MatchResultInfo matchCond, TacletApp ruleApp) { final JTerm term = (JTerm) posOfFind.sequentFormula().formula(); - final IntIterator it = posOfFind.posInTerm().iterator(); final JTerm rwTemplate = gt.replaceWith(); JTerm formula = replace(term, rwTemplate, termLabelState, new TacletLabelHint(rwTemplate), - posOfFind, it, matchCond, term.sort(), goal, services, ruleApp); + posOfFind, posOfFind.posInTerm(), 0, matchCond, term.sort(), goal, services, ruleApp); formula = TermLabelManager.refactorSequentFormula(termLabelState, services, formula, posOfFind, taclet, goal, null, rwTemplate); if (term == formula) { diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ElementaryUpdateHead.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ElementaryUpdateHead.java new file mode 100644 index 00000000000..cbf6531f410 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ElementaryUpdateHead.java @@ -0,0 +1,73 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import java.util.List; + +import de.uka.ilkd.key.logic.op.ElementaryUpdate; +import de.uka.ilkd.key.logic.op.LocationVariable; + +import org.key_project.logic.Term; +import org.key_project.logic.op.sv.SchemaVariable; +import org.key_project.prover.rules.matcher.compiler.MatchHead; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getCheckNodeKindInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchIdentityInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchInstructionForSV; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.gotoNextInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.gotoNextSiblingInstruction; + +/** + * Match head for an {@link ElementaryUpdate} {@code lhs := value}: it matches the operator and the + * left-hand side; the value subterm is matched by the enclosing + * {@link org.key_project.prover.rules.matcher.compiler.OperatorPlan}. Mirrors the elementary-update + * fragments of the hand-written interpreter generator and compiled matcher. + */ +public final class ElementaryUpdateHead implements MatchHead { + + private final MatchInstruction lhsMatcher; + /** whether the left-hand side is a schema variable (it advances by sibling, not by descent). */ + private final boolean lhsIsSchemaVariable; + + private ElementaryUpdateHead(MatchInstruction lhsMatcher, boolean lhsIsSchemaVariable) { + this.lhsMatcher = lhsMatcher; + this.lhsIsSchemaVariable = lhsIsSchemaVariable; + } + + /** + * @param elUp the elementary update pattern + * @return a head for {@code elUp}, or {@code null} if its left-hand side is neither a schema + * variable nor a concrete location variable (then the caller falls back) + */ + public static @Nullable ElementaryUpdateHead of(ElementaryUpdate elUp) { + if (elUp.lhs() instanceof SchemaVariable sv) { + return new ElementaryUpdateHead(getMatchInstructionForSV(sv), true); + } else if (elUp.lhs() instanceof LocationVariable locVar) { + return new ElementaryUpdateHead(getMatchIdentityInstruction(locVar), false); + } + return null; + } + + @Override + public void emit(List out) { + out.add(getCheckNodeKindInstruction(ElementaryUpdate.class)); + out.add(gotoNextInstruction()); + out.add(lhsMatcher); + out.add(lhsIsSchemaVariable ? gotoNextSiblingInstruction() : gotoNextInstruction()); + } + + @Override + public MatchProgram compileHeadCheck() { + final MatchInstruction lhs = lhsMatcher; + return (element, mc, + services) -> ((Term) element).op() instanceof ElementaryUpdate actualElUp + ? lhs.match(actualElUp.lhs(), mc, services) + : null; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaBinderMatcher.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaBinderMatcher.java new file mode 100644 index 00000000000..15d74b455c4 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaBinderMatcher.java @@ -0,0 +1,46 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import de.uka.ilkd.key.rule.MatchConditions; +import de.uka.ilkd.key.rule.match.vm.instructions.UnbindVariablesInstruction; + +import org.key_project.logic.op.QuantifiableVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.compiler.BinderMatcher; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; +import org.key_project.util.collection.ImmutableArray; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.matchAndBindVariables; + +/** + * Java-DL implementation of the {@link BinderMatcher} SPI: bound variables are bound via the + * {@code matchAndBindVariables} instruction and the renaming scope is popped via + * {@link UnbindVariablesInstruction} (interpreter) / {@link MatchConditions#shrinkRenameTable()} + * (compiled). + */ +public final class JavaBinderMatcher implements BinderMatcher { + + /** stateless; a single shared instance suffices. */ + public static final JavaBinderMatcher INSTANCE = new JavaBinderMatcher(); + + private JavaBinderMatcher() {} + + @SuppressWarnings("unchecked") + @Override + public MatchInstruction binder(ImmutableArray boundVars) { + return matchAndBindVariables((ImmutableArray) boundVars); + } + + @Override + public VMInstruction unbinderInstruction() { + return new UnbindVariablesInstruction(); + } + + @Override + public MatchResultInfo unbind(MatchResultInfo mc) { + return ((MatchConditions) mc).shrinkRenameTable(); + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaMatchPlanBuilder.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaMatchPlanBuilder.java new file mode 100644 index 00000000000..06471eb8003 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaMatchPlanBuilder.java @@ -0,0 +1,196 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import java.util.ArrayList; +import java.util.List; + +import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.logic.op.ElementaryUpdate; +import de.uka.ilkd.key.logic.op.ParametricFunctionInstance; + +import org.key_project.logic.op.Modality; +import org.key_project.logic.op.Operator; +import org.key_project.logic.op.sv.SchemaVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.compiler.GenericOperatorHead; +import org.key_project.prover.rules.matcher.compiler.MatchHead; +import org.key_project.prover.rules.matcher.compiler.MatchPlan; +import org.key_project.prover.rules.matcher.compiler.OperatorPlan; +import org.key_project.prover.rules.matcher.compiler.SchemaVarPlan; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchInstructionForSV; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.matchTermLabelSV; + +/** + * The single Java-DL dispatch that builds a {@link MatchPlan} for a find pattern, from which both + * the interpreter and the compiled find-matcher are derived. Describing a construct here gives both + * back-ends at once -- this is the sole source of truth for find-matching; there is no separate + * hand-written interpreter generator or compiled matcher to keep in sync. + * + *

+ * It covers the whole Java-DL find-taclet base: the FOL term skeleton (schema variables, ordinary + * operators with their subterms, bound variables), term labels, elementary updates, parametric + * function instances and modalities (the Java program is matched through a + * {@link org.key_project.prover.rules.matcher.compiler.ProgramMatchHook}). A pattern the dispatch + * genuinely cannot model yields {@code null} and the {@linkplain #interpreterProgram facades} raise + * a clear error pointing at the missing head (no current taclet hits this; adding a construct means + * adding one head, see the developer docs). + */ +public final class JavaMatchPlanBuilder { + + private JavaMatchPlanBuilder() {} + + /** + * Builds the interpreter program for {@code pattern} through the match-plan framework, reading + * the {@code key.matcher.programInstructions} flag. + * + * @param pattern the find / assumes pattern + * @return the VM instruction program + */ + public static VMInstruction[] interpreterProgram(JTerm pattern) { + return interpreterProgram(pattern, + Boolean.getBoolean(SyntaxElementMatchProgramGenerator.PROGRAM_INSTRUCTIONS_PROPERTY)); + } + + /** + * Builds the interpreter program for {@code pattern} through the match-plan framework. + * + * @param pattern the find / assumes pattern + * @param programInstructions whether modality programs are converted to VM instructions + * @return the VM instruction program + */ + public static VMInstruction[] interpreterProgram(JTerm pattern, boolean programInstructions) { + final List out = new ArrayList<>(); + planOrThrow(pattern, programInstructions).emitInstructions(out); + return out.toArray(new VMInstruction[0]); + } + + /** + * Builds the cursor-free compiled matcher for {@code pattern} through the match-plan framework. + * + * @param pattern the find pattern + * @return the compiled matcher + */ + public static MatchProgram compiledProgram(JTerm pattern) { + return planOrThrow(pattern, false).compile(); + } + + /** + * Like {@link #compiledProgram(JTerm)}, but returns {@code null} instead of throwing when the + * dispatch has no head for {@code pattern} (so the caller can fall back to the interpreter). + * Used for {@code \assumes} formulas, which are not guaranteed to be among the patterns the + * find-matcher coverage is validated against. + * + * @param pattern the find / assumes pattern + * @return the compiled matcher, or {@code null} if the pattern is not compilable + */ + public static @Nullable MatchProgram compiledProgramOrNull(JTerm pattern) { + final MatchPlan plan = buildPlan(pattern, false); + return plan == null ? null : plan.compile(); + } + + private static MatchPlan planOrThrow(JTerm pattern, boolean programInstructions) { + final MatchPlan plan = buildPlan(pattern, programInstructions); + if (plan == null) { + throw new UnsupportedOperationException( + "the match-plan framework has no head for this find pattern (op " + + pattern.op() + "); add one (see the taclet-matching developer docs): " + + pattern); + } + return plan; + } + + /** + * Builds a match plan for {@code pattern}, or returns {@code null} if it uses a construct the + * dispatch cannot model (no current taclet does). + * + * @param pattern the find (sub)pattern + * @param programInstructions whether modality programs are converted to VM instructions on the + * interpreter side (irrelevant for the FOL skeleton and the compiled back-end) + * @return a match plan, or {@code null} + */ + public static @Nullable MatchPlan buildPlan(JTerm pattern, boolean programInstructions) { + final MatchPlan core = buildCore(pattern, programInstructions); + if (core == null || !pattern.hasLabels()) { + return core; + } + // term labels are matched in place (no cursor move), before the operator/subterms + return new LabelPlan(matchTermLabelSV(pattern.getLabels()), core); + } + + private static @Nullable MatchPlan buildCore(JTerm pattern, boolean programInstructions) { + final Operator op = pattern.op(); + + if (op instanceof SchemaVariable sv) { + if (pattern.arity() != 0) { + return null; // unusual schema-variable shape + } + return new SchemaVarPlan(getMatchInstructionForSV(sv), pattern.boundVars(), + JavaBinderMatcher.INSTANCE); + } + + final MatchHead head = buildHead(pattern, programInstructions); + if (head == null) { + return null; // unsupported construct or uncompilable program + } + + // the operator head plus a plan per subterm + final int arity = pattern.arity(); + final List children = new ArrayList<>(arity); + for (int i = 0; i < arity; i++) { + final MatchPlan child = buildPlan(pattern.sub(i), programInstructions); + if (child == null) { + return null; // a subterm is not handled -> the whole pattern is unsupported + } + children.add(child); + } + return new OperatorPlan(head, children, pattern.boundVars(), JavaBinderMatcher.INSTANCE); + } + + /** + * The operator-specific head for {@code pattern}'s operator, or {@code null} if the operator is + * not supported (or, for a modality, its program cannot be matched by the framework). + */ + private static @Nullable MatchHead buildHead(JTerm pattern, boolean programInstructions) { + final Operator op = pattern.op(); + if (op instanceof ElementaryUpdate elUp) { + return ElementaryUpdateHead.of(elUp); + } + if (op instanceof ParametricFunctionInstance pfi) { + return ParametricFunctionHead.of(pfi); + } + if (op instanceof Modality mod) { + return ModalityHead.of(mod, pattern.javaBlock().program(), programInstructions); + } + return new GenericOperatorHead(op); + } + + /** + * Wraps a plan with a term-label match: the labels are matched in place (the same + * {@code matchTermLabelSV} instruction the interpreter uses, no cursor move) before the wrapped + * operator/subterm matching. Reused by both back-ends. + */ + private record LabelPlan(MatchInstruction labelInstr, MatchPlan inner) implements MatchPlan { + @Override + public void emitInstructions(List out) { + out.add(labelInstr); + inner.emitInstructions(out); + } + + @Override + public MatchProgram compile() { + final MatchProgram innerCompiled = inner.compile(); + return (element, mc, services) -> { + final MatchResultInfo r = labelInstr.match(element, mc, services); + return r == null ? null : innerCompiled.match(element, r, services); + }; + } + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramCompiler.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramCompiler.java new file mode 100644 index 00000000000..83f009f0bac --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramCompiler.java @@ -0,0 +1,201 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import de.uka.ilkd.key.java.Services; +import de.uka.ilkd.key.java.ast.ContextStatementBlock; +import de.uka.ilkd.key.java.ast.JavaProgramElement; +import de.uka.ilkd.key.java.ast.ProgramElement; +import de.uka.ilkd.key.java.ast.SourceData; +import de.uka.ilkd.key.logic.JavaBlock; +import de.uka.ilkd.key.logic.op.ProgramSV; +import de.uka.ilkd.key.rule.MatchConditions; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.logic.op.sv.SchemaVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.ProgramChildrenMatcher; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchInstructionForSV; + +/** + * Compiles the cursor-free matcher for the Java program of a modality, used by the Java + * {@link org.key_project.prover.rules.matcher.compiler.ProgramMatchHook} (the compiled side of the + * match-plan framework). It navigates the Java AST directly via {@code getChild(i)} instead of a + * cursor, mirroring the converted program VM instructions of + * {@link SyntaxElementMatchProgramGenerator}. + * + *

+ * A top-level {@link ContextStatementBlock} keeps phases (1)(2)(4) of the context match in + * {@code ContextStatementBlock.match} and compiles only phase (3); generic structural elements are + * matched by class equality + exact-size child recursion; elements with their own {@code match} + * (value literals, type references, loops, ...) and generic elements with variable-arity children + * (a + * list schema variable {@code #slist}) are reused cursor-free by {@linkplain #delegateToMatch + * delegating to their own match}. + * + * @see org.key_project.prover.rules.matcher.vm.VMProgramInterpreter + */ +final class JavaProgramCompiler { + + private JavaProgramCompiler() {} + + /** + * A single compiled matching step over a program (sub)element. Navigates the Java AST directly + * via {@code getChild(i)} instead of a cursor, mirroring the converted program VM instructions. + */ + @FunctionalInterface + private interface ProgStep { + @Nullable + MatchResultInfo match(SyntaxElement actual, MatchResultInfo mc, LogicServices services); + } + + /** + * Compiles the cursor-free matcher for the Java program {@code prog} of a modality, applied + * directly to the source {@link JavaBlock} (it extracts the block's program element). A + * top-level + * {@link ContextStatementBlock} keeps phases (1)(2)(4) of the context match in + * {@code ContextStatementBlock.match} and compiles only phase (3) (each active statement + * consumes + * one source child), unless an active statement is variable-arity (a list SV) or otherwise + * uncompilable -- then the whole context match is delegated to + * {@code ContextStatementBlock.match} + * (its {@code matchChildren} handles list SVs) while the surrounding term skeleton stays + * compiled. + * Any other program is compiled by {@link #compileProgram}. Returns {@code null} only if that + * generic compilation cannot handle the program. + */ + static @Nullable MatchProgram compiledProgramMatcher(JavaProgramElement prog) { + if (prog instanceof ContextStatementBlock csb) { + final ProgStep[] active = compileActiveStatements(csb); + if (active != null) { + // phase (3) of the context match, cursor-free: each active statement consumes one + // child + final ProgramChildrenMatcher phase3 = (parent, startChild, mc, services) -> { + MatchResultInfo r = mc; + for (int k = 0; k < active.length; k++) { + r = active[k].match(parent.getChild(startChild + k), r, services); + if (r == null) { + return null; + } + } + return r; + }; + // phases (1)(2)(4) stay in ContextStatementBlock.match; only phase (3) is compiled + return (block, mc, services) -> csb.match( + new SourceData(((JavaBlock) block).program(), -1, (Services) services), + (MatchConditions) mc, phase3); + } + // an active statement is variable-arity (a list SV) or otherwise uncompilable: + // delegate the whole context match to the interpreter (its matchChildren handles + // list SVs); the surrounding term skeleton stays compiled + return (block, mc, services) -> csb.match( + new SourceData(((JavaBlock) block).program(), -1, (Services) services), + (MatchConditions) mc); + } + final ProgStep ps = compileProgram(prog); + if (ps == null) { + return null; + } + return (block, mc, services) -> ps.match(((JavaBlock) block).program(), mc, services); + } + + /** + * Compiles a Java program (sub)element: a generic-match element with a fixed, one-source-child + * structure is matched by direct {@code getChild} navigation (class equality + exact-size child + * recursion); a non-list program schema variable reuses its program-SV instruction. Anything + * else that is still a {@link ProgramElement} -- an element with its own {@code match} (value + * literals, type references, loops, ...) or a generic element whose children are not a + * fixed one-to-one structure (e.g. they contain a list schema variable {@code #slist}) -- is + * matched cursor-free by {@linkplain #delegateToMatch delegating to its own match}. Returns + * {@code null} only for a list schema variable on its own (variable arity: its enclosing + * element + * delegates) and for non-program schema variables. + */ + private static @Nullable ProgStep compileProgram(SyntaxElement pe) { + if (pe instanceof ProgramSV psv) { + if (psv.isListSV()) { + // a list SV by itself is variable-arity; the enclosing element delegates instead + return null; + } + final MatchInstruction svInstr = getMatchInstructionForSV(psv); + return svInstr::match; + } + if (pe instanceof SchemaVariable) { + return null; // other schema variables in programs: be safe, fall back + } + if (!(pe instanceof ProgramElement progEl)) { + return null; + } + if (SyntaxElementMatchProgramGenerator.isGenericMatch(progEl)) { + final int childCount = pe.getChildCount(); + final ProgStep[] subs = new ProgStep[childCount]; + boolean fixedStructure = true; + for (int i = 0; i < childCount; i++) { + final ProgStep s = compileProgram(pe.getChild(i)); + if (s == null) { + fixedStructure = false; // e.g. a list SV child -> not one-to-one + break; + } + subs[i] = s; + } + if (fixedStructure) { + final Class kind = pe.getClass(); + return (actual, mc, services) -> { + if (actual.getClass() != kind || actual.getChildCount() != childCount) { + return null; + } + MatchResultInfo r = mc; + for (int i = 0; i < childCount; i++) { + r = subs[i].match(actual.getChild(i), r, services); + if (r == null) { + return null; + } + } + return r; + }; + } + } + // an element with its own match (value literals, TypeRef, SchematicFieldReference, + // VariableSpecification, loops, ...) or a generic element with variable-arity children + // (a list SV): reuse its own match cursor-free (see delegateToMatch) + return delegateToMatch(progEl); + } + + /** + * Matches {@code progEl} by reusing its own {@code match(SourceData, MatchConditions)} + * cursor-free, exactly as {@code MatchProgramInstruction} does at the program root. This keeps + * the surrounding program compiled (only this sub-element delegates) and is + * behaviour-preserving + * by construction: it is the very match the interpreter would call, including the + * {@code matchChildren} handling of list schema variables. + */ + private static ProgStep delegateToMatch(ProgramElement progEl) { + return (actual, mc, services) -> progEl.match( + new SourceData((ProgramElement) actual, -1, (Services) services), (MatchConditions) mc); + } + + /** + * Compiles the active statements of a context block (its children from the active offset, i.e. + * skipping the execution context if present), or returns {@code null} if any active statement + * uses a construct the compiler does not handle. + */ + private static ProgStep @Nullable [] compileActiveStatements(ContextStatementBlock csb) { + final int offset = csb.getExecutionContext() == null ? 0 : 1; + final ProgStep[] active = new ProgStep[csb.getChildCount() - offset]; + for (int i = offset, n = csb.getChildCount(); i < n; i++) { + final ProgStep s = compileProgram(csb.getChildAt(i)); + if (s == null) { + return null; + } + active[i - offset] = s; + } + return active; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramMatchHook.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramMatchHook.java new file mode 100644 index 00000000000..167623ddb8f --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/JavaProgramMatchHook.java @@ -0,0 +1,67 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import de.uka.ilkd.key.java.ast.JavaProgramElement; + +import org.key_project.prover.rules.matcher.compiler.ProgramMatchHook; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.SyntaxElementMatchProgramGenerator.buildProgramInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.matchProgram; + +/** + * Java-DL implementation of the {@link ProgramMatchHook} program-AST axis: it matches the + * {@code JavaBlock} program of a modality. The interpreter side reuses the generator's converted + * program instruction ({@link SyntaxElementMatchProgramGenerator#buildProgramInstruction}, falling + * back to the monolithic {@code MatchProgramInstruction} when conversion is off or unavailable); + * the + * compiled side reuses {@link JavaProgramCompiler#compiledProgramMatcher} (context-block phases + + * generic {@code getChild} navigation + value-leaf / list-SV delegation). Both are lifted verbatim + * from the hand-written matchers, so the framework reproduces them exactly. + */ +public final class JavaProgramMatchHook implements ProgramMatchHook { + + private final JavaProgramElement prog; + private final boolean programInstructions; + private final MatchProgram compiled; + + private JavaProgramMatchHook(JavaProgramElement prog, boolean programInstructions, + MatchProgram compiled) { + this.prog = prog; + this.programInstructions = programInstructions; + this.compiled = compiled; + } + + /** + * @param prog the modality's program pattern + * @param programInstructions whether the interpreter side converts the program to VM + * instructions (otherwise the monolithic {@code MatchProgramInstruction} is used) + * @return a hook for {@code prog}, or {@code null} if the compiled side cannot handle the + * program + * (then the enclosing modality falls back to the legacy matcher) + */ + public static @Nullable JavaProgramMatchHook of(JavaProgramElement prog, + boolean programInstructions) { + final MatchProgram compiled = JavaProgramCompiler.compiledProgramMatcher(prog); + if (compiled == null) { + return null; + } + return new JavaProgramMatchHook(prog, programInstructions, compiled); + } + + @Override + public VMInstruction programInstruction() { + final VMInstruction converted = programInstructions ? buildProgramInstruction(prog) : null; + return converted != null ? converted : matchProgram(prog); + } + + @Override + public MatchProgram compileProgram() { + return compiled; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ModalityHead.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ModalityHead.java new file mode 100644 index 00000000000..d1accbea58b --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ModalityHead.java @@ -0,0 +1,92 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import java.util.List; + +import de.uka.ilkd.key.java.ast.JavaProgramElement; +import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.logic.op.ModalOperatorSV; + +import org.key_project.logic.Term; +import org.key_project.logic.op.Modality; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.compiler.MatchHead; +import org.key_project.prover.rules.matcher.compiler.ProgramMatchHook; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getCheckNodeKindInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchIdentityInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.gotoNextInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.gotoNextSiblingInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.matchModalOperatorSV; + +/** + * Match head for a {@link Modality} {@code \<{ prog }\> post}: it matches the operator, the modal + * kind (a {@link ModalOperatorSV} or a concrete kind by identity) and the Java program (through a + * {@link ProgramMatchHook}); the post-condition subterm is matched by the enclosing + * {@link org.key_project.prover.rules.matcher.compiler.OperatorPlan}. The program AST is the second + * cross-language axis (see {@link ProgramMatchHook}); the kind and skeleton are lifted from the + * hand-written interpreter generator and compiled matcher. + */ +public final class ModalityHead implements MatchHead { + + private final MatchInstruction kindInstr; + private final ProgramMatchHook programHook; + + private ModalityHead(MatchInstruction kindInstr, ProgramMatchHook programHook) { + this.kindInstr = kindInstr; + this.programHook = programHook; + } + + /** + * @param mod the modality pattern + * @param prog the modality's Java program ({@code pattern.javaBlock().program()}) + * @param programInstructions whether the interpreter side converts the program to VM + * instructions + * @return a head for {@code mod}, or {@code null} if the program cannot be matched by the + * framework (then the caller falls back) + */ + public static @Nullable ModalityHead of(Modality mod, JavaProgramElement prog, + boolean programInstructions) { + final JavaProgramMatchHook hook = JavaProgramMatchHook.of(prog, programInstructions); + if (hook == null) { + return null; + } + final MatchInstruction kindInstr = mod.kind() instanceof ModalOperatorSV sv + ? matchModalOperatorSV(sv) + : getMatchIdentityInstruction(mod.kind()); + return new ModalityHead(kindInstr, hook); + } + + @Override + public void emit(List out) { + out.add(getCheckNodeKindInstruction(Modality.class)); + out.add(gotoNextInstruction()); + out.add(kindInstr); + out.add(gotoNextInstruction()); + out.add(programHook.programInstruction()); + out.add(gotoNextSiblingInstruction()); + } + + @Override + public MatchProgram compileHeadCheck() { + final MatchInstruction kind = kindInstr; + final MatchProgram programMatch = programHook.compileProgram(); + return (element, mc, services) -> { + if (!(((Term) element).op() instanceof Modality m)) { + return null; + } + final MatchResultInfo r = kind.match(m.kind(), mc, services); + if (r == null) { + return null; + } + return programMatch.match(((JTerm) element).javaBlock(), r, services); + }; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ParametricFunctionHead.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ParametricFunctionHead.java new file mode 100644 index 00000000000..c67cf949670 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/ParametricFunctionHead.java @@ -0,0 +1,94 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import java.util.List; + +import de.uka.ilkd.key.logic.GenericArgument; +import de.uka.ilkd.key.logic.op.ParametricFunctionInstance; +import de.uka.ilkd.key.logic.sort.GenericSort; +import de.uka.ilkd.key.logic.sort.ParametricSortInstance; + +import org.key_project.logic.Term; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.compiler.MatchHead; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +import org.jspecify.annotations.Nullable; + +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getCheckNodeKindInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchGenericSortInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getMatchIdentityInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.getSimilarParametricFunctionInstruction; +import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.gotoNextInstruction; + +/** + * Match head for a {@link ParametricFunctionInstance}: it checks that the operator has the same + * base + * and matches the generic arguments (a generic sort, or a concrete argument by identity); the + * function's subterms are matched by the enclosing + * {@link org.key_project.prover.rules.matcher.compiler.OperatorPlan}. Mirrors the + * parametric-function fragments of the hand-written matchers. + */ +public final class ParametricFunctionHead implements MatchHead { + + private final MatchInstruction similar; + private final MatchInstruction[] argMatchers; + + private ParametricFunctionHead(MatchInstruction similar, MatchInstruction[] argMatchers) { + this.similar = similar; + this.argMatchers = argMatchers; + } + + /** + * @param pfi the parametric function instance pattern + * @return a head for {@code pfi}, or {@code null} if a generic argument uses a parametric sort + * instance (which the matchers do not handle; then the caller falls back) + */ + public static @Nullable ParametricFunctionHead of(ParametricFunctionInstance pfi) { + final int argCount = pfi.getChildCount(); + final MatchInstruction[] argMatchers = new MatchInstruction[argCount]; + for (int i = 0; i < argCount; i++) { + final GenericArgument arg = (GenericArgument) pfi.getChild(i); + if (arg.sort() instanceof GenericSort gs) { + argMatchers[i] = getMatchGenericSortInstruction(gs); + } else if (arg.sort() instanceof ParametricSortInstance) { + return null; + } else { + argMatchers[i] = getMatchIdentityInstruction(arg); + } + } + return new ParametricFunctionHead(getSimilarParametricFunctionInstruction(pfi), + argMatchers); + } + + @Override + public void emit(List out) { + out.add(getCheckNodeKindInstruction(ParametricFunctionInstance.class)); + out.add(similar); + out.add(gotoNextInstruction()); + for (MatchInstruction argMatcher : argMatchers) { + out.add(argMatcher); + out.add(gotoNextInstruction()); + } + } + + @Override + public MatchProgram compileHeadCheck() { + final MatchInstruction sim = similar; + final MatchInstruction[] args = argMatchers; + return (element, mc, services) -> { + if (!(((Term) element).op() instanceof ParametricFunctionInstance actualPfi)) { + return null; + } + MatchResultInfo r = sim.match(actualPfi, mc, services); + for (int i = 0; r != null && i < args.length; i++) { + r = args[i].match(actualPfi.getChild(i), r, services); + } + return r; + }; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/SyntaxElementMatchProgramGenerator.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/SyntaxElementMatchProgramGenerator.java index b62cff84d0b..0afdb914e3c 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/SyntaxElementMatchProgramGenerator.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/SyntaxElementMatchProgramGenerator.java @@ -4,130 +4,162 @@ package de.uka.ilkd.key.rule.match.vm; import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; -import de.uka.ilkd.key.logic.GenericArgument; -import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.java.ast.ContextStatementBlock; +import de.uka.ilkd.key.java.ast.JavaNonTerminalProgramElement; +import de.uka.ilkd.key.java.ast.JavaProgramElement; +import de.uka.ilkd.key.java.ast.ProgramElement; +import de.uka.ilkd.key.java.ast.SourceData; import de.uka.ilkd.key.logic.op.*; -import de.uka.ilkd.key.logic.sort.GenericSort; -import de.uka.ilkd.key.logic.sort.ParametricSortInstance; +import de.uka.ilkd.key.rule.MatchConditions; +import de.uka.ilkd.key.rule.match.vm.instructions.MatchContextStatementBlockInstruction; +import de.uka.ilkd.key.rule.match.vm.instructions.MatchProgramElementInstruction; +import de.uka.ilkd.key.rule.match.vm.instructions.MatchSubProgramInstruction; -import org.key_project.logic.op.Modality; -import org.key_project.logic.op.Operator; -import org.key_project.logic.op.QuantifiableVariable; +import org.key_project.logic.SyntaxElement; import org.key_project.logic.op.sv.SchemaVariable; -import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.VMProgramInterpreter; import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; -import org.key_project.util.collection.ImmutableArray; + +import org.jspecify.annotations.Nullable; import static de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet.*; /** - * This class generates a matching program for a given syntax element that can be - * interpreted by the virtual machine's interpreter + * Converts the Java program of a modality into VM match-instructions ({@link VMInstruction}s) by + * direct tree navigation, for the interpreter side of the match-plan framework (used by the Java + * {@link org.key_project.prover.rules.matcher.compiler.ProgramMatchHook}). The term skeleton itself + * is built by {@link JavaMatchPlanBuilder}; this class only handles the program-element conversion + * (generic structural elements and non-list program schema variables; anything else is matched by + * the monolithic {@code MatchProgramInstruction}). * * @see org.key_project.prover.rules.matcher.vm.VMProgramInterpreter */ public class SyntaxElementMatchProgramGenerator { /** - * creates a matcher for the given pattern - * - * @param pattern the {@link JTerm} specifying the pattern - * @return the specialized matcher for the given pattern + * System property ({@code -Dkey.matcher.programInstructions=true}) selecting whether the Java + * program of a modality is matched by a sub-program of {@link VMInstruction}s (a {@link + * MatchSubProgramInstruction}) instead of the monolithic {@code MatchProgramInstruction}. Only + * patterns built from generic-match element kinds + (non-list) program schema variables are + * converted; anything else (context blocks, loops, value literals, list SVs) falls back to the + * interpreter. Default {@code false} keeps the existing behaviour. + *

+ * Read at matcher-construction time (i.e. when the taclet base is loaded) rather than once at + * class load, so toggling it and reloading the proof switches the behaviour. */ - public static VMInstruction[] createProgram(JTerm pattern) { - ArrayList program = new ArrayList<>(); - createProgram(pattern, program); - return program.toArray(new VMInstruction[0]); - } + public static final String PROGRAM_INSTRUCTIONS_PROPERTY = "key.matcher.programInstructions"; /** - * creates a matching program for the given pattern. It appends the necessary match instruction - * to the given list of instructions - * - * @param pattern the {@link JTerm} used as pattern for which to create a matcher - * @param program the list of {@link MatchInstruction} to which the instructions for matching - * {@code pattern} are added. + * caches, per program-element class, whether it uses the generic {@code match} (no override). */ - private static void createProgram(JTerm pattern, ArrayList program) { - final Operator op = pattern.op(); + private static final Map, Boolean> GENERIC_MATCH = new ConcurrentHashMap<>(); - final ImmutableArray boundVars = pattern.boundVars(); - - if (!boundVars.isEmpty()) { - program.add(matchAndBindVariables(boundVars)); - } - - if (pattern.hasLabels()) { - program.add(matchTermLabelSV(pattern.getLabels())); + /** + * Builds the instruction matching the Java program {@code prog} of a modality by direct tree + * navigation, or returns {@code null} if {@code prog} uses a construct the converter does not + * handle (the caller then falls back to the monolithic {@code MatchProgramInstruction}). A + * top-level {@link ContextStatementBlock} (the {@code .. ...} pattern of symbolic-execution + * taclets) is matched by a {@link MatchContextStatementBlockInstruction} that converts only the + * active-statement matching; any other program is matched generically by a + * {@link MatchSubProgramInstruction}. + */ + static @Nullable VMInstruction buildProgramInstruction(JavaProgramElement prog) { + if (prog instanceof ContextStatementBlock csb) { + final VMInstruction[] active = buildContextActiveStatementsProgram(csb); + return active == null ? null + : new MatchContextStatementBlockInstruction(csb, + new VMProgramInterpreter(active)); } + final VMInstruction[] sub = buildProgramSubProgram(prog); + return sub == null ? null : new MatchSubProgramInstruction(new VMProgramInterpreter(sub)); + } - if (op instanceof SchemaVariable sv) { - program.add(getMatchInstructionForSV(sv)); - program.add(gotoNextSiblingInstruction()); - } else { - program.add(getCheckNodeKindInstruction(JTerm.class)); - program.add(gotoNextInstruction()); - switch (op) { - case ParametricFunctionInstance pfi -> { - program.add(getCheckNodeKindInstruction(ParametricFunctionInstance.class)); - program.add(getSimilarParametricFunctionInstruction(pfi)); - program.add(gotoNextInstruction()); - for (int i = 0; i < pfi.getChildCount(); i++) { - var arg = (GenericArgument) pfi.getChild(i); - if (arg.sort() instanceof GenericSort gs) { - program.add(getMatchGenericSortInstruction(gs)); - } else if (arg.sort() instanceof ParametricSortInstance) { - throw new UnsupportedOperationException( - "TODO @ DD: Parametric sort in generic args!"); - } else { - program.add(getMatchIdentityInstruction(arg)); - } - program.add(gotoNextInstruction()); - } - } - case ElementaryUpdate elUp -> { - program.add(getCheckNodeKindInstruction(ElementaryUpdate.class)); - program.add(gotoNextInstruction()); - if (elUp.lhs() instanceof SchemaVariable sv) { - program.add(getMatchInstructionForSV(sv)); - program.add(gotoNextSiblingInstruction()); - } else if (elUp.lhs() instanceof LocationVariable locVar) { - program.add(getMatchIdentityInstruction(locVar)); - program.add(gotoNextInstruction()); - } - } - case Modality mod -> { - program.add(getCheckNodeKindInstruction(Modality.class)); - program.add(gotoNextInstruction()); - if (mod.kind() instanceof ModalOperatorSV modKindSV) { - program.add(matchModalOperatorSV(modKindSV)); - } else { - program.add(getMatchIdentityInstruction(mod.kind())); - } - program.add(gotoNextInstruction()); - program.add(matchProgram(pattern.javaBlock().program())); - program.add(gotoNextSiblingInstruction()); - } - default -> { - program.add(getMatchIdentityInstruction(op)); - program.add(gotoNextInstruction()); - } + /** + * Builds a sub-program matching the active statements of the context block {@code csb} (its + * children from the active offset, i.e. skipping the execution context if present), or returns + * {@code null} if any active statement uses a construct the converter does not handle. The + * resulting program is meant to be run via + * {@link VMProgramInterpreter#matchChildrenFrom(org.key_project.logic.SyntaxElement, int, org.key_project.prover.rules.instantiation.MatchResultInfo, org.key_project.logic.LogicServices)} + * starting at the located source child, so that each per-statement matcher consumes exactly one + * source child -- mirroring {@code matchChildren} on the interpreter side. + */ + private static VMInstruction @Nullable [] buildContextActiveStatementsProgram( + ContextStatementBlock csb) { + final int offset = csb.getExecutionContext() == null ? 0 : 1; + final List out = new ArrayList<>(); + for (int i = offset, n = csb.getChildCount(); i < n; i++) { + if (!appendProgram(csb.getChildAt(i), out)) { + return null; } } + return out.toArray(new VMInstruction[0]); + } + + /** + * Builds a sub-program of {@link VMInstruction}s matching the given Java program by direct tree + * navigation, or returns {@code null} if the program uses a construct the converter does not + * handle (the caller then falls back to the monolithic {@code MatchProgramInstruction}). + */ + private static VMInstruction @Nullable [] buildProgramSubProgram(JavaProgramElement prog) { + final List out = new ArrayList<>(); + return appendProgram(prog, out) ? out.toArray(new VMInstruction[0]) : null; + } - if (!boundVars.isEmpty()) { - for (int i = 0; i < boundVars.size(); i++) { - program.add(gotoNextSiblingInstruction()); + /** + * Appends instructions matching {@code pe} (and its subtree) to {@code out}, mirroring the + * generic program match (class equality + exact-size child recursion) and reusing the existing + * program-SV instruction. Returns {@code false} (leaving {@code out} unusable) for any + * construct + * that is not safe to convert: list schema variables, other schema variables, and element types + * that override {@code match} (context blocks, loops, value-checking literals, ...). + */ + private static boolean appendProgram(SyntaxElement pe, List out) { + if (pe instanceof ProgramSV psv) { + if (psv.isListSV()) { + return false; // list SV -> variable block size, leave it to the interpreter } + out.add(getMatchInstructionForSV(psv)); + out.add(gotoNextSiblingInstruction()); + return true; } - - for (int i = 0; i < pattern.arity(); i++) { - createProgram(pattern.sub(i), program); + if (pe instanceof SchemaVariable) { + return false; // other schema variables in programs: be safe, fall back } - - if (!boundVars.isEmpty()) { - program.add(unbindVariables(boundVars)); + if (!(pe instanceof ProgramElement progEl) || !isGenericMatch(progEl)) { + return false; // overrides match (context block, loop, value literal, ...) -> fall back } + final int childCount = pe.getChildCount(); + out.add(new MatchProgramElementInstruction(pe.getClass(), childCount)); + out.add(gotoNextInstruction()); + for (int i = 0; i < childCount; i++) { + if (!appendProgram(pe.getChild(i), out)) { + return false; + } + } + return true; + } + + /** + * @return whether the element's class uses the generic + * {@code match(SourceData, MatchConditions)} (declared in {@code JavaProgramElement} or + * {@code JavaNonTerminalProgramElement}: class equality + exact-size child recursion) + * rather than its own override. + */ + static boolean isGenericMatch(ProgramElement pe) { + return GENERIC_MATCH.computeIfAbsent(pe.getClass(), c -> { + try { + final Class declaring = + c.getMethod("match", SourceData.class, MatchConditions.class) + .getDeclaringClass(); + return declaring == JavaProgramElement.class + || declaring == JavaNonTerminalProgramElement.class; + } catch (NoSuchMethodException e) { + return false; + } + }); } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/VMTacletMatcher.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/VMTacletMatcher.java index 2a877e4fabc..b0506e3f103 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/VMTacletMatcher.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/VMTacletMatcher.java @@ -17,6 +17,7 @@ import de.uka.ilkd.key.rule.match.TacletMatcherKit; import de.uka.ilkd.key.rule.match.vm.instructions.JavaDLMatchVMInstructionSet; import de.uka.ilkd.key.rule.match.vm.instructions.MatchSchemaVariableInstruction; +import de.uka.ilkd.key.settings.FeatureSettings; import org.key_project.logic.LogicServices; import org.key_project.logic.SyntaxElement; @@ -29,6 +30,7 @@ import org.key_project.prover.rules.instantiation.AssumesFormulaInstantiation; import org.key_project.prover.rules.instantiation.AssumesMatchResult; import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; import org.key_project.prover.rules.matcher.vm.VMProgramInterpreter; import org.key_project.prover.sequent.Sequent; import org.key_project.prover.sequent.SequentFormula; @@ -56,10 +58,42 @@ */ public class VMTacletMatcher implements TacletMatcher { + /** + * System property ({@code -Dkey.matcher.interpreter=true}) forcing the legacy cursor-based + * interpreter find-matcher. The cursor-free compiled matcher is the default; this is mainly for + * headless / CI A/B comparison. In the GUI use the {@link #INTERPRETER_MATCHER_FEATURE} feature + * flag instead. + *

+ * Read in the constructor (i.e. per taclet, when the taclet base is loaded) rather than once at + * class load, so toggling it and reloading the proof switches matchers. + */ + public static final String INTERPRETER_MATCHER_PROPERTY = "key.matcher.interpreter"; + + /** + * Feature flag (Settings → Feature Flags, persistent) forcing the legacy interpreter + * find-matcher, the GUI-friendly equivalent of {@link #INTERPRETER_MATCHER_PROPERTY}. The + * compiled matcher is the default; activate this to fall back to the interpreter. Like the + * property it is read per taclet at construction time, so it takes effect for newly loaded + * proofs (or after reloading the current one) -- hence {@code restartRequired = true}. + */ + public static final FeatureSettings.Feature INTERPRETER_MATCHER_FEATURE = + FeatureSettings.createFeature("MATCHER_INTERPRETER", + "Use the legacy interpreter taclet find-matcher instead of the compiled one " + + "(reload the proof to apply).", + true); + + /** + * System property ({@code -Dkey.matcher.interpreterAssumes=true}) forcing the interpreter for + * {@code \assumes} formula matching even when the compiled find-matcher is selected. The + * compiled matcher (incl. the Java program of a modality) is used for assumes by default; this + * is mainly for headless A/B comparison of the compiled-assumes extension. + */ + public static final String INTERPRETER_ASSUMES_PROPERTY = "key.matcher.interpreterAssumes"; + /** the matcher for the find expression of the taclet */ - private final VMProgramInterpreter findMatchProgram; + private final MatchProgram findMatchProgram; /** the matcher for the taclet's assumes formulas */ - private final HashMap assumesMatchPrograms = + private final HashMap assumesMatchPrograms = new HashMap<>(); /** @@ -95,24 +129,48 @@ public VMTacletMatcher(Taclet taclet) { boundVars = taclet.getBoundVariables(); varsNotFreeIn = taclet.varsNotFreeIn(); + // both back-ends are derived from the unified match-plan framework (one dispatch per + // construct, see JavaMatchPlanBuilder); the compiled matcher is the default, the + // interpreter is used only when explicitly selected (property/feature flag) or as the + // automatic fallback for a pattern the compiler does not handle + final boolean useInterpreter = Boolean.getBoolean(INTERPRETER_MATCHER_PROPERTY) + || FeatureSettings.isFeatureActivated(INTERPRETER_MATCHER_FEATURE); + if (taclet instanceof final FindTaclet findTaclet) { findExp = findTaclet.find(); ignoreTopLevelUpdates = taclet.ignoreTopLevelUpdates() && !(findExp.op() instanceof UpdateApplication); - findMatchProgram = - new VMProgramInterpreter(SyntaxElementMatchProgramGenerator.createProgram(findExp)); - + findMatchProgram = matchProgramFor(findExp, useInterpreter); } else { ignoreTopLevelUpdates = false; findExp = null; findMatchProgram = null; } + // The taclet's \assumes formulas use the same back-end as the find: when the compiled + // matcher is selected they are compiled too (cursor-free, including the Java program of a + // modality), unless -Dkey.matcher.interpreterAssumes forces the interpreter for them. + final boolean assumesInterpreter = + useInterpreter || Boolean.getBoolean(INTERPRETER_ASSUMES_PROPERTY); for (final SequentFormula sf : assumesSequent) { assumesMatchPrograms.put(sf.formula(), - new VMProgramInterpreter( - SyntaxElementMatchProgramGenerator.createProgram((JTerm) sf.formula()))); + matchProgramFor((JTerm) sf.formula(), assumesInterpreter)); + } + } + + /** + * Builds the matcher for a find / assumes {@code pattern}: the cursor-free compiled matcher + * unless the interpreter is requested or the compiler has no head for the pattern (then the + * interpreter is used as a fallback). + */ + private static MatchProgram matchProgramFor(JTerm pattern, boolean interpreter) { + if (!interpreter) { + final MatchProgram compiled = JavaMatchPlanBuilder.compiledProgramOrNull(pattern); + if (compiled != null) { + return compiled; + } } + return new VMProgramInterpreter(JavaMatchPlanBuilder.interpreterProgram(pattern)); } /** @@ -127,7 +185,7 @@ public VMTacletMatcher(Taclet taclet) { @NonNull Term p_template, @NonNull MatchResultInfo p_matchCond, @NonNull LogicServices p_services) { - VMProgramInterpreter interpreter = assumesMatchPrograms.get(p_template); + MatchProgram program = assumesMatchPrograms.get(p_template); final var mc = (MatchConditions) p_matchCond; ImmutableList resFormulas = ImmutableSLList.nil(); @@ -149,7 +207,7 @@ public VMTacletMatcher(Taclet taclet) { } if (formula != null) {// update context not present or update context match succeeded final MatchResultInfo newMC = - checkConditions(interpreter.match(formula, mc, p_services), p_services); + checkConditions(program.match(formula, mc, p_services), p_services); if (newMC != null) { resFormulas = resFormulas.prepend(cf); diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchContextStatementBlockInstruction.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchContextStatementBlockInstruction.java new file mode 100644 index 00000000000..1c5955bc2ea --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchContextStatementBlockInstruction.java @@ -0,0 +1,51 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm.instructions; + +import de.uka.ilkd.key.java.Services; +import de.uka.ilkd.key.java.ast.ContextStatementBlock; +import de.uka.ilkd.key.java.ast.SourceData; +import de.uka.ilkd.key.logic.JavaBlock; +import de.uka.ilkd.key.rule.MatchConditions; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.VMProgramInterpreter; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; + +import org.jspecify.annotations.Nullable; + +/** + * Matches the Java program of a modality whose program is a {@link ContextStatementBlock} (the + * {@code .. ... } pattern that is ubiquitous in symbolic-execution taclets). The intricate context + * bookkeeping (variable-length prefix descent, inner execution context, prefix/suffix positions) is + * still performed by {@link ContextStatementBlock#match}; only the matching of the active + * statements + * is delegated to the supplied VM sub-program, replacing the monolithic + * {@code MatchProgramInstruction} + * for that subtree. The current element is the modality's {@link JavaBlock} (as for + * {@code MatchProgramInstruction}). + * + * @see ContextStatementBlock#match(SourceData, MatchConditions, VMProgramInterpreter) + */ +public final class MatchContextStatementBlockInstruction implements MatchInstruction { + + private final ContextStatementBlock contextBlock; + private final VMProgramInterpreter activeStatements; + + public MatchContextStatementBlockInstruction(ContextStatementBlock contextBlock, + VMProgramInterpreter activeStatements) { + this.contextBlock = contextBlock; + this.activeStatements = activeStatements; + } + + @Override + public @Nullable MatchResultInfo match(SyntaxElement actualElement, + MatchResultInfo matchConditions, LogicServices services) { + return contextBlock.match( + new SourceData(((JavaBlock) actualElement).program(), -1, (Services) services), + (MatchConditions) matchConditions, activeStatements); + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchProgramElementInstruction.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchProgramElementInstruction.java new file mode 100644 index 00000000000..fe02c7538c9 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchProgramElementInstruction.java @@ -0,0 +1,39 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm.instructions; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; + +import org.jspecify.annotations.Nullable; + +/** + * Matches a single program element structurally: the current element must have exactly the expected + * concrete class and number of children. This mirrors the generic program matching + * ({@code JavaProgramElement.match} / {@code JavaNonTerminalProgramElement.match}: class equality + * plus an exact block size), and is only emitted for element types that use that generic match (the + * compiler falls back to the interpreter's {@code MatchProgramInstruction} for any type that + * overrides {@code match}, e.g. context blocks, loops or value-checking literals). + */ +public final class MatchProgramElementInstruction implements MatchInstruction { + + private final Class kind; + private final int childCount; + + public MatchProgramElementInstruction(Class kind, int childCount) { + this.kind = kind; + this.childCount = childCount; + } + + @Override + public @Nullable MatchResultInfo match(SyntaxElement actualElement, + MatchResultInfo matchConditions, LogicServices services) { + if (actualElement.getClass() == kind && actualElement.getChildCount() == childCount) { + return matchConditions; + } + return null; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchSubProgramInstruction.java b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchSubProgramInstruction.java new file mode 100644 index 00000000000..7578fd0dc59 --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/match/vm/instructions/MatchSubProgramInstruction.java @@ -0,0 +1,39 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm.instructions; + +import de.uka.ilkd.key.logic.JavaBlock; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.VMProgramInterpreter; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; + +import org.jspecify.annotations.Nullable; + +/** + * Matches the Java program of a modality by running a sub-program of {@code VMInstruction}s over + * the + * program tree (with its own cursor), instead of the monolithic {@code MatchProgramInstruction} + * which delegates to the separate {@code ProgramElement.match} AST matcher. The current element is + * the modality's {@link JavaBlock} (as for {@code MatchProgramInstruction}); the sub-program runs + * on + * its {@code program()}, leaving the outer cursor at the {@code JavaBlock} so the surrounding + * navigation is unchanged. + */ +public final class MatchSubProgramInstruction implements MatchInstruction { + + private final VMProgramInterpreter program; + + public MatchSubProgramInstruction(VMProgramInterpreter program) { + this.program = program; + } + + @Override + public @Nullable MatchResultInfo match(SyntaxElement actualElement, + MatchResultInfo matchConditions, LogicServices services) { + return program.match(((JavaBlock) actualElement).program(), matchConditions, services); + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/merge/MergeRule.java b/key.core/src/main/java/de/uka/ilkd/key/rule/merge/MergeRule.java index 22fec157f8e..33f741fe329 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/merge/MergeRule.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/merge/MergeRule.java @@ -16,6 +16,7 @@ import de.uka.ilkd.key.logic.op.*; import de.uka.ilkd.key.proof.Goal; import de.uka.ilkd.key.proof.Node; +import de.uka.ilkd.key.prover.impl.ParallelProver; import de.uka.ilkd.key.rule.BuiltInRule; import de.uka.ilkd.key.rule.IBuiltInRuleApp; import de.uka.ilkd.key.rule.NoPosTacletApp; @@ -590,6 +591,13 @@ protected ValuesMergeResult mergeHeaps(final MergeProcedure mergeRule, */ @Override public boolean isApplicable(Goal goal, PosInOccurrence pio) { + // MergeRule links several goals into one and would therefore need to lock multiple goals + // at once. That is not yet safe under goal-level concurrency, so the rule is disabled while + // a multi-worker parallel run is active (single-threaded proving is unaffected). See the + // multithreading effort (branch bubel/mt-goals). + if (ParallelProver.isMultiThreadedRunActive()) { + return false; + } return isOfAdmissibleForm(goal, pio, true); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/MethodCall.java b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/MethodCall.java index 9d889a2b8e4..047d7dfce21 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/MethodCall.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/MethodCall.java @@ -48,9 +48,19 @@ import org.slf4j.LoggerFactory; /** - * Symbolically executes a method invocation + * Symbolically executes a method invocation. + * + *

+ * This class is intentionally {@code final}. Its {@link #transform} runs on a fresh per-call copy + * (see there) so that the instance shared via the taclet base is never mutated and concurrent + * method-call expansions in the parallel prover do not race on the scratch instance fields. That + * delegation copies {@code this} by constructing a plain {@code MethodCall}; if a subclass existed + * and overrode any of the transform helpers, the copy would silently drop the override and run the + * wrong transformation. Forbidding subclasses keeps the copy faithful and the thread-safety + * argument sound. Behaviour is varied through the taclet/{@link ProgramElement} arguments, not by + * subclassing, so nothing is lost. */ -public class MethodCall extends ProgramTransformer { +public final class MethodCall extends ProgramTransformer { public static final Logger LOGGER = LoggerFactory.getLogger(MethodCall.class); private final SchemaVariable resultVar; @@ -184,7 +194,14 @@ private KeYJavaType getSuperType(ExecutionContext ex, Services services) { } /** - * performs the program transformation needed for symbolic program execution + * Performs the program transformation needed for symbolic program execution. + * + *

+ * The actual work ({@link #transformImpl}) fills several scratch instance fields. Because this + * transformer object is part of the shared taclet base, running it on {@code this} would race + * across concurrent workers (the parallel prover). We therefore run on a fresh, per-call copy: + * its scratch fields are confined to this invocation, and it is throwaway state — never + * inserted into any AST. (MethodCall has no subclasses, so the copy faithfully replaces this.) * * @param services the Services with all necessary information about the java programs * @param svInst the instantiations esp. of the inner and outer label @@ -193,6 +210,12 @@ private KeYJavaType getSuperType(ExecutionContext ex, Services services) { @Override public ProgramElement[] transform(ProgramElement pe, Services services, SVInstantiations svInst) { + return new MethodCall((ProgramSV) execContextSV, resultVar, body()) + .transformImpl(pe, services, svInst); + } + + private ProgramElement[] transformImpl(ProgramElement pe, Services services, + SVInstantiations svInst) { LOGGER.trace("method-call: called for {}", pe); if (resultVar != null) { pvar = (ProgramVariable) svInst.getInstantiation(resultVar); diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Monomial.java b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Monomial.java index e3dcd55ea85..e7aca6544f7 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Monomial.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Monomial.java @@ -5,6 +5,7 @@ import java.math.BigInteger; import java.util.Iterator; +import java.util.Map; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.ldt.IntegerLDT; @@ -16,7 +17,6 @@ import org.key_project.logic.Term; import org.key_project.logic.op.Operator; -import org.key_project.util.LRUCache; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -36,7 +36,7 @@ private Monomial(final ImmutableList parts, final BigInteger coefficient) public static final Monomial ONE = new Monomial(ImmutableSLList.nil(), BigInteger.ONE); public static Monomial create(Term monoTerm, Services services) { - final LRUCache monomialCache = services.getCaches().getMonomialCache(); + final Map monomialCache = services.getCaches().getMonomialCache(); monoTerm = TermLabelManager.removeIrrelevantLabels((JTerm) monoTerm, services); Monomial res; diff --git a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Polynomial.java b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Polynomial.java index 6fd46319acb..9264e5db0f6 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Polynomial.java +++ b/key.core/src/main/java/de/uka/ilkd/key/rule/metaconstruct/arith/Polynomial.java @@ -5,6 +5,7 @@ import java.math.BigInteger; import java.util.Iterator; +import java.util.Map; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.java.TypeConverter; @@ -15,7 +16,6 @@ import org.key_project.logic.Term; import org.key_project.logic.op.Operator; -import org.key_project.util.LRUCache; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -49,7 +49,7 @@ private Polynomial(ImmutableList parts, BigInteger constantPart) { } public static Polynomial create(Term polyTerm, Services services) { - final LRUCache cache = services.getCaches().getPolynomialCache(); + final Map cache = services.getCaches().getPolynomialCache(); polyTerm = TermLabelManager.removeIrrelevantLabels((JTerm) polyTerm, services); diff --git a/key.core/src/main/java/de/uka/ilkd/key/settings/GeneralSettings.java b/key.core/src/main/java/de/uka/ilkd/key/settings/GeneralSettings.java index 436ec981253..c440f7fd8d9 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/settings/GeneralSettings.java +++ b/key.core/src/main/java/de/uka/ilkd/key/settings/GeneralSettings.java @@ -48,6 +48,14 @@ public class GeneralSettings extends AbstractSettings { */ private static final String ENSURE_SOURCE_CONSISTENCY = "EnsureSourceConsistency"; + /** Whether automatic proof search uses the multi-core (parallel) prover. */ + public static final String PARALLEL_PROVER_ENABLED = "ParallelProverEnabled"; + /** The number of worker threads the multi-core prover uses. */ + public static final String PARALLEL_PROVER_THREADS = "ParallelProverThreadCount"; + + /** Default worker count when the multi-core prover is first enabled. */ + public static final int PARALLEL_PROVER_THREADS_DEFAULT = 4; + /** Default value for {@link #getJmlEnabledKeys()} */ public static final Set JML_ENABLED_KEYS_DEFAULT = Set.of("key"); @@ -84,6 +92,24 @@ public class GeneralSettings extends AbstractSettings { */ private boolean ensureSourceConsistency = true; + /** + * Whether automatic proof search uses the multi-core (parallel) prover instead of the legacy + * single-threaded one. Single-core is the safe fallback and keeps the single-core-only features + * (proof caching, slicing, merge rule, ...) available. + *

+ * Enabled by default in this pull-request build so the multi-core prover is easy to try; this + * is + * to be reverted to {@code false} (single-core) when the change is accepted for {@code main}. + */ + private boolean parallelProverEnabled = true; + + /** + * Number of worker threads for the multi-core prover. Only relevant when + * {@link #parallelProverEnabled} is set; the effective count is clamped to the available + * processors. + */ + private int parallelProverThreadCount = PARALLEL_PROVER_THREADS_DEFAULT; + GeneralSettings() { // addSettingsListener(AutoSaver.settingsListener); } @@ -122,6 +148,21 @@ public boolean isEnsureSourceConsistency() { return ensureSourceConsistency; } + /** + * @return whether automatic proof search uses the multi-core (parallel) prover + */ + public boolean isParallelProverEnabled() { + return parallelProverEnabled; + } + + /** + * @return the configured number of worker threads for the multi-core prover (not yet clamped to + * the available processors) + */ + public int getParallelProverThreadCount() { + return parallelProverThreadCount; + } + // setter public void setTacletFilter(boolean b) { var old = tacletFilter; @@ -165,6 +206,18 @@ public void setEnsureSourceConsistency(boolean b) { firePropertyChange(ENSURE_SOURCE_CONSISTENCY, old, ensureSourceConsistency); } + public void setParallelProverEnabled(boolean b) { + var old = parallelProverEnabled; + parallelProverEnabled = b; + firePropertyChange(PARALLEL_PROVER_ENABLED, old, parallelProverEnabled); + } + + public void setParallelProverThreadCount(int count) { + var old = parallelProverThreadCount; + parallelProverThreadCount = Math.max(1, count); + firePropertyChange(PARALLEL_PROVER_THREADS, old, parallelProverThreadCount); + } + /** * gets a Properties object and has to perform the necessary steps in order to change this * object in a way that it represents the stored settings @@ -208,6 +261,20 @@ public void readSettings(Properties props) { setEnsureSourceConsistency(Boolean.parseBoolean(val)); } + val = props.getProperty(prefix + PARALLEL_PROVER_ENABLED); + if (val != null) { + setParallelProverEnabled(Boolean.parseBoolean(val)); + } + + val = props.getProperty(prefix + PARALLEL_PROVER_THREADS); + if (val != null) { + try { + setParallelProverThreadCount(Integer.parseInt(val)); + } catch (NumberFormatException e) { + setParallelProverThreadCount(PARALLEL_PROVER_THREADS_DEFAULT); + } + } + { String sysProp = System.getProperty(KEY_JML_ENABLED_KEYS); if (sysProp != null) { @@ -241,6 +308,9 @@ public void writeSettings(Properties props) { props.setProperty(prefix + AUTO_SAVE, String.valueOf(autoSave)); props.setProperty(prefix + ENSURE_SOURCE_CONSISTENCY, String.valueOf(ensureSourceConsistency)); + props.setProperty(prefix + PARALLEL_PROVER_ENABLED, String.valueOf(parallelProverEnabled)); + props.setProperty(prefix + PARALLEL_PROVER_THREADS, + String.valueOf(parallelProverThreadCount)); props.setProperty(KEY_JML_ENABLED_KEYS, String.join(",", jmlEnabledKeys)); } @@ -259,6 +329,9 @@ public void readSettings(Configuration props) { setAutoSave(0); } setEnsureSourceConsistency(props.getBool(ENSURE_SOURCE_CONSISTENCY)); + setParallelProverEnabled(props.getBool(PARALLEL_PROVER_ENABLED, false)); + setParallelProverThreadCount( + props.getInt(PARALLEL_PROVER_THREADS, PARALLEL_PROVER_THREADS_DEFAULT)); var sysProp = System.getProperty(KEY_JML_ENABLED_KEYS); if (sysProp != null) { @@ -277,6 +350,8 @@ public void writeSettings(Configuration props) { props.set(USE_JML_KEY, useJML); props.set(AUTO_SAVE, autoSave); props.set(ENSURE_SOURCE_CONSISTENCY, ensureSourceConsistency); + props.set(PARALLEL_PROVER_ENABLED, parallelProverEnabled); + props.set(PARALLEL_PROVER_THREADS, parallelProverThreadCount); props.set(KEY_JML_ENABLED_KEYS, jmlEnabledKeys.stream().toList()); } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/speclang/njml/JmlFacade.java b/key.core/src/main/java/de/uka/ilkd/key/speclang/njml/JmlFacade.java index 80b93be0ce9..c06f0143751 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/speclang/njml/JmlFacade.java +++ b/key.core/src/main/java/de/uka/ilkd/key/speclang/njml/JmlFacade.java @@ -39,6 +39,21 @@ private JmlFacade() { return new JmlLexer(stream); } + /** + * Releases the ANTLR prediction (DFA) cache of the JML parser. It is a pure, lazily-built cache + * held on the generated parser's static fields, only needed while parsing, not during proof + * search; ANTLR rebuilds it transparently on the next parse. See + * {@code ParsingFacade.clearParserCaches}. + */ + public static void clearCaches() { + try { + new JmlParser(new CommonTokenStream(createLexer(CharStreams.fromString("")))) + .getInterpreter().clearDFA(); + } catch (RuntimeException ignored) { + // best-effort cache release; a failure here only forgoes the memory saving + } + } + /** * Creates a JML lexer for the given string with position. The position information of the lexer * is changed accordingly. diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/BuiltInRuleAppContainer.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/BuiltInRuleAppContainer.java index cbcb861dd3f..b52f6df4541 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/BuiltInRuleAppContainer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/BuiltInRuleAppContainer.java @@ -101,7 +101,8 @@ private PosInOccurrence getPosInOccurrence(Goal p_goal) { static RuleAppContainer createAppContainer(IBuiltInRuleApp bir, PosInOccurrence pio, Goal goal) { - final RuleAppCost cost = goal.getGoalStrategy().computeCost(bir, pio, goal); + final RuleAppCost cost = + withAge(goal.getGoalStrategy().computeCost(bir, pio, goal), goal); return new BuiltInRuleAppContainer(bir, pio, cost, goal); } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/CostReuse.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/CostReuse.java new file mode 100644 index 00000000000..87e1731d3ac --- /dev/null +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/CostReuse.java @@ -0,0 +1,238 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.strategy; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import de.uka.ilkd.key.strategy.feature.AbstractNonDuplicateAppFeature; +import de.uka.ilkd.key.strategy.feature.NonDuplicateAppFeature; +import de.uka.ilkd.key.strategy.feature.RuleSetDispatchFeature; + +import org.key_project.prover.rules.Taclet; +import org.key_project.prover.strategy.costbased.feature.CostLocal; +import org.key_project.prover.strategy.costbased.feature.CostNonLocal; +import org.key_project.prover.strategy.costbased.feature.Feature; +import org.key_project.prover.strategy.costbased.termgenerator.TermGenerator; + +import org.jspecify.annotations.Nullable; + +/** + * Phase-2 cost reuse (perf round 3): decide whether a taclet's strategy cost is a pure function of + * the rule app and its find-position subterm (plus the always-recomputed age term and + * {@code NonDuplicateApp}-family vetoes). For such "cost-local" taclets the base re-cost performed + * by {@link TacletAppContainer#createFurtherApps} on every peek round can be replaced by arithmetic + * (carry the stored cost forward, refresh only the age term), avoiding the dominant + * {@code Strategy.computeCost} work. + * + *

Classification (sound-by-construction, default-impure)

+ * A taclet is eligible iff every {@link Feature} reachable in its per-taclet cost bindings resolves + * to local. Per feature, in order: + *
    + *
  1. veto ({@link AbstractNonDuplicateAppFeature}): a 0/Top guard. Collected and re-checked + * at reuse time (an app that became a duplicate must still be dropped); not descended into.
  2. + *
  3. explicit override: {@link CostNonLocal} forces non-local (the author wins).
  4. + *
  5. {@link CostLocal}: local. For a leaf that is the whole story; for a composite it means + * "transparent" -- the walk still recurses into its child features, so it stays local only if they + * all are. There is NO automatic "any feature with children is transparent" guess: a composite is + * trusted only because its author annotated it after checking that its own computation (including + * any non-Feature inputs such as projections/term-generators) is find-local. The transparent + * combinators are annotated once (Sum/Shannon/Scale/Let/ComprehendedSum/...).
  6. + *
  7. otherwise (unannotated): non-local (the SAFE default). A new feature -- leaf or + * composite -- is non-local until someone reviews it and adds {@link CostLocal}; forgetting costs + * only performance, never soundness.
  8. + *
+ * A {@link CostLocal} composite is local only if, in addition to its child features, every child + * {@link TermGenerator} it holds is also {@link CostLocal} -- a generator is a non-Feature input + * that decides locality (e.g. {@code SuperTermGenerator} is find-local, {@code + * SequentFormulasGenerator} reads the whole sequent and is not). The walk descends only through + * {@link Feature}- and {@link TermGenerator}-typed references (never arbitrary objects): the live + * feature tree holds mutable scratch state (e.g. TermBuffers) that must not be traversed. + * + *

+ * Optional verification (-Dkey.strategy.costReuse.verify): when reuse is applied also + * recompute the cost and log a warning on any mismatch -- a development aid to catch a feature that + * is mis-classified local (it should then get {@link CostNonLocal}). + */ +public final class CostReuse { + + public static final boolean VERIFY = Boolean.getBoolean("key.strategy.costReuse.verify"); + + private static final org.slf4j.Logger LOGGER = + org.slf4j.LoggerFactory.getLogger(CostReuse.class); + + private CostReuse() {} + + private static volatile @Nullable List dispatchers; + /** + * taclet -> collected veto features. An ELIGIBLE taclet always has at least the top-level + * NonDuplicateApp veto, so an empty array is used as the INELIGIBLE sentinel (ConcurrentHashMap + * forbids null values). + */ + private static final Map classification = new ConcurrentHashMap<>(); + /** Per-class locality decision, cached (class annotations are stable for the JVM run). */ + private static final Map, Kind> kindCache = new ConcurrentHashMap<>(); + /** Cached "not eligible for reuse" marker (the map forbids null values). */ + private static final Feature[] INELIGIBLE = new Feature[0]; + + private enum Kind { + VETO, NON_LOCAL, LOCAL + } + + /** + * @return the {@link AbstractNonDuplicateAppFeature} vetoes to re-check for a cost-local + * taclet, + * or {@code null} if the taclet is not eligible for cost reuse. + */ + public static Feature @Nullable [] vetoesIfEligible(Object strategy, Taclet taclet) { + final Feature[] r = classification.computeIfAbsent(taclet, t -> { + final Feature[] res = classify(strategy, t); + return res == null ? INELIGIBLE : res; + }); + return r == INELIGIBLE ? null : r; + } + + private static Feature @Nullable [] classify(Object strategy, Taclet taclet) { + final Set vetoes = Collections.newSetFromMap(new IdentityHashMap<>()); + vetoes.add(NonDuplicateAppFeature.INSTANCE); // top-level veto, applies to every taclet + final boolean[] local = { true }; + final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (RuleSetDispatchFeature d : dispatchers(strategy)) { + var rs = taclet.getRuleSets(); + while (!rs.isEmpty()) { + final Feature f = d.get(rs.head()); + if (f != null) { + walk(f, vetoes, local, seen); + } + rs = rs.tail(); + } + } + return local[0] ? vetoes.toArray(new Feature[0]) : null; + } + + /** + * Classify a feature; for a transparent composite, recurse into its child features and require + * its child term-generators to be local too (see the class comment). + */ + private static void walk(Feature f, Set vetoes, boolean[] local, Set seen) { + if (!local[0] || !seen.add(f)) { + return; + } + switch (kind(f)) { + case VETO -> vetoes.add(f); + case NON_LOCAL -> local[0] = false; + // LOCAL: a leaf is done; a composite stays local iff all its child FEATURES are local + // AND all its child TERM-GENERATORS are local. The generator matters because e.g. + // ComprehendedSumFeature sums its body over a generator: SuperTermGenerator (find + // ancestors) is local, but SequentFormulasGenerator (whole sequent) is not -- and a + // generator is a non-Feature input the feature recursion would otherwise miss. + case LOCAL -> forEachChild(f, child -> { + if (child instanceof Feature cf) { + walk(cf, vetoes, local, seen); + } else if (!isLocal(child.getClass())) { // a TermGenerator (or similar input) + local[0] = false; + } + }); + } + } + + /** + * A non-Feature classifying input (e.g. a TermGenerator) is local only if {@link CostLocal}. + */ + private static boolean isLocal(Class c) { + return !c.isAnnotationPresent(CostNonLocal.class) && c.isAnnotationPresent(CostLocal.class); + } + + /** + * Classify a feature's class (cached). SOUND-by-construction: a feature is treated as local + * ONLY if it is explicitly {@link CostLocal}-annotated (its author asserts it depends only on + * the app + find subterm, modulo its child features) -- there is no structural "any composite + * is transparent" guess. {@link CostNonLocal} forces non-local; everything unannotated is + * non-local (the safe default). + */ + private static Kind kind(Feature f) { + return kindCache.computeIfAbsent(f.getClass(), c -> { + if (c.isAnnotationPresent(CostNonLocal.class)) { + return Kind.NON_LOCAL; // explicit author override wins + } + if (f instanceof AbstractNonDuplicateAppFeature) { + return Kind.VETO; + } + return c.isAnnotationPresent(CostLocal.class) ? Kind.LOCAL : Kind.NON_LOCAL; + }); + } + + /** + * Apply {@code action} to each {@link Feature} and {@link TermGenerator} held one structural + * step inside {@code f}. + */ + private static void forEachChild(Feature f, java.util.function.Consumer action) { + for (Field fld : allFields(f.getClass())) { + if (Modifier.isStatic(fld.getModifiers()) || fld.getType().isPrimitive()) { + continue; + } + try { + fld.setAccessible(true); + follow(fld.get(f), action); + } catch (Throwable ignored) { + } + } + } + + private static void follow(@Nullable Object o, java.util.function.Consumer action) { + if (o == null) { + return; + } + if (o instanceof Feature || o instanceof TermGenerator) { + action.accept(o); + return; + } + Class c = o.getClass(); + if (c.isArray()) { + if (!c.getComponentType().isPrimitive()) { + int n = Array.getLength(o); + for (int i = 0; i < n; i++) { + follow(Array.get(o, i), action); + } + } + return; + } + if (o instanceof Iterable it) { + for (Object e : it) { + follow(e, action); + } + } + // other object types (TermBuffer, ProjectionToTerm, Name, ...) are NOT traversed + } + + /** + * Verification aid (only when {@link #VERIFY}): warn if a reused cost differs from the freshly + * recomputed one, i.e. some feature is mis-classified local and should be {@link CostNonLocal}. + */ + static void warnMismatch(Taclet taclet, Object reused, Object fresh) { + LOGGER.warn("cost-reuse mismatch for taclet {}: a feature is mis-classified local; " + + "annotate it @CostNonLocal (reused={}, fresh={})", taclet.name(), reused, fresh); + } + + private static List dispatchers(Object strategy) { + List d = dispatchers; + if (d == null) { + d = strategy instanceof ModularJavaDLStrategy m ? m.costRuleSetDispatchers() + : List.of(); + dispatchers = d; + } + return d; + } + + private static List allFields(Class c) { + List fs = new ArrayList<>(); + for (Class k = c; k != null && k != Object.class; k = k.getSuperclass()) { + fs.addAll(Arrays.asList(k.getDeclaredFields())); + } + return fs; + } +} diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/FIFOStrategy.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/FIFOStrategy.java index 9aaac0ef5a5..90319d42885 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/FIFOStrategy.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/FIFOStrategy.java @@ -40,7 +40,9 @@ private FIFOStrategy() { PosInOccurrence pio, Goal goal, MutableState mState) { - return NumberRuleAppCost.create(((de.uka.ilkd.key.proof.Goal) goal).getTime()); + // FIFO ordering is purely the goal age, which RuleAppContainer.withAge adds once per + // container, so the age-free strategy cost is zero. + return NumberRuleAppCost.getZeroCost(); } /** diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/FindTacletAppContainer.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/FindTacletAppContainer.java index 886be2b0b58..1892a519177 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/FindTacletAppContainer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/FindTacletAppContainer.java @@ -49,14 +49,15 @@ public class FindTacletAppContainer extends TacletAppContainer { * * @param app the taclet application * @param pio the position in occurrence - * @param cost the rule application cost + * @param ageFreeCost the rule application cost without the goal-age term + * @param cost the rule application cost (age-free cost plus the goal-age term) * @param goal the goal to apply the taclet on * @param age the age */ FindTacletAppContainer(NoPosTacletApp app, PosInOccurrence pio, - RuleAppCost cost, Goal goal, + RuleAppCost ageFreeCost, RuleAppCost cost, Goal goal, long age) { - super(app, cost, age); + super(app, ageFreeCost, cost, age); applicationPosition = pio; final FormulaTag posTag = goal.getFormulaTagManager().getTagForPos(pio.topLevel()); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/ModularJavaDLStrategy.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/ModularJavaDLStrategy.java index e7f9bce2798..9e9923a3024 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/ModularJavaDLStrategy.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/ModularJavaDLStrategy.java @@ -10,7 +10,6 @@ import de.uka.ilkd.key.proof.Goal; import de.uka.ilkd.key.proof.Proof; import de.uka.ilkd.key.strategy.ComponentStrategy.StrategyAspect; -import de.uka.ilkd.key.strategy.feature.AgeFeature; import de.uka.ilkd.key.strategy.feature.MatchedAssumesFeature; import de.uka.ilkd.key.strategy.feature.NonDuplicateAppFeature; import de.uka.ilkd.key.strategy.feature.RuleSetDispatchFeature; @@ -57,6 +56,9 @@ public class ModularJavaDLStrategy extends AbstractFeatureStrategy { private final ResponsibleStrategyCache responsibleStrategyCache; + /// the conflict-resolution cost dispatcher; kept for {@link #costRuleSetDispatchers} + private final RuleSetDispatchFeature conflictCostDispatcher; + public ModularJavaDLStrategy(Proof proof, List componentStrategies, StrategyProperties properties) { super(proof); @@ -69,7 +71,7 @@ public ModularJavaDLStrategy(Proof proof, List componentStrat // if more than one strategy is responsible for a _ruleset_ we need to determine how to // resolve the // competing computations - RuleSetDispatchFeature conflictCostDispatcher = resolveConflicts(); + conflictCostDispatcher = resolveConflicts(); final Feature ifMatchedF = ifZero(MatchedAssumesFeature.INSTANCE, longConst(+1)); Feature reduceCostTillMaxF = new ReduceTillMaxFeature(Feature::computeCost, @@ -80,10 +82,12 @@ public ModularJavaDLStrategy(Proof proof, List componentStrat (rule) -> responsibleStrategyCache.getResponsibleStrategies(rule, strategies, StrategyAspect.Instantiation)); - // the feature for the cost computation + // the feature for the cost computation. Age (goal time) is NOT part of the strategy cost: + // it is a first-class container-level term added once by RuleAppContainer.withAge, so the + // cost here is age-free (this lets cost reuse carry the age-free base forward verbatim). totalCost = add(AutomatedRuleFeature.getInstance(), ifMatchedF, NonDuplicateAppFeature.INSTANCE, - reduceCostTillMaxF, conflictCostDispatcher, AgeFeature.INSTANCE); + reduceCostTillMaxF, conflictCostDispatcher); // The feature for instantiateApp, built once instead of on every call. // Note that no conflict dispatcher takes part in this sum: resolveConflicts() @@ -93,10 +97,28 @@ public ModularJavaDLStrategy(Proof proof, List componentStrat enableInstantiate(); totalInstCost = add(AutomatedRuleFeature.getInstance(), ifMatchedF, NonDuplicateAppFeature.INSTANCE, - reduceInstTillMaxF, AgeFeature.INSTANCE); + reduceInstTillMaxF); disableInstantiate(); } + /** + * The {@link RuleSetDispatchFeature}s that contribute to a rule's COST (the conflict-resolution + * dispatcher plus each component strategy's cost dispatcher). Exposed for {@link CostReuse}'s + * feature-locality classification, which must NOT reach this via reflection from the strategy + * object (that would traverse the live proof graph the strategy references). + */ + public List costRuleSetDispatchers() { + final List result = new ArrayList<>(); + result.add(conflictCostDispatcher); + for (ComponentStrategy s : strategies) { + final RuleSetDispatchFeature d = s.getDispatcher(StrategyAspect.Cost); + if (d != null) { + result.add(d); + } + } + return result; + } + private record StratAndDispatcher(ComponentStrategy strategy, RuleSetDispatchFeature dispatcher) { } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/NoFindTacletAppContainer.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/NoFindTacletAppContainer.java index c1ca6c8fb87..9a521d58451 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/NoFindTacletAppContainer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/NoFindTacletAppContainer.java @@ -13,8 +13,9 @@ */ public class NoFindTacletAppContainer extends TacletAppContainer { - NoFindTacletAppContainer(NoPosTacletApp p_app, RuleAppCost p_cost, long p_age) { - super(p_app, p_cost, p_age); + NoFindTacletAppContainer(NoPosTacletApp p_app, RuleAppCost p_ageFreeCost, RuleAppCost p_cost, + long p_age) { + super(p_app, p_ageFreeCost, p_cost, p_age); } /** diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/QueueRuleApplicationManager.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/QueueRuleApplicationManager.java index ff7fbb523af..5c56c0cf2a7 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/QueueRuleApplicationManager.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/QueueRuleApplicationManager.java @@ -5,13 +5,27 @@ import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicLong; +import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.logic.op.UpdateApplication; import de.uka.ilkd.key.proof.Goal; +import de.uka.ilkd.key.rule.NoPosTacletApp; +import de.uka.ilkd.key.rule.inst.SVInstantiations; +import org.key_project.logic.op.Operator; +import org.key_project.logic.op.sv.SchemaVariable; import org.key_project.prover.proof.ProofGoal; import org.key_project.prover.rules.RuleApp; +import org.key_project.prover.sequent.FormulaChangeInfo; import org.key_project.prover.sequent.PosInOccurrence; +import org.key_project.prover.sequent.Sequent; +import org.key_project.prover.sequent.SequentChangeInfo; +import org.key_project.prover.sequent.SequentFormula; import org.key_project.prover.strategy.RuleApplicationManager; import org.key_project.prover.strategy.costbased.MutableState; import org.key_project.prover.strategy.costbased.RuleAppCost; @@ -67,6 +81,47 @@ public class QueueRuleApplicationManager implements RuleApplicationManager private long nextRuleTime; + /** + * Parked assumes-incomplete taclet bases, indexed by the concrete top operator(s) of their + * {@code \assumes} formulas. Such bases re-expand to nothing (no new assumes match) on almost + * every round (profiling: 96.8% of queue pops fail at the unmatched {@code \assumes}, and + * 97-99.6% of the resulting re-expansions yield no new instance -- the dominant remaining queue + * churn), so instead of re-popping and re-expanding them each round they are parked here and + * woken only when a formula they could match appears. A base is stored under each of its wake + * operators and woken when any of them is added/modified. Insertion-ordered for determinism; + * {@code null} until the first base is parked. + *

+ * Sound (byte-identical) by construction, unlike the earlier sequent-growth heuristic: + *

    + *
  • Only effectively-indexable bases are parked -- every {@code \assumes} formula's + * top operator is concrete or a schema variable already bound by the find-match (see + * {@link #assumesWakeOps}). Bases with an unbound-generic top (which would match any formula) + * are never parked; they stay in the active queue and re-expand every round exactly as without + * parking.
  • + *
  • A parked base is woken (re-inserted into the active queue) on exactly the round a formula + * with a matching top operator is added or modified ({@link #sequentChanged}/{@code + * pendingWakeOps}) -- the same round its non-parked counterpart would first see that formula as + * "new" and re-expand to the instance. So the instance enters the queue at the identical round, + * with the identical (current) age and cost, and the proof is byte-identical.
  • + *
  • The wake set is a sound superset: it walks the changed formula's update-prefix + * spine of operators (the assumes matcher strips the update context before matching, see + * {@code VMTacletMatcher.matchUpdateContext}). Over-waking is harmless -- a spuriously woken + * base + * is popped, re-expands to nothing, and is re-parked, exactly as a non-parked base would behave + * that round. Only missing a wake would diverge, and that cannot happen for an + * effectively-indexable base.
  • + *
  • All structures are insertion-ordered ({@link LinkedHashMap}/{@link LinkedHashSet}) so + * parking introduces no non-determinism.
  • + *
+ */ + private @Nullable LinkedHashMap> parkedByOp = null; + /** + * Top operators (along the update-prefix spine) of formulas added/modified since the previous + * round; the wake candidates consumed at the start of the next round. Insertion-ordered for + * determinism; {@code null} until the first change is recorded. + */ + private @Nullable LinkedHashSet pendingWakeOps = null; + @Override public void setGoal(Goal p_goal) { goal = p_goal; @@ -79,6 +134,8 @@ public void setGoal(Goal p_goal) { public void clearCache() { queue = null; previousMinimum = null; + parkedByOp = null; + pendingWakeOps = null; if (goal != null) { goal.proof().getServices().getCaches().getIfInstantiationCache().releaseAll(); } @@ -279,11 +336,20 @@ private void computeNextRuleApp(ImmutableHeap<@NonNull RuleAppContainer> further */ ImmutableList workingList = ImmutableSLList.nil(); + // Wake parked assumes-bases whose \assumes top operator matches a formula added/modified + // since the last round, re-inserting them into the active queue so they re-expand during + // THIS + // round -- the identical round their non-parked counterparts would first see that formula + // as + // new. No completeness net is needed (or wanted): an effectively-indexable base is always + // woken on its matching round, and a late re-injection would surface its instance at the + // wrong round, the very divergence parking must avoid. + wakeParkedBases(); + /* * Try to find a rule app that can be completed until both queues are exhausted. */ while (nextRuleApp == null && !(queue.isEmpty() && furtherAppsQueue.isEmpty())) { - /* * Determine the minimum rule app container, ranging over both queues. Putting this into * a separate method would be convenient. But since we are using immutable data @@ -353,11 +419,24 @@ private void computeNextRuleApp(ImmutableHeap<@NonNull RuleAppContainer> further * Create further apps if found in main queue. Rule apps obtained this way will * be considered during the current round. */ + final ImmutableList further = + minRuleAppContainer.createFurtherApps(goal); + // Empty assumes yield (the re-expansion is just the re-costed base itself, with + // the now-current age): if the base is effectively indexable, park it instead + // of + // re-adding so it stops being re-popped every round. Park further.head() (the + // freshly re-costed container) so the parked age advances exactly as the + // non-parked base's would, keeping later assumes matches from re-deriving stale + // instances. A non-indexable base falls through and is re-added unchanged. + if (further.size() == 1 + && further.head() instanceof TacletAppContainer base + && !base.getTacletApp().assumesInstantionsComplete() + && park(base)) { + continue; + } var time = System.nanoTime(); try { - furtherAppsQueue = - push(minRuleAppContainer.createFurtherApps(goal).iterator(), - furtherAppsQueue); + furtherAppsQueue = push(further.iterator(), furtherAppsQueue); } finally { PERF_QUEUE_OPS.addAndGet(System.nanoTime() - time); } @@ -382,6 +461,170 @@ private void computeNextRuleApp(ImmutableHeap<@NonNull RuleAppContainer> further } } + // --------------------------------------------------------------------------------------------- + // Assumes-base parking (see parkedByOp) + // --------------------------------------------------------------------------------------------- + + /** + * Park an assumes-incomplete base, indexing it under the top operator(s) of its + * {@code \assumes} + * formulas so it can be woken when a matching formula appears. + * + * @param base the re-costed base container to park (carries the current age) + * @return {@code true} if the base was parked; {@code false} if it is not effectively indexable + * (an unbound-generic {@code \assumes} top), in which case the caller must keep it in + * the + * active queue + */ + private boolean park(TacletAppContainer base) { + final List ops = assumesWakeOps(base); + if (ops == null) { + return false; + } + if (parkedByOp == null) { + parkedByOp = new LinkedHashMap<>(); + } + for (Operator op : ops) { + parkedByOp.computeIfAbsent(op, k -> new LinkedHashSet<>()).add(base); + } + return true; + } + + /** + * Re-insert into the active queue every parked base waiting on an operator that was added or + * modified since the previous round (see {@link #pendingWakeOps}). Woken bases are collected in + * insertion order (deterministic) and removed from all their index buckets. + */ + private void wakeParkedBases() { + if (pendingWakeOps == null) { + return; + } + if (parkedByOp != null && !parkedByOp.isEmpty()) { + LinkedHashSet woken = null; + for (Operator op : pendingWakeOps) { + final LinkedHashSet bucket = parkedByOp.get(op); + if (bucket != null) { + if (woken == null) { + woken = new LinkedHashSet<>(); + } + woken.addAll(bucket); + } + } + if (woken != null) { + for (RuleAppContainer c : woken) { + unindexParked(c); + } + var time = System.nanoTime(); + try { + queue = queue.insert(woken.iterator()); + } finally { + PERF_QUEUE_OPS.addAndGet(System.nanoTime() - time); + } + } + } + pendingWakeOps.clear(); + } + + /** Remove a woken container from every operator bucket it was parked under. */ + private void unindexParked(RuleAppContainer c) { + if (parkedByOp == null || !(c instanceof TacletAppContainer tac)) { + return; + } + final List ops = assumesWakeOps(tac); + if (ops == null) { + return; + } + for (Operator op : ops) { + final LinkedHashSet bucket = parkedByOp.get(op); + if (bucket != null) { + bucket.remove(c); + if (bucket.isEmpty()) { + parkedByOp.remove(op); + } + } + } + } + + /** + * The concrete top operator(s) of the {@code \assumes} formulas of the given base, resolved + * through the find-match's schema-variable instantiations. + * + * @return the wake operators, or {@code null} if the base is not effectively indexable + * -- i.e. some {@code \assumes} formula has a top that is an unbound schema variable + * (it + * would match any formula, so no precise wake operator exists) or has no + * {@code \assumes} + * formulas at all + */ + private static @Nullable List assumesWakeOps(TacletAppContainer base) { + final NoPosTacletApp app = base.getTacletApp(); + final Sequent assumesSeq = app.taclet().assumesSequent(); + if (assumesSeq.isEmpty()) { + return null; + } + final SVInstantiations insts = app.instantiations(); + final List ops = new ArrayList<>(assumesSeq.size()); + for (SequentFormula sf : assumesSeq) { + Operator op = sf.formula().op(); + if (op instanceof SchemaVariable sv) { + final Object inst = insts.getInstantiation(sv); + if (!(inst instanceof JTerm instTerm)) { + return null; // unbound (or non-term) generic top -> not indexable + } + op = instTerm.op(); + if (op instanceof SchemaVariable) { + return null; // still schematic -> not indexable + } + } + ops.add(op); + } + return ops; + } + + /** + * Record, for the next round's wake-up, the top operators of every formula added or modified by + * this sequent change. The assumes matcher strips the update context before matching, so the + * whole update-prefix spine of each changed formula is recorded -- a sound superset of the + * operators a parked base could match (see {@link #parkedByOp}). Called by {@code Goal} on + * every + * sequent change. + */ + public void sequentChanged(SequentChangeInfo sci) { + recordWakeOps(sci.addedFormulas(true)); + recordWakeOps(sci.addedFormulas(false)); + recordModifiedWakeOps(sci.modifiedFormulas(true)); + recordModifiedWakeOps(sci.modifiedFormulas(false)); + } + + private void recordWakeOps(ImmutableList added) { + for (SequentFormula sf : added) { + recordSpineOps(sf.formula()); + } + } + + private void recordModifiedWakeOps(ImmutableList modified) { + for (FormulaChangeInfo fci : modified) { + recordSpineOps(fci.newFormula().formula()); + } + } + + /** Add the operators along a formula's update-application spine to {@link #pendingWakeOps}. */ + private void recordSpineOps(org.key_project.logic.Term formula) { + if (pendingWakeOps == null) { + pendingWakeOps = new LinkedHashSet<>(); + } + org.key_project.logic.Term t = formula; + while (true) { + final Operator op = t.op(); + pendingWakeOps.add(op); + if (op instanceof UpdateApplication && t instanceof JTerm jt) { + t = UpdateApplication.getTarget(jt); + } else { + break; + } + } + } + @Override public RuleApplicationManager copy() { // noinspection unchecked @@ -393,6 +636,19 @@ public Object clone() { QueueRuleApplicationManager res = new QueueRuleApplicationManager(); res.queue = queue; res.previousMinimum = previousMinimum; + // the parking structures are mutable and goal-local: deep-copy so split goals park/wake + // independently (the contained containers and operators are immutable and shared) + if (parkedByOp != null) { + final LinkedHashMap> copy = + new LinkedHashMap<>(parkedByOp.size()); + for (Map.Entry> e : parkedByOp.entrySet()) { + copy.put(e.getKey(), new LinkedHashSet<>(e.getValue())); + } + res.parkedByOp = copy; + } + if (pendingWakeOps != null) { + res.pendingWakeOps = new LinkedHashSet<>(pendingWakeOps); + } return res; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/RuleAppContainer.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/RuleAppContainer.java index 5e622bbb551..cba962e6a06 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/RuleAppContainer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/RuleAppContainer.java @@ -10,6 +10,7 @@ import org.key_project.prover.rules.RuleApp; import org.key_project.prover.sequent.PosInOccurrence; +import org.key_project.prover.strategy.costbased.NumberRuleAppCost; import org.key_project.prover.strategy.costbased.RuleAppCost; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; @@ -61,6 +62,18 @@ public final RuleAppCost getCost() { return cost; } + /** + * Add the goal-age term to a strategy-computed cost. Age (the goal time, i.e. number of rules + * applied so far) is a single first-class component of every container's cost, contributed here + * rather than inside any {@link de.uka.ilkd.key.strategy.Strategy#computeCost} -- so a strategy + * (and each of its components) computes only its age-free cost, and age is added exactly once + * per queued container regardless of how strategies are composed. {@code Top} stays + * {@code Top}. + */ + protected static RuleAppCost withAge(RuleAppCost ageFreeCost, Goal goal) { + return ageFreeCost.add(NumberRuleAppCost.create(goal.getTime())); + } + /** * Create container for a RuleApp. * diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/SimpleFilteredStrategy.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/SimpleFilteredStrategy.java index 7c20b783df1..b532efeb4b2 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/SimpleFilteredStrategy.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/SimpleFilteredStrategy.java @@ -66,7 +66,9 @@ public Name name() { return res; } - long cost = ((de.uka.ilkd.key.proof.Goal) goal).getTime(); + // The goal-age ordering is added once by RuleAppContainer.withAge; only the age-free + // malus remains in the strategy cost. + long cost = 0; if (app instanceof TacletApp tacletApp && !tacletApp.assumesInstantionsComplete()) { cost += IF_NOT_MATCHED_MALUS; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/TacletAppContainer.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/TacletAppContainer.java index 22282daa683..75ec1d7eab1 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/TacletAppContainer.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/TacletAppContainer.java @@ -18,12 +18,17 @@ import org.key_project.prover.rules.instantiation.AssumesFormulaInstantiation; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.sequent.Sequent; +import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.NumberRuleAppCost; import org.key_project.prover.strategy.costbased.RuleAppCost; import org.key_project.prover.strategy.costbased.TopRuleAppCost; +import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableSLList; import org.key_project.util.collection.ImmutableSet; +import org.jspecify.annotations.Nullable; + /** * Instances of this class are immutable */ @@ -36,13 +41,32 @@ public abstract class TacletAppContainer extends RuleAppContainer { // save any memory (at the moment). // This is because Java's memory alingment. + /** + * Creation time of this container ({@code -1} for an initial/just-loaded container). Since age + * became a first-class container-level cost term ({@link RuleAppContainer#withAge}) this field + * no longer feeds the cost; it is purely the {@link AssumesInstantiator} freshness key (was + * this + * container built before or after a given if-formula). + */ private final long age; + /** + * The age-free strategy cost: {@code getCost()} without the goal-age term that + * {@link RuleAppContainer#withAge} adds. Stored so cost reuse can carry it forward unchanged + * across re-expansion and only re-add the current age, with no reconstruction arithmetic. + */ + private final RuleAppCost ageFreeCost; - protected TacletAppContainer(RuleApp p_app, RuleAppCost p_cost, long p_age) { + protected TacletAppContainer(RuleApp p_app, RuleAppCost p_ageFreeCost, RuleAppCost p_cost, + long p_age) { super(p_app, p_cost); + ageFreeCost = p_ageFreeCost; age = p_age; } + RuleAppCost getAgeFreeCost() { + return ageFreeCost; + } + protected NoPosTacletApp getTacletApp() { return (NoPosTacletApp) getRuleApp(); } @@ -66,14 +90,15 @@ protected static TacletAppContainer createContainer(NoPosTacletApp p_app, private static TacletAppContainer createContainer(NoPosTacletApp p_app, PosInOccurrence p_pio, - Goal p_goal, RuleAppCost p_cost, boolean p_initial) { + Goal p_goal, RuleAppCost p_ageFreeCost, boolean p_initial) { // This relies on the fact that the method Goal.getTime() // never returns a value less than zero final long localage = p_initial ? -1 : p_goal.getTime(); + final RuleAppCost cost = withAge(p_ageFreeCost, p_goal); if (p_pio == null) { - return new NoFindTacletAppContainer(p_app, p_cost, localage); + return new NoFindTacletAppContainer(p_app, p_ageFreeCost, cost, localage); } else { - return new FindTacletAppContainer(p_app, p_pio, p_cost, p_goal, localage); + return new FindTacletAppContainer(p_app, p_pio, p_ageFreeCost, cost, p_goal, localage); } } @@ -88,7 +113,11 @@ public final ImmutableList createFurtherApps(Goal p_goal) { return ImmutableSLList.nil(); } - final TacletAppContainer newCont = createContainer(p_goal); + final TacletAppContainer newCont = costLocalReusedContainerOr(p_goal); + if (newCont == null) { + // a veto fired on the cost-local fast path: the re-costed base would be infinite + return ImmutableSLList.nil(); + } if (newCont.getCost() instanceof TopRuleAppCost) { return ImmutableSLList.nil(); } @@ -183,6 +212,45 @@ private TacletAppContainer createContainer(Goal p_goal) { return createContainer(getTacletApp(), getPosInOccurrence(p_goal), p_goal, false); } + /** + * Re-cost the base app for {@link #createFurtherApps}. On the cost-reuse fast path (taclet + * classified cost-local by {@link CostReuse}, numeric age-free cost) the full + * {@link de.uka.ilkd.key.strategy.Strategy#computeCost} is skipped: the stored age-free cost is + * carried forward verbatim and {@link RuleAppContainer#withAge} re-adds the current goal age + * when the new container is built -- no reconstruction arithmetic, and initial containers + * (age {@code -1}) reuse soundly too, since age is no longer part of the stored cost. The + * {@code NonDuplicateApp}-family vetoes that contribute are re-evaluated first; if one fires + * the + * full cost would be {@link TopRuleAppCost}, so {@code null} is returned (drop the app). + * Otherwise, and whenever reuse is disabled/inapplicable, falls back to the normal recompute. + */ + private @Nullable TacletAppContainer costLocalReusedContainerOr(Goal p_goal) { + if (getAgeFreeCost() instanceof NumberRuleAppCost base) { + final Feature[] vetoes = + CostReuse.vetoesIfEligible(p_goal.getGoalStrategy(), getTacletApp().taclet()); + if (vetoes != null) { + final PosInOccurrence pos = getPosInOccurrence(p_goal); + final MutableState mState = new MutableState(); + for (Feature veto : vetoes) { + if (veto.computeCost(getTacletApp(), pos, p_goal, + mState) instanceof TopRuleAppCost) { + return null; + } + } + if (CostReuse.VERIFY) { + final RuleAppCost freshBase = + p_goal.getGoalStrategy().computeCost(getTacletApp(), pos, p_goal); + if (!base.equals(freshBase)) { + CostReuse.warnMismatch(getTacletApp().taclet(), base, freshBase); + } + } + // carry the age-free base forward; createContainer re-adds the current age + return createContainer(getTacletApp(), pos, p_goal, base, false); + } + } + return createContainer(p_goal); + } + /** * Create containers for NoFindTaclets. */ diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AbstractMonomialSmallerThanFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AbstractMonomialSmallerThanFeature.java index 3c69ff78ab3..e96922e243a 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AbstractMonomialSmallerThanFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AbstractMonomialSmallerThanFeature.java @@ -3,6 +3,8 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.strategy.feature; +import java.util.Map; + import de.uka.ilkd.key.ldt.IntegerLDT; import de.uka.ilkd.key.logic.op.SkolemTermSV; import de.uka.ilkd.key.proof.Goal; @@ -14,7 +16,6 @@ import org.key_project.logic.op.Operator; import org.key_project.prover.rules.RuleApp; import org.key_project.prover.rules.RuleSet; -import org.key_project.util.LRUCache; import org.key_project.util.collection.ImmutableList; public abstract class AbstractMonomialSmallerThanFeature extends SmallerThanFeature { @@ -34,7 +35,7 @@ protected int introductionTime(Operator op, Goal goal) { return -1; } - final LRUCache introductionTimeCache = + final Map introductionTimeCache = goal.proof().getServices().getCaches().getIntroductionTimeCache(); Integer res; @@ -44,8 +45,19 @@ protected int introductionTime(Operator op, Goal goal) { if (res == null) { res = introductionTimeHelp(op, goal); - synchronized (introductionTimeCache) { - introductionTimeCache.put(op, res); + // Do NOT cache the "not introduced (yet)" answer (-1): op may be introduced by a later + // rule application, after which introductionTimeHelp would find a real time. Caching + // the + // -1 would freeze it, making the value depend on whether op happened to be first + // queried + // before or after its introduction -- i.e. on the access pattern (which features run, + // when). That makes term ordering, and hence OneStepSimplifier rewriting, subtly + // non-deterministic. A real introduction time, once found, is stable (the introducing + // rule stays in the applied-rule prefix), so it is safe to cache. + if (res != -1) { + synchronized (introductionTimeCache) { + introductionTimeCache.put(op, res); + } } } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AgeFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AgeFeature.java deleted file mode 100644 index 3428ec49609..00000000000 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/AgeFeature.java +++ /dev/null @@ -1,34 +0,0 @@ -/* This file is part of KeY - https://key-project.org - * KeY is licensed under the GNU General Public License Version 2 - * SPDX-License-Identifier: GPL-2.0-only */ -package de.uka.ilkd.key.strategy.feature; - -import org.key_project.prover.proof.ProofGoal; -import org.key_project.prover.rules.RuleApp; -import org.key_project.prover.sequent.PosInOccurrence; -import org.key_project.prover.strategy.costbased.MutableState; -import org.key_project.prover.strategy.costbased.NumberRuleAppCost; -import org.key_project.prover.strategy.costbased.RuleAppCost; -import org.key_project.prover.strategy.costbased.feature.Feature; - -import org.jspecify.annotations.NonNull; - -/** - * Feature that computes the age of the goal (i.e. total number of rules applications that have been - * performed at the goal) to which a rule is supposed to be applied - */ -public class AgeFeature implements Feature { - - public static final Feature INSTANCE = new AgeFeature(); - - private AgeFeature() {} - - @Override - public > RuleAppCost computeCost(RuleApp app, - PosInOccurrence pos, Goal goal, MutableState mState) { - return NumberRuleAppCost.create(((de.uka.ilkd.key.proof.Goal) goal).getTime()); - // return LongRuleAppCost.create ( goal.getTime() / goal.sequent ().size () ); - // return LongRuleAppCost.create ( (long)Math.sqrt ( goal.getTime () ) ); - } - -} diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/CheckApplyEqFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/CheckApplyEqFeature.java index 6cf5302230b..c9bbf5c347d 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/CheckApplyEqFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/CheckApplyEqFeature.java @@ -13,6 +13,7 @@ import org.key_project.prover.sequent.PIOPathIterator; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; /** @@ -20,6 +21,7 @@ * rule application must not be one side of an equation that is the instantiation of the first * if-formula. If the rule application is admissible, zero is returned. */ +@CostLocal public class CheckApplyEqFeature extends BinaryTacletAppFeature { public static final Feature INSTANCE = new CheckApplyEqFeature(); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/ComprehendedSumFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/ComprehendedSumFeature.java index 2eabf7ed9ce..ec13ad5cd01 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/ComprehendedSumFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/ComprehendedSumFeature.java @@ -11,6 +11,7 @@ import org.key_project.prover.strategy.costbased.NumberRuleAppCost; import org.key_project.prover.strategy.costbased.RuleAppCost; import org.key_project.prover.strategy.costbased.TopRuleAppCost; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.TermBuffer; import org.key_project.prover.strategy.costbased.termgenerator.TermGenerator; @@ -21,6 +22,10 @@ * A feature that computes the sum of the values of a feature term when a given variable ranges over * a sequence of terms */ +// @CostLocal: transparent -- it sums its (recursed) body over a TermGenerator. CostReuse also +// classifies that generator: it stays local only with a find-local generator (e.g. +// SuperTermGenerator), and is non-local with a sequent-scanning one (SequentFormulasGenerator). +@CostLocal public class ComprehendedSumFeature> implements Feature { private final TermBuffer var; diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/FindRightishFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/FindRightishFeature.java index a3ddddddbb5..cfbf42e18f3 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/FindRightishFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/FindRightishFeature.java @@ -14,6 +14,7 @@ import org.key_project.prover.strategy.costbased.MutableState; import org.key_project.prover.strategy.costbased.NumberRuleAppCost; import org.key_project.prover.strategy.costbased.RuleAppCost; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.jspecify.annotations.NonNull; @@ -23,6 +24,7 @@ * choose the left branch (subterm) and how the right branches. This is used to identify the * upper/righter/bigger summands in a polynomial that is arranged in a left-associated way. */ +@CostLocal public class FindRightishFeature implements Feature { private final Operator add; private final static RuleAppCost one = NumberRuleAppCost.create(1); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/InstantiatedSVFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/InstantiatedSVFeature.java index 650c6f6abac..30cda232d76 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/InstantiatedSVFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/InstantiatedSVFeature.java @@ -10,6 +10,7 @@ import org.key_project.logic.Name; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.ProjectionToTerm; @@ -17,6 +18,7 @@ * Feature that returns zero iff a certain schema variable is instantiated. If the schemavariable is * not instantiated schema variable or does not occur in the taclet infinity costs are returned. */ +@CostLocal public class InstantiatedSVFeature extends BinaryTacletAppFeature { private final ProjectionToTerm instProj; diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MatchedAssumesFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MatchedAssumesFeature.java index d712c639ec5..ed53957cd49 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MatchedAssumesFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MatchedAssumesFeature.java @@ -8,12 +8,14 @@ import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; /** * Binary features that returns zero iff the if-formulas of a Taclet are instantiated or the Taclet * does not have any if-formulas. */ +@CostLocal public final class MatchedAssumesFeature extends BinaryTacletAppFeature { public static final Feature INSTANCE = new MatchedAssumesFeature(); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MonomialsSmallerThanFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MonomialsSmallerThanFeature.java index b9209f7d756..75aacbb0b6a 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MonomialsSmallerThanFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/MonomialsSmallerThanFeature.java @@ -14,6 +14,7 @@ import org.key_project.prover.strategy.costbased.MutableState; import org.key_project.prover.strategy.costbased.NumberRuleAppCost; import org.key_project.prover.strategy.costbased.TopRuleAppCost; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.ProjectionToTerm; import org.key_project.prover.strategy.costbased.termfeature.BinarySumTermFeature; @@ -28,6 +29,7 @@ * Feature that returns zero iff each monomial of one polynomial is smaller than all monomials of a * second polynomial */ +@CostLocal public class MonomialsSmallerThanFeature extends AbstractMonomialSmallerThanFeature { private final TermFeature hasCoeff; diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/NoSelfApplicationFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/NoSelfApplicationFeature.java index 4b9d1c21a48..6bd69a25079 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/NoSelfApplicationFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/NoSelfApplicationFeature.java @@ -9,6 +9,7 @@ import org.key_project.prover.rules.instantiation.AssumesFormulaInstantiation; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.util.collection.ImmutableList; @@ -16,6 +17,7 @@ * This feature checks that the position of application is not contained in the if-formulas. If the * rule application is admissible, zero is returned. */ +@CostLocal public class NoSelfApplicationFeature extends BinaryTacletAppFeature { public static final Feature INSTANCE = new NoSelfApplicationFeature(); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TermSmallerThanFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TermSmallerThanFeature.java index ba1d7620655..93ce0456a42 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TermSmallerThanFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TermSmallerThanFeature.java @@ -8,12 +8,14 @@ import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.ProjectionToTerm; /** * Feature that returns zero iff one term is smaller than another term in the current term ordering */ +@CostLocal public class TermSmallerThanFeature extends SmallerThanFeature { private final ProjectionToTerm left, right; diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TrivialMonomialLCRFeature.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TrivialMonomialLCRFeature.java index e211b83ffbc..4382a0578a3 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TrivialMonomialLCRFeature.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/feature/TrivialMonomialLCRFeature.java @@ -10,6 +10,7 @@ import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.ProjectionToTerm; @@ -20,6 +21,7 @@ *

* "A critical-pair/completion algorithm for finitely generated ideals in rings" */ +@CostLocal public class TrivialMonomialLCRFeature extends BinaryTacletAppFeature { private final ProjectionToTerm a, b; diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/HandleArith.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/HandleArith.java index c69511d94a1..003826944ef 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/HandleArith.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/HandleArith.java @@ -3,6 +3,8 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.strategy.quantifierHeuristics; +import java.util.Map; + import de.uka.ilkd.key.java.ServiceCaches; import de.uka.ilkd.key.java.Services; import de.uka.ilkd.key.ldt.IntegerLDT; @@ -13,7 +15,6 @@ import org.key_project.logic.op.Function; import org.key_project.logic.op.Operator; -import org.key_project.util.LRUCache; import org.key_project.util.collection.Pair; import static de.uka.ilkd.key.logic.equality.IrrelevantTermLabelsProperty.IRRELEVANT_TERM_LABELS_PROPERTY; @@ -36,7 +37,7 @@ private HandleArith() {} * problem if it cann't be proved. */ public static JTerm provedByArith(JTerm problem, Services services) { - final LRUCache provedByArithCache = + final Map provedByArithCache = services.getCaches().getProvedByArithFstCache(); JTerm result; synchronized (provedByArithCache) { @@ -75,7 +76,7 @@ public static JTerm provedByArith(JTerm problem, Services services) { - private static void putInTermCache(final LRUCache provedByArithCache, + private static void putInTermCache(final Map provedByArithCache, final JTerm key, final JTerm value) { synchronized (provedByArithCache) { provedByArithCache.put(key, value); @@ -128,7 +129,7 @@ private static JTerm provedArithEqual(JTerm problem, TermBuilder tb, Services se */ public static JTerm provedByArith(JTerm problem, JTerm axiom, Services services) { final Pair key = new Pair<>(problem, axiom); - final LRUCache, JTerm> provedByArithCache = + final Map, JTerm> provedByArithCache = services.getCaches().getProvedByArithSndCache(); JTerm result; synchronized (provedByArithCache) { @@ -189,7 +190,7 @@ public static JTerm provedByArith(JTerm problem, JTerm axiom, Services services) */ private static JTerm formatArithTerm(final JTerm problem, TermBuilder tb, IntegerLDT ig, ServiceCaches caches) { - final LRUCache formattedTermCache = caches.getFormattedTermCache(); + final Map formattedTermCache = caches.getFormattedTermCache(); JTerm pro; synchronized (formattedTermCache) { pro = formattedTermCache.get(problem); diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/Metavariable.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/Metavariable.java index 8d70369fea4..bded9e4f0bb 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/Metavariable.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/Metavariable.java @@ -3,6 +3,8 @@ * SPDX-License-Identifier: GPL-2.0-only */ package de.uka.ilkd.key.strategy.quantifierHeuristics; +import java.util.concurrent.atomic.AtomicInteger; + import de.uka.ilkd.key.ldt.JavaDLTheory; import de.uka.ilkd.key.logic.op.JAbstractSortedOperator; @@ -15,24 +17,21 @@ public final class Metavariable extends JAbstractSortedOperator implements Comparable, TerminalSyntaxElement, Named { - // Used to define an alternative order of all existing - // metavariables - private static int maxSerial = 0; - private int serial; + // Used to define an alternative order of all existing metavariables. Atomic so that serials + // stay unique when metavariables are created concurrently (the previous `synchronized` method + // guarded only the per-instance monitor, not this shared static counter). + private static final AtomicInteger maxSerial = new AtomicInteger(0); + private final int serial; private final boolean isTemporaryVariable; - private synchronized void setSerial() { - serial = maxSerial++; - } - private Metavariable(Name name, Sort sort, boolean isTemporaryVariable) { super(name, sort, true); if (sort == JavaDLTheory.FORMULA) { throw new RuntimeException("Attempt to create metavariable of type formula"); } this.isTemporaryVariable = isTemporaryVariable; - setSerial(); + this.serial = maxSerial.getAndIncrement(); // assert false : "metavariables are disabled"; } diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/UniTrigger.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/UniTrigger.java index 1461de0b5b2..b95e5d4fad3 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/UniTrigger.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/quantifierHeuristics/UniTrigger.java @@ -10,7 +10,7 @@ import org.key_project.logic.Term; import org.key_project.logic.op.QuantifiableVariable; -import org.key_project.util.LRUCache; +import org.key_project.util.ConcurrentLruCache; import org.key_project.util.collection.DefaultImmutableSet; import org.key_project.util.collection.ImmutableList; import org.key_project.util.collection.ImmutableMap; @@ -28,8 +28,16 @@ class UniTrigger implements Trigger { private final boolean onlyUnify; private final boolean isElementOfMultitrigger; - private final LRUCache> matchResults = - new LRUCache<>(1000); + // A TriggersSet is cached per proof (ServiceCaches.triggerSetCache) and thus shared across the + // parallel-prover workers, so this match-result cache is hit concurrently on the cost path. The + // exact ConcurrentLruCache is used (not the striped one): the cached substitutions are + // expensive + // to recompute, so the better hit rate of exact LRU eviction outweighs the trivial contention + // on + // get/put. The get-then-put below stays non-atomic on purpose (the expensive matching runs + // outside the lock); at worst two workers redundantly compute the same (pure) result. + private final ConcurrentLruCache> matchResults = + new ConcurrentLruCache<>(1000); UniTrigger(Term trigger, ImmutableSet uqvs, boolean isUnify, boolean isElementOfMultitrigger, TriggersSet triggerSetThisBelongsTo) { diff --git a/key.core/src/main/java/de/uka/ilkd/key/strategy/termgenerator/SuperTermGenerator.java b/key.core/src/main/java/de/uka/ilkd/key/strategy/termgenerator/SuperTermGenerator.java index 20fe63a4c66..3a953109864 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/strategy/termgenerator/SuperTermGenerator.java +++ b/key.core/src/main/java/de/uka/ilkd/key/strategy/termgenerator/SuperTermGenerator.java @@ -23,10 +23,12 @@ import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.prover.strategy.costbased.MutableState; import org.key_project.prover.strategy.costbased.TopRuleAppCost; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.termfeature.TermFeature; import org.key_project.prover.strategy.costbased.termgenerator.TermGenerator; import org.key_project.util.collection.ImmutableArray; +@CostLocal public abstract class SuperTermGenerator implements TermGenerator { private final TermFeature cond; diff --git a/key.core/src/main/java/de/uka/ilkd/key/util/ProofStarter.java b/key.core/src/main/java/de/uka/ilkd/key/util/ProofStarter.java index b02e9ba865f..a8417bb3adf 100644 --- a/key.core/src/main/java/de/uka/ilkd/key/util/ProofStarter.java +++ b/key.core/src/main/java/de/uka/ilkd/key/util/ProofStarter.java @@ -17,7 +17,7 @@ import de.uka.ilkd.key.proof.io.AutoSaver; import de.uka.ilkd.key.proof.io.ProofSaver; import de.uka.ilkd.key.proof.mgt.ProofEnvironment; -import de.uka.ilkd.key.prover.impl.ApplyStrategy; +import de.uka.ilkd.key.prover.impl.AutoProvers; import de.uka.ilkd.key.rule.OneStepSimplifier; import de.uka.ilkd.key.strategy.Strategy; import de.uka.ilkd.key.strategy.StrategyFactory; @@ -251,7 +251,7 @@ public ProofSearchInformation start(ImmutableList goals) { OneStepSimplifier.refreshOSS(proof); var goalChooser = profile.getSelectedGoalChooserBuilder().create(); - ProverCore prover = new ApplyStrategy(goalChooser); + ProverCore prover = AutoProvers.create(goalChooser, profile); if (ptl != null) { prover.addProverTaskObserver(ptl); } diff --git a/key.core/src/main/resources/de/uka/ilkd/key/proof/rules/sequence/seqRules.key b/key.core/src/main/resources/de/uka/ilkd/key/proof/rules/sequence/seqRules.key index 859e9bf83db..c5249415413 100644 --- a/key.core/src/main/resources/de/uka/ilkd/key/proof/rules/sequence/seqRules.key +++ b/key.core/src/main/resources/de/uka/ilkd/key/proof/rules/sequence/seqRules.key @@ -494,7 +494,7 @@ \replacewith(\if(from < to) \then(to - from) \else(0)) - \heuristics(simplify, find_term_not_in_assumes) + // \heuristics(simplify, find_term_not_in_assumes) \displayname "lenOfSeqSub" }; diff --git a/key.core/src/test/java/de/uka/ilkd/key/proof/runallproofs/ProofCollections.java b/key.core/src/test/java/de/uka/ilkd/key/proof/runallproofs/ProofCollections.java index cdf13ad9404..a34f470e8d0 100644 --- a/key.core/src/test/java/de/uka/ilkd/key/proof/runallproofs/ProofCollections.java +++ b/key.core/src/test/java/de/uka/ilkd/key/proof/runallproofs/ProofCollections.java @@ -103,6 +103,14 @@ public static ProofCollection automaticJavaDL() throws IOException { */ // runOnlyOn = group1, group2 (the space after each comma is mandatory) // settings.setRunOnlyOn("performance, performancePOConstruction"); + // perf round 3: the perfTest group (defined below) is the curated measurement set. By + // default ALL groups run (full regression, exactly like main); pass + // -Dkey.runallproofs.runOnlyOn=perfTest to restrict to it, e.g. to reproduce the combined + // benchmark without running the whole suite. + String runOnly = System.getProperty("key.runallproofs.runOnlyOn", ""); + if (!runOnly.isBlank()) { + settings.setRunOnlyOn(runOnly); + } settings.setKeySettings(GenerateUnitTestsUtil.loadFromFile("automaticJAVADL.properties")); @@ -188,6 +196,33 @@ public static ProofCollection automaticJavaDL() throws IOException { // .provable("performance-test/updateSimplification/loop_5000.key"); + // ---------------------------------------------------------------------------------- + // Perf round 3 (cost-memoization / queue-redesign experiment) goal set. + // + // The 12 goals supplied for evaluating strategy-cost memoization, split into a + // development ("test") subset used while iterating on the change and a held-out + // ("validation") subset only consulted to confirm we did not overfit. The split is + // stratified across categories (heap lists, search/sort, arithmetic, information flow, + // static init) so each subset is representative. + // + // Selected to run via setRunOnlyOn("perfTest, perfValidation") above. REVERT before + // merging to main. + var perfTest = c.group("perfTest"); + perfTest.provable("heap/list_seq/SimplifiedLinkedList.remove.key"); + perfTest.provable("standard_key/arith/gemplusDecimal/add.key"); + perfTest.provable("heap/saddleback_search/Saddleback_search.key"); + perfTest.provable("standard_key/java_dl/symmArray.key"); + perfTest.provable("heap/coincidence_count/project.key"); + perfTest.provable("heap/list_seq/ArrayList.remove.1.key"); + + var perfValidation = c.group("perfValidation"); + perfValidation.provable("standard_key/java_dl/jml-information-flow.key"); + perfValidation.provable("heap/quicksort/sort.key"); + perfValidation.provable("heap/list/ArrayList_concatenate.key"); + perfValidation.provable("standard_key/arith/median.key"); + perfValidation.provable("standard_key/staticInitialisation/objectOfErroneousClass.key"); + perfValidation.provable("heap/removeDups/removeDup.key"); + // Tests for rule application restrictions var g = c.group("applicationRestrictions"); g.provable("heap/polarity_tests/wellformed1.key"); diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ConcurrentMatchingStressTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ConcurrentMatchingStressTest.java new file mode 100644 index 00000000000..6d950096b42 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ConcurrentMatchingStressTest.java @@ -0,0 +1,90 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.init.JavaProfile; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.util.ProofStarter; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Stress test for the goal-level parallel prover once rule selection runs outside the + * commit lock (i.e. matching/cost truly run concurrently). A correctness bug there is typically + * nondeterministic, so a single run is not enough: this repeats high-worker-count proofs many times + * and asserts each one still closes and stays equivalent (modulo branch order) to the + * single-threaded proof. + * + *

+ * The problems chosen each stress a different concurrent surface: {@code prop_split} (independent + * branches), {@code fol_split_skolem} (concurrent fresh-name minting across branches), and + * {@code arith_poly} (the eviction/history-sensitive arithmetic caches). + * + * @author Claude (KeY multithreading effort) + */ +public class ConcurrentMatchingStressTest { + + private static final int WORKERS = 8; + private static final int ITERATIONS = 30; + + @ParameterizedTest(name = "stress {0}") + @ValueSource(strings = { "prop_split.key", "fol_split_skolem.key", "arith_poly.key" }) + void repeatedMultiWorkerRunsStayEquivalent(String file) throws Exception { + ProofFingerprint baseline = prove(file, false); + assertTrue(baseline.closed, () -> file + " did not close single-threaded"); + + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(WORKERS)); + try { + for (int i = 0; i < ITERATIONS; i++) { + ProofFingerprint parallel = prove(file, true); + final int iter = i; + assertTrue(parallel.closed, + () -> file + " did not close on parallel iteration " + iter + ": " + parallel); + assertTrue(baseline.equalsModuloOrder(parallel), + () -> file + " diverged on parallel iteration " + iter + ":\n baseline=" + + baseline + "\n parallel=" + parallel); + } + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + } + } + + private static ProofFingerprint prove(String file, boolean parallel) throws Exception { + URL url = ConcurrentMatchingStressTest.class + .getResource("/de/uka/ilkd/key/prover/mt/equiv/" + file); + Path keyFile = Paths.get(url.toURI()); + KeYEnvironment env = + KeYEnvironment.load(JavaProfile.getDefaultInstance(), keyFile, null, null, null, true); + try { + Proof proof = env.getLoadedProof(); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + return ProofFingerprint.of(proof); + } finally { + env.dispose(); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/GoalSchedulerTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/GoalSchedulerTest.java new file mode 100644 index 00000000000..e15c947f3ba --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/GoalSchedulerTest.java @@ -0,0 +1,180 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import de.uka.ilkd.key.prover.impl.GoalScheduler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Concurrency tests for {@link GoalScheduler}: each offered goal is claimed exactly once, the queue + * deduplicates, and a pool of workers terminates exactly when the queue becomes quiescent — + * including when processing a goal produces further goals (the proof-splitting case). + * + * @author Claude (KeY multithreading effort) + */ +public class GoalSchedulerTest { + + @Test + void basicLifecycleAndQuiescence() { + GoalScheduler scheduler = new GoalScheduler<>(); + assertTrue(scheduler.isQuiescent()); + + scheduler.offer("a"); + scheduler.offer("b"); + assertEquals(2, scheduler.availableCount()); + assertFalse(scheduler.isQuiescent()); + + String first = scheduler.claimNext(); + assertEquals(1, scheduler.inFlightCount()); + assertFalse(scheduler.isQuiescent(), "in-flight work means not quiescent"); + + String second = scheduler.claimNext(); // claim the other (order is an impl detail) + assertNull(scheduler.claimNext(), "nothing left to claim"); + + scheduler.complete(first); + assertFalse(scheduler.isQuiescent(), "one goal still in flight"); + scheduler.complete(second); + assertTrue(scheduler.isQuiescent(), "all goals completed"); + } + + @Test + void deduplicatesByIdentity() { + GoalScheduler scheduler = new GoalScheduler<>(); + String g = "goal"; + scheduler.offer(g); + scheduler.offer(g); // same identity, ignored + assertEquals(1, scheduler.availableCount()); + + String claimed = scheduler.claimNext(); + scheduler.offer(claimed); // in flight, must not be re-queued + assertEquals(0, scheduler.availableCount()); + } + + @Test + void concurrentWorkersClaimEachGoalExactlyOnceAndTerminate() throws Exception { + final int workers = 6; + final int initialGoals = 500; + // Each goal, when processed, "splits" into children a bounded number of times, modelling + // proof-tree growth. Total processed count is deterministic regardless of scheduling. + final GoalScheduler scheduler = new GoalScheduler<>(); + + final Set claimedOnce = ConcurrentHashMap.newKeySet(); + final List claimedTwice = new CopyOnWriteArrayList<>(); + final AtomicInteger processed = new AtomicInteger(); + final List failures = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < initialGoals; i++) { + scheduler.offer(new int[] { 3 }); // depth budget 3 + } + + final CountDownLatch ready = new CountDownLatch(workers); + final CountDownLatch go = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int w = 0; w < workers; w++) { + Thread t = new Thread(() -> { + try { + ready.countDown(); + go.await(); + int[] goal; + while ((goal = scheduler.claimOrAwait()) != null) { + if (!claimedOnce.add(goal)) { + claimedTwice.add(goal); + } + processed.incrementAndGet(); + // "Splitting": produce two children with a smaller depth budget. + if (goal[0] > 0) { + scheduler.offer(new int[] { goal[0] - 1 }); + scheduler.offer(new int[] { goal[0] - 1 }); + } + scheduler.complete(goal); + } + } catch (Throwable th) { + failures.add(th); + } + }, "sched-worker-" + w); + threads.add(t); + t.start(); + } + + assertTrue(ready.await(5, TimeUnit.SECONDS)); + go.countDown(); + for (Thread t : threads) { + t.join(60_000); + assertFalse(t.isAlive(), "worker did not terminate at quiescence"); + } + + assertTrue(failures.isEmpty(), () -> "worker(s) threw: " + failures); + assertTrue(claimedTwice.isEmpty(), "a goal was claimed by more than one worker"); + // Each initial goal spawns a full binary tree of depth 3: 1 + 2 + 4 + 8 = 15 nodes. + assertEquals(initialGoals * 15, processed.get(), "unexpected number of processed goals"); + assertTrue(scheduler.isQuiescent(), "scheduler should be quiescent after all work"); + } + + /** + * Models the prover's step exactly: a goal is completed AND its successors offered in one + * atomic + * {@link GoalScheduler#completeAndOffer} call. Starting from a single goal (so the depth-first + * frontier stays small and the available queue is frequently empty mid-step), the whole tree + * must still be processed. Were completion and offering not atomic, a worker could observe a + * spurious quiescence in the gap and terminate the search early, processing fewer nodes. + */ + @Test + void completeAndOfferDoesNotTerminateSearchEarly() throws Exception { + final int workers = 8; + final int depth = 13; // full binary tree: 2^(depth+1) - 1 nodes + final int expected = (1 << (depth + 1)) - 1; + for (int rep = 0; rep < 5; rep++) { + final GoalScheduler scheduler = new GoalScheduler<>(); + final AtomicInteger processed = new AtomicInteger(); + final List failures = new CopyOnWriteArrayList<>(); + scheduler.offer(new int[] { depth }); // single root -> small frontier + + final CountDownLatch go = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int w = 0; w < workers; w++) { + Thread t = new Thread(() -> { + try { + go.await(); + int[] goal; + while ((goal = scheduler.claimOrAwait()) != null) { + processed.incrementAndGet(); + List kids = goal[0] > 0 + ? List.of(new int[] { goal[0] - 1 }, new int[] { goal[0] - 1 }) + : null; + scheduler.completeAndOffer(goal, kids); + } + } catch (Throwable th) { + failures.add(th); + } + }, "sched-worker-" + w); + threads.add(t); + t.start(); + } + go.countDown(); + for (Thread t : threads) { + t.join(60_000); + assertFalse(t.isAlive(), "worker did not terminate at quiescence"); + } + assertTrue(failures.isEmpty(), () -> "worker(s) threw: " + failures); + assertEquals(expected, processed.get(), + "search terminated early (rep " + rep + ") -- completeAndOffer must be atomic"); + assertTrue(scheduler.isQuiescent()); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ListenerSuspensionTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ListenerSuspensionTest.java new file mode 100644 index 00000000000..4ca72c57241 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ListenerSuspensionTest.java @@ -0,0 +1,215 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicInteger; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.EssentialProofListener; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.ProofEvent; +import de.uka.ilkd.key.proof.ProofTreeAdapter; +import de.uka.ilkd.key.proof.ProofTreeEvent; +import de.uka.ilkd.key.proof.RuleAppListener; +import de.uka.ilkd.key.proof.init.JavaProfile; +import de.uka.ilkd.key.util.ProofStarter; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the deregistration foundation ({@link Proof#suspendNonEssentialListeners()}) for the + * goal-level multithreading effort. + * + *

+ * Verifies the three properties the parallel prover will depend on: + *

    + *
  1. non-essential observers do not fire while suspended, and fire again after restoration; + *
  2. {@link EssentialProofListener}s keep firing throughout a suspended run; + *
  3. suspending observers does not change the resulting proof (its {@link ProofFingerprint} is + * identical with and without an observer attached during the run). + *
+ * + * @author Claude (KeY multithreading effort) + */ +public class ListenerSuspensionTest { + + private static final String CORPUS_DIR = "/de/uka/ilkd/key/prover/mt/equiv/"; + + /** A plain observer: counts rule-application events. Not marked essential. */ + private static final class CountingObserver implements RuleAppListener { + final AtomicInteger count = new AtomicInteger(); + + @Override + public void ruleApplied(ProofEvent e) { + count.incrementAndGet(); + } + } + + /** An essential observer: same behaviour, but tagged so it survives suspension. */ + private static final class EssentialCountingObserver + implements RuleAppListener, EssentialProofListener { + final AtomicInteger count = new AtomicInteger(); + + @Override + public void ruleApplied(ProofEvent e) { + count.incrementAndGet(); + } + } + + /** A non-essential proof-tree observer: counts {@code proofClosed} events. */ + private static final class CountingTreeObserver extends ProofTreeAdapter { + final AtomicInteger closedCount = new AtomicInteger(); + + @Override + public void proofClosed(ProofTreeEvent e) { + closedCount.incrementAndGet(); + } + } + + /** + * If the proof closes while the listeners are suspended (as it does on the multi-core prover), + * the {@code proofClosed} event must be re-delivered to the suspended listeners when the + * suspension scope closes -- otherwise the GUI's "proof closed" notification never appears. + */ + @Test + void proofClosedIsRedeliveredToSuspendedListenersAfterTheRun() throws Exception { + KeYEnvironment env = load("prop_chain.key"); + try { + Proof proof = env.getLoadedProof(); + CountingTreeObserver observer = new CountingTreeObserver(); + proof.addProofTreeListener(observer); + + try (var ignored = proof.suspendNonEssentialListeners()) { + runToCompletion(proof); // closes the proof while the listener is suspended + assertEquals(0, observer.closedCount.get(), + "proofClosed reached the listener while it should have been suspended"); + } + + assertTrue(proof.closed(), "proof did not close"); + assertEquals(1, observer.closedCount.get(), + "proofClosed was not re-delivered to the suspended listener after the run"); + } finally { + env.dispose(); + } + } + + @Test + void nonEssentialObserverIsSilencedWhileSuspended() throws Exception { + KeYEnvironment env = load("prop_chain.key"); + try { + Proof proof = env.getLoadedProof(); + CountingObserver observer = new CountingObserver(); + proof.addRuleAppListener(observer); + + try (var ignored = proof.suspendNonEssentialListeners()) { + runToCompletion(proof); + } + + assertEquals(0, observer.count.get(), + "non-essential observer fired while it should have been suspended"); + assertTrue(proof.closed(), "proof did not close"); + } finally { + env.dispose(); + } + } + + @Test + void observerIsReattachedAfterSuspensionScope() throws Exception { + KeYEnvironment env = load("prop_chain.key"); + try { + Proof proof = env.getLoadedProof(); + CountingObserver observer = new CountingObserver(); + proof.addRuleAppListener(observer); + + // Open and immediately close the suspension scope without proving: the observer must + // be restored so that the subsequent (unsuspended) run delivers events to it. + try (var ignored = proof.suspendNonEssentialListeners()) { + // nothing proved here + } + runToCompletion(proof); + + assertTrue(proof.closed(), "proof did not close"); + assertTrue(observer.count.get() > 0, + "observer was not re-attached after the suspension scope closed"); + } finally { + env.dispose(); + } + } + + @Test + void essentialObserverKeepsFiringWhileSuspended() throws Exception { + KeYEnvironment env = load("prop_chain.key"); + try { + Proof proof = env.getLoadedProof(); + EssentialCountingObserver essential = new EssentialCountingObserver(); + proof.addRuleAppListener(essential); + + try (var ignored = proof.suspendNonEssentialListeners()) { + runToCompletion(proof); + } + + assertTrue(proof.closed(), "proof did not close"); + assertTrue(essential.count.get() > 0, + "essential observer was wrongly suspended (received no events)"); + } finally { + env.dispose(); + } + } + + @Test + void suspendingObserversDoesNotChangeTheProof() throws Exception { + // Baseline: prove with no extra observer attached. + ProofFingerprint baseline; + KeYEnvironment env1 = load("prop_split.key"); + try { + Proof proof = env1.getLoadedProof(); + try (var ignored = proof.suspendNonEssentialListeners()) { + runToCompletion(proof); + } + baseline = ProofFingerprint.of(proof); + } finally { + env1.dispose(); + } + + // Same problem, but with a non-essential observer attached and then suspended for the run. + ProofFingerprint withSuspendedObserver; + KeYEnvironment env2 = load("prop_split.key"); + try { + Proof proof = env2.getLoadedProof(); + proof.addRuleAppListener(new CountingObserver()); + try (var ignored = proof.suspendNonEssentialListeners()) { + runToCompletion(proof); + } + withSuspendedObserver = ProofFingerprint.of(proof); + } finally { + env2.dispose(); + } + + assertTrue(baseline.closed, "baseline proof did not close"); + assertEquals(baseline, withSuspendedObserver, + () -> "suspending an observer changed the proof:\n baseline=" + baseline + + "\n withObserver=" + withSuspendedObserver); + } + + private static void runToCompletion(Proof proof) { + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + } + + private static KeYEnvironment load(String file) throws Exception { + URL url = ListenerSuspensionTest.class.getResource(CORPUS_DIR + file); + assertNotNull(url, () -> "Corpus file not on classpath: " + CORPUS_DIR + file); + Path keyFile = Paths.get(url.toURI()); + return KeYEnvironment.load(JavaProfile.getDefaultInstance(), keyFile, null, null, null, + true); + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MergeRuleMultiThreadGuardTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MergeRuleMultiThreadGuardTest.java new file mode 100644 index 00000000000..e6294c327e3 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MergeRuleMultiThreadGuardTest.java @@ -0,0 +1,54 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.rule.merge.MergeRule; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link MergeRule} disables itself while a multi-worker parallel run is active. + * + *

+ * MergeRule links several goals into one and would need to lock multiple goals at once, which is + * not + * yet safe under goal-level concurrency, so it is switched off for the duration of a multi-worker + * run (single-threaded proving keeps it). This test pins the gating mechanism + * ({@link ParallelProver#isMultiThreadedRunActive()} / + * {@link ParallelProver#enterMultiThreadedRun()}) + * and that {@code MergeRule.isApplicable} consults it. A full end-to-end test (a mergeable proof + * under several workers) is deferred until the lock-narrowing step. + * + * @author Claude (KeY multithreading effort) + */ +public class MergeRuleMultiThreadGuardTest { + + @Test + void runScopeTogglesTheActiveFlagAndBalances() { + assertFalse(ParallelProver.isMultiThreadedRunActive(), "must be inactive by default"); + try (var outer = ParallelProver.enterMultiThreadedRun()) { + assertTrue(ParallelProver.isMultiThreadedRunActive()); + try (var inner = ParallelProver.enterMultiThreadedRun()) { + assertTrue(ParallelProver.isMultiThreadedRunActive(), "nested scope still active"); + } + assertTrue(ParallelProver.isMultiThreadedRunActive(), + "outer scope keeps it active after inner closes"); + } + assertFalse(ParallelProver.isMultiThreadedRunActive(), "must be inactive after all scopes"); + } + + @Test + void mergeRuleIsDisabledWhileMultiThreadedRunActive() { + // The guard short-circuits before touching its arguments, so null is safe here. + try (var scope = ParallelProver.enterMultiThreadedRun()) { + assertFalse(MergeRule.INSTANCE.isApplicable(null, null), + "MergeRule must be inapplicable while a multi-worker run is active"); + } + assertFalse(ParallelProver.isMultiThreadedRunActive()); + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtJfrProbe.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtJfrProbe.java new file mode 100644 index 00000000000..591c9ef262d --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtJfrProbe.java @@ -0,0 +1,83 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.util.ProofStarter; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * A focused driver that runs only the goal-level parallel prover on a single proof, with + * no + * warm-up and no single-threaded baseline, so that a Flight Recording of the test JVM captures the + * parallel run cleanly. Used to investigate the parallel prover's lock contention/hot paths and the + * nondeterministic non-closure race (a goal whose {@code next()} returns null spuriously at >1 + * worker). Enable with {@code -Dkey.mt.jfr.probe=true}; record with {@code -Dkey.mt.jfr=}. + * + *

+ * Knobs: {@code -Dkey.mt.jfr.proof} (example-relative path, default symmArray), + * {@code -Dkey.mt.jfr.workers} (default 2), {@code -Dkey.mt.jfr.reps} (default 1). Run several reps + * to raise the odds of capturing a failing (non-closing) run; each rep prints + * closed/open/nodes/reason so the recording can be correlated with an outcome. + * + * @author Claude (KeY multithreading effort) + */ +@EnabledIfSystemProperty(named = "key.mt.jfr.probe", matches = "true") +public class MtJfrProbe { + + @Test + void run() throws Exception { + final String rel = + System.getProperty("key.mt.jfr.proof", "standard_key/java_dl/symmArray.key"); + final int workers = Integer.getInteger("key.mt.jfr.workers", 2); + final int reps = Integer.getInteger("key.mt.jfr.reps", 1); + + final Path examples = FindResources.getExampleDirectory(); + if (examples == null) { + System.out.println("[jfr-probe] examples directory not found; nothing to do"); + return; + } + final Path keyFile = examples.resolve(rel); + if (!Files.exists(keyFile)) { + System.out.printf("[jfr-probe] missing proof %s%n", rel); + return; + } + + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(workers)); + System.out.printf("[jfr-probe] proof=%s workers=%d reps=%d%n", rel, workers, reps); + + int failures = 0; + for (int i = 0; i < reps; i++) { + KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + Proof proof = env.getLoadedProof(); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + long t0 = System.nanoTime(); + var info = starter.start(); + long millis = (System.nanoTime() - t0) / 1_000_000L; + boolean closed = proof.closed(); + if (!closed) { + failures++; + } + System.out.printf( + "[jfr-probe] rep=%d closed=%s open=%d nodes=%d time=%dms reason=%s%n", + i, closed, proof.openGoals().size(), proof.countNodes(), millis, info.reason()); + } finally { + env.dispose(); + } + } + System.out.printf("[jfr-probe] done: %d/%d runs did NOT close%n", failures, reps); + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtMacroStressTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtMacroStressTest.java new file mode 100644 index 00000000000..acebeafeeae --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtMacroStressTest.java @@ -0,0 +1,99 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.macros.TryCloseMacro; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Stress test for proof macros under the multi-core prover, in particular the + * {@link TryCloseMacro} prune path: when a try-close attempt does not close within its step budget, + * the macro prunes the (parallel, partially explored) subtree it produced. That prune is a + * tree-shrinking mutation, and this test guards that it stays safe and non-corrupting under many + * workers — the macro routes through the parallel prover, whose {@code start()} only returns + * once every worker has stopped, so the prune runs with no live workers. + * + *

+ * Each repetition: run try-close with a tiny budget at a high worker count (forces a prune of a + * parallel subtree), assert the proof is back to open, then run try-close to completion (the + * parallel macro close path) and assert it closes. A corrupted tree from the prune would show up as + * a failure to close, an inconsistent goal set, or an exception. Gated by {@code -Dkey.mt.stress}; + * runs via the {@code testMtStress} Gradle task. + * + * @author Claude (KeY multithreading effort) + */ +@EnabledIfSystemProperty(named = "key.mt.stress", matches = "true") +public class MtMacroStressTest { + + private static final String PROOF = "standard_key/java_dl/symmArray.key"; + private static final int WORKERS = 8; + private static final int REPS = 3; + private static final int TINY_BUDGET = 50; + + @Test + void tryCloseMacroPruneAndCloseStaySafeUnderManyWorkers() throws Exception { + final Path examples = FindResources.getExampleDirectory(); + assumeTrue(examples != null, "examples directory not found"); + final Path keyFile = examples.resolve(PROOF); + assertTrue(Files.exists(keyFile), () -> "missing example proof " + PROOF); + + final String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + final String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(WORKERS)); + try { + for (int rep = 0; rep < REPS; rep++) { + final int r = rep; + final KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + final Proof proof = env.getLoadedProof(); + + // Tiny budget: the attempt cannot close this proof, so the macro prunes the + // (parallel) subtree it explored, restoring the open goal. + new TryCloseMacro(TINY_BUDGET).applyTo(null, proof.root(), null, null); + assertFalse(proof.closed(), + () -> PROOF + " unexpectedly closed within " + TINY_BUDGET + + " steps (rep " + r + ")"); + assertTrue(proof.openGoals().size() >= 1, + () -> "no open goal after try-close prune (rep " + r + + ") -- the prune may have corrupted the tree"); + + // Full budget: the parallel macro close path must now close the (re-opened) + // proof, proving the earlier prune left a consistent, completable tree. + new TryCloseMacro().applyTo(null, proof.root(), null, null); + assertTrue(proof.closed(), + () -> PROOF + " did not close after try-close to completion (rep " + r + + ") -- likely tree corruption from a prune under the multi-core prover"); + } finally { + env.dispose(); + } + } + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtScriptStressTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtScriptStressTest.java new file mode 100644 index 00000000000..4510240101d --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtScriptStressTest.java @@ -0,0 +1,156 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; + +import de.uka.ilkd.key.control.DefaultUserInterfaceControl; +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.nparser.ParsingFacade; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.io.ProofSaver; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.scripts.ProofScriptEngine; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Stress test: a real, position-sensitive proof script must still close the proof when the + * multi-core prover is active. + * + *

+ * The quicksort {@code sort.script} is the worst case for parallel execution: it runs a + * parallel-capable {@code macro "autopilot-prep"} and then applies literal + * {@code select}/{@code rule formula="..."} commands that reference fresh names (e.g. + * {@code heapAfter_sort_0}) introduced by that macro. If a parallel run reordered goals or + * worker-tagged those fresh names, the later literal commands would fail to apply and the script + * would not close. This pins that scripts keep working under the multi-core prover. + * + *

+ * Gated behind {@code -Dkey.mt.stress=true} (slow: runs the autopilot preparation several times) + * and + * executed by the {@code testMtStress} Gradle task, alongside {@link MtStressTest} and + * {@link MtMacroStressTest}. + */ +@EnabledIfSystemProperty(named = "key.mt.stress", matches = "true") +public class MtScriptStressTest { + + /** + * Over-subscribed worker count to maximise interleaving (the machine need not have this many). + */ + private static final int MT_WORKERS = 8; + /** Number of multi-core repetitions; a race would surface as an occasional non-closure. */ + private static final int MT_REPS = 3; + + @Test + void sortScriptClosesUnderMultiCore() throws Exception { + Path examples = FindResources.getExampleDirectory(); + Path keyFile = examples.resolve("heap/quicksort/sort.key"); + String scriptText = Files.readString(examples.resolve("heap/quicksort/sort.script")); + + // Baseline: the script must close on the single-core prover. + assertTrue(runScript(scriptText, keyFile, 0), + "sort.script did not close on the single-core prover"); + + // It must also close on every multi-core run, despite the parallel macro and the literal + // name references that follow it. + for (int rep = 1; rep <= MT_REPS; rep++) { + assertTrue(runScript(scriptText, keyFile, MT_WORKERS), + "sort.script did not close on the multi-core prover (run " + rep + " of " + MT_REPS + + ")"); + } + } + + /** + * A proof produced under the multi-core prover (with worker-tagged fresh names and a + * parallel-explored tree) must save and reload as a valid, closed proof under the single-core + * prover -- i.e. the saved artifact carries no multi-core-specific corruption and replays + * exactly like any other proof. + */ + @Test + void multiCoreProofSavesAndReloadsSingleCore() throws Exception { + Path examples = FindResources.getExampleDirectory(); + Path keyFile = examples.resolve("heap/quicksort/sort.key"); + String scriptText = Files.readString(examples.resolve("heap/quicksort/sort.script")); + // Save next to sort.key so the proof's relative \javaSource "." resolves on reload. + Path saved = Files.createTempFile(keyFile.getParent(), "mt-sort-proof", ".proof"); + + // 1) Produce the proof under the multi-core prover, then save it. + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(MT_WORKERS)); + KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + Proof proof = env.getLoadedProof(); + new ProofScriptEngine(ParsingFacade.parseScript(scriptText)).execute(env.getUi(), + proof); + assertTrue(proof.closed(), "the multi-core proof did not close before saving"); + String status = new ProofSaver(proof, saved).save(); + assertTrue(status == null || status.isEmpty(), + "saving the multi-core proof reported an error: " + status); + } finally { + System.clearProperty(ParallelProver.PARALLEL_PROPERTY); + System.clearProperty(ParallelProver.THREADS_PROPERTY); + env.dispose(); + } + + // 2) Reload it with the single-core prover; it must load cleanly and still be closed. + KeYEnvironment reloadEnv = KeYEnvironment.load(saved); + try { + Proof reloaded = reloadEnv.getLoadedProof(); + assertNotNull(reloaded, "reloading the saved multi-core proof produced no proof"); + assertTrue(reloaded.closed(), + "the saved multi-core proof did not reload as a closed proof"); + } finally { + reloadEnv.dispose(); + Files.deleteIfExists(saved); + } + } + + /** + * Loads the proof obligation fresh, runs the proof script, and reports whether the proof + * closed. + * + * @param scriptText the proof script to execute + * @param keyFile the proof obligation to load + * @param workers {@code 0} for the single-core prover, otherwise the parallel worker count + * @return whether the proof is closed after the script ran + */ + private static boolean runScript(String scriptText, Path keyFile, int workers) + throws Exception { + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + if (workers > 0) { + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(workers)); + } else { + System.clearProperty(ParallelProver.PARALLEL_PROPERTY); + } + KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + Proof proof = env.getLoadedProof(); + var script = ParsingFacade.parseScript(scriptText); + new ProofScriptEngine(script).execute(env.getUi(), proof); + return proof.closed(); + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + env.dispose(); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSpeedupBenchmark.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSpeedupBenchmark.java new file mode 100644 index 00000000000..1cf4e7bef6f --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSpeedupBenchmark.java @@ -0,0 +1,202 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.settings.ProofSettings; +import de.uka.ilkd.key.util.ProofStarter; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Manual speedup/scaling benchmark for the goal-level parallel prover. Not a correctness + * gate + * — it only measures and reports; it asserts nothing. Enable with {@code -Dkey.mt.benchmark=true} + * (skipped otherwise, because the proofs take minutes). + * + *

+ * For each large real proof it runs single-threaded automatic search (the {@code ApplyStrategy} + * baseline, parallel prover off) and then the parallel prover at several worker counts, printing + * wall-time, speedup, whether the proof closed, and the node count. Caveats baked into the + * interpretation, not the run: + *

    + *
  • Pure automode only. Driven via {@link ProofStarter}; proofs that need a proof + * script/macro to close will simply not close here (visible as {@code closed=false} even + * single-threaded) and are not meaningful benchmark entries. + *
  • MT-disabled features. {@code MergeRule} is off while a multi-worker run is active, so + * a + * merge-dependent proof may close single-threaded but not in parallel — the table shows this rather + * than failing. + *
  • Rough timing. One run per configuration, single JVM; run with a single test fork + * (e.g. {@code -PrapForks=1} / no parallel forks) and an otherwise idle machine, and take the + * median + * of a few invocations for anything you want to quote. Worker count is capped at the available + * cores. + *
+ * + * Worker counts come from {@code -Dkey.mt.benchmark.threads} (default {@code 1,2,4,8}); the corpus + * from {@code -Dkey.mt.benchmark.proofs} (comma-separated example-relative paths) or a built-in + * default set of large automode proofs. + * + * @author Claude (KeY multithreading effort) + */ +@EnabledIfSystemProperty(named = "key.mt.benchmark", matches = "true") +public class MtSpeedupBenchmark { + + /** + * Default corpus: large, automode-closeable, script-free real proofs (a {@code perfTest}-style + * set). Entries that do not close under pure automode show {@code closed=false} in the table + * and + * are not meaningful speedup entries — curate via {@code -Dkey.mt.benchmark.proofs} as needed. + */ + private static final String[] DEFAULT_PROOFS = { + "newBook/09.list_modelfield/ArrayList.remFirst.key", + "standard_key/java_dl/symmArray.key", + "heap/saddleback_search/Saddleback_search.key", + "heap/list_seq/SimplifiedLinkedList.remove.key", + }; + + /** + * Clean snapshot of the global proof settings, captured before any proof is loaded. Loading a + * {@code .key}/{@code .proof} mutates {@link ProofSettings#DEFAULT_SETTINGS}; without restoring + * it before each proof, a proof would inherit the previous corpus entry's strategy settings and + * may take a different (or non-closing) search path -- making per-proof numbers depend on + * corpus + * order. {@link #run} restores this snapshot before every load. + */ + private static String settingsBaseline; + + @Test + void benchmark() throws Exception { + Path examples = FindResources.getExampleDirectory(); + if (examples == null) { + System.out.println("[mt-benchmark] examples directory not found; nothing to do"); + return; + } + settingsBaseline = ProofSettings.DEFAULT_SETTINGS.settingsToString(); + int cores = Runtime.getRuntime().availableProcessors(); + List workerCounts = workerCounts(cores); + String[] proofs = System.getProperty("key.mt.benchmark.proofs", "").isBlank() + ? DEFAULT_PROOFS + : System.getProperty("key.mt.benchmark.proofs").split("\\s*,\\s*"); + + System.out.printf("%n[mt-benchmark] cores=%d worker-counts=%s%n", cores, workerCounts); + System.out.printf("%-52s %10s %8s %7s %s%n", "proof", "config", "time(ms)", "speedup", + "closed/nodes"); + + // Paste-ready markdown table for the pull-request descriptions, accumulated alongside the + // human-readable output above. + StringBuilder md = new StringBuilder("\n[mt-benchmark-markdown]\n| # | Proof | main (s) |"); + for (int w : workerCounts) { + md.append(' ').append(w).append("× |"); + } + md.append("\n|---|---|---|"); + for (int i = 0; i < workerCounts.size(); i++) { + md.append("---|"); + } + md.append('\n'); + + int row = 0; + for (String rel : proofs) { + Path keyFile = examples.resolve(rel); + if (!Files.exists(keyFile)) { + System.out.printf("%-52s (missing)%n", rel); + continue; + } + run(keyFile, 0); // warm-up (untimed): JIT-compile this proof's code paths so the + // single-threaded baseline below is not penalised by cold compilation. + Run base = run(keyFile, 0); // single-threaded ApplyStrategy baseline + System.out.printf("%-52s %10s %8d %7s %s/%d%n", shortName(rel), "single", base.millis, + "1.00x", base.closed, base.nodes); + md.append("| ").append(++row).append(" | ").append(shortName(rel)).append(" | ") + .append(String.format("%.1f", base.millis / 1000.0)).append(" |"); + for (int w : workerCounts) { + Run r = run(keyFile, w); + double speedup = r.millis > 0 ? (double) base.millis / r.millis : 0.0; + String why = r.closed ? "" : " [open=" + r.openGoals + " reason=" + r.reason + "]"; + System.out.printf("%-52s %10s %8d %6.2fx %s/%d%s%n", "", w + "-worker", r.millis, + speedup, r.closed, r.nodes, why); + md.append(String.format(" %.2f× |", speedup)); + } + md.append('\n'); + } + System.out.println(md); + } + + private static List workerCounts(int cores) { + String prop = System.getProperty("key.mt.benchmark.threads", "1,2,4,8"); + List result = new ArrayList<>(); + for (String s : prop.split("\\s*,\\s*")) { + int w = Math.min(Integer.parseInt(s.trim()), cores); + if (!result.contains(w)) { + result.add(w); + } + } + return result; + } + + /** Runs one proof; {@code workers == 0} means the single-threaded baseline (parallel off). */ + private static Run run(Path keyFile, int workers) throws Exception { + // Isolate each proof from the previous one's settings (see settingsBaseline). + if (settingsBaseline != null) { + ProofSettings.DEFAULT_SETTINGS.loadSettingsFromPropertyString(settingsBaseline); + } + KeYEnvironment env = KeYEnvironment.load(keyFile); + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + if (workers > 0) { + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(workers)); + } else { + System.clearProperty(ParallelProver.PARALLEL_PROPERTY); + } + try { + Proof proof = env.getLoadedProof(); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + // Optional step-budget override: some proofs need more rule applications than the + // proof's stored strategy budget to close in pure automode + // (-Dkey.mt.benchmark.maxsteps). + String maxStepsProp = System.getProperty("key.mt.benchmark.maxsteps"); + if (maxStepsProp != null && !maxStepsProp.isBlank()) { + starter.setMaxRuleApplications(Integer.parseInt(maxStepsProp.trim())); + } + long t0 = System.nanoTime(); + var info = starter.start(); + long millis = (System.nanoTime() - t0) / 1_000_000L; + return new Run(millis, proof.closed(), proof.countNodes(), + proof.openGoals().size(), info.reason()); + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + env.dispose(); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } + + private static String shortName(String rel) { + int slash = rel.lastIndexOf('/'); + return slash >= 0 ? rel.substring(slash + 1) : rel; + } + + private record Run(long millis, boolean closed, int nodes, int openGoals, String reason) { + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStopTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStopTest.java new file mode 100644 index 00000000000..ebb2fcd554c --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStopTest.java @@ -0,0 +1,115 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.util.ProofStarter; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for the GUI freeze when the user stops a running multi-core auto-mode. + * + *

+ * Stopping auto mode interrupts the background thread running {@link ParallelProver#start}. The + * bug: + * the listener suspension (which detaches the Swing proof-tree listeners for the duration of the + * run) + * is a try-with-resources resource and was therefore restored before the worker pool was + * shut down, and the shutdown did not wait for the workers. So {@code start()} returned while a + * worker was still mid-step; that worker then delivered a {@code proofExpanded} event into the + * now-live Swing {@code GUIProofTreeModel} off the EDT and deadlocked against it (the EDT held the + * AWT tree lock and wanted the model monitor; the worker held the model monitor and wanted the AWT + * tree lock). + * + *

+ * This headless test cannot involve Swing, so it asserts the invariant whose violation enabled the + * deadlock: once {@code start()} returns after an interrupt, no parallel worker thread is + * still alive. The fix waits for the pool to terminate while the listeners are still suspended, so + * the property holds and the GUI listeners are only re-attached once the proof is quiescent. + * + * @author Claude (KeY multithreading effort) + */ +public class MtStopTest { + + @Test + @Timeout(120) + void interruptingTheRunLeavesNoWorkerAliveWhenStartReturns() throws Exception { + // A real, sizeable proof so the run is still going when we interrupt it ~400 ms in. + Path examples = FindResources.getExampleDirectory(); + Assumptions.assumeTrue(examples != null, "examples directory not found"); + Path keyFile = examples.resolve("standard_key/java_dl/symmArray.key"); + Assumptions.assumeTrue(Files.exists(keyFile), "symmArray example missing"); + + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, "4"); + + KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + Proof proof = env.getLoadedProof(); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + + AtomicReference failure = new AtomicReference<>(); + Thread driver = new Thread(() -> { + try { + starter.start(); + } catch (Throwable t) { + failure.set(t); + } + }, "mt-stop-driver"); + driver.start(); + + // Let the parallel run get underway, then stop it the way SwingWorker.cancel(true) + // does. + Thread.sleep(400); + driver.interrupt(); + + driver.join(60_000); + assertFalse(driver.isAlive(), + "start() did not return within 60s after interrupt (hang)"); + + // The invariant: start() must not return until every worker has stopped, otherwise a + // lingering worker can deliver proof-tree events into the re-attached Swing listeners. + List live = liveWorkerThreads(); + assertTrue(live.isEmpty(), + "parallel worker(s) still alive after start() returned: " + live); + } finally { + env.dispose(); + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + } + } + + private static List liveWorkerThreads() { + return Thread.getAllStackTraces().keySet().stream().filter(Thread::isAlive) + .map(Thread::getName).filter(n -> n.startsWith("key-prover-")) + .collect(Collectors.toList()); + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStressTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStressTest.java new file mode 100644 index 00000000000..b35faad44ef --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtStressTest.java @@ -0,0 +1,96 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.util.ProofStarter; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Regression stress test for the goal-level parallel prover: a guard against reintroducing a + * concurrency bug that makes proofs nondeterministically fail to close under more than one + * worker. + * + *

+ * The unit-level {@link RealProofMtEquivalenceTest} gate uses small proofs at a few workers and one + * run each, which is too tame to surface such races (the one that motivated this test — a + * shared mutable key in the taclet-index cache, see {@code PrefixTermTacletAppIndexCacheImpl} + * — showed up only on large splitting proofs with array/arithmetic reasoning at high + * worker counts, and even then only ~50% of runs). This test deliberately uses exactly that shape: + * a few large splitting proofs, run at a high (intentionally over-subscribed) worker count, several + * times each, asserting that every run closes. A reintroduced race shows up as a run that + * leaves the proof open. + * + *

+ * It is slow (minutes) and therefore gated by {@code -Dkey.mt.stress=true}; CI runs it via the + * dedicated {@code testMtStress} Gradle task, not in the fast unit-test suite. The worker count is + * deliberately not capped at the available cores: over-subscription increases thread interleaving, + * which makes a race more likely to be caught on the few-core CI runners. + * + * @author Claude (KeY multithreading effort) + */ +@EnabledIfSystemProperty(named = "key.mt.stress", matches = "true") +public class MtStressTest { + + @ParameterizedTest(name = "{0} ({1} reps @ {2} workers)") + @CsvSource({ + "standard_key/java_dl/symmArray.key, 8, 8", + "heap/list_seq/SimplifiedLinkedList.remove.key, 3, 8", + }) + void splittingProofClosesEveryRunInParallel(String relPath, int reps, int workers) + throws Exception { + final Path examples = FindResources.getExampleDirectory(); + assumeTrue(examples != null, "examples directory not found"); + final Path keyFile = examples.resolve(relPath); + assertTrue(Files.exists(keyFile), () -> "missing example proof " + relPath); + + final String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + final String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(workers)); + try { + for (int i = 0; i < reps; i++) { + final int rep = i; + final KeYEnvironment env = KeYEnvironment.load(keyFile); + try { + final Proof proof = env.getLoadedProof(); + final ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + assertTrue(proof.closed(), + () -> relPath + " did not close on rep " + rep + " with " + workers + + " workers (open goals=" + proof.openGoals().size() + + "). This proof closes single-threaded, so a parallel run leaving it" + + " open means a reintroduced concurrency race."); + } finally { + env.dispose(); + } + } + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSyntheticBenchmark.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSyntheticBenchmark.java new file mode 100644 index 00000000000..8cd67d834b9 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/MtSyntheticBenchmark.java @@ -0,0 +1,275 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Node; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.strategy.StrategyProperties; +import de.uka.ilkd.key.util.ProofStarter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Synthetic speedup/scaling benchmark for the goal-level parallel prover, complementing the + * real-proof {@link MtSpeedupBenchmark}. It generates self-contained Java-DL {@code .key} problems + * whose proof-tree shape is controlled, to map out the best/worst case of goal-level + * parallelism. Enable with {@code -Dkey.mt.benchmark=true} (skipped otherwise). It asserts nothing, + * only measures and prints. + * + *

+ * The governing intuition: the wall-clock of a goal-parallel run is bounded below by the + * critical path (the most expensive single branch), so speedup tracks how the work is + * distributed over independent branches. + *

    + *
  • split_ifs — N independent {@code if}s on symbolic booleans, so symbolic execution fans + * out into 2^N balanced, cheap leaves. Best case for parallelism (lots of short independent + * branches), but split/commit overhead per node is exposed because the leaves are tiny. + *
  • split_work — the same 2^N fan-out, but each leaf then runs a block of straight-line + * work, so there is substantial independent work per branch. This is the configuration + * where parallelism should pay off most (work dominates the serial split overhead). + *
  • linear_assign — one long straight-line block: a single branch, no splitting. Worst + * case: the critical path is the whole proof, so no speedup is possible (the ceiling is ~1x; this + * isolates the overhead the parallel machinery adds on a non-parallel proof). + *
  • while_unroll — a bounded loop unrolled (LOOP_EXPAND): an almost-linear chain with a + * tiny exit branch per iteration. Realistic worst case (a loop without an invariant). + *
+ * + *

+ * Sizes come from system properties (so the shape can be swept without recompiling): + * {@code -Dkey.mt.synth.splits} (default 11), {@code -Dkey.mt.synth.work} (default 250), + * {@code -Dkey.mt.synth.linear} (default 2500), {@code -Dkey.mt.synth.loop} (default 1200). Worker + * counts from {@code -Dkey.mt.benchmark.threads} (default {@code 1,2,4,8}). + * + * @author Claude (KeY multithreading effort) + */ +@EnabledIfSystemProperty(named = "key.mt.benchmark", matches = "true") +public class MtSyntheticBenchmark { + + private static final int MAX_STEPS = 2_000_000; + + private record Case(String name, String content, boolean loopExpand) { + } + + @Test + void benchmark() throws Exception { + // split_work fans out less than split_ifs (2^worksplits branches, each with real work) so + // the total node count stays in the same ballpark as a large real proof (~tens of + // thousands), within the test heap. split_ifs keeps the wide-but-shallow fan-out. + final int splits = intProp("key.mt.synth.splits", 11); + final int worksplits = intProp("key.mt.synth.worksplits", 7); + final int work = intProp("key.mt.synth.work", 120); + final int linear = intProp("key.mt.synth.linear", 800); + final int loop = intProp("key.mt.synth.loop", 400); + + List cases = List.of( + new Case("split_ifs(" + splits + ")", splitIfs(splits), false), + new Case("split_work(" + worksplits + "x" + work + ")", splitWork(worksplits, work), + false), + new Case("linear_assign(" + linear + ")", linearAssign(linear), false), + new Case("while_unroll(" + loop + ")", whileUnroll(loop), true)); + + int cores = Runtime.getRuntime().availableProcessors(); + List workerCounts = workerCounts(cores); + + System.out.printf("%n[mt-synthetic] cores=%d worker-counts=%s%n", cores, workerCounts); + System.out.printf("%-26s %10s %8s %7s %8s %7s %6s%n", "case", "config", "time(ms)", + "speedup", "nodes", "leaves", "depth"); + + for (Case c : cases) { + Path keyFile = Files.createTempFile("mt-synth-", ".key"); + Files.writeString(keyFile, c.content); + try { + // Warm up BOTH the single-threaded and the parallel code paths (JIT) before timing. + // Otherwise the first parallel configuration pays for cold compilation of the whole + // parallel machinery and reads as a spurious slowdown. + int warmWorkers = workerCounts.get(workerCounts.size() - 1); + run(keyFile, 0, c.loopExpand); + if (warmWorkers > 0) { + run(keyFile, warmWorkers, c.loopExpand); + } + Run base = best(keyFile, 0, c.loopExpand); + System.out.printf("%-26s %10s %8d %7s %8d %7d %6d%n", c.name, "single", base.millis, + "1.00x", base.nodes, base.leaves, base.depth); + for (int w : workerCounts) { + Run r = best(keyFile, w, c.loopExpand); + double speedup = r.millis > 0 ? (double) base.millis / r.millis : 0.0; + System.out.printf("%-26s %10s %8d %6.2fx %8d %7d %6d%n", "", w + "-worker", + r.millis, speedup, r.nodes, r.leaves, r.depth); + } + } catch (Throwable t) { + // Keep going with the remaining cases: a too-large case (e.g. OOM) should not abort + // the whole table. + System.out.printf("%-26s FAILED: %s%n", c.name, t); + } finally { + Files.deleteIfExists(keyFile); + } + } + } + + /** Best (fastest) of two timed runs — reduces the effect of GC/scheduling noise. */ + private static Run best(Path keyFile, int workers, boolean loopExpand) throws Exception { + Run a = run(keyFile, workers, loopExpand); + Run b = run(keyFile, workers, loopExpand); + return a.millis <= b.millis ? a : b; + } + + /** Runs one generated proof; {@code workers == 0} means the single-threaded baseline. */ + private static Run run(Path keyFile, int workers, boolean loopExpand) throws Exception { + KeYEnvironment env = KeYEnvironment.load(keyFile); + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + if (workers > 0) { + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(workers)); + } else { + System.clearProperty(ParallelProver.PARALLEL_PROPERTY); + } + try { + Proof proof = env.getLoadedProof(); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.setMaxRuleApplications(MAX_STEPS); + if (loopExpand) { + StrategyProperties sp = + proof.getSettings().getStrategySettings().getActiveStrategyProperties(); + sp.setProperty(StrategyProperties.LOOP_OPTIONS_KEY, StrategyProperties.LOOP_EXPAND); + starter.setStrategyProperties(sp); + } + long t0 = System.nanoTime(); + starter.start(); + long millis = (System.nanoTime() - t0) / 1_000_000L; + return new Run(millis, proof.closed(), proof.countNodes(), countLeaves(proof), + maxDepth(proof)); + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + env.dispose(); + } + } + + // ---- problem generators ------------------------------------------------- + + /** N independent ifs on symbolic booleans -> 2^N balanced, cheap leaves. */ + private static String splitIfs(int n) { + StringBuilder vars = new StringBuilder("int x;"); + StringBuilder prog = new StringBuilder(); + for (int i = 0; i < n; i++) { + vars.append(" boolean b").append(i).append(";"); + prog.append("if (b").append(i).append(") { x = x + 1; } else { x = x - 1; }\n"); + } + return problem(vars.toString(), prog.toString(), "true"); + } + + /** 2^N fan-out, then a straight-line block of work executed in every leaf. */ + private static String splitWork(int n, int work) { + StringBuilder vars = new StringBuilder("int x;"); + StringBuilder prog = new StringBuilder(); + for (int i = 0; i < n; i++) { + vars.append(" boolean b").append(i).append(";"); + prog.append("if (b").append(i).append(") { x = x + 1; } else { x = x - 1; }\n"); + } + for (int j = 0; j < work; j++) { + prog.append("x = x + 1;\n"); + } + return problem(vars.toString(), prog.toString(), "true"); + } + + /** One long straight-line block: a single branch, no splitting (speedup ceiling ~1x). */ + private static String linearAssign(int n) { + StringBuilder prog = new StringBuilder("x = 0;\n"); + for (int i = 0; i < n; i++) { + prog.append("x = x + 1;\n"); + } + return problem("int x;", prog.toString(), "true"); + } + + /** A bounded loop, unrolled (LOOP_EXPAND): almost-linear with a tiny exit branch per step. */ + private static String whileUnroll(int bound) { + String prog = "i = 0; x = 0;\nwhile (i < " + bound + ") { x = x + i; i = i + 1; }\n"; + return problem("int x; int i;", prog, "true"); + } + + private static String problem(String vars, String prog, String post) { + return "\\programVariables {\n " + vars + "\n}\n\n" + + "\\problem {\n \\<{\n" + prog + " }\\> " + post + "\n}\n"; + } + + // ---- proof-tree shape --------------------------------------------------- + + private static int countLeaves(Proof proof) { + int leaves = 0; + Deque stack = new ArrayDeque<>(); + stack.push(proof.root()); + while (!stack.isEmpty()) { + Node n = stack.pop(); + int kids = n.childrenCount(); + if (kids == 0) { + leaves++; + } else { + for (int i = 0; i < kids; i++) { + stack.push(n.child(i)); + } + } + } + return leaves; + } + + private static int maxDepth(Proof proof) { + int max = 0; + Deque nodes = new ArrayDeque<>(); + Deque depths = new ArrayDeque<>(); + nodes.push(proof.root()); + depths.push(1); + while (!nodes.isEmpty()) { + Node n = nodes.pop(); + int d = depths.pop(); + max = Math.max(max, d); + for (int i = 0; i < n.childrenCount(); i++) { + nodes.push(n.child(i)); + depths.push(d + 1); + } + } + return max; + } + + // ---- plumbing ----------------------------------------------------------- + + private static List workerCounts(int cores) { + String prop = System.getProperty("key.mt.benchmark.threads", "1,2,4,8"); + List result = new ArrayList<>(); + for (String s : prop.split("\\s*,\\s*")) { + int w = Math.min(Integer.parseInt(s.trim()), cores); + if (!result.contains(w)) { + result.add(w); + } + } + return result; + } + + private static int intProp(String key, int dflt) { + String v = System.getProperty(key); + return v == null || v.isBlank() ? dflt : Integer.parseInt(v.trim()); + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } + + private record Run(long millis, boolean closed, int nodes, int leaves, int depth) { + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NameAllocatorStressTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NameAllocatorStressTest.java new file mode 100644 index 00000000000..5ab8d3536ad --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NameAllocatorStressTest.java @@ -0,0 +1,107 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import de.uka.ilkd.key.prover.impl.ParallelNameAllocator; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The Phase-2 acceptance check (multithreading effort): hammering {@link ParallelNameAllocator} + * from many threads must never produce a duplicate name and must be per-worker deterministic. + * + * @author Claude (KeY multithreading effort) + */ +public class NameAllocatorStressTest { + + private static final String[] BASES = { "x", "y", "skolem", "heap", "self" }; + + @Test + void concurrentMintingNeverCollides() throws Exception { + final int workers = 8; + final int namesPerWorker = 20_000; + final ParallelNameAllocator allocator = new ParallelNameAllocator(); + + final Set all = ConcurrentHashMap.newKeySet(); + final List failures = new CopyOnWriteArrayList<>(); + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(workers); + final List threads = new ArrayList<>(); + + for (int w = 0; w < workers; w++) { + final int workerId = w; + Thread t = new Thread(() -> { + try { + start.await(); + ParallelNameAllocator.runAsWorker(workerId, () -> { + for (int i = 0; i < namesPerWorker; i++) { + all.add(allocator.freshName(BASES[i % BASES.length])); + } + }); + } catch (Throwable th) { + failures.add(th); + } finally { + done.countDown(); + } + }, "stress-worker-" + w); + threads.add(t); + t.start(); + } + + start.countDown(); + assertTrue(done.await(60, TimeUnit.SECONDS), "stress workers did not finish in time"); + for (Thread t : threads) { + t.join(); + } + + assertTrue(failures.isEmpty(), () -> "worker(s) threw: " + failures); + // No duplicate names anywhere: the set holds exactly one entry per mint call. + assertEquals(workers * namesPerWorker, all.size(), + "duplicate names were produced under concurrent minting"); + } + + @Test + void perWorkerSequenceIsDeterministic() { + List first = mintSequence(3); + List second = mintSequence(3); + assertEquals(first, second, + "the same worker minting the same sequence produced different names"); + } + + @Test + void differentWorkersGetDisjointNames() { + List w0 = mintSequence(0); + List w1 = mintSequence(1); + assertTrue(Collections.disjoint(w0, w1), + () -> "worker 0 and worker 1 names overlap: " + w0 + " vs " + w1); + // Worker 0 keeps the legacy untagged form; others are tagged. + assertTrue(w0.stream().noneMatch(n -> n.contains("__t")), + "worker 0 names must be untagged"); + assertTrue(w1.stream().allMatch(n -> n.contains("__t1")), "worker 1 names must carry __t1"); + } + + /** Mints a fixed sequence of names as the given worker on a fresh allocator. */ + private static List mintSequence(int workerId) { + ParallelNameAllocator allocator = new ParallelNameAllocator(); + List names = new ArrayList<>(); + ParallelNameAllocator.runAsWorker(workerId, () -> { + for (int i = 0; i < BASES.length * 3; i++) { + names.add(allocator.freshName(BASES[i % BASES.length])); + } + }); + return names; + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NamespaceDeferralTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NamespaceDeferralTest.java new file mode 100644 index 00000000000..8afafc77d97 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/NamespaceDeferralTest.java @@ -0,0 +1,102 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.init.JavaProfile; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.util.ProofStarter; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the namespace flush-deferral of the multi-worker run (multithreading effort, branch + * {@code bubel/mt-goals}). + * + *

+ * During a multi-worker run, fresh symbols are kept node-local instead of being flushed into the + * shared proof namespace (so that namespace can later stay immutable while matching moves out of + * the + * commit lock). Single-threaded proving also keeps proving-time symbols in the local namespace + * layers (the flush targets a local copy, not the global {@code Services} namespace), so deferral + * must leave the global namespace exactly as single-threaded proving does. This test pins + * that invariant: a multi-worker run adds the same number of functions to the global namespace as a + * single-threaded run of the same skolemizing, splitting problem. + * + * @author Claude (KeY multithreading effort) + */ +public class NamespaceDeferralTest { + + /** Splits into independent branches AND skolemizes in each, so fresh functions are minted. */ + private static final String FILE = "fol_split_skolem.key"; + + @Test + void deferralLeavesGlobalNamespaceIdenticalToSingleThreaded() throws Exception { + int singleThreadedNewFunctions = proveAndCountNewFunctions(1, false); + int multiWorkerNewFunctions = proveAndCountNewFunctions(4, true); + + assertEquals(singleThreadedNewFunctions, multiWorkerNewFunctions, + "multi-worker deferral changed how many functions end up in the global namespace " + + "compared to single-threaded proving"); + } + + /** + * Proves {@link #FILE} and returns how many functions the run added to the shared proof + * namespace. + * + * @param threads worker count + * @param parallel whether to enable the parallel prover + */ + private static int proveAndCountNewFunctions(int threads, boolean parallel) throws Exception { + KeYEnvironment env = KeYEnvironment.load(JavaProfile.getDefaultInstance(), corpusFile(), + null, null, null, true); + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + try { + Proof proof = env.getLoadedProof(); + assertNotNull(proof); + int before = proof.getServices().getNamespaces().functions().elements().size(); + + if (parallel) { + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(threads)); + } + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + + assertTrue(proof.closed(), "proof did not close"); + int after = proof.getServices().getNamespaces().functions().elements().size(); + return after - before; + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + env.dispose(); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } + + private static Path corpusFile() throws Exception { + URL url = NamespaceDeferralTest.class.getResource( + "/de/uka/ilkd/key/prover/mt/equiv/" + FILE); + assertNotNull(url, () -> "Corpus file not on classpath: " + FILE); + return Paths.get(url.toURI()); + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ProofEquivalenceTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ProofEquivalenceTest.java new file mode 100644 index 00000000000..38fc972a59d --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/ProofEquivalenceTest.java @@ -0,0 +1,193 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.proof.init.JavaProfile; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.settings.ProofSettings; +import de.uka.ilkd.key.util.ProofStarter; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The equivalence gate for the goal-level multithreading effort (branch {@code bubel/mt-goals}). + * + *

+ * The gate runs each problem in the corpus and asserts {@link ProofFingerprint}s agree across + * configurations: + *

    + *
  1. {@link #singleThreadedIsDeterministic} — automatic search is bit-for-bit reproducible + * across runs (the property a parallel run must preserve); + *
  2. {@link #parallelMatchesSingleThreaded} — the {@code ParallelProver} with one worker + * produces a proof equivalent (modulo branch order) to {@code ApplyStrategy}; + *
  3. {@link #multiWorkerMatchesSingleThreaded} — the {@code ParallelProver} with several + * workers does too. + *
+ * Equivalence "modulo order" is used for the parallel comparisons because independent branches may + * be scheduled differently; the strict single-threaded determinism check stays as the baseline. + * + * @author Claude (KeY multithreading effort) + */ +public class ProofEquivalenceTest { + + /** Classpath-relative directory holding the corpus {@code .key} files. */ + private static final String CORPUS_DIR = "/de/uka/ilkd/key/prover/mt/equiv/"; + + /** + * Loading the corpus mutates the global {@link ProofSettings#DEFAULT_SETTINGS} (KeY applies a + * problem's embedded settings on load). Snapshot it before and restore it after so the gate + * does + * not leak settings into later tests that share the JVM. + */ + private static String settingsSnapshot; + + @BeforeAll + static void snapshotSettings() { + settingsSnapshot = ProofSettings.DEFAULT_SETTINGS.settingsToString(); + } + + @AfterAll + static void restoreSettings() { + ProofSettings.DEFAULT_SETTINGS.loadSettingsFromPropertyString(settingsSnapshot); + } + + /** The corpus. Small, fast, deterministic problems with non-trivial proof trees. */ + private static final String[] CORPUS = + { "prop_chain.key", "prop_split.key", "fol_quant.key", "fol_split_skolem.key", + "arith_poly.key" }; + + @ParameterizedTest(name = "deterministic: {0}") + @ValueSource( + strings = { "prop_chain.key", "prop_split.key", "fol_quant.key", "fol_split_skolem.key", + "arith_poly.key" }) + void singleThreadedIsDeterministic(String file) throws Exception { + ProofFingerprint first = proveAndFingerprint(file); + ProofFingerprint second = proveAndFingerprint(file); + + assertTrue(first.closed, () -> file + " did not close on the first run: " + first); + assertTrue(second.closed, () -> file + " did not close on the second run: " + second); + // Strict equality, including the scheduling-sensitive ordered digest: single-threaded + // search must be bit-for-bit reproducible. + assertEquals(first, second, + () -> "Non-deterministic proof for " + file + ":\n run1=" + first + "\n run2=" + + second); + } + + /** + * The goal-level parallelism gate with a single worker. The proof produced by the + * {@code ParallelProver} (selected via {@code -Dkey.prover.parallel}) must be equivalent up to + * branch order to the single-threaded one. + */ + @ParameterizedTest(name = "parallel==single: {0}") + @ValueSource( + strings = { "prop_chain.key", "prop_split.key", "fol_quant.key", "fol_split_skolem.key", + "arith_poly.key" }) + void parallelMatchesSingleThreaded(String file) throws Exception { + assertParallelMatchesSingle(file, 1); + } + + /** + * The same gate with multiple worker threads: real goal-level parallelism (modulo the current + * coarse commit lock) must still produce a proof equivalent to the single-threaded one. + */ + @ParameterizedTest(name = "parallel(4)==single: {0}") + @ValueSource( + strings = { "prop_chain.key", "prop_split.key", "fol_quant.key", "fol_split_skolem.key", + "arith_poly.key" }) + void multiWorkerMatchesSingleThreaded(String file) throws Exception { + assertParallelMatchesSingle(file, 4); + } + + /** + * Proves {@code file} single-threaded and with {@code threads} workers; asserts equivalence. + */ + private static void assertParallelMatchesSingle(String file, int threads) throws Exception { + ProofFingerprint single = proveAndFingerprint(file); + + ProofFingerprint parallel; + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(threads)); + try { + parallel = proveAndFingerprint(file); + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + } + + assertTrue(single.closed, () -> file + " did not close single-threaded: " + single); + assertTrue(parallel.closed, + () -> file + " did not close with " + threads + " workers: " + parallel); + assertTrue(single.equalsModuloOrder(parallel), + () -> "Parallel run (" + threads + " workers) diverged from single-threaded for " + file + + ":\n single=" + single + "\n parallel=" + parallel); + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } + + /** + * Guards against a degenerate fingerprint. The equivalence gate is only meaningful if the + * fingerprint actually distinguishes different proofs and reflects non-trivial trees, so we + * assert the corpus produces distinct fingerprints with more than one node each. + */ + @Test + void fingerprintIsDiscriminating() throws Exception { + var seen = new java.util.HashSet(); + for (String file : CORPUS) { + ProofFingerprint fp = proveAndFingerprint(file); + assertTrue(fp.nodeCount > 1, () -> file + " produced a trivial proof tree: " + fp); + assertTrue(seen.add(fp.canonicalDigest), + () -> "Duplicate canonical digest for " + file + + " (fingerprint not discriminating)"); + } + } + + /** + * Loads a corpus problem, runs automatic proof search to completion, returns its fingerprint. + */ + private static ProofFingerprint proveAndFingerprint(String file) throws Exception { + Path keyFile = corpusFile(file); + KeYEnvironment env = + KeYEnvironment.load(JavaProfile.getDefaultInstance(), keyFile, null, null, null, true); + try { + Proof proof = env.getLoadedProof(); + assertNotNull(proof, () -> "No proof loaded for " + file); + + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + + return ProofFingerprint.of(proof); + } finally { + env.dispose(); + } + } + + private static Path corpusFile(String file) throws Exception { + URL url = ProofEquivalenceTest.class.getResource(CORPUS_DIR + file); + assertNotNull(url, () -> "Corpus file not on classpath: " + CORPUS_DIR + file); + return Paths.get(url.toURI()); + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/prover/mt/RealProofMtEquivalenceTest.java b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/RealProofMtEquivalenceTest.java new file mode 100644 index 00000000000..dde149fac82 --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/prover/mt/RealProofMtEquivalenceTest.java @@ -0,0 +1,140 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.file.Files; +import java.nio.file.Path; + +import de.uka.ilkd.key.control.KeYEnvironment; +import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.prover.impl.ParallelProver; +import de.uka.ilkd.key.settings.ProofSettings; +import de.uka.ilkd.key.util.ProofStarter; + +import org.key_project.util.helper.FindResources; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Equivalence gate over a curated set of real proofs from {@code key.ui/examples} (drawn + * from the {@code RunAllProofs} functional collection), for the goal-level multithreading effort. + * + *

+ * Unlike the hand-written synthetic corpus, these exercise actual Java-DL / heap / arithmetic proof + * search. The set deliberately mixes provable problems (must close) with not-provable + * ones (must not close — the suite tests that proofs do not accidentally close). + * + *

+ * What is (and is not) asserted. The gate asserts soundness: the parallel run reaches + * the same closed/open status as expected (and as single-threaded). It does not + * require an identical proof tree. Real proofs legitimately diverge under parallel search because + * KeY's strategy cost is order/age-dependent (rule-app age, and fresh names are worker-tagged), so + * processing goals in a different order — which the scheduler does even with a single worker, + * and which thread interleaving does non-deterministically with several — yields a different + * but equally valid proof. (Measured on {@code sum0.key}: single 297 nodes, parallel 295–296, + * varying run to run; all closed.) Identity holds only for order-insensitive problems like the + * synthetic corpus in {@link ProofEquivalenceTest}, which keeps the stricter check. + * + *

+ * These are kept small/fast so the gate is CI-friendly. Speedup and scaling on the largest proofs + * are a separate, manual concern. + * + * @author Claude (KeY multithreading effort) + */ +public class RealProofMtEquivalenceTest { + + private static final int WORKERS = 4; + + /** + * Loading the example proofs mutates the global {@link ProofSettings#DEFAULT_SETTINGS} (KeY + * applies a problem's embedded settings on load). Snapshot it before and restore it after so + * the + * gate does not leak settings into later tests that share the JVM. + */ + private static String settingsSnapshot; + + @BeforeAll + static void snapshotSettings() { + settingsSnapshot = ProofSettings.DEFAULT_SETTINGS.settingsToString(); + } + + @AfterAll + static void restoreSettings() { + ProofSettings.DEFAULT_SETTINGS.loadSettingsFromPropertyString(settingsSnapshot); + } + + /** + * Curated {@code path, provable} rows. Paths are relative to {@code key.ui/examples}. {@code + * provable=true} means the proof must close; {@code false} means it must stay open. + */ + @ParameterizedTest(name = "{0} (provable={1})") + @CsvSource({ + // provable (must close) + "standard_key/BookExamples/02FirstOrderLogic/Ex2.58.key, true", + "standard_key/BookExamples/03DynamicLogic/Sect3.3.1.key, true", + "heap/comprehensions/sum0.key, true", + // not provable (must NOT accidentally close, even under parallel search) + "standard_key/prop_log/reallySimple.key, false", + "standard_key/pred_log/sameName1.key, false", + "standard_key/java_dl/danglingElse.key, false", + "standard_key/java_dl/jml-min/min-unprovable1.key, false", + "heap/polarity_tests/wellformed2.key, false", + }) + void parallelMatchesSingleThreadedOnRealProof(String relPath, boolean provable) + throws Exception { + Path examples = FindResources.getExampleDirectory(); + Assumptions.assumeTrue(examples != null, "examples directory not found"); + Path keyFile = examples.resolve(relPath); + Assumptions.assumeTrue(Files.exists(keyFile), () -> "missing example: " + keyFile); + + ProofFingerprint single = prove(keyFile, false); + ProofFingerprint parallel = prove(keyFile, true); + + // Soundness: the closed/open status matches RunAllProofs' expectation in both modes. + // (A provable problem must close; a not-provable one must not accidentally close.) + assertEquals(provable, single.closed, + () -> relPath + " single-threaded closed=" + single.closed + " expected " + provable); + assertEquals(provable, parallel.closed, + () -> relPath + " parallel closed=" + parallel.closed + " expected " + provable); + // The proof trees may differ (order/age-dependent strategy cost) -- that is expected and + // sound; we deliberately do NOT assert fingerprint equality here. + } + + private static ProofFingerprint prove(Path keyFile, boolean parallel) throws Exception { + KeYEnvironment env = KeYEnvironment.load(keyFile); + String prevEnabled = System.getProperty(ParallelProver.PARALLEL_PROPERTY); + String prevThreads = System.getProperty(ParallelProver.THREADS_PROPERTY); + if (parallel) { + System.setProperty(ParallelProver.PARALLEL_PROPERTY, "true"); + System.setProperty(ParallelProver.THREADS_PROPERTY, Integer.toString(WORKERS)); + } + try { + Proof proof = env.getLoadedProof(); + assertNotNull(proof, () -> "no proof loaded for " + keyFile); + ProofStarter starter = new ProofStarter(false); + starter.init(proof); + starter.start(); + return ProofFingerprint.of(proof); + } finally { + restore(ParallelProver.PARALLEL_PROPERTY, prevEnabled); + restore(ParallelProver.THREADS_PROPERTY, prevThreads); + env.dispose(); + } + } + + private static void restore(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramBenchmark.java b/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramBenchmark.java new file mode 100644 index 00000000000..4f01ffafe5e --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramBenchmark.java @@ -0,0 +1,165 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import de.uka.ilkd.key.java.Services; +import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.proof.ProofAggregate; +import de.uka.ilkd.key.rule.FindTaclet; +import de.uka.ilkd.key.rule.MatchConditions; +import de.uka.ilkd.key.rule.Taclet; +import de.uka.ilkd.key.util.HelperClassForTests; + +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.VMProgramInterpreter; +import org.key_project.prover.sequent.SequentFormula; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Isolated micro-benchmark for the find matcher (no taclet index, strategy or proof pipeline): it + * compares the interpreter ({@link VMProgramInterpreter} over the framework's instruction program) + * against the cursor-free compiled matcher, both built by the match-plan framework + * ({@link JavaMatchPlanBuilder}), over the real taclet base. + * + *

+ * By default it runs on the self-contained {@code tacletMatch1.key}. Point it at a wider set (e.g. + * the bundled TPTP PUZ/SYN problems, which load the full FOL taclet base) with + * {@code -Dbench.problems=/abs/a.key,/abs/b.key}. Run with + * {@code ./gradlew :key.core:test --tests *CompiledMatchProgramBenchmark}. + */ +public class CompiledMatchProgramBenchmark { + + private static final MatchResultInfo EMPTY = MatchConditions.EMPTY_MATCHCONDITIONS; + + /** supplementary closed formulas (used when a problem's own sequent yields few terms). */ + private static final String[] CORPUS_FORMULAS = { + "A & B", "(!A | (A <-> B)) & B", "A -> (B -> A)", "\\forall int x; x >= 0", + "\\forall int x; x + 1 > x", "\\forall int x; \\exists int y; x + y = 0", + "1 + 2 * 3 = 7", "\\forall int x; \\forall int y; (x + y = y + x)" + }; + + private record Task(List interps, List comps, + List corpus, Services services) { + } + + @Test + public void benchmarkInterpreterVsCompiled() { + final List tasks = new ArrayList<>(); + for (String p : problemPaths()) { + final Task t = buildTask(p); + if (t != null) { + tasks.add(t); + } + } + if (tasks.isEmpty()) { + return; + } + + // warmup + for (int pass = 0; pass < 5; pass++) { + for (Task t : tasks) { + run(t.interps, t); + run(t.comps, t); + } + } + + // timed: alternate phases per pass to average out JIT / cache effects + final int passes = 30; + long interpMatches = 0, compMatches = 0, interpNanos = 0, compNanos = 0; + for (int pass = 0; pass < passes; pass++) { + for (Task t : tasks) { + long t0 = System.nanoTime(); + interpMatches += run(t.interps, t); + interpNanos += System.nanoTime() - t0; + + t0 = System.nanoTime(); + compMatches += run(t.comps, t); + compNanos += System.nanoTime() - t0; + } + } + + System.out.printf("[isolated matcher, %d problem(s)] interpreter=%.1f ms compiled=%.1f ms" + + " speedup=%.2fx (matches interp=%d comp=%d)%n", + tasks.size(), interpNanos / 1e6, compNanos / 1e6, + (double) interpNanos / compNanos, interpMatches / passes, compMatches / passes); + assertEquals(interpMatches, compMatches, + "compiled and interpreter must agree on the number of matches"); + } + + private static List problemPaths() { + final String prop = System.getProperty("bench.problems"); + if (prop != null && !prop.isBlank()) { + return List.of(prop.split(",")); + } + return List.of(HelperClassForTests.TESTCASE_DIRECTORY.resolve("tacletmatch") + .resolve("tacletMatch1.key").toString()); + } + + private static Task buildTask(String pathStr) { + final Path path = Path.of(pathStr.trim()); + if (!Files.exists(path)) { + System.out.println(" (skip, not found) " + path); + return null; + } + final ProofAggregate pa = HelperClassForTests.parse(path); + final Services services = pa.getFirstProof().getServices(); + + final List corpus = new ArrayList<>(); + for (SequentFormula sf : pa.getFirstProof().root().sequent()) { + collectSubterms((JTerm) sf.formula(), corpus); + } + for (String f : CORPUS_FORMULAS) { + try { + collectSubterms(services.getTermBuilder().parseTerm(f), corpus); + } catch (Exception ignored) { + // formula not parseable in this problem's signature + } + } + + final List interps = new ArrayList<>(); + final List comps = new ArrayList<>(); + int findTaclets = 0; + for (Taclet t : pa.getFirstProof().getInitConfig().activatedTaclets()) { + if (!(t instanceof FindTaclet ft)) { + continue; + } + findTaclets++; + final JTerm find = (JTerm) ft.find(); + comps.add(JavaMatchPlanBuilder.compiledProgram(find)); + interps.add(new VMProgramInterpreter(JavaMatchPlanBuilder.interpreterProgram(find))); + } + System.out.printf(" %-22s findTaclets=%4d corpus=%d%n", + path.getFileName(), findTaclets, corpus.size()); + return new Task(interps, comps, corpus, services); + } + + private static long run(List programs, Task t) { + long matches = 0; + for (int p = 0, np = programs.size(); p < np; p++) { + final MatchProgram prog = programs.get(p); + for (int i = 0, n = t.corpus.size(); i < n; i++) { + if (prog.match(t.corpus.get(i), EMPTY, t.services) != null) { + matches++; + } + } + } + return matches; + } + + private static void collectSubterms(JTerm t, List out) { + out.add(t); + for (int i = 0, n = t.arity(); i < n; i++) { + collectSubterms(t.sub(i), out); + } + } +} diff --git a/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramTest.java b/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramTest.java new file mode 100644 index 00000000000..49d29c6843c --- /dev/null +++ b/key.core/src/test/java/de/uka/ilkd/key/rule/match/vm/CompiledMatchProgramTest.java @@ -0,0 +1,123 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.rule.match.vm; + +import de.uka.ilkd.key.java.Services; +import de.uka.ilkd.key.logic.JTerm; +import de.uka.ilkd.key.parser.ParserException; +import de.uka.ilkd.key.proof.ProofAggregate; +import de.uka.ilkd.key.rule.FindTaclet; +import de.uka.ilkd.key.rule.MatchConditions; +import de.uka.ilkd.key.rule.Taclet; +import de.uka.ilkd.key.util.HelperClassForTests; + +import org.key_project.logic.Name; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the cursor-free compiled find-matcher built by the match-plan framework + * ({@link JavaMatchPlanBuilder#compiledProgram}), the compiled counterpart of + * {@link VMTacletMatcherTest} (which covers the interpreter). For the same taclets and the same + * matching / non-matching terms it asserts that the compiled matcher produces the expected result + * -- success with the expected schema-variable instantiations, and {@code null} on the failure + * cases -- so the compiled path is checked against explicit expectations. + * + *

+ * Coverage focuses on term-level matching (propositional / function patterns) and, importantly, + * {@link #compiledBoundVariableMatching() bound variables} (quantifiers / renaming), which the + * compiler handles cursor-free. + */ +public class CompiledMatchProgramTest { + + private static final MatchResultInfo EMPTY = MatchConditions.EMPTY_MATCHCONDITIONS; + + private static Services services; + private static FindTaclet propositional; // taclet_match_rule_1: phi & psi + private static FindTaclet function; // taclet_match_rule_2: f(...) + private static FindTaclet binder; // taclet_match_rule_3: \forall x; ... + + @BeforeAll + public static void init() { + final ProofAggregate pa = HelperClassForTests.parse( + HelperClassForTests.TESTCASE_DIRECTORY.resolve("tacletmatch") + .resolve("tacletMatch1.key")); + services = pa.getFirstProof().getServices(); + propositional = findTaclet(pa, "taclet_match_rule_1"); + function = findTaclet(pa, "taclet_match_rule_2"); + binder = findTaclet(pa, "taclet_match_rule_3"); + } + + private static FindTaclet findTaclet(ProofAggregate pa, String name) { + final Taclet t = + pa.getFirstProof().getInitConfig().lookupActiveTaclet(new Name(name)); + assertTrue(t instanceof FindTaclet, name + " must be a find taclet"); + return (FindTaclet) t; + } + + /** compiles the find expression; the taclets here are all within the framework's coverage. */ + private static MatchProgram compile(FindTaclet t) { + final MatchProgram p = JavaMatchPlanBuilder.compiledProgram((JTerm) t.find()); + assertNotNull(p, "find pattern of " + t.name() + " was expected to compile"); + return p; + } + + private MatchResultInfo match(MatchProgram p, String term) throws ParserException { + return p.match(services.getTermBuilder().parseTerm(term), EMPTY, services); + } + + @Test + public void compiledPropositionalMatching() throws ParserException { + final MatchProgram p = compile(propositional); + + final JTerm toMatch = services.getTermBuilder().parseTerm("A & B"); + final MatchResultInfo mc = p.match(toMatch, EMPTY, services); + assertNotNull(mc, "compiled matcher should match A & B"); + assertSame(toMatch.sub(0), mc.getInstantiations().lookupValue(new Name("phi"))); + assertSame(toMatch.sub(1), mc.getInstantiations().lookupValue(new Name("psi"))); + + for (String matching : new String[] { "(!A | (A<->B)) & B", "A & (B & A)", + "(\\forall int x; x>=0) & A" }) { + assertNotNull(match(p, matching), "compiled matcher should match " + matching); + } + // failure cases + for (String nonMatching : new String[] { "A | (B & A)", "A", + "\\forall int x;(x>=0 & A)" }) { + assertNull(match(p, nonMatching), "compiled matcher should not match " + nonMatching); + } + } + + @Test + public void compiledFunctionMatching() throws ParserException { + final MatchProgram p = compile(function); + + for (String matching : new String[] { "f(1, 1, 2)", "f(c, c, d)" }) { + assertNotNull(match(p, matching), "compiled matcher should match " + matching); + } + // failure cases: wrong shape / different head symbol / repeated-SV mismatch + for (String nonMatching : new String[] { "f(1,2,1)", "g(1,1,2)", "h(1,1)", "c", + "z(1,1,1,1)", "f(c,d,c)" }) { + assertNull(match(p, nonMatching), "compiled matcher should not match " + nonMatching); + } + } + + @Test + public void compiledBoundVariableMatching() throws ParserException { + final MatchProgram p = compile(binder); + + assertNotNull(match(p, "\\forall int x; x + 1 > 0"), + "compiled matcher should match the bound-variable pattern"); + // failure case: the body shape differs (1 + x rather than x + 1) + assertNull(match(p, "\\forall int x; 1 + x > 0"), + "compiled matcher should not match a differing bound-variable body"); + } +} diff --git a/key.core/src/test/resources/de/uka/ilkd/key/nparser/taclets.old.txt b/key.core/src/test/resources/de/uka/ilkd/key/nparser/taclets.old.txt index 69b18dc9c6b..7c195cd9116 100644 --- a/key.core/src/test/resources/de/uka/ilkd/key/nparser/taclets.old.txt +++ b/key.core/src/test/resources/de/uka/ilkd/key/nparser/taclets.old.txt @@ -12185,7 +12185,6 @@ lenOfSeqSubEQ { \assumes ([equals(seqSub(seq,from,to),EQ)]==>[]) \find(seqLen(EQ)) \sameUpdateLevel\replacewith(if-then-else(lt(from,to),sub(to,from),Z(0(#)))) -\heuristics(find_term_not_in_assumes, simplify) Choices: sequences:on} ----------------------------------------------------- == lenOfSeqUpd (lenOfSeqUpd) ========================================= diff --git a/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/arith_poly.key b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/arith_poly.key new file mode 100644 index 00000000000..bb8d61eb807 --- /dev/null +++ b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/arith_poly.key @@ -0,0 +1,12 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ + +// Integer-arithmetic tautology: exercises polynomial/monomial normalisation and the +// introduction-time ordering features, i.e. the eviction/history-sensitive caches +// (monomialCache, polynomialCache, introductionTimeCache) that the propositional/FOL +// corpus problems do not touch. This is the case where a subtle cache bug would surface. +\problem { + \forall int x; \forall int y; \forall int z; + ((x + y) * z = x * z + y * z) +} diff --git a/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_quant.key b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_quant.key new file mode 100644 index 00000000000..5e81fcb2ddb --- /dev/null +++ b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_quant.key @@ -0,0 +1,18 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ + +// A small first-order tautology so the corpus also exercises quantifier +// instantiation, not only propositional splitting. +\sorts { + s; +} + +\predicates { + P(s); + Q(s); +} + +\problem { + (\forall s x; (P(x) -> Q(x))) -> ((\forall s x; P(x)) -> (\forall s x; Q(x))) +} diff --git a/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_split_skolem.key b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_split_skolem.key new file mode 100644 index 00000000000..16f962dd1ef --- /dev/null +++ b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/fol_split_skolem.key @@ -0,0 +1,22 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ + +// A first-order tautology that BOTH splits into independent branches (the conjunction in the +// succedent) AND forces skolemization in each branch (the universal quantifiers). This is the +// case the parallel name allocator exists for: concurrent workers minting fresh skolem names in +// sibling branches. +\sorts { + s; +} + +\predicates { + P(s); + Q(s); + R(s); +} + +\problem { + ((\forall s x; P(x)) & (\forall s x; Q(x)) & (\forall s x; R(x))) + -> ((\forall s x; P(x)) & (\forall s x; Q(x)) & (\forall s x; R(x))) +} diff --git a/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_chain.key b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_chain.key new file mode 100644 index 00000000000..b8ecac313d3 --- /dev/null +++ b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_chain.key @@ -0,0 +1,15 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ + +// Propositional tautology that forces a handful of splitting rule +// applications, giving the proof tree a non-trivial branching shape. +\predicates { + p; + q; + r; +} + +\problem { + ((p -> q) & (q -> r)) -> (p -> r) +} diff --git a/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_split.key b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_split.key new file mode 100644 index 00000000000..a81a2f27605 --- /dev/null +++ b/key.core/src/test/resources/de/uka/ilkd/key/prover/mt/equiv/prop_split.key @@ -0,0 +1,15 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ + +// Distribution law: produces multiple branches that close independently, +// the kind of structure the parallel chooser will schedule across workers. +\predicates { + p; + q; + r; +} + +\problem { + (p & (q | r)) -> ((p & q) | (p & r)) +} diff --git a/key.core/src/testFixtures/java/de/uka/ilkd/key/proof/runallproofs/proofcollection/ForkedTestFileRunner.java b/key.core/src/testFixtures/java/de/uka/ilkd/key/proof/runallproofs/proofcollection/ForkedTestFileRunner.java index 8ff67fe1a4f..72e333f45ef 100644 --- a/key.core/src/testFixtures/java/de/uka/ilkd/key/proof/runallproofs/proofcollection/ForkedTestFileRunner.java +++ b/key.core/src/testFixtures/java/de/uka/ilkd/key/proof/runallproofs/proofcollection/ForkedTestFileRunner.java @@ -97,8 +97,18 @@ public static List processTestFiles(List testFiles, Path p new ProcessBuilder("java", "-classpath", System.getProperty("java.class.path"), // pass through the value of key.disregardSettings "-D" + PathConfig.DISREGARD_SETTINGS_PROPERTY + "=" - + Boolean.getBoolean(PathConfig.DISREGARD_SETTINGS_PROPERTY)); + + Boolean.getBoolean(PathConfig.DISREGARD_SETTINGS_PROPERTY), + // Run the forked proofs on the same prover the parent test selected, defaulting to + // the single-threaded prover. The forked JVM does not see the parent's system + // properties, so without this it would fall back to the persisted prover-mode + // default (multi-core in this build) and the regression suite would run non- + // deterministically on the parallel prover. + "-Dkey.prover.parallel=" + System.getProperty("key.prover.parallel", "false")); List command = pb.command(); + String parallelThreads = System.getProperty("key.prover.parallel.threads"); + if (parallelThreads != null) { + command.add("-Dkey.prover.parallel.threads=" + parallelThreads); + } // TODO make sure no injection happens here? String forkMemory = settings.getForkMemory(); diff --git a/key.core/src/testFixtures/java/de/uka/ilkd/key/prover/mt/ProofFingerprint.java b/key.core/src/testFixtures/java/de/uka/ilkd/key/prover/mt/ProofFingerprint.java new file mode 100644 index 00000000000..139aea29371 --- /dev/null +++ b/key.core/src/testFixtures/java/de/uka/ilkd/key/prover/mt/ProofFingerprint.java @@ -0,0 +1,189 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.prover.mt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import de.uka.ilkd.key.proof.Node; +import de.uka.ilkd.key.proof.Proof; + +import org.key_project.prover.rules.RuleApp; + +/** + * Deterministic, prover-agnostic summary of a finished {@link Proof}. + * + *

+ * This is the measurement primitive for the multithreading equivalence gate: run the same proof + * obligation under two prover configurations (e.g. single-threaded vs. one-thread-per-goal) and + * assert their fingerprints match. If they differ, the parallel run produced a different proof and + * is therefore not yet trustworthy. + * + *

+ * Two structural digests are recorded: + *

    + *
  • {@link #orderedDigest} hashes the proof tree in child-insertion order. It is the strictest + * check and is appropriate whenever scheduling is deterministic (e.g. two single-threaded runs). + *
  • {@link #canonicalDigest} hashes each node together with its children sorted by their own + * subtree digest. It is invariant under sibling reordering, so it stays stable even when a parallel + * scheduler inserts independent branches in a different order. The parallel gate compares this one. + *
+ * + * @author Claude (KeY multithreading effort) + */ +public final class ProofFingerprint { + + /** Marker emitted for a closed leaf that has no applied rule of its own. */ + private static final String CLOSED_LEAF = ""; + /** Marker emitted for an open leaf (a remaining goal). */ + private static final String OPEN_LEAF = ""; + + /** Whether the whole proof is closed. */ + public final boolean closed; + /** Number of open goals remaining. */ + public final int openGoals; + /** Number of closed goals. */ + public final int closedGoals; + /** Total number of nodes in the proof tree. */ + public final int nodeCount; + /** Number of branches in the proof tree. */ + public final int branchCount; + /** Digest of the tree walked in child-insertion order (scheduling-sensitive). */ + public final String orderedDigest; + /** Digest of the tree with siblings canonically ordered (scheduling-insensitive). */ + public final String canonicalDigest; + + private ProofFingerprint(boolean closed, int openGoals, int closedGoals, int nodeCount, + int branchCount, String orderedDigest, String canonicalDigest) { + this.closed = closed; + this.openGoals = openGoals; + this.closedGoals = closedGoals; + this.nodeCount = nodeCount; + this.branchCount = branchCount; + this.orderedDigest = orderedDigest; + this.canonicalDigest = canonicalDigest; + } + + /** + * Computes the fingerprint of a (typically finished) proof. + * + * @param proof the proof to summarize; must not be {@code null} + * @return its fingerprint + */ + public static ProofFingerprint of(Proof proof) { + Objects.requireNonNull(proof, "proof"); + StringBuilder ordered = new StringBuilder(); + String canonical = digest(proof.root(), false, ordered); + return new ProofFingerprint(proof.closed(), proof.openGoals().size(), + proof.closedGoals().size(), proof.countNodes(), proof.countBranches(), + sha256(ordered.toString()), canonical); + } + + /** + * Recursively builds the subtree digest of {@code node}. + * + * @param node the subtree root + * @param canonicalOrder unused flag kept for symmetry; ordering is handled per-call below + * @param orderedOut accumulates the insertion-order traversal as a side effect + * @return the canonical (sibling-order-independent) digest of this subtree + */ + private static String digest(Node node, boolean canonicalOrder, StringBuilder orderedOut) { + String label = labelOf(node); + orderedOut.append(label).append('('); + + int n = node.childrenCount(); + List childDigests = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + childDigests.add(digest(node.child(i), canonicalOrder, orderedOut)); + } + orderedOut.append(')'); + + // Canonical digest: order children by their own subtree digest so that two proofs that + // differ only in the order independent branches were explored hash identically. + childDigests.sort(null); + StringBuilder canonical = new StringBuilder(label).append('('); + for (String cd : childDigests) { + canonical.append(cd).append(','); + } + canonical.append(')'); + return sha256(canonical.toString()); + } + + /** The stable label of a node: its applied rule name, or a leaf marker. */ + private static String labelOf(Node node) { + RuleApp app = node.getAppliedRuleApp(); + if (app != null) { + return app.rule().name().toString(); + } + return node.isClosed() ? CLOSED_LEAF : OPEN_LEAF; + } + + private static String sha256(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(s.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + /** + * Strict equality: every field including the scheduling-sensitive {@link #orderedDigest}. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProofFingerprint other)) { + return false; + } + return closed == other.closed && openGoals == other.openGoals + && closedGoals == other.closedGoals && nodeCount == other.nodeCount + && branchCount == other.branchCount && orderedDigest.equals(other.orderedDigest) + && canonicalDigest.equals(other.canonicalDigest); + } + + @Override + public int hashCode() { + return Objects.hash(closed, openGoals, closedGoals, nodeCount, branchCount, orderedDigest, + canonicalDigest); + } + + /** + * Equivalence up to sibling ordering: the two proofs reach the same result with the same tree + * shape, but independent branches may have been explored in a different order. This is the + * relation the parallel-vs-single gate asserts. + * + * @param other the fingerprint to compare against + * @return {@code true} if the proofs are equivalent modulo branch order + */ + public boolean equalsModuloOrder(ProofFingerprint other) { + if (other == null) { + return false; + } + return closed == other.closed && openGoals == other.openGoals + && closedGoals == other.closedGoals && nodeCount == other.nodeCount + && branchCount == other.branchCount + && canonicalDigest.equals(other.canonicalDigest); + } + + @Override + public String toString() { + return "ProofFingerprint{closed=" + closed + ", open=" + openGoals + ", closed=" + + closedGoals + ", nodes=" + nodeCount + ", branches=" + branchCount + ", ordered=" + + orderedDigest.substring(0, 12) + ", canonical=" + canonicalDigest.substring(0, 12) + + '}'; + } +} diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/MatchProgram.java b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/MatchProgram.java new file mode 100644 index 00000000000..adce1a3ea8d --- /dev/null +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/MatchProgram.java @@ -0,0 +1,35 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.vm; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.prover.rules.instantiation.MatchResultInfo; + +import org.jspecify.annotations.Nullable; + +/** + * A program that matches a syntax element against a fixed pattern (the find expression of a + * taclet). + *

+ * There are two implementations: the {@link VMProgramInterpreter}, which interprets a sequence of + * {@code VMInstruction}s over a generic cursor, and a compiled variant that navigates the term + * structure directly (no cursor) for the patterns it supports. Both are interchangeable; which one + * a + * {@code VMTacletMatcher} uses is selected at construction time, so the system can always fall back + * to the pure interpreter. + */ +public interface MatchProgram { + + /** + * Attempts to match the given syntax element against this program's pattern. + * + * @param toMatch the {@link SyntaxElement} to be matched + * @param mc the initial match conditions; may be extended on success + * @param services the {@link LogicServices} + * @return the resulting {@link MatchResultInfo} on success, or {@code null} if no match + */ + @Nullable + MatchResultInfo match(SyntaxElement toMatch, MatchResultInfo mc, LogicServices services); +} diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/ProgramChildrenMatcher.java b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/ProgramChildrenMatcher.java new file mode 100644 index 00000000000..0ac8082ff90 --- /dev/null +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/ProgramChildrenMatcher.java @@ -0,0 +1,37 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.vm; + +import org.key_project.logic.LogicServices; +import org.key_project.logic.SyntaxElement; +import org.key_project.prover.rules.instantiation.MatchResultInfo; + +import org.jspecify.annotations.Nullable; + +/** + * Matches a contiguous run of children of a parent syntax element, starting at a given child index. + * This is the abstraction used for the active statements of a context block (phase (3) of the + * context match): the located source element and the offset of the first active statement are + * provided, and the run of active-statement matchers consumes one child each. + *

+ * It is implemented both by the interpreter ({@link VMProgramInterpreter#matchChildrenFrom}, which + * navigates the children with a cursor) and by the compiled matcher (which navigates them directly + * via {@code getChild}), so the same context-block bookkeeping can drive either matcher. + */ +@FunctionalInterface +public interface ProgramChildrenMatcher { + + /** + * Matches the children of {@code parent} starting at index {@code startChild}. + * + * @param parent the element whose children are to be matched + * @param startChild the index of the first child to match against + * @param mc the initial match conditions + * @param services the logic services + * @return the resulting match conditions, or {@code null} if the match fails + */ + @Nullable + MatchResultInfo matchChildrenFrom(SyntaxElement parent, int startChild, MatchResultInfo mc, + LogicServices services); +} diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/VMProgramInterpreter.java b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/VMProgramInterpreter.java index 711c6138a05..6bb4ed14385 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/VMProgramInterpreter.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/rules/matcher/vm/VMProgramInterpreter.java @@ -26,7 +26,7 @@ * constraints such as variable instantiations if successful, or {@code null} * if the match fails. */ -public class VMProgramInterpreter { +public class VMProgramInterpreter implements MatchProgram, ProgramChildrenMatcher { /** * The sequence of instructions to be executed. @@ -55,6 +55,7 @@ public VMProgramInterpreter(VMInstruction[] instruction) { * @return a {@link MatchResultInfo} containing the result of the match, * or {@code null} if no match was possible */ + @Override public @Nullable MatchResultInfo match(SyntaxElement toMatch, MatchResultInfo mc, LogicServices services) { MatchResultInfo result = mc; @@ -67,4 +68,45 @@ public VMProgramInterpreter(VMInstruction[] instruction) { navi.release(); return result; } + + /** + * Executes the program against the children of {@code parent} starting at child index + * {@code startChild}, i.e. the program is interpreted as a sequence of per-child matchers each + * consuming exactly one child of {@code parent} (advancing via {@code gotoNextSibling}). This + * is + * used to match the active statements of a context block, where matching does not start at the + * root but at a child offset of the located source element (the equivalent of + * {@code matchChildren(source, mc, offset)} on the interpreter side). + *

+ * The caller must guarantee that {@code parent} has at least {@code startChild + k} children, + * where {@code k} is the number of children this program consumes; otherwise the cursor would + * run past the available children. (The context-block matcher ensures this before calling.) + * + * @param parent the element whose children are to be matched + * @param startChild the index of the first child to match against + * @param mc the initial match conditions + * @param services the logic services + * @return the resulting match conditions, or {@code null} if the match fails + */ + @Override + public @Nullable MatchResultInfo matchChildrenFrom(SyntaxElement parent, int startChild, + MatchResultInfo mc, LogicServices services) { + if (instruction.length == 0) { + // nothing to match (empty active-statement block) -> succeed unchanged + return mc; + } + MatchResultInfo result = mc; + final PoolSyntaxElementCursor navi = PoolSyntaxElementCursor.get(parent); + navi.gotoNext(); // descend to the first child of parent + for (int i = 0; i < startChild; i++) { + navi.gotoNextSibling(); // advance to child number startChild + } + int instrPtr = 0; + while (result != null && instrPtr < instruction.length) { + result = instruction[instrPtr].match(navi, result, services); + instrPtr++; + } + navi.release(); + return result; + } } diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ConstFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ConstFeature.java index 8d1ba7bf3ed..e7ef17721a4 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ConstFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ConstFeature.java @@ -12,6 +12,7 @@ import org.jspecify.annotations.NonNull; /// A feature that returns a constant value +@CostLocal public class ConstFeature implements Feature { @Override diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostLocal.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostLocal.java new file mode 100644 index 00000000000..7ff7fa360e4 --- /dev/null +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostLocal.java @@ -0,0 +1,36 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.strategy.costbased.feature; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a {@link Feature} whose cost is a pure function of the rule application and the subterm at + * its find position -- i.e. it does NOT depend on other formulas of the sequent, the set of applied + * rules on the branch, or any other goal-global state. + * + *

+ * This lets the strategy-cost reuse (see {@code de.uka.ilkd.key.strategy.CostReuse}) carry a + * container's cost forward across the per-round re-expansion instead of recomputing it, as long as + * the find position is unmodified. The default (no annotation) is the SAFE one: an unannotated leaf + * feature is treated as non-local, so cost reuse simply does not apply to taclets that use it (they + * are re-costed in full). Forgetting to annotate a new feature therefore costs performance, never + * soundness. + * + *

+ * For a composite feature (one that combines child features) this annotation means "transparent": + * the classifier recurses into the child features, so the composite counts as local only when all + * of them are. There is no automatic structural transparency -- a composite is trusted only because + * its author annotated it after checking that its own computation (including any non-Feature inputs + * such as projections or term-generators) is find-local. Use {@link CostNonLocal} to force a + * feature + * to be treated as non-local. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CostLocal { +} diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostNonLocal.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostNonLocal.java new file mode 100644 index 00000000000..d36694796fb --- /dev/null +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/CostNonLocal.java @@ -0,0 +1,28 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.strategy.costbased.feature; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Explicitly marks a {@link Feature} as NON-local for strategy-cost reuse (see + * {@code de.uka.ilkd.key.strategy.CostReuse}): its cost may depend on goal-global state (other + * sequent formulas, applied rules, instantiation context, ...), so it must be recomputed on every + * re-expansion. + * + *

+ * This is an override: it wins over the automatic classification. Use it on a composite feature + * that would otherwise be auto-classified local (because all its children are local) but that + * itself reads goal-global state, or to defensively pin a feature whose locality is in doubt. The + * default for an unannotated leaf feature is already non-local, so this annotation is only needed + * to + * override the automatic decision. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CostNonLocal { +} diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/FindDepthFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/FindDepthFeature.java index a87d7b0c86d..3a2a37f0965 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/FindDepthFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/FindDepthFeature.java @@ -17,6 +17,7 @@ /// depth zero or if not a find taclet) /// /// TODO: eliminate this class and use term features instead +@CostLocal public class FindDepthFeature implements Feature { private static final Feature INSTANCE = new FindDepthFeature(); diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/LetFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/LetFeature.java index bf31a9a4275..f84a957f0ea 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/LetFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/LetFeature.java @@ -18,6 +18,10 @@ /// is generated by a ProjectionToTerm. This is mostly useful to make feature terms /// more /// readable, and to avoid repeated evaluation of projections. +// @CostLocal: a let-binding is transparent -- its bound value is a ProjectionToTerm over the +// app/find term and its body is recursed; local iff the body is. (Reuse only applies while the +// find position is unmodified, so the projected focus term is stable.) +@CostLocal public class LetFeature> implements Feature { private final TermBuffer var; diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ScaleFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ScaleFeature.java index bfdde9c2908..a155cd30e0e 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ScaleFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ScaleFeature.java @@ -147,6 +147,7 @@ protected static boolean isZero(double p) { return Math.abs(p) < 0.0000001; } + @CostLocal private static class MultFeature extends ScaleFeature { /// the coefficient diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ShannonFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ShannonFeature.java index 1e723e2e4cd..3e2253718f5 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ShannonFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/ShannonFeature.java @@ -19,6 +19,7 @@ /// value of the whole expression is f1 (if c returns zero, or more /// general /// if c returns a distinguished value trueCost) or f2 +@CostLocal public class ShannonFeature implements Feature { /// The filter that decides which sub-feature is to be evaluated diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/SumFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/SumFeature.java index 05f4f131360..32b05c15efc 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/SumFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/feature/SumFeature.java @@ -16,6 +16,7 @@ import org.jspecify.annotations.NonNull; /// A feature that computes the sum of a given list (vector) of features +@CostLocal public class SumFeature implements Feature { @Override diff --git a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/termfeature/ApplyTFFeature.java b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/termfeature/ApplyTFFeature.java index 1bb33ac65a3..5066fe51aea 100644 --- a/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/termfeature/ApplyTFFeature.java +++ b/key.ncore.calculus/src/main/java/org/key_project/prover/strategy/costbased/termfeature/ApplyTFFeature.java @@ -10,12 +10,14 @@ import org.key_project.prover.strategy.costbased.MutableState; import org.key_project.prover.strategy.costbased.RuleAppCost; import org.key_project.prover.strategy.costbased.TopRuleAppCost; +import org.key_project.prover.strategy.costbased.feature.CostLocal; import org.key_project.prover.strategy.costbased.feature.Feature; import org.key_project.prover.strategy.costbased.termProjection.ProjectionToTerm; import org.jspecify.annotations.NonNull; /// Feature for invoking a term feature on the instantiation of a schema variable +@CostLocal public class ApplyTFFeature> implements Feature { private final ProjectionToTerm proj; diff --git a/key.ncore.compiler/build.gradle b/key.ncore.compiler/build.gradle new file mode 100644 index 00000000000..a1bf3d11a48 --- /dev/null +++ b/key.ncore.compiler/build.gradle @@ -0,0 +1,25 @@ +repositories { + mavenCentral() +} + +dependencies { + api project(':key.util') + api project(':key.ncore') + api project(':key.ncore.calculus') + implementation('org.jspecify:jspecify:1.0.0') +} + +checkerFramework { + if (System.getProperty("ENABLE_NULLNESS")) { + checkers = [ + "org.checkerframework.checker.nullness.NullnessChecker", + ] + extraJavacArgs = [ + "-Xmaxerrs", "10000", + "-Astubs=$rootDir/key.util/src/main/checkerframework:permit-nullness-assertion-exception.astub", + "-AstubNoWarnIfNotFound", + "-Werror", + "-Aversion", + ] + } +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/BinderMatcher.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/BinderMatcher.java new file mode 100644 index 00000000000..08341f22c53 --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/BinderMatcher.java @@ -0,0 +1,51 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import org.key_project.logic.op.QuantifiableVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; +import org.key_project.util.collection.ImmutableArray; + +/** + * Language SPI for matching bound variables (the variables introduced by a binder such as + * a quantifier, a substitution or a {@code let}). This is one of the two axes on which the + * Java/Rust/Solidity front-ends differ: each binds its own kind of logic / schema variables (e.g. + * {@code LogicVariable} vs. {@code BoundVariable}) and keeps its own renaming/instantiation state. + * + *

+ * The match-plan framework owns the scaffolding (bind the pattern's bound variables before + * matching the operator and subterms, then unbind afterwards, in both back-ends); a language plugs + * in the actual binding behaviour here. The {@linkplain #binder(ImmutableArray) binder} matches the + * pattern's bound variables against the source element's own bound variables and is shared by both + * back-ends (it is element-based); only the un-binding is back-end specific (an instruction for the + * interpreter, a direct call for the compiler). + */ +public interface BinderMatcher { + + /** + * The element-based instruction that binds the given pattern bound variables (it reads the + * source element's own bound variables). Used by both back-ends. + * + * @param boundVars the pattern's bound variables + * @return the binding instruction + */ + MatchInstruction binder(ImmutableArray boundVars); + + /** + * The interpreter instruction that pops the binding scope opened by {@link #binder}. + * + * @return the un-binding instruction + */ + VMInstruction unbinderInstruction(); + + /** + * Pops the binding scope opened by {@link #binder} for the compiled back-end. + * + * @param mc the match result after matching the binder body + * @return the match result with the binding scope removed + */ + MatchResultInfo unbind(MatchResultInfo mc); +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/GenericOperatorHead.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/GenericOperatorHead.java new file mode 100644 index 00000000000..a074927fb89 --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/GenericOperatorHead.java @@ -0,0 +1,40 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import java.util.List; + +import org.key_project.logic.Term; +import org.key_project.logic.op.Operator; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.GotoNextInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.MatchIdentityInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +/** + * The head for an ordinary operator: the operator must be (reference-)identical to the pattern's. + * This is the language-agnostic default for any operator that has no special matching (i.e. is + * matched by {@code MatchIdentityInstruction} in the interpreter and by an {@code op() == op} check + * in the compiler). + */ +public final class GenericOperatorHead implements MatchHead { + + private final Operator op; + + public GenericOperatorHead(Operator op) { + this.op = op; + } + + @Override + public void emit(List out) { + out.add(new MatchIdentityInstruction(op)); + out.add(GotoNextInstruction.INSTANCE); + } + + @Override + public MatchProgram compileHeadCheck() { + final Operator expected = op; + return (element, mc, services) -> ((Term) element).op() == expected ? mc : null; + } +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchHead.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchHead.java new file mode 100644 index 00000000000..8423dfc4cea --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchHead.java @@ -0,0 +1,43 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import java.util.List; + +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +/** + * The operator-specific "head" of an {@link OperatorPlan}: it checks the operator of a term and any + * operator-specific data (e.g. a modal-operator kind, a parametric function's generic arguments, + * an elementary update's left-hand side), but not the subterms -- those are recursed by + * the + * enclosing {@link OperatorPlan}. + * + *

+ * Generic heads (ordinary operators) live in this module; language-specific heads (modalities, + * parametric functions, ...) are supplied by the front-end. A head carries both back-ends, lifted + * from the corresponding hand-written matcher fragments. + */ +public interface MatchHead { + + /** + * Appends the interpreter instructions matching this head. On entry the cursor points at the + * operator; on completion it must point at the first subterm so the enclosing + * {@link OperatorPlan} can match the subterms. + * + * @param out the instruction list being built + */ + void emit(List out); + + /** + * Builds the compiled head check: applied to the term element, it verifies the operator (and + * head-specific data) and returns the extended match result, or {@code null} on failure. It + * does + * not recurse into subterms. + * + * @return the compiled head matcher + */ + MatchProgram compileHeadCheck(); +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchPlan.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchPlan.java new file mode 100644 index 00000000000..6daac292c75 --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/MatchPlan.java @@ -0,0 +1,56 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import java.util.List; + +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +/** + * A node of a match plan: a single, language-agnostic description of how to match one + * (sub)pattern, from which both back-ends are derived. + * + *

+ * A match plan is built once per find pattern (when the taclet base is loaded) by a per-language + * dispatch that composes plan nodes for each syntax construct. The point is that each construct is + * described in exactly one place: a node carries both + *

    + *
  • {@link #emitInstructions(List)} — the interpreted back-end: it appends the cursor-based + * {@link VMInstruction}s executed by {@code VMProgramInterpreter}; and
  • + *
  • {@link #compile()} — the compiled back-end: it builds a cursor-free {@link MatchProgram} that + * navigates the syntax element directly.
  • + *
+ * Adding a construct (or fixing its matching) is therefore done once, in the node, and both the + * interpreter and the compiler stay in sync by construction. + * + *

+ * Both emissions are produced at plan-construction time, so neither adds runtime overhead over the + * hand-written matchers they replace: the interpreter still runs a {@code VMInstruction[]} and the + * compiler still runs the resulting {@link MatchProgram}. + */ +public interface MatchPlan { + + /** + * Appends, to {@code out}, the {@link VMInstruction}s matching this (sub)pattern for the + * cursor-based interpreter. The cursor is expected to point at the element to be matched and, + * on + * completion of the appended instructions, to have advanced past it (to its next sibling), so + * that sibling plans can be appended directly after. + * + * @param out the instruction list being built + */ + void emitInstructions(List out); + + /** + * Builds the cursor-free compiled matcher for this (sub)pattern. The returned + * {@link MatchProgram} is applied to the syntax element to be matched (the same element the + * interpreter's cursor would point at) and returns the extended match result, or {@code null} + * on + * failure. + * + * @return the compiled matcher for this plan node + */ + MatchProgram compile(); +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/OperatorPlan.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/OperatorPlan.java new file mode 100644 index 00000000000..b2236a78420 --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/OperatorPlan.java @@ -0,0 +1,110 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import java.util.List; + +import org.key_project.logic.Term; +import org.key_project.logic.op.QuantifiableVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.CheckNodeKindInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.GotoNextInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.GotoNextSiblingInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; +import org.key_project.util.collection.ImmutableArray; + +import org.jspecify.annotations.Nullable; + +/** + * Plan for a term whose top operator is matched by a {@link MatchHead} (the operator + any + * operator-specific data) and whose subterms are matched by child plans. Bound variables, if any, + * are bound around the whole node via the {@link BinderMatcher}. + * + *

+ * This is the language-agnostic counterpart of the non-schema-variable branch of the hand-written + * matchers: the interpreter emission reproduces {@code checkNodeKind(Term) + gotoNext + head + + * (skip bound variables) + subterms} (wrapped in bind/unbind), and the compiled emission checks the + * head then recurses the subterms (wrapped in bind/unbind). + */ +public final class OperatorPlan implements MatchPlan { + + private final MatchHead head; + private final List children; + private final ImmutableArray boundVars; + private final BinderMatcher binder; + + /** + * @param head the operator head (operator + operator-specific checks) + * @param children one plan per subterm, in order + * @param boundVars the term's bound variables (possibly empty) + * @param binder the binder SPI (used only if {@code boundVars} is non-empty) + */ + public OperatorPlan(MatchHead head, List children, + ImmutableArray boundVars, BinderMatcher binder) { + this.head = head; + this.children = children; + this.boundVars = boundVars; + this.binder = binder; + } + + @Override + public void emitInstructions(List out) { + final boolean bound = !boundVars.isEmpty(); + if (bound) { + out.add(binder.binder(boundVars)); + } + out.add(new CheckNodeKindInstruction(Term.class)); + out.add(GotoNextInstruction.INSTANCE); + head.emit(out); + if (bound) { + for (int i = 0, n = boundVars.size(); i < n; i++) { + out.add(GotoNextSiblingInstruction.INSTANCE); + } + } + for (MatchPlan child : children) { + child.emitInstructions(out); + } + if (bound) { + out.add(binder.unbinderInstruction()); + } + } + + @Override + public MatchProgram compile() { + final MatchProgram headCheck = head.compileHeadCheck(); + final int n = children.size(); + final MatchProgram[] childMatchers = new MatchProgram[n]; + for (int i = 0; i < n; i++) { + childMatchers[i] = children.get(i).compile(); + } + final MatchProgram core = (element, mc, services) -> { + MatchResultInfo r = headCheck.match(element, mc, services); + if (r == null) { + return null; + } + final Term term = (Term) element; + for (int i = 0; i < n; i++) { + r = childMatchers[i].match(term.sub(i), r, services); + if (r == null) { + return null; + } + } + return r; + }; + if (boundVars.isEmpty()) { + return core; + } + final MatchInstruction bind = binder.binder(boundVars); + return (element, mc, services) -> { + final @Nullable MatchResultInfo bound = bind.match(element, mc, services); + if (bound == null) { + return null; + } + final @Nullable MatchResultInfo body = core.match(element, bound, services); + return body == null ? null : binder.unbind(body); + }; + } +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/ProgramMatchHook.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/ProgramMatchHook.java new file mode 100644 index 00000000000..cc47ce845f4 --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/ProgramMatchHook.java @@ -0,0 +1,52 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; + +/** + * Language SPI for matching the program carried by a modality (the {@code \<{ ... }\>} of + * a + * symbolic-execution taclet). This is the second of the two axes on which the Java/Rust/Solidity + * front-ends differ (the first is {@link BinderMatcher}): each has its own program AST -- Java's + * {@code JavaBlock}/{@code ContextStatementBlock}, Rust's {@code RustyBlock}, Solidity's block -- + * but all are {@link org.key_project.logic.SyntaxElement}s navigated through {@code getChild}. + * + *

+ * A hook is built per modality pattern (it captures that pattern's program) and exposes the program + * matcher for both back-ends. On the interpreter side it is a single {@link VMInstruction} run with + * the cursor positioned at the program block; on the compiled side it is a {@link MatchProgram} + * applied directly to the source program block. Both consume exactly one element (the block), so + * the + * enclosing modality head can advance to the post-condition subterm afterwards. + * + *

+ * The framework owns the surrounding modality skeleton (check the modality, match its kind, then + * the + * program, then recurse the subterms); a language plugs in only the divergent program matching + * here. + * Java additionally has the rich {@code ContextStatementBlock} prefix/suffix machinery, which is + * entirely encapsulated behind this hook; Rust and Solidity supply their own simpler block + * matchers. + */ +public interface ProgramMatchHook { + + /** + * The interpreter instruction matching the modality's program. On entry the cursor points at + * the + * source program block; it consumes that block. + * + * @return the program-matching instruction + */ + VMInstruction programInstruction(); + + /** + * The compiled matcher for the modality's program, applied directly to the source program + * block. + * + * @return the compiled program matcher + */ + MatchProgram compileProgram(); +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/SchemaVarPlan.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/SchemaVarPlan.java new file mode 100644 index 00000000000..ce61c1c12dc --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/SchemaVarPlan.java @@ -0,0 +1,75 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.prover.rules.matcher.compiler; + +import java.util.List; + +import org.key_project.logic.op.QuantifiableVariable; +import org.key_project.prover.rules.instantiation.MatchResultInfo; +import org.key_project.prover.rules.matcher.vm.MatchProgram; +import org.key_project.prover.rules.matcher.vm.instruction.GotoNextSiblingInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.MatchInstruction; +import org.key_project.prover.rules.matcher.vm.instruction.VMInstruction; +import org.key_project.util.collection.ImmutableArray; + +import org.jspecify.annotations.Nullable; + +/** + * Plan for a (sub)pattern that is a schema variable: it matches the whole element via the provided + * schema-variable {@link MatchInstruction} (which the front-end supplies, since schema-variable + * kinds are language-specific). Bound variables, if any, are bound around it via the + * {@link BinderMatcher}. + * + *

+ * Language-agnostic counterpart of the schema-variable branch of the hand-written matchers: the + * interpreter emission is {@code matchSV + gotoNextSibling} (wrapped in bind/unbind), the compiled + * emission applies the schema-variable instruction directly (wrapped in bind/unbind). + */ +public final class SchemaVarPlan implements MatchPlan { + + private final MatchInstruction schemaVarInstruction; + private final ImmutableArray boundVars; + private final BinderMatcher binder; + + public SchemaVarPlan(MatchInstruction schemaVarInstruction, + ImmutableArray boundVars, BinderMatcher binder) { + this.schemaVarInstruction = schemaVarInstruction; + this.boundVars = boundVars; + this.binder = binder; + } + + @Override + public void emitInstructions(List out) { + final boolean bound = !boundVars.isEmpty(); + if (bound) { + out.add(binder.binder(boundVars)); + } + out.add(schemaVarInstruction); + out.add(GotoNextSiblingInstruction.INSTANCE); + if (bound) { + for (int i = 0, n = boundVars.size(); i < n; i++) { + out.add(GotoNextSiblingInstruction.INSTANCE); + } + out.add(binder.unbinderInstruction()); + } + } + + @Override + public MatchProgram compile() { + final MatchInstruction sv = schemaVarInstruction; + final MatchProgram core = sv::match; + if (boundVars.isEmpty()) { + return core; + } + final MatchInstruction bind = binder.binder(boundVars); + return (element, mc, services) -> { + final @Nullable MatchResultInfo bound = bind.match(element, mc, services); + if (bound == null) { + return null; + } + final @Nullable MatchResultInfo body = core.match(element, bound, services); + return body == null ? null : binder.unbind(body); + }; + } +} diff --git a/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/package-info.java b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/package-info.java new file mode 100644 index 00000000000..7c0238be3aa --- /dev/null +++ b/key.ncore.compiler/src/main/java/org/key_project/prover/rules/matcher/compiler/package-info.java @@ -0,0 +1,25 @@ +/* + * This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only + */ + +/** + * A language-agnostic framework for taclet find-matching that produces, from a single description + * of + * a pattern, both an interpreted matcher (a sequence of + * {@link org.key_project.prover.rules.matcher.vm.instruction.VMInstruction}s run by + * {@link org.key_project.prover.rules.matcher.vm.VMProgramInterpreter}) and a cursor-free compiled + * matcher. + * + *

+ * It works purely over {@link org.key_project.logic.SyntaxElement} / + * {@link org.key_project.logic.Term} + * and the calculus matcher abstractions ({@code MatchResultInfo}, {@code VMInstruction}, + * {@code MatchProgram}); it depends only on {@code key.ncore}, {@code key.ncore.calculus} and + * {@code key.util}, never on the Java-DL specific {@code key.core}. Language-specific behaviour + * (the concrete syntax constructs, the program/AST sub-matching and the binding of bound variables) + * is supplied through small SPIs so that the Java, Rust and Solidity front-ends can share this core + * and only add their own constructs. + */ +package org.key_project.prover.rules.matcher.compiler; diff --git a/key.ncore/src/main/java/org/key_project/logic/PoolSyntaxElementCursor.java b/key.ncore/src/main/java/org/key_project/logic/PoolSyntaxElementCursor.java index 2a1e60c8955..ac1b12d1031 100644 --- a/key.ncore/src/main/java/org/key_project/logic/PoolSyntaxElementCursor.java +++ b/key.ncore/src/main/java/org/key_project/logic/PoolSyntaxElementCursor.java @@ -23,17 +23,28 @@ public class PoolSyntaxElementCursor { private static final int INITIAL_STACK_SIZE = 64; /** - * A pool of {@link PoolSyntaxElementCursor} as these are created very often and short-living we - * reuse them as far as possible - *
- * The used PoolSyntaxElementCursor have to be explicitly released by the user via - * {@link #release()} + * A pool of {@link PoolSyntaxElementCursor}: as these are created very often and are + * short-living, we reuse them as far as possible. The used cursors have to be explicitly + * released by the user via {@link #release()}. + * + *

+ * The pool is per thread. A cursor is acquired and released within a single matching + * step on one thread, so a thread-local free list gives the same reuse without any + * synchronization. A single shared, lock-guarded pool was a severe contention point under the + * multi-core prover: every worker's innermost matching loop serialized on it, which at high + * worker counts degraded into an effective livelock. The list fills lazily (capped at + * {@link #POOL_SIZE} per thread) and is discarded when the thread ends. */ - private static final ArrayDeque CURSOR_POOL = new ArrayDeque<>(); - static { - for (int i = 0; i < POOL_SIZE; i++) { - CURSOR_POOL.push(new PoolSyntaxElementCursor()); - } + // The nullness checker rejects the ThreadLocal type argument here (a known limitation of its + // ThreadLocal model); the per-thread pool is always non-null via withInitial. + @SuppressWarnings("nullness") + private static final ThreadLocal> CURSOR_POOL = + ThreadLocal.withInitial(ArrayDeque::new); + + /** The calling thread's pool, lazily created (never {@code null}: see {@link #CURSOR_POOL}). */ + @SuppressWarnings("nullness") // get() is non-null because withInitial always supplies a deque + private static ArrayDeque pool() { + return CURSOR_POOL.get(); } /** @@ -45,15 +56,9 @@ public class PoolSyntaxElementCursor { * currently empty */ public static PoolSyntaxElementCursor get(SyntaxElement se) { - PoolSyntaxElementCursor c = null; - synchronized (CURSOR_POOL) { - if (!CURSOR_POOL.isEmpty()) { - c = CURSOR_POOL.pop(); - } - } - if (c == null) { - c = new PoolSyntaxElementCursor(); - } + final ArrayDeque pool = pool(); + final PoolSyntaxElementCursor c = + pool.isEmpty() ? new PoolSyntaxElementCursor() : pool.pop(); c.push(se); return c; } @@ -142,10 +147,9 @@ public void release() { elements[top] = null; top--; } - synchronized (CURSOR_POOL) { - if (CURSOR_POOL.size() < POOL_SIZE) { - CURSOR_POOL.push(this); - } + final ArrayDeque pool = pool(); + if (pool.size() < POOL_SIZE) { + pool.push(this); } } } diff --git a/key.ui/src/main/java/de/uka/ilkd/key/core/Main.java b/key.ui/src/main/java/de/uka/ilkd/key/core/Main.java index d83cf09ba7a..155ff4886f2 100644 --- a/key.ui/src/main/java/de/uka/ilkd/key/core/Main.java +++ b/key.ui/src/main/java/de/uka/ilkd/key/core/Main.java @@ -87,6 +87,18 @@ public final class Main implements Callable { defaultValue = "0") private int autoSaveSteps = 0; + /** + * Number of worker threads for the multi-core prover. A value >= 1 enables the multi-core + * prover (capped at the available processors); 0 leaves the persisted prover-mode setting + * untouched (single-core by default). + */ + @Option(names = "--threads", paramLabel = "INT", + description = "run automatic proof search on the multi-core prover with INT worker threads " + + "(>= 1, capped at the available processors). Omit for the single-core prover. " + + "The single-core-only features (proof caching, slicing, merge rule) are off under " + + "the multi-core prover, including with one thread.") + private int proverThreads = 0; + /** * Lists all features currently marked as experimental. Unless invoked with * command line option --experimental , those will be deactivated. @@ -248,6 +260,12 @@ public Integer call() throws Exception { GeneralSettings.noPruningClosed = isNoPruningClosed; GeneralSettings.keepFileRepos = isKeepFileRepos; + if (proverThreads >= 1) { + GeneralSettings gs = ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings(); + gs.setParallelProverThreadCount(proverThreads); + gs.setParallelProverEnabled(true); + } + // this property overrides the default if (Boolean.getBoolean("key.verbose-ui")) { verbosity = Verbosity.TRACE; diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/MainWindow.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/MainWindow.java index 140604cd34f..daa0b1604f8 100644 --- a/key.ui/src/main/java/de/uka/ilkd/key/gui/MainWindow.java +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/MainWindow.java @@ -1788,6 +1788,11 @@ public synchronized void autoModeStopped(ProofEvent e) { unfreezeExceptAutoModeButton(); disableCurrentGoalView = false; getMediator().addKeYSelectionListenerChecked(proofListener); + // Refresh the sequent view from the final state explicitly. The selection listener was + // detached for the duration of the run, so the view would otherwise only update if a + // selectedNodeChanged event happens to fire afterwards -- which is not guaranteed (the + // run may end with the selection unchanged), leaving the displayed sequent stale. + SwingUtilities.invokeLater(MainWindow.this::updateSequentView); } @Override diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/SingleCoreFeatureGate.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/SingleCoreFeatureGate.java new file mode 100644 index 00000000000..bf445781e25 --- /dev/null +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/SingleCoreFeatureGate.java @@ -0,0 +1,117 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.gui; + +import java.awt.Component; +import java.awt.Container; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.Action; +import javax.swing.JComponent; + +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; + +/** + * Single source of truth for greying out the single-core-only GUI contributions while the + * multi-core prover is active (proof caching, proof slicing, ...). + * + *

+ * Instead of chasing every individual widget an extension adds, a feature registers the + * top-level + * component it contributes (a toolbar, a tab panel, ...) once via + * {@link #registerAutoDisabled(JComponent, String)}. This gate then enables/disables that component + * and all of its children in lock-step with the + * {@link GeneralSettings#PARALLEL_PROVER_ENABLED} setting, so any widget added inside a registered + * container is covered automatically. + * + *

+ * Components that manage their own enabled state (e.g. buttons whose availability depends on the + * proof) cannot simply be registered; they consult {@link #isActive()} in their own update logic + * and + * may use {@link #setEnabledRecursively(Component, boolean)} as the shared disabling primitive. + * + * @author Claude (KeY multithreading effort) + */ +public final class SingleCoreFeatureGate { + + /** Standard tooltip for a control disabled because the multi-core prover is active. */ + public static final String DISABLED_TOOLTIP = + "Unavailable while the multi-core prover is active. " + + "Switch to the single-core prover to use it."; + + /** Top-level components that are disabled wholesale while the multi-core prover is active. */ + private static final List REGISTERED = new CopyOnWriteArrayList<>(); + + /** + * Actions disabled while the multi-core prover is active. Disabling the action greys every + * toolbar button and menu item bound to it, so a single registration covers all of its UI. + */ + private static final List REGISTERED_ACTIONS = new CopyOnWriteArrayList<>(); + + private record AutoDisabled(JComponent component, String enabledTooltip) { + } + + static { + ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings().addPropertyChangeListener( + GeneralSettings.PARALLEL_PROVER_ENABLED, evt -> refreshAll()); + } + + private SingleCoreFeatureGate() {} + + /** + * @return whether the multi-core prover is active, i.e. the single-core-only features are + * currently unavailable + */ + public static boolean isActive() { + return ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings() + .isParallelProverEnabled(); + } + + /** + * Registers a single-core-only top-level component. It and all of its children are disabled + * while the multi-core prover is active and restored otherwise. + * + * @param component the top-level component contributed by the feature (a toolbar, tab, ...) + * @param enabledTooltip the tooltip to restore when single-core (may be {@code null}) + */ + public static void registerAutoDisabled(JComponent component, String enabledTooltip) { + AutoDisabled entry = new AutoDisabled(component, enabledTooltip); + REGISTERED.add(entry); + apply(entry); + } + + /** + * Registers a single-core-only {@link Action}; it is disabled (greying every toolbar button and + * menu item bound to it) while the multi-core prover is active and re-enabled otherwise. + * + * @param action the action contributed by a single-core-only feature + */ + public static void registerAutoDisabled(Action action) { + REGISTERED_ACTIONS.add(action); + action.setEnabled(!isActive()); + } + + /** Recursively sets the enabled state of {@code component} and all of its descendants. */ + public static void setEnabledRecursively(Component component, boolean enabled) { + component.setEnabled(enabled); + if (component instanceof Container container) { + for (Component child : container.getComponents()) { + setEnabledRecursively(child, enabled); + } + } + } + + private static void refreshAll() { + REGISTERED.forEach(SingleCoreFeatureGate::apply); + boolean enabled = !isActive(); + REGISTERED_ACTIONS.forEach(a -> a.setEnabled(enabled)); + } + + private static void apply(AutoDisabled entry) { + boolean active = isActive(); + setEnabledRecursively(entry.component(), !active); + entry.component().setToolTipText(active ? DISABLED_TOOLTIP : entry.enabledTooltip()); + } +} diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java index 37141d6090e..b6be1de5d85 100644 --- a/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java @@ -20,6 +20,8 @@ import de.uka.ilkd.key.proof.Proof; import de.uka.ilkd.key.proof.init.JavaProfile; import de.uka.ilkd.key.proof.init.Profile; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; import de.uka.ilkd.key.settings.ProofSettings; import de.uka.ilkd.key.strategy.JavaCardDLStrategy; import de.uka.ilkd.key.strategy.Strategy; @@ -105,6 +107,10 @@ public void selectedProofChanged(KeYSelectionEvent e) { public StrategySelectionView() { layoutPane(); + // Keep the merge-point option in sync with the prover mode (merge is single-core only). + ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings().addPropertyChangeListener( + GeneralSettings.PARALLEL_PROVER_ENABLED, + evt -> refresh(mediator == null ? null : mediator.getSelectedProof())); refresh(mediator == null ? null : mediator.getSelectedProof()); setVisible(true); addComponentListener(new java.awt.event.ComponentAdapter() { @@ -482,11 +488,40 @@ public void refresh(Proof proof) { } } enableAll(true); + reflectParallelProverMergeLock(); refreshDefaultButton(); } } + /** + * Reflects in the strategy view that the merge rule is unavailable while the multi-core prover + * is active: the merge-point option group is forced to skip and greyed with an + * explanatory tooltip. This is view-only -- the stored strategy property is left untouched, so + * the user's real choice reappears when they switch back to the single-core prover. (The merge + * rule is additionally disabled at the engine level during parallel runs.) + */ + private void reflectParallelProverMergeLock() { + boolean mt = SingleCoreFeatureGate.isActive(); + List mergeButtons = + components.getPropertyButtons().get(StrategyProperties.MPS_OPTIONS_KEY); + if (mergeButtons == null) { + return; + } + for (JRadioButton button : mergeButtons) { + if (mt) { + button.setSelected( + StrategyProperties.MPS_SKIP.equals(button.getActionCommand())); + button.setEnabled(false); + button.setToolTipText("The merge rule is disabled while the multi-core prover is " + + "active (forced to 'skip'). Switch to the single-core prover to use it."); + } else { + button.setEnabled(true); + button.setToolTipText(null); + } + } + } + private void refreshDefaultButton() { if (mediator.getSelectedProof() != null) { components.getDefaultButton().setEnabled(predefChanged); diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/extension/impl/ParallelProverStatusIndicator.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/extension/impl/ParallelProverStatusIndicator.java new file mode 100644 index 00000000000..dcd3b8b1c50 --- /dev/null +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/extension/impl/ParallelProverStatusIndicator.java @@ -0,0 +1,147 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.gui.extension.impl; + +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; +import javax.swing.*; + +import de.uka.ilkd.key.core.KeYMediator; +import de.uka.ilkd.key.gui.MainWindow; +import de.uka.ilkd.key.gui.extension.api.KeYGuiExtension; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; + +/** + * Status-line indicator showing the active prover mode: {@code SC} for the single-core (legacy) + * prover, or {@code MT N×} for the multi-core prover with {@code N} worker threads. Left-clicking + * it + * switches between the two modes; right-clicking offers a menu to pick the worker count. + * + *

+ * It reads and writes the same {@link GeneralSettings#PARALLEL_PROVER_ENABLED} setting as the + * preference pane and reacts to its change events, so the whole UI stays consistent however the + * mode is changed. + * + * @author Claude (KeY multithreading effort) + */ +@KeYGuiExtension.Info(experimental = false, name = "Prover Mode in Status Line", optional = false, + description = "Shows and toggles the single-core / multi-core prover mode in the status line.") +public class ParallelProverStatusIndicator + implements KeYGuiExtension, KeYGuiExtension.StatusLine, KeYGuiExtension.Startup { + + private final JButton button = new JButton(); + + @Override + public void init(MainWindow window, KeYMediator mediator) { + button.setFocusable(false); + button.addActionListener(e -> toggleMode()); + button.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + maybeShowMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowMenu(e); + } + }); + generalSettings().addPropertyChangeListener(GeneralSettings.PARALLEL_PROVER_ENABLED, + evt -> refresh()); + generalSettings().addPropertyChangeListener(GeneralSettings.PARALLEL_PROVER_THREADS, + evt -> refresh()); + fixSize(); + refresh(); + } + + private static GeneralSettings generalSettings() { + return ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings(); + } + + private static int cores() { + return Math.max(1, Runtime.getRuntime().availableProcessors()); + } + + /** + * Keep the button a constant size regardless of whether {@code SC} or {@code MT N×} is shown, + * by + * sizing it for the widest possible label. + */ + private void fixSize() { + FontMetrics fm = button.getFontMetrics(button.getFont()); + Insets in = button.getInsets(); + int w = fm.stringWidth("MT " + cores() + "x") + in.left + in.right + 12; + int h = fm.getHeight() + in.top + in.bottom + 4; + Dimension d = new Dimension(w, h); + button.setPreferredSize(d); + button.setMinimumSize(d); + button.setMaximumSize(d); + } + + private void toggleMode() { + generalSettings().setParallelProverEnabled(!generalSettings().isParallelProverEnabled()); + } + + private void maybeShowMenu(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + GeneralSettings gs = generalSettings(); + JPopupMenu menu = new JPopupMenu(); + + JRadioButtonMenuItem single = new JRadioButtonMenuItem("Single-core"); + single.setSelected(!gs.isParallelProverEnabled()); + single.addActionListener(a -> gs.setParallelProverEnabled(false)); + menu.add(single); + menu.addSeparator(); + + // Offer a small, sensible set of worker counts capped at the available processors. + TreeSet counts = new TreeSet<>(List.of(2, 4, 8, cores())); + for (Integer n : new ArrayList<>(counts)) { + if (n < 2 || n > cores()) { + continue; + } + JRadioButtonMenuItem item = new JRadioButtonMenuItem("Multi-core, " + n + " workers"); + item.setSelected(gs.isParallelProverEnabled() + && effectiveThreads(gs) == n); + item.addActionListener(a -> { + gs.setParallelProverThreadCount(n); + gs.setParallelProverEnabled(true); + }); + menu.add(item); + } + menu.show(button, e.getX(), e.getY()); + } + + private static int effectiveThreads(GeneralSettings gs) { + return Math.max(1, Math.min(gs.getParallelProverThreadCount(), cores())); + } + + private void refresh() { + GeneralSettings gs = generalSettings(); + if (gs.isParallelProverEnabled()) { + int threads = effectiveThreads(gs); + button.setText("MT " + threads + "x"); + button.setToolTipText("Multi-core prover active (" + threads + + " workers). Left-click for single-core; right-click to choose the worker count."); + } else { + button.setText("SC"); + button.setToolTipText("Single-core prover active. Left-click for multi-core; " + + "right-click to choose the worker count."); + } + } + + @Override + public List getStatusLineComponents() { + // A leading strut adds a bit of space between this indicator and its neighbours. + return List.of((JComponent) Box.createHorizontalStrut(8), button); + } +} diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/ParallelProverSettingsProvider.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/ParallelProverSettingsProvider.java new file mode 100644 index 00000000000..6538dd57eb3 --- /dev/null +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/ParallelProverSettingsProvider.java @@ -0,0 +1,98 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package de.uka.ilkd.key.gui.settings; + +import javax.swing.*; + +import de.uka.ilkd.key.gui.MainWindow; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; + +/** + * Settings pane to choose between the legacy single-threaded prover and the multi-core (parallel) + * prover, and to configure the worker-thread count of the latter. + * + *

+ * The multi-core prover trades a set of single-core-only features (proof caching, slicing, the + * merge + * rule) for parallel proof search; that trade-off is explained inline and enforced elsewhere by a + * listener on {@link GeneralSettings#PARALLEL_PROVER_ENABLED}. + * + * @author Claude (KeY multithreading effort) + */ +public class ParallelProverSettingsProvider extends SettingsPanel implements SettingsProvider { + + private static final String DESCRIPTION = "Prover (Single / Multi-Core)"; + + private static final String INFO_ENABLED = + """ + Run automatic proof search on several worker threads (one per open goal) instead of the \ + legacy single-threaded prover. + + While the multi-core prover is active the single-core-only features are switched off and \ + restored when you switch back: proof caching, proof slicing, and the merge rule \ + (forced to 'skip' in the strategy options). Only the standard Java profile runs in \ + parallel; the well-definedness, information-flow and symbolic-execution profiles always \ + use the single-threaded prover."""; + + private static final String INFO_THREADS = + """ + Number of worker threads for the multi-core prover. The effective count is capped at the \ + number of available processors. Proofs that split widely benefit most; narrow proofs are \ + bounded by their longest branch."""; + + private final int availableProcessors = Runtime.getRuntime().availableProcessors(); + + private final JCheckBox chkEnabled; + private final JSpinner spThreads; + + public ParallelProverSettingsProvider() { + setHeaderText(getDescription()); + + addSeparator("Multi-core prover"); + chkEnabled = addCheckBox("Use the multi-core prover (parallel proof search)", INFO_ENABLED, + false, emptyValidator()); + + spThreads = createNumberTextField( + new SpinnerNumberModel(GeneralSettings.PARALLEL_PROVER_THREADS_DEFAULT, 1, + Math.max(1, availableProcessors), 1), + emptyValidator()); + // The worker count is a small number; keep the field narrow (room for three digits). + if (spThreads.getEditor() instanceof JSpinner.DefaultEditor editor) { + editor.getTextField().setColumns(3); + } + addTitledComponent("Worker threads (max " + availableProcessors + ")", spThreads, + INFO_THREADS); + + chkEnabled.addActionListener(e -> updateEnabledState()); + } + + private void updateEnabledState() { + spThreads.setEnabled(chkEnabled.isSelected()); + } + + @Override + public String getDescription() { + return DESCRIPTION; + } + + @Override + public JPanel getPanel(MainWindow window) { + GeneralSettings gs = ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings(); + chkEnabled.setSelected(gs.isParallelProverEnabled()); + int configured = Math.max(1, Math.min(gs.getParallelProverThreadCount(), + Math.max(1, availableProcessors))); + spThreads.setValue(configured); + updateEnabledState(); + return this; + } + + @Override + public void applySettings(MainWindow window) { + GeneralSettings gs = ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings(); + gs.setParallelProverThreadCount((Integer) spThreads.getValue()); + // Set the enabled flag last so the feature-gating listener sees the final thread count. + gs.setParallelProverEnabled(chkEnabled.isSelected()); + } +} diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/SettingsManager.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/SettingsManager.java index de5f4c9cc88..2548637e5fd 100644 --- a/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/SettingsManager.java +++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/settings/SettingsManager.java @@ -20,6 +20,7 @@ import de.uka.ilkd.key.gui.keyshortcuts.ShortcutSettings; import de.uka.ilkd.key.gui.smt.settings.SMTSettingsProvider; import de.uka.ilkd.key.proof.Proof; +import de.uka.ilkd.key.rule.match.vm.VMTacletMatcher; import de.uka.ilkd.key.settings.*; import org.slf4j.Logger; @@ -39,6 +40,17 @@ public class SettingsManager { public static final StandardUISettings STANDARD_UI_SETTINGS = new StandardUISettings(); public static final ColorSettingsProvider COLOR_SETTINGS = new ColorSettingsProvider(); public static final FeatureSettingsPanel FEATURE_SETTINGS_PANEL = new FeatureSettingsPanel(); + public static final ParallelProverSettingsProvider PARALLEL_PROVER_SETTINGS = + new ParallelProverSettingsProvider(); + + /** + * Registration anchor: referencing a feature flag declared in a lazily-loaded core class (here + * the interpreter-matcher fallback flag in {@link VMTacletMatcher}) forces its registration so + * it shows in the {@link FeatureSettingsPanel} on a fresh start, before any proof is loaded. + */ + @SuppressWarnings("unused") + public static final FeatureSettings.Feature INTERPRETER_MATCHER_FEATURE = + VMTacletMatcher.INTERPRETER_MATCHER_FEATURE; private static SettingsManager INSTANCE; private final List settingsProviders = new LinkedList<>(); @@ -60,6 +72,7 @@ public static SettingsManager getInstance() { INSTANCE.add(TACLET_OPTIONS_SETTINGS); // INSTANCE.add(COLOR_SETTINGS); INSTANCE.add(FEATURE_SETTINGS_PANEL); + INSTANCE.add(PARALLEL_PROVER_SETTINGS); } return INSTANCE; } diff --git a/key.ui/src/main/java/de/uka/ilkd/key/ui/MediatorProofControl.java b/key.ui/src/main/java/de/uka/ilkd/key/ui/MediatorProofControl.java index fdf93e18640..b0002af4ab3 100644 --- a/key.ui/src/main/java/de/uka/ilkd/key/ui/MediatorProofControl.java +++ b/key.ui/src/main/java/de/uka/ilkd/key/ui/MediatorProofControl.java @@ -19,11 +19,12 @@ import de.uka.ilkd.key.gui.notification.events.GeneralInformationEvent; import de.uka.ilkd.key.macros.ProofMacro; import de.uka.ilkd.key.proof.*; -import de.uka.ilkd.key.prover.impl.ApplyStrategy; +import de.uka.ilkd.key.prover.impl.AutoProvers; import de.uka.ilkd.key.rule.Taclet; import de.uka.ilkd.key.strategy.StrategyProperties; import org.key_project.prover.engine.ProofSearchInformation; +import org.key_project.prover.engine.ProverCore; import org.key_project.prover.engine.ProverTaskListener; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.util.collection.ImmutableList; @@ -157,7 +158,7 @@ private class AutoModeWorker extends SwingWorker initialGoals; private final ImmutableList goals; - private final ApplyStrategy applyStrategy; + private final ProverCore applyStrategy; private ProofSearchInformation info; public AutoModeWorker(final Proof proof, final ImmutableList goals, @@ -165,9 +166,15 @@ public AutoModeWorker(final Proof proof, final ImmutableList goals, this.proof = proof; this.goals = goals; this.initialGoals = goals.stream().map(Goal::node).collect(Collectors.toList()); - this.applyStrategy = new ApplyStrategy( + // Route through AutoProvers so the GUI auto button (and the proof-tree / context-menu + // "start auto" actions that funnel through here) run on whichever prover the user has + // selected -- single-core or multi-core. Historically this was hardcoded to + // ApplyStrategy + // only because no alternative engine existed. + this.applyStrategy = AutoProvers.create( proof.getInitConfig().getProfile().getSelectedGoalChooserBuilder() - .create()); + .create(), + proof.getInitConfig().getProfile()); if (ptl != null) { applyStrategy.addProverTaskObserver(ptl); } diff --git a/key.ui/src/main/resources/META-INF/services/de.uka.ilkd.key.gui.extension.api.KeYGuiExtension b/key.ui/src/main/resources/META-INF/services/de.uka.ilkd.key.gui.extension.api.KeYGuiExtension index 646d2f4c102..10a31d6f353 100644 --- a/key.ui/src/main/resources/META-INF/services/de.uka.ilkd.key.gui.extension.api.KeYGuiExtension +++ b/key.ui/src/main/resources/META-INF/services/de.uka.ilkd.key.gui.extension.api.KeYGuiExtension @@ -9,4 +9,5 @@ de.uka.ilkd.key.gui.plugins.javac.JavacExtension de.uka.ilkd.key.gui.utilities.HeapStatusExt de.uka.ilkd.key.gui.JmlEnabledKeysIndicator de.uka.ilkd.key.gui.extension.impl.ProfileNameInStatusBar +de.uka.ilkd.key.gui.extension.impl.ParallelProverStatusIndicator de.uka.ilkd.key.gui.profileloading.WDLoadDialogOptionPanel \ No newline at end of file diff --git a/key.util/src/main/java/org/key_project/util/ConcurrentLruCache.java b/key.util/src/main/java/org/key_project/util/ConcurrentLruCache.java new file mode 100644 index 00000000000..c8b84eadef5 --- /dev/null +++ b/key.util/src/main/java/org/key_project/util/ConcurrentLruCache.java @@ -0,0 +1,156 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.util; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * A thread-safe {@link LRUCache} with exact least-recently-used eviction order. + * + *

+ * This is a single-lock cache: every operation (including {@link #get}, which reorders the access + * recency) is serialized on the cache instance. That makes it a behaviour-preserving, drop-in + * replacement for the {@code Collections.synchronizedMap(new LRUCache<>(n))} idiom -- it delegates + * to exactly that, only behind a named, reusable type with an atomic {@link #computeIfAbsent}. + * + *

+ * Use this flavour when the eviction order matters for correctness, i.e. when a cached value + * depends + * on when it was first computed (KeY has such caches -- e.g. the introduction-time cache, + * whose value reflects the goal history at first-cache time). For such caches an approximate or + * striped eviction policy would change proofs, so the exact, fully-serialized order is mandatory. + * For caches whose value is a pure function of the key (eviction only affects the hit rate, never + * the result), prefer {@link StripedLruCache}, which trades exact global order for far lower lock + * contention. + * + *

+ * The collection views ({@link #keySet()}, {@link #values()}, {@link #entrySet()}) inherit the + * {@code Collections.synchronizedMap} contract: iterating them must be done while synchronizing on + * this cache instance. + * + * @param the key type + * @param the value type + */ +// keyfor: this is a faithful delegate of Collections.synchronizedMap; the @KeyFor relationships the +// Map interface declares (on put/keySet/values/entrySet) cannot be expressed across the delegation. +// Null-safety is unaffected -- the nullable returns are annotated below. +@SuppressWarnings({ "keyfor", "override.return.invalid" }) +public final class ConcurrentLruCache implements Map { + + private final Map delegate; + + /** + * Creates a thread-safe exact-LRU cache holding at most {@code maxEntries} entries. + * + * @param maxEntries the maximum number of entries before the least recently used one is evicted + */ + public ConcurrentLruCache(int maxEntries) { + this.delegate = Collections.synchronizedMap(new LRUCache<>(maxEntries)); + } + + @Override + public @Nullable V get(Object key) { + return delegate.get(key); + } + + @Override + public @Nullable V put(K key, V value) { + return delegate.put(key, value); + } + + @Override + public @Nullable V computeIfAbsent(K key, + Function mappingFunction) { + return delegate.computeIfAbsent(key, mappingFunction); + } + + @Override + public @Nullable V computeIfPresent(K key, + BiFunction remappingFunction) { + return delegate.computeIfPresent(key, remappingFunction); + } + + @Override + public @Nullable V compute(K key, + BiFunction remappingFunction) { + return delegate.compute(key, remappingFunction); + } + + @Override + public @Nullable V putIfAbsent(K key, V value) { + return delegate.putIfAbsent(key, value); + } + + @Override + public @Nullable V remove(Object key) { + return delegate.remove(key); + } + + @Override + public void putAll(Map m) { + delegate.putAll(m); + } + + @Override + public void clear() { + delegate.clear(); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return delegate.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return delegate.containsValue(value); + } + + @Override + public @Nullable V getOrDefault(Object key, V defaultValue) { + return delegate.getOrDefault(key, defaultValue); + } + + @Override + public Set keySet() { + return delegate.keySet(); + } + + @Override + public Collection values() { + return delegate.values(); + } + + @Override + public Set> entrySet() { + return delegate.entrySet(); + } + + /** + * The lock that guards this cache. Synchronize on it (not on this instance) when iterating one + * of the collection views, mirroring the {@code Collections.synchronizedMap} contract. + * + * @return the monitor to hold while iterating a view + */ + public Object mutex() { + return delegate; + } +} diff --git a/key.util/src/main/java/org/key_project/util/StripedLruCache.java b/key.util/src/main/java/org/key_project/util/StripedLruCache.java new file mode 100644 index 00000000000..3dcd874930c --- /dev/null +++ b/key.util/src/main/java/org/key_project/util/StripedLruCache.java @@ -0,0 +1,136 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.util; + +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * A thread-safe, bounded LRU cache that reduces lock contention by striping: keys are + * partitioned into independently locked segments, each a small exact {@link LRUCache}. An operation + * locks only the one segment its key maps to, so concurrent workers touching different segments do + * not block one another. + * + *

+ * The trade-off is that eviction is exact only within a segment, not globally: the + * least-recently-used entry of the whole cache is not necessarily the next evicted. This is sound + * only for pure caches, i.e. caches whose value is a function of the key alone, so that + * eviction can only force a recomputation of the same value and never changes a result. Do not use + * it for caches whose value depends on when it was first computed (use {@link ConcurrentLruCache} + * for those). + * + *

+ * The total capacity is still hard-bounded (each of the {@code stripes} segments holds at most + * {@code ceil(maxEntries / stripes)} entries), so the cache cannot grow without limit. + * + * @param the key type + * @param the value type + */ +public final class StripedLruCache { + + private final LRUCache[] segments; + private final Object[] locks; + private final int mask; + + /** + * Creates a striped cache holding at most about {@code maxEntries} entries in total, split over + * {@code stripes} independently locked segments (rounded up to a power of two). + * + * @param maxEntries the approximate total capacity (across all segments) + * @param stripes the desired number of segments; rounded up to the next power of two, min 1 + */ + @SuppressWarnings("unchecked") + public StripedLruCache(int maxEntries, int stripes) { + int n = 1; + while (n < stripes) { + n <<= 1; + } + this.mask = n - 1; + this.segments = new LRUCache[n]; + this.locks = new Object[n]; + final int perSegment = Math.max(1, (maxEntries + n - 1) / n); + for (int i = 0; i < n; i++) { + segments[i] = new LRUCache<>(perSegment); + locks[i] = new Object(); + } + } + + /** + * Spreads the hash so that the low bits used for striping are well distributed (mirrors the + * spreading {@code ConcurrentHashMap} applies). + */ + private int segmentFor(Object key) { + int h = key.hashCode(); + h ^= (h >>> 16); + return h & mask; + } + + /** + * @param key the key to look up (non-null) + * @return the cached value, or {@code null} if absent + */ + public @Nullable V get(K key) { + final int i = segmentFor(key); + synchronized (locks[i]) { + return segments[i].get(key); + } + } + + /** + * @param key the key to store under (non-null) + * @param value the value to cache + */ + public void put(K key, V value) { + final int i = segmentFor(key); + synchronized (locks[i]) { + segments[i].put(key, value); + } + } + + /** + * Returns the cached value for {@code key}, computing and storing it via + * {@code mappingFunction} + * if absent. The computation runs while holding the key's segment lock; keep it short and free + * of calls back into this cache to avoid contention. + * + * @param key the key (non-null) + * @param mappingFunction computes the value when absent + * @return the cached or freshly computed value + */ + public V computeIfAbsent(K key, Function mappingFunction) { + final int i = segmentFor(key); + synchronized (locks[i]) { + @Nullable + V value = segments[i].get(key); + if (value == null) { + value = mappingFunction.apply(key); + segments[i].put(key, value); + } + return value; + } + } + + /** Removes all entries from every segment. */ + public void clear() { + for (int i = 0; i < segments.length; i++) { + synchronized (locks[i]) { + segments[i].clear(); + } + } + } + + /** + * @return the total number of entries currently held across all segments + */ + public int size() { + int total = 0; + for (int i = 0; i < segments.length; i++) { + synchronized (locks[i]) { + total += segments[i].size(); + } + } + return total; + } +} diff --git a/key.util/src/main/java/org/key_project/util/collection/Pair.java b/key.util/src/main/java/org/key_project/util/collection/Pair.java index 29a7d3f9393..bef98dfa5fd 100644 --- a/key.util/src/main/java/org/key_project/util/collection/Pair.java +++ b/key.util/src/main/java/org/key_project/util/collection/Pair.java @@ -55,7 +55,10 @@ public boolean equals(@Nullable Object o) { @Override public int hashCode() { - return Objects.hash(first, second); + // Same value as Objects.hash(first, second) but without allocating the varargs Object[] + // on every call (Pair is heavily used as a hash-map key during proof search). + int result = 31 + (first == null ? 0 : first.hashCode()); + return 31 * result + (second == null ? 0 : second.hashCode()); } /////////////////////////////////////////////////////////// diff --git a/key.util/src/main/java/org/key_project/util/collection/WeakValueInterner.java b/key.util/src/main/java/org/key_project/util/collection/WeakValueInterner.java new file mode 100644 index 00000000000..c50311fc8d0 --- /dev/null +++ b/key.util/src/main/java/org/key_project/util/collection/WeakValueInterner.java @@ -0,0 +1,59 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.util.collection; + +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Function; + +/** + * A thread-safe interner that guarantees a single canonical instance per key. + * + *

+ * KeY interns various derived objects (e.g. {@code ElementaryUpdate} per left-hand side, + * sort-depending + * functions per sort) so that equal objects are the same object and can be compared with + * {@code ==}. The classic idiom — a {@code WeakHashMap>} with a + * get-then-create-then-put — is not thread-safe: under concurrency the map itself corrupts + * and, + * worse, two threads can create two distinct instances for the same key, silently breaking the + * identity invariant. This class encapsulates that idiom correctly: + *

    + *
  • weak keys (a {@link WeakHashMap}) so entries vanish once the key is unreachable; + *
  • weak values ({@link WeakReference}) so an interned instance can still be collected; + *
  • atomic get-or-create, so exactly one canonical instance exists per key. + *
+ * + * @param the key type + * @param the interned value type + * @author Claude (KeY multithreading effort) + */ +public final class WeakValueInterner { + + private final Map> instances = + Collections.synchronizedMap(new WeakHashMap<>()); + + /** + * Returns the canonical instance for {@code key}, creating it with {@code factory} (and + * interning + * it) if none exists yet (or the previously interned one has been collected). + * + * @param key the key + * @param factory creates the value for a key on a cache miss; must not return {@code null} + * @return the canonical value for {@code key}, identical for equal keys + */ + public V intern(K key, Function factory) { + synchronized (instances) { + WeakReference ref = instances.get(key); + V value = ref != null ? ref.get() : null; + if (value == null) { + value = factory.apply(key); + instances.put(key, new WeakReference<>(value)); + } + return value; + } + } +} diff --git a/key.util/src/test/java/org/key_project/util/LruCacheConcurrencyTest.java b/key.util/src/test/java/org/key_project/util/LruCacheConcurrencyTest.java new file mode 100644 index 00000000000..1d228f88457 --- /dev/null +++ b/key.util/src/test/java/org/key_project/util/LruCacheConcurrencyTest.java @@ -0,0 +1,200 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.util; + +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Concurrency and bound tests for {@link ConcurrentLruCache} and {@link StripedLruCache}. + */ +public class LruCacheConcurrencyTest { + + private static final int THREADS = 16; + private static final int KEYS = 2000; + private static final int ITERS = 50; + + /** The exact cache must stay consistent and honour its size bound under heavy concurrency. */ + @Test + public void concurrentLruCacheStaysConsistentAndBounded() throws Exception { + final int cap = 256; + final ConcurrentLruCache cache = new ConcurrentLruCache<>(cap); + hammer(cache::get, cache::put); + assertTrue(cache.size() <= cap, "exact cache exceeded its capacity: " + cache.size()); + // every value present must equal its key's square (the only thing we ever stored) + synchronized (cache.mutex()) { + for (var e : cache.entrySet()) { + assertEquals(e.getKey() * e.getKey(), e.getValue()); + } + } + } + + /** + * The striped cache must stay consistent and honour its total bound under heavy concurrency. + */ + @Test + public void stripedLruCacheStaysConsistentAndBounded() throws Exception { + final int cap = 256; + final StripedLruCache cache = new StripedLruCache<>(cap, 8); + hammer(cache::get, cache::put); + // per-segment rounding lets the total slightly exceed cap; it must stay hard-bounded though + assertTrue(cache.size() <= cap + 8, "striped cache grew unbounded: " + cache.size()); + } + + /** computeIfAbsent must call the factory exactly once per key even under contention. */ + @Test + public void stripedComputeIfAbsentIsAtomicPerKey() throws Exception { + final StripedLruCache cache = new StripedLruCache<>(KEYS * 2, 16); + final ConcurrentHashMap factoryCalls = new ConcurrentHashMap<>(); + final CyclicBarrier barrier = new CyclicBarrier(THREADS); + final Thread[] ts = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + ts[t] = new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + throw new RuntimeException(e); + } + for (int k = 0; k < KEYS; k++) { + final int key = k; + int v = cache.computeIfAbsent(key, kk -> { + factoryCalls.computeIfAbsent(kk, x -> new AtomicInteger()) + .incrementAndGet(); + return kk * kk; + }); + assertEquals(key * key, v); + } + }); + } + for (Thread th : ts) { + th.start(); + } + for (Thread th : ts) { + th.join(); + } + // capacity is generous (no eviction), so each key is computed exactly once despite races + for (var e : factoryCalls.entrySet()) { + assertEquals(1, e.getValue().get(), "key " + e.getKey() + " computed more than once"); + } + } + + /** + * The exact cache must evict in true least-recently-used order, and a {@code get} must count as + * a use. This is the property that makes {@link ConcurrentLruCache} (rather than the striped + * cache) mandatory for the eviction-sensitive caches, so it is worth pinning. + */ + @Test + public void concurrentLruCacheEvictsLeastRecentlyUsed() { + final ConcurrentLruCache cache = new ConcurrentLruCache<>(3); + cache.put(1, 1); + cache.put(2, 2); + cache.put(3, 3); + // touch 1, so the least-recently-used entry is now 2 + assertEquals(1, Objects.requireNonNull(cache.get(1))); + // inserting a fourth entry must evict 2 (the LRU), keeping 1, 3 and 4 + cache.put(4, 4); + assertNull(cache.get(2), "least-recently-used entry was not evicted"); + assertEquals(1, Objects.requireNonNull(cache.get(1))); + assertEquals(3, Objects.requireNonNull(cache.get(3))); + assertEquals(4, Objects.requireNonNull(cache.get(4))); + assertTrue(cache.size() <= 3, "exact cache exceeded its capacity"); + } + + /** + * computeIfAbsent on the exact cache must call the factory exactly once per key under races. + */ + @Test + public void concurrentLruCacheComputeIfAbsentIsAtomicPerKey() throws Exception { + final ConcurrentLruCache cache = new ConcurrentLruCache<>(KEYS * 2); + final ConcurrentHashMap factoryCalls = new ConcurrentHashMap<>(); + final CyclicBarrier barrier = new CyclicBarrier(THREADS); + final Thread[] ts = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + ts[t] = new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + throw new RuntimeException(e); + } + for (int k = 0; k < KEYS; k++) { + int v = Objects.requireNonNull(cache.computeIfAbsent(k, kk -> { + factoryCalls.computeIfAbsent(kk, x -> new AtomicInteger()) + .incrementAndGet(); + return kk * kk; + })); + assertEquals(k * k, v); + } + }); + } + for (Thread th : ts) { + th.start(); + } + for (Thread th : ts) { + th.join(); + } + for (var e : factoryCalls.entrySet()) { + assertEquals(1, e.getValue().get(), "key " + e.getKey() + " computed more than once"); + } + } + + /** Sanity: a miss returns null, a stored entry is retrievable (single-threaded). */ + @Test + public void basicGetPut() { + ConcurrentLruCache exact = new ConcurrentLruCache<>(4); + StripedLruCache striped = new StripedLruCache<>(4, 4); + assertNull(exact.get("x")); + assertNull(striped.get("x")); + exact.put("x", "1"); + striped.put("x", "1"); + assertEquals("1", exact.get("x")); + assertEquals("1", striped.get("x")); + } + + private interface Getter { + @Nullable + Integer get(Integer key); + } + + private interface Putter { + void put(Integer key, Integer value); + } + + private static void hammer(Getter get, Putter put) throws Exception { + final CyclicBarrier barrier = new CyclicBarrier(THREADS); + final Thread[] ts = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + ts[t] = new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + throw new RuntimeException(e); + } + for (int it = 0; it < ITERS; it++) { + for (int k = 0; k < KEYS; k++) { + put.put(k, k * k); + Integer v = get.get(k); + if (v != null) { + assertEquals(k * k, v.intValue()); + } + } + } + }); + } + for (Thread th : ts) { + th.start(); + } + for (Thread th : ts) { + th.join(); + } + } +} diff --git a/key.util/src/test/java/org/key_project/util/collection/WeakValueInternerTest.java b/key.util/src/test/java/org/key_project/util/collection/WeakValueInternerTest.java new file mode 100644 index 00000000000..70c66376c6c --- /dev/null +++ b/key.util/src/test/java/org/key_project/util/collection/WeakValueInternerTest.java @@ -0,0 +1,74 @@ +/* This file is part of KeY - https://key-project.org + * KeY is licensed under the GNU General Public License Version 2 + * SPDX-License-Identifier: GPL-2.0-only */ +package org.key_project.util.collection; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link WeakValueInterner}: it must return the same canonical instance for equal keys, even + * under concurrent access (the property that makes interned objects safe to compare with + * {@code ==}). + */ +public class WeakValueInternerTest { + + @Test + void sameKeyYieldsSameInstance() { + WeakValueInterner interner = new WeakValueInterner<>(); + Object a = interner.intern("k", k -> new Object()); + Object b = interner.intern("k", k -> new Object()); + assertSame(a, b, "equal keys must intern to the same instance"); + Object c = interner.intern("other", k -> new Object()); + assertTrue(a != c, "different keys must intern to different instances"); + } + + @Test + void concurrentInternNeverProducesDistinctInstancesForSameKey() throws Exception { + final int threads = 16; + final int keys = 500; + final WeakValueInterner interner = new WeakValueInterner<>(); + // For each key, collect every instance any thread received. Identity holds iff each key + // maps + // to exactly one instance across all threads. + final ConcurrentHashMap> perKey = new ConcurrentHashMap<>(); + final CopyOnWriteArrayList failures = new CopyOnWriteArrayList<>(); + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + + for (int t = 0; t < threads; t++) { + new Thread(() -> { + try { + start.await(); + for (int i = 0; i < keys; i++) { + Object v = interner.intern(i, k -> new Object()); + perKey.computeIfAbsent(i, k -> ConcurrentHashMap.newKeySet()).add(v); + } + } catch (Throwable th) { + failures.add(th); + } finally { + done.countDown(); + } + }).start(); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS), "interner threads did not finish"); + + assertTrue(failures.isEmpty(), () -> "thread(s) failed: " + failures); + assertEquals(keys, perKey.size()); + for (var entry : perKey.entrySet()) { + assertEquals(1, entry.getValue().size(), + "key " + entry.getKey() + " interned to more than one instance: " + + entry.getValue().size()); + } + } +} diff --git a/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/CachingExtension.java b/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/CachingExtension.java index fe20af2784b..41477433498 100644 --- a/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/CachingExtension.java +++ b/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/CachingExtension.java @@ -13,6 +13,7 @@ import de.uka.ilkd.key.core.KeYSelectionEvent; import de.uka.ilkd.key.core.KeYSelectionListener; import de.uka.ilkd.key.gui.MainWindow; +import de.uka.ilkd.key.gui.SingleCoreFeatureGate; import de.uka.ilkd.key.gui.extension.api.ContextMenuKind; import de.uka.ilkd.key.gui.extension.api.KeYGuiExtension; import de.uka.ilkd.key.gui.help.HelpInfo; @@ -39,6 +40,7 @@ import de.uka.ilkd.key.proof.reference.ReferenceSearcher; import de.uka.ilkd.key.proof.replay.CopyingProofReplayer; import de.uka.ilkd.key.prover.impl.ApplyStrategy; +import de.uka.ilkd.key.settings.ProofIndependentSettings; import org.key_project.prover.engine.ProverTaskListener; import org.key_project.prover.engine.TaskFinishedInfo; @@ -86,6 +88,9 @@ public class CachingExtension private void initActions(MainWindow mainWindow) { if (toggleAction == null) { toggleAction = new CachingToggleAction(mainWindow); + // Proof caching is single-core only: greying the action covers every toolbar button and + // menu item bound to it while the multi-core prover is active. + SingleCoreFeatureGate.registerAutoDisabled(toggleAction); } } @@ -115,7 +120,11 @@ public void updateGUIState(Proof proof) { } public boolean getProofCachingEnabled() { - return toggleAction.isSelected(); + // Proof caching is a single-core-only feature: the multi-core prover closes goals on worker + // threads, which does not mix with caching's reference search and goal disabling. It is + // forced off (and reported as such in the UI) while the multi-core prover is active. + return toggleAction.isSelected() && !ProofIndependentSettings.DEFAULT_INSTANCE + .getGeneralSettings().isParallelProverEnabled(); } @Override diff --git a/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/ReferenceSearchButton.java b/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/ReferenceSearchButton.java index 3503ad8f878..4649237aea6 100644 --- a/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/ReferenceSearchButton.java +++ b/keyext.caching/src/main/java/de/uka/ilkd/key/gui/plugins/caching/ReferenceSearchButton.java @@ -11,12 +11,15 @@ import de.uka.ilkd.key.core.KeYMediator; import de.uka.ilkd.key.core.KeYSelectionEvent; import de.uka.ilkd.key.core.KeYSelectionListener; +import de.uka.ilkd.key.gui.SingleCoreFeatureGate; import de.uka.ilkd.key.gui.colors.ColorSettings; import de.uka.ilkd.key.proof.Goal; import de.uka.ilkd.key.proof.Node; import de.uka.ilkd.key.proof.Proof; import de.uka.ilkd.key.proof.reference.ClosedBy; import de.uka.ilkd.key.proof.reference.ReferenceSearcher; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; /** * Status line button to indicate whether cached goals are present. @@ -47,6 +50,11 @@ public ReferenceSearchButton(KeYMediator mediator) { mediator.addKeYSelectionListener(this); addActionListener(this); setEnabled(false); + // Reflect the prover mode live: when the user switches to the multi-core prover the button + // greys out (proof caching is single-core only), and back when they return to single-core. + ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings().addPropertyChangeListener( + GeneralSettings.PARALLEL_PROVER_ENABLED, + evt -> updateState(mediator.getSelectedProof())); } @@ -82,6 +90,14 @@ public void selectedNodeChanged(KeYSelectionEvent e) { * @param p the currently selected proof */ public void updateState(Proof p) { + if (SingleCoreFeatureGate.isActive()) { + setText("Proof Caching"); + setForeground(null); + setEnabled(false); + setToolTipText(SingleCoreFeatureGate.DISABLED_TOOLTIP); + return; + } + setToolTipText(null); if (p == null) { setText("Proof Caching"); setForeground(null); diff --git a/keyext.slicing/src/main/java/org/key_project/slicing/DependencyTracker.java b/keyext.slicing/src/main/java/org/key_project/slicing/DependencyTracker.java index ce8b9f6ede6..ebbeb893647 100644 --- a/keyext.slicing/src/main/java/org/key_project/slicing/DependencyTracker.java +++ b/keyext.slicing/src/main/java/org/key_project/slicing/DependencyTracker.java @@ -160,11 +160,13 @@ private List> inputsOfNode(Node n, } } if (!added) { - // should only happen if the formula is the initial proof obligation - if (!proof.root().sequent().contains(in.sequentFormula())) { - throw new IllegalStateException( - "found formula that was not produced by any rule! " + in.sequentFormula()); - } + // Normally only the initial proof obligation reaches here. A formula that is + // neither + // produced by a tracked rule nor part of the root sequent means the tracker missed + // some rule applications -- e.g. it was suspended for the duration of a multi-core + // prover run. Degrade gracefully (treat it as an external input) instead of + // throwing, + // so slicing stays usable on a proof that was partly built without tracking. TrackedFormula formula = new TrackedFormula(in.sequentFormula(), loc, in.isInAntec(), proof.getServices()); diff --git a/keyext.slicing/src/main/java/org/key_project/slicing/SlicingExtension.java b/keyext.slicing/src/main/java/org/key_project/slicing/SlicingExtension.java index cbea0acfafd..c7f684c60b8 100644 --- a/keyext.slicing/src/main/java/org/key_project/slicing/SlicingExtension.java +++ b/keyext.slicing/src/main/java/org/key_project/slicing/SlicingExtension.java @@ -26,6 +26,7 @@ import de.uka.ilkd.key.proof.Proof; import de.uka.ilkd.key.proof.event.ProofDisposedEvent; import de.uka.ilkd.key.proof.event.ProofDisposedListener; +import de.uka.ilkd.key.settings.ProofIndependentSettings; import org.key_project.prover.sequent.PosInOccurrence; import org.key_project.slicing.graph.GraphNode; @@ -129,6 +130,15 @@ public void init(MainWindow window, KeYMediator mediator) { } private void createTrackerForProof(Proof newProof) { + // Proof slicing is a single-core-only feature. Its DependencyTracker is a per-rule listener + // that the parallel prover suspends for the duration of each run; it would therefore miss + // every rule the multi-core prover applies and end up inconsistent (walking the tree later + // throws "found formula that was not produced by any rule"). Do not attach it while the + // multi-core prover is enabled. + if (ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings() + .isParallelProverEnabled()) { + return; + } trackers.computeIfAbsent(newProof, proof -> { if (proof == null) { return null; diff --git a/keyext.slicing/src/main/java/org/key_project/slicing/ui/SlicingLeftPanel.java b/keyext.slicing/src/main/java/org/key_project/slicing/ui/SlicingLeftPanel.java index f8c408348fc..a8564c99581 100644 --- a/keyext.slicing/src/main/java/org/key_project/slicing/ui/SlicingLeftPanel.java +++ b/keyext.slicing/src/main/java/org/key_project/slicing/ui/SlicingLeftPanel.java @@ -26,6 +26,7 @@ import de.uka.ilkd.key.gui.IssueDialog; import de.uka.ilkd.key.gui.KeYFileChooser; import de.uka.ilkd.key.gui.MainWindow; +import de.uka.ilkd.key.gui.SingleCoreFeatureGate; import de.uka.ilkd.key.gui.extension.api.TabPanel; import de.uka.ilkd.key.gui.fonticons.IconFactory; import de.uka.ilkd.key.gui.help.HelpFacade; @@ -35,6 +36,8 @@ import de.uka.ilkd.key.proof.ProofTreeListener; import de.uka.ilkd.key.proof.io.ProblemLoader; import de.uka.ilkd.key.proof.io.ProblemLoaderControl; +import de.uka.ilkd.key.settings.GeneralSettings; +import de.uka.ilkd.key.settings.ProofIndependentSettings; import org.key_project.slicing.DependencyTracker; import org.key_project.slicing.SlicingExtension; @@ -194,6 +197,10 @@ public SlicingLeftPanel(KeYMediator mediator, SlicingExtension extension) { this.mediator = mediator; this.extension = extension; + // Keep the slice buttons in sync with the prover mode (slicing is single-core only). + ProofIndependentSettings.DEFAULT_INSTANCE.getGeneralSettings().addPropertyChangeListener( + GeneralSettings.PARALLEL_PROVER_ENABLED, evt -> updateUIState()); + updateGraphLabelsTimer = new Timer(100, e -> { if (updateGraphLabels) { displayGraphLabels(); @@ -571,6 +578,16 @@ public void proofPruned(ProofTreeEvent e) { } private void updateUIState() { + // Proof slicing is single-core only: its dependency tracker does not record while the + // multi-core prover is active (its listeners are suspended during parallel runs). Grey out + // the whole panel -- buttons and options -- through the single source of truth, rather than + // tracking individual widgets. + if (SingleCoreFeatureGate.isActive()) { + SingleCoreFeatureGate.setEnabledRecursively(this, false); + return; + } + // Restore everything that the multi-core disable may have greyed, then refine below. + SingleCoreFeatureGate.setEnabledRecursively(this, true); boolean noProofLoaded = currentProof == null; if (noProofLoaded) { String noProof = "No proof selected"; diff --git a/settings.gradle b/settings.gradle index 00226f6fa82..e217520e491 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,7 @@ plugins { include "key.util" include "key.ncore" include 'key.ncore.calculus' +include 'key.ncore.compiler' include "key.core" //include "key.core.rifl"