diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/automata/MethodAutomataAccessPathSubscription.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/automata/MethodAutomataAccessPathSubscription.kt index 89c4ef6e2..bbf9d55a3 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/automata/MethodAutomataAccessPathSubscription.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/automata/MethodAutomataAccessPathSubscription.kt @@ -151,8 +151,8 @@ class MethodAutomataAccessPathSubscription : CommonAPSub = FactStorage(idx) - override fun relevantStorageIndices(summaryInitialFact: AccessGraph): BitSet = - graphIndex.localizeIndexedGraphContainsAllGraph(summaryInitialFact) + override fun forEachRelevantStorageIndex(summaryInitialFact: AccessGraph, body: (Int) -> Unit) = + graphIndex.localizeIndexedGraphContainsAllGraph(summaryInitialFact).forEach(body) } } diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/cactus/MethodCactusAccessPathSubscription.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/cactus/MethodCactusAccessPathSubscription.kt index 166a094de..7fc425e3b 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/cactus/MethodCactusAccessPathSubscription.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/cactus/MethodCactusAccessPathSubscription.kt @@ -97,8 +97,9 @@ private class NDSubStorage(private val cactusManager: CactusApManager, callerEp: current?.filterStartsWith(summaryInitialFact)?.let { dst.add(it) } } } - override fun relevantStorageIndices(summaryInitialFact: AccessPathWithCycles.AccessNode?): BitSet = - BitSet().also { it.set(0, maxIdx + 1) } + override fun forEachRelevantStorageIndex(summaryInitialFact: AccessPathWithCycles.AccessNode?, body: (Int) -> Unit) { + for (idx in 0..maxIdx) body(idx) + } } private class ZeroEdgeSubBuilder(override val cactusManager: CactusApManager) : CommonZeroEdgeSubBuilder(), CactusFinalApAccess diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/common/ndf2f/DefaultNDF2FSubStorage.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/common/ndf2f/DefaultNDF2FSubStorage.kt index dfb0643ca..8edf33e1e 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/common/ndf2f/DefaultNDF2FSubStorage.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/common/ndf2f/DefaultNDF2FSubStorage.kt @@ -18,7 +18,14 @@ abstract class DefaultNDF2FSubStorage : fun collect(dst: MutableList, summaryInitialFact: IAP) } - abstract fun relevantStorageIndices(summaryInitialFact: IAP): BitSet + /** + * Feeds [body] every storage index whose stored fact may match [summaryInitialFact], ascending. + * + * This used to hand back a `BitSet`. The tree implementation's index is sparse - 87.75 % of its + * nodes hold a single item - so materialising one would have cost more than the set it + * describes, the backing `long[]` being sized by the largest index rather than by the count. + */ + abstract fun forEachRelevantStorageIndex(summaryInitialFact: IAP, body: (Int) -> Unit) override fun add( callerInitial: Set, @@ -35,7 +42,7 @@ abstract class DefaultNDF2FSubStorage : summaryInitialFact: IAP, emptyDeltaRequired: Boolean, ) { - relevantStorageIndices(summaryInitialFact).forEach { storageIdx -> + forEachRelevantStorageIndex(summaryInitialFact) { storageIdx -> val callerInitialAp = initialApStorage[storageIdx] val callerExitAp = exitApStorage[storageIdx] collectToListWithPostProcess( diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/AccessBasedStorage.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/AccessBasedStorage.kt index 68039f863..3b99ed4cf 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/AccessBasedStorage.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/AccessBasedStorage.kt @@ -4,6 +4,7 @@ import it.unimi.dsi.fastutil.ints.IntArrayList import org.opentaint.dataflow.ap.ifds.access.tree.AccessPath.AccessNode.Companion.createNodeFromAccessors import org.opentaint.dataflow.ap.ifds.access.util.AccessorIdx import org.opentaint.dataflow.ap.ifds.access.util.AccessorInterner.Companion.FINAL_ACCESSOR_IDX +import org.opentaint.dataflow.util.ConcurrentReadSafeInt2ObjectMap import org.opentaint.dataflow.util.forEachEntry import org.opentaint.dataflow.util.forEachInt import org.opentaint.dataflow.util.getOrCreateNullable @@ -12,7 +13,33 @@ import org.opentaint.dataflow.util.int2ObjectMap abstract class AccessBasedStorage>( val manager: TreeApManager ) { - private val children = int2ObjectMap() + /** + * Allocated on the first child, not on construction. + * + * A premise trie is mostly leaves: in a ThingsBoard heap dump 85.5 % of the 1,652,208 + * `IF2FFStorage` nodes had no children at all and 94.2 % had at most one, yet every node + * carried a full [ConcurrentReadSafeInt2ObjectMap] - the map plus its two 17-slot tables is + * ~256 B, and those empty maps alone were 345 MiB of a 9.7 GiB live heap. + * + * `@Volatile` is load-bearing, not decoration. The field used to be a `val`, so final-field + * semantics published the map's internals for free; a plain `var` assigned after construction + * would let a reader see a non-null map whose `key`/`value` tables are not yet visible, which + * the map's own seqlock cannot repair - it guards mutations of a published map, not the + * publication of the map itself. The volatile write here is the release that pairs with the + * reader's acquiring load, and it happens once per node. + */ + @Volatile + private var children: ConcurrentReadSafeInt2ObjectMap? = null + + /** Double-checked under the monitor, so racing writers cannot each install a table. */ + private fun childrenForWrite(): ConcurrentReadSafeInt2ObjectMap { + children?.let { return it } + + synchronized(this) { + children?.let { return it } + return int2ObjectMap().also { children = it } + } + } abstract fun createStorage(): S @@ -57,7 +84,7 @@ abstract class AccessBasedStorage>( nodes.add(this as S) if (pattern.isFinal) { - children.get(FINAL_ACCESSOR_IDX)?.let { nodes.add(it) } + children?.get(FINAL_ACCESSOR_IDX)?.let { nodes.add(it) } } pattern.forEachAccessor { accessor, accessorPattern -> @@ -70,7 +97,7 @@ abstract class AccessBasedStorage>( accessor: AccessorIdx, nodes: MutableList ) { - children.get(accessor)?.collectNodesContains(pattern, nodes) + children?.get(accessor)?.collectNodesContains(pattern, nodes) } fun allNodes(): Sequence { @@ -82,7 +109,7 @@ abstract class AccessBasedStorage>( @Suppress("UNCHECKED_CAST") storages.add(storage as S) - storage.children.forEachEntry { _, s -> + storage.children?.forEachEntry { _, s -> if (s == null) return@forEachEntry unprocessedStorages.add(s) } @@ -106,7 +133,7 @@ abstract class AccessBasedStorage>( @Suppress("UNCHECKED_CAST") body(accessors, storage as S) - storage.children.forEachEntry { accessor, s -> + storage.children?.forEachEntry { accessor, s -> if (s == null) return@forEachEntry val childrenAccessors = accessors.clone() @@ -118,6 +145,7 @@ abstract class AccessBasedStorage>( } fun removeChildren(predicate: (AccessorIdx, S) -> Boolean) { + val children = this.children ?: return val accessorsToRemove = IntArrayList() children.forEachEntry { accessor, s -> @@ -136,10 +164,10 @@ abstract class AccessBasedStorage>( } open fun getOrCreateChild(accessor: AccessorIdx): S = - children.getOrCreateNullable(accessor) { createStorage() } + childrenForWrite().getOrCreateNullable(accessor) { createStorage() } open fun findChild(accessor: AccessorIdx): S? = - children.get(accessor) + children?.get(accessor) override fun toString(): String = buildString { print(this, prefix = "") @@ -149,7 +177,7 @@ abstract class AccessBasedStorage>( fun print(builder: StringBuilder, prefix: String) { builder.appendLine("$prefix${printStorageNode()}") - children.forEachEntry { accessorIdx, s -> + children?.forEachEntry { accessorIdx, s -> if (s == null) return@forEachEntry val accessor = with(manager) { accessorIdx.accessor } builder.appendLine("$prefix$accessor ->") diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesFinalTreeApSet.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesFinalTreeApSet.kt index 116c44b4a..33acd1cac 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesFinalTreeApSet.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesFinalTreeApSet.kt @@ -8,24 +8,25 @@ import org.opentaint.dataflow.ap.ifds.access.tree.AccessTree.AccessNode as Acces class MethodEdgesFinalTreeApSet( methodInitialStatement: CommonInst, - private val maxInstIdx: Int, + @Suppress("UNUSED_PARAMETER") maxInstIdx: Int, private val languageManager: LanguageManager, override val apManager: TreeApManager, ) : CommonZ2FSet(methodInitialStatement), TreeFinalApAccess { override fun createApStorage(): ApStorage = - ZeroInitialFactEdges(maxInstIdx, languageManager, apManager) + ZeroInitialFactEdges(languageManager, apManager) private class ZeroInitialFactEdges( - maxInstIdx: Int, private val languageManager: LanguageManager, manager: TreeApManager, - ): TreeSetWithCompression(maxInstIdx, manager), ApStorage { + ): TreeSetWithCompression(COLUMNS, manager), ApStorage { override fun addEdge(statement: CommonInst, accessPath: AccessTreeNode): AccessTreeNode? { val factSetIdx = instructionStorageIdx(statement, languageManager) - val factSet = edges[factSetIdx] + val row = rowsForWrite(factSetIdx) + val offset = offsetOf(row, factSetIdx) + val factSet = row.values[offset] as AccessTreeNode? if (factSet == null) { - edges[factSetIdx] = internIfRequired(accessPath) + row.values[offset] = internIfRequired(accessPath) return accessPath } @@ -34,13 +35,21 @@ class MethodEdgesFinalTreeApSet( return null } - edges[factSetIdx] = internIfRequired(mergedFacts) - intern(factSetIdx) + val storedFacts = internIfRequired(mergedFacts) + row.values[offset] = storedFacts + intern(storedFacts) return mergedFacts } override fun collectApAtStatement(statement: CommonInst, dst: MutableList) { - dst += edges[instructionStorageIdx(statement, languageManager)] ?: return + val row = rows() ?: return + val offset = offsetOf(row, instructionStorageIdx(statement, languageManager)) + if (offset < 0) return + dst += row.values[offset] as AccessTreeNode? ?: return + } + + private companion object { + private const val COLUMNS = 1 } } } diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesInitialToFinalTreeApSet.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesInitialToFinalTreeApSet.kt index 12164dfa6..030d49d58 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesInitialToFinalTreeApSet.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodEdgesInitialToFinalTreeApSet.kt @@ -10,7 +10,7 @@ import org.opentaint.ir.api.common.cfg.CommonInst class MethodEdgesInitialToFinalTreeApSet( methodInitialStatement: CommonInst, - private val maxInstIdx: Int, + @Suppress("UNUSED_PARAMETER") maxInstIdx: Int, private val languageManager: LanguageManager, override val apManager: TreeApManager, ) : CommonF2FSet(methodInitialStatement), @@ -22,7 +22,7 @@ class MethodEdgesInitialToFinalTreeApSet( override fun mostAbstractPattern(base: AccessPathBase): AccessPath.AccessNode? = null private inner class TaintedFactAccessEdgeStorage : ApStorage { - private val sameInitialAccessEdges = IF2FFStorage(maxInstIdx, languageManager, apManager) + private val sameInitialAccessEdges = IF2FFStorage(languageManager, apManager) override fun add( statement: CommonInst, @@ -60,42 +60,49 @@ class MethodEdgesInitialToFinalTreeApSet( } private class IF2FFStorage( - val maxInstIdx: Int, private val languageManager: LanguageManager, manager: TreeApManager, ) : AccessBasedStorage(manager) { - val current = EdgeNonUniverseExclusionMergingStorage(maxInstIdx, languageManager, manager) + val current = EdgeNonUniverseExclusionMergingStorage(languageManager, manager) override fun createStorage(): IF2FFStorage = - IF2FFStorage(maxInstIdx, languageManager, manager) + IF2FFStorage(languageManager, manager) override fun printStorageNode(): String = current.toString() } + /** + * Two columns per instruction: the merged access tree and the merged exclusion set. They are + * written and cleared together - measured on a ThingsBoard dump, the two former arrays had + * byte-identical non-null distributions - so one sparse table carries both, and a reader that + * finds a row is guaranteed the pair belongs to the same instruction. + */ private class EdgeNonUniverseExclusionMergingStorage( - maxInstIdx: Int, private val languageManager: LanguageManager, manager: TreeApManager, - ): TreeSetWithCompression(maxInstIdx, manager) { - private val exclusions = arrayOfNulls(MethodAnalyzerEdges.instructionStorageSize(maxInstIdx)) + ): TreeSetWithCompression(COLUMNS, manager) { fun add( statement: CommonInst, accessWithExclusion: AccessWithExclusion ): AccessWithExclusion? { val edgeSetIdx = MethodAnalyzerEdges.instructionStorageIdx(statement, languageManager) - val currentExclusion = exclusions[edgeSetIdx] + val row = rowsForWrite(edgeSetIdx) + val offset = offsetOf(row, edgeSetIdx) + + val currentExclusion = row.values[offset + EXCLUSION] as ExclusionSet? if (currentExclusion == null) { - exclusions[edgeSetIdx] = accessWithExclusion.exclusion - edges[edgeSetIdx] = internIfRequired(accessWithExclusion.access) + // Access first: a reader takes a non-null exclusion as the sign that the row is populated. + row.values[offset + ACCESS] = internIfRequired(accessWithExclusion.access) + row.values[offset + EXCLUSION] = accessWithExclusion.exclusion return accessWithExclusion } val mergedExclusion = currentExclusion.union(accessWithExclusion.exclusion) - exclusions[edgeSetIdx] = mergedExclusion + row.values[offset + EXCLUSION] = mergedExclusion - val currentAccess = edges[edgeSetIdx]!! + val currentAccess = row.values[offset + ACCESS] as AccessTree.AccessNode val mergedAccess = currentAccess.mergeAdd(accessWithExclusion.access) if (mergedAccess === currentAccess) { if (mergedExclusion === currentExclusion) return null @@ -103,17 +110,28 @@ class MethodEdgesInitialToFinalTreeApSet( return AccessWithExclusion(mergedAccess, mergedExclusion) } - edges[edgeSetIdx] = internIfRequired(mergedAccess) - intern(edgeSetIdx) + val storedAccess = internIfRequired(mergedAccess) + row.values[offset + ACCESS] = storedAccess + intern(storedAccess) return AccessWithExclusion(mergedAccess, mergedExclusion) } fun allApAtStatement(dst: MutableList>, statement: CommonInst) { val edgeSetIdx = MethodAnalyzerEdges.instructionStorageIdx(statement, languageManager) - val currentExclusion = exclusions[edgeSetIdx] ?: return - val access = edges[edgeSetIdx] ?: return + val row = rows() ?: return + val offset = offsetOf(row, edgeSetIdx) + if (offset < 0) return + + val currentExclusion = row.values[offset + EXCLUSION] as ExclusionSet? ?: return + val access = row.values[offset + ACCESS] as AccessTree.AccessNode? ?: return dst += AccessWithExclusion(access, currentExclusion) } + + companion object { + private const val ACCESS = 0 + private const val EXCLUSION = 1 + private const val COLUMNS = 2 + } } } diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodTreeAccessPathSubscription.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodTreeAccessPathSubscription.kt index beabda52b..4af3169a4 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodTreeAccessPathSubscription.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/MethodTreeAccessPathSubscription.kt @@ -9,7 +9,6 @@ import org.opentaint.dataflow.ap.ifds.access.common.CommonZeroEdgeSubBuilder import org.opentaint.dataflow.ap.ifds.access.common.ndf2f.DefaultNDF2FSubStorage import org.opentaint.dataflow.ap.ifds.access.util.AccessorIdx import org.opentaint.dataflow.ap.ifds.access.util.AccessorInterner.Companion.FINAL_ACCESSOR_IDX -import org.opentaint.dataflow.util.PersistentBitSet.Companion.emptyPersistentBitSet import org.opentaint.dataflow.util.SoftReferenceManager import org.opentaint.dataflow.util.forEach import org.opentaint.dataflow.util.getOrCreate @@ -17,6 +16,7 @@ import org.opentaint.dataflow.util.getOrCreateIndex import org.opentaint.dataflow.util.object2IntMap import org.opentaint.ir.api.common.cfg.CommonInst import java.lang.ref.Reference +import java.util.Arrays import java.util.BitSet class MethodTreeAccessPathSubscription( @@ -102,13 +102,13 @@ private class SummaryEdgeNDFactSubStorage( return FactStorage(idx) } - override fun relevantStorageIndices(summaryInitialFact: AccessPath.AccessNode?): BitSet { + override fun forEachRelevantStorageIndex(summaryInitialFact: AccessPath.AccessNode?, body: (Int) -> Unit) { if (summaryInitialFact == null) { - return BitSet().also { it.set(0, maxIdx + 1) } + for (idx in 0..maxIdx) body(idx) + return } - return edgeIndex.findStartsWith(summaryInitialFact) - ?: emptyPersistentBitSet() + edgeIndex.forEachStartsWith(summaryInitialFact, body) } } @@ -173,14 +173,14 @@ private class SummaryEdgeFactAbstractTreeSubscriptionStorage( dst.add(storageFinalFacts[index], callerInitialAp) } } else { - val relevantIndices = edgeIndex.findStartsWith(summaryInitialFact) - relevantIndices?.forEach { storageIdx -> + edgeIndex.forEachStartsWith(summaryInitialFact) { storageIdx -> val callerExitAp = storageFinalFacts[storageIdx] val filteredExitAp = callerExitAp.filterStartsWith(summaryInitialFact) - ?: return@forEach - dst.add(filteredExitAp, storageInitialFacts[storageIdx]) + if (filteredExitAp != null) { + dst.add(filteredExitAp, storageInitialFacts[storageIdx]) + } } } } @@ -207,12 +207,13 @@ private abstract class AccessTreeIndex(private val refManager: SoftReferenceMana indexReference?.get()?.add(rootTreeNode, idx) } - fun findStartsWith(path: AccessPath.AccessNode): BitSet? { + fun forEachStartsWith(path: AccessPath.AccessNode, body: (Int) -> Unit) { if (maxIdx < INDEX_LIMIT) { - return BitSet().also { it.set(0, maxIdx + 1) } + for (idx in 0..maxIdx) body(idx) + return } - return getOrCreateIndex().findStartsWith(path) + getOrCreateIndex().forEachStartsWith(path, body) } private fun getOrCreateIndex(): AccessTreeIndexImpl { @@ -236,10 +237,33 @@ private abstract class AccessTreeIndex(private val refManager: SoftReferenceMana } } +/** + * A prefix trie over stored access trees; each node carries the set of stored items that reach it. + * + * That set used to be a `java.util.BitSet` per node, whose backing `long[]` is sized by the + * *largest* item index the node holds, not by how many it holds. Measured on a ThingsBoard heap + * dump (`-Xmx12g`, `java/security/ssrf.yaml:ssrf`, 300 s, dumped at t=251 s), across all 260,670 + * nodes: + * + * long[] length : mean 775 words (6.2 KiB); 98.4 % exceed 32 words, max 2746 + * set bits : mean 7.46; **87.75 % hold exactly one**, 95.4 % hold at most three + * + * The bitsets were 1545.7 MiB - 15.8 % of a 9.70 GiB live heap, and 97 % of every `long[]` byte in + * it. So a node keeps an ascending [IntArray] of item indices until it holds [SPARSE_LIMIT] of + * them, and only then pays for a BitSet. A sparse node with the mean 7.46 items is ~48 B against + * ~6.2 KiB. + * + * The crossover is far above [SPARSE_LIMIT]: at 512 items a sparse node is 2 KiB against a dense + * node's 6.2 KiB, because the dense cost tracks the maximum index rather than the count. The limit + * is set where it is to bound insertion, which copies, not because dense wins there. + */ private class AccessTreeIndexImpl { private class Node { private var children: Int2ObjectOpenHashMap? = null - val index = BitSet() + + /** Ascending and distinct while the node is sparse; `null` once [dense] has taken over. */ + private var sparse: IntArray? = EMPTY_INDICES + private var dense: BitSet? = null private fun getChildren(): Int2ObjectOpenHashMap = children ?: Int2ObjectOpenHashMap().also { children = it } @@ -248,6 +272,47 @@ private class AccessTreeIndexImpl { getChildren().getOrCreate(accessor, ::Node) fun findChild(accessor: AccessorIdx): Node? = children?.get(accessor) + + fun addIndex(idx: Int) { + val sparse = this.sparse + if (sparse == null) { + dense!!.set(idx) + return + } + + // Re-adding the item just added is the common case: an index is set on every node of + // every delta the item contributes. + if (sparse.isNotEmpty() && sparse[sparse.size - 1] == idx) return + + val at = Arrays.binarySearch(sparse, idx) + if (at >= 0) return + + if (sparse.size >= SPARSE_LIMIT) { + val promoted = BitSet() + for (element in sparse) promoted.set(element) + promoted.set(idx) + this.dense = promoted + this.sparse = null + return + } + + val insertAt = -at - 1 + val next = IntArray(sparse.size + 1) + System.arraycopy(sparse, 0, next, 0, insertAt) + next[insertAt] = idx + System.arraycopy(sparse, insertAt, next, insertAt + 1, sparse.size - insertAt) + this.sparse = next + } + + fun forEachIndex(body: (Int) -> Unit) { + val sparse = this.sparse + if (sparse != null) { + for (idx in sparse) body(idx) + return + } + + dense!!.forEach(body) + } } private val root = Node() @@ -257,11 +322,11 @@ private class AccessTreeIndexImpl { while (unprocessed.isNotEmpty()) { val (indexNode, treeNode) = unprocessed.removeLast() - indexNode.index.set(idx) + indexNode.addIndex(idx) if (treeNode.isFinal) { val indexChild = indexNode.getOrCreateChild(FINAL_ACCESSOR_IDX) - indexChild.index.set(idx) + indexChild.addIndex(idx) } treeNode.forEachAccessor { accessor, treeChild -> @@ -271,15 +336,22 @@ private class AccessTreeIndexImpl { } } - fun findStartsWith(path: AccessPath.AccessNode): BitSet? { + fun forEachStartsWith(path: AccessPath.AccessNode, body: (Int) -> Unit) { var currentNode = root var currentPath = path while (true) { - currentNode = currentNode.findChild(currentPath.accessor) ?: return null - currentPath = currentPath.next ?: return currentNode.index + currentNode = currentNode.findChild(currentPath.accessor) ?: return + currentPath = currentPath.next ?: return currentNode.forEachIndex(body) } } + + private companion object { + private val EMPTY_INDICES = IntArray(0) + + /** Bounds the O(n) insert, not the point where a BitSet would become cheaper. */ + private const val SPARSE_LIMIT = 512 + } } private class SummaryEdgeFactTreeSubscriptionStorage( diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeFinalFactList.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeFinalFactList.kt index b70b5a5d8..483fb26a9 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeFinalFactList.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeFinalFactList.kt @@ -21,16 +21,19 @@ class TreeFinalFactList( override fun get(idx: Int): AccessTree.AccessNode = storage[idx] override fun removeLast(): AccessTree.AccessNode = storage.removeLast() - private val interner = AccessTreeSoftInterner(apManager) + private var interner: AccessTreeSoftInterner? = null private var operationsBeforeIntern = INTERN_RATE private var maxTreeSize = 0L + private fun interner(): AccessTreeSoftInterner = + interner ?: AccessTreeSoftInterner(apManager).also { interner = it } + fun internIfRequired(node: AccessTree.AccessNode): AccessTree.AccessNode { if (node.size < SIZE_TO_FORCE_INTERN) return node - return interner.intern(node) + return interner().intern(node) } - fun intern(): Unit = interner.internImpl( + fun intern(): Unit = internImpl( apManager.cancellation, lastUpdated = storage.last(), size = storage.size, @@ -38,6 +41,7 @@ class TreeFinalFactList( updateMaxNodeSize = { maxTreeSize = it }, decOperations = { operationsBeforeIntern-- }, resetOperation = { operationsBeforeIntern = INTERN_RATE }, + getInterner = { interner() }, getNode = { storage[it] }, setNode = { i, n -> storage[i] = n } ) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeSetWithCompression.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeSetWithCompression.kt index 6f4ca90ee..3a09a7a35 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeSetWithCompression.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeSetWithCompression.kt @@ -1,35 +1,134 @@ package org.opentaint.dataflow.ap.ifds.access.tree -import org.opentaint.dataflow.ap.ifds.MethodAnalyzerEdges.Companion.instructionStorageSize import org.opentaint.dataflow.util.Cancellation import org.opentaint.dataflow.ap.ifds.access.tree.AccessTree.AccessNode as AccessTreeNode -open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { - val edges = arrayOfNulls(instructionStorageSize(maxInstIdx)) +/** + * Per-instruction storage for the facts of one premise, held sparsely. + * + * This used to be one `arrayOfNulls(maxInstIdx + 1)` per column, allocated at full + * instruction-count length whether or not the premise reached those instructions. Measured on a + * ThingsBoard heap dump (`-Xmx12g`, `java/security/ssrf.yaml:ssrf`, 300 s, dumped at t=251 s): + * across the 1,652,208 `EdgeNonUniverseExclusionMergingStorage` instances the arrays average + * 29.65 slots of which **2.64 are non-null** - 8.9 % occupancy. 84.9 % of the storages use two + * slots or fewer; the widest uses 377. The two dense arrays were 434.0 MiB of a 9.70 GiB live + * heap. + * + * So instructions are kept as an ascending key array beside a value array of [columns] slots per + * key. The two are wrapped in one immutable [Row] published through a single `@Volatile` field, + * which makes a growing table strictly safer than the pair of plain arrays it replaces: a reader + * takes one acquiring load and can never pair a resized key array with a stale value array. + * In-place writes to an existing row keep exactly the visibility the plain arrays had. + */ +open class TreeSetWithCompression( + private val columns: Int, + val manager: TreeApManager, +) { + /** + * [keys] is ascending and distinct; [values] holds [columns] consecutive slots per key, so the + * slots for `keys[i]` start at `i * columns`. Neither array is ever mutated in length or key + * order once published - only the value slots of an existing row are overwritten. + */ + protected class Row(@JvmField val keys: IntArray, @JvmField val values: Array) - private val interner = AccessTreeSoftInterner(manager) + @Volatile + private var rows: Row? = null + + /** The current table, or `null` while no instruction has been written. */ + protected fun rows(): Row? = rows + + /** Offset into [Row.values] of the slots for [instIdx], or `-1` if it has no row. */ + protected fun offsetOf(row: Row, instIdx: Int): Int { + val pos = findKey(row.keys, instIdx) + return if (pos < 0) -1 else pos * columns + } + + /** + * Returns a table in which [instIdx] has a row, installing one if needed. Callers are the + * storage's own writer, so the returned table is the current one until they publish another. + */ + protected fun rowsForWrite(instIdx: Int): Row { + val current = rows + if (current != null && findKey(current.keys, instIdx) >= 0) return current + + val oldKeys = current?.keys ?: EMPTY_KEYS + val oldValues = current?.values ?: EMPTY_VALUES + val size = oldKeys.size + val at = -findKey(oldKeys, instIdx) - 1 + + val keys = IntArray(size + 1) + System.arraycopy(oldKeys, 0, keys, 0, at) + keys[at] = instIdx + System.arraycopy(oldKeys, at, keys, at + 1, size - at) + + val values = arrayOfNulls((size + 1) * columns) + System.arraycopy(oldValues, 0, values, 0, at * columns) + System.arraycopy(oldValues, at * columns, values, (at + 1) * columns, (size - at) * columns) + + return Row(keys, values).also { rows = it } + } + + private var interner: AccessTreeSoftInterner? = null private var operationsBeforeIntern = INTERN_RATE private var maxTreeSize = 0L + /** + * Allocated on the first intern that actually reaches the interner, not on construction: only + * 16.3 % of these storages ever pass the gates in [internImpl]. + */ + private fun interner(): AccessTreeSoftInterner = + interner ?: AccessTreeSoftInterner(manager).also { interner = it } + fun internIfRequired(node: AccessTreeNode): AccessTreeNode { if (node.size < SIZE_TO_FORCE_INTERN) return node - return interner.intern(node) + return interner().intern(node) } - fun intern(idx: Int): Unit = interner.internImpl( - manager.cancellation, - lastUpdated = edges[idx], - size = edges.size, - maxNodeSize = maxTreeSize, - updateMaxNodeSize = { maxTreeSize = it }, - decOperations = { operationsBeforeIntern-- }, - resetOperation = { operationsBeforeIntern = INTERN_RATE }, - getNode = { edges[it] }, - setNode = { i, n -> edges[i] = n } - ) + /** [lastUpdated] is the node just stored in column 0, i.e. what `edges[idx]` used to be read back as. */ + fun intern(lastUpdated: AccessTreeNode?) { + val row = rows ?: return + + internImpl( + manager.cancellation, + lastUpdated = lastUpdated, + size = row.keys.size, + maxNodeSize = maxTreeSize, + updateMaxNodeSize = { maxTreeSize = it }, + decOperations = { operationsBeforeIntern-- }, + resetOperation = { operationsBeforeIntern = INTERN_RATE }, + getInterner = { interner() }, + getNode = { row.values[it * columns] as AccessTreeNode? }, + setNode = { i, n -> row.values[i * columns] = n } + ) + } companion object { - inline fun AccessTreeSoftInterner.internImpl( + private val EMPTY_KEYS = IntArray(0) + private val EMPTY_VALUES = arrayOfNulls(0) + + /** + * Rows are few - 93.8 % of the storages hold at most eight - so a scan beats the binary + * search's branches up to [LINEAR_SCAN_LIMIT]. Returns the position of [key], or + * `-(insertionPoint) - 1`, exactly like `Arrays.binarySearch`. + */ + private fun findKey(keys: IntArray, key: Int): Int { + if (keys.size > LINEAR_SCAN_LIMIT) return keys.binarySearch(key) + + for (i in keys.indices) { + val k = keys[i] + if (k == key) return i + if (k > key) return -(i + 1) + } + return -(keys.size + 1) + } + + private fun IntArray.binarySearch(key: Int): Int = java.util.Arrays.binarySearch(this, key) + + /** + * `getInterner` is called only after every gate has passed, so a caller may allocate its + * interner there rather than up front. + */ + inline fun internImpl( cancellation: Cancellation, lastUpdated: AccessTreeNode?, size: Int, @@ -37,6 +136,7 @@ open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { updateMaxNodeSize: (Long) -> Unit, decOperations: () -> Int, resetOperation: () -> Unit, + getInterner: () -> AccessTreeSoftInterner, crossinline getNode: (Int) -> AccessTreeNode?, crossinline setNode: (Int, AccessTreeNode) -> Unit, ) { @@ -46,7 +146,7 @@ open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { if (maxNodeSize < MIN_SIZE_TO_INTERN) return resetOperation() - withInterner { interner, cache -> + getInterner().withInterner { interner, cache -> for (i in 0 until size) { val node = getNode(i) ?: continue setNode(i, node.internNodes(interner, cache)) @@ -57,5 +157,6 @@ open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { const val MIN_SIZE_TO_INTERN = 100 const val SIZE_TO_FORCE_INTERN = 100_000 private const val INTERN_RATE = 100 + private const val LINEAR_SCAN_LIMIT = 8 } }