From 976084e13c0c191688d9f3032bc933dbf6b73d22 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:24:47 +0000 Subject: [PATCH 1/4] perf(dataflow): allocate the access-tree interner on first use, not per storage TreeSetWithCompression built an AccessTreeSoftInterner in its constructor, and there is one of those storages per premise trie node. A ThingsBoard heap dump (-Xmx12g, ssrf, 300 s, dumped at t=251 s) holds 2,272,578 AccessTreeSoftInterner instances, of which only 369,423 - 16.3 % - have ever created the AccessTreeInterner behind their soft reference. The other 83.7 % are 32 bytes of nothing each, ~58 MiB. They are empty because internImpl gates twice before it touches the interner: one attempt in INTERN_RATE, and only once some tree in the storage has reached MIN_SIZE_TO_INTERN. internImpl now takes the interner through a `getInterner` lambda evaluated after both gates, so the holder can allocate there instead of up front. The function is inline, so the lambda costs nothing. Representation only: the same trees are interned, by the same interner, at the same moments. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 4799efe77a5ec4dda06c937545d8f284b8579bc5) (cherry picked from commit e24a6364162fd0085ebf3671526c3f501cbe8666) --- .../ap/ifds/access/tree/TreeFinalFactList.kt | 10 ++++-- .../access/tree/TreeSetWithCompression.kt | 31 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) 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..0a03658c3 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 @@ -7,16 +7,31 @@ import org.opentaint.dataflow.ap.ifds.access.tree.AccessTree.AccessNode as Acces open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { val edges = arrayOfNulls(instructionStorageSize(maxInstIdx)) - private val interner = AccessTreeSoftInterner(manager) + /** + * Allocated on the first intern that actually reaches the interner, not on construction. + * + * There is one of these storages per premise trie node, and the analyzer builds millions of + * them: an eagerly allocated interner was 2.27 M live objects in a ThingsBoard heap dump, of + * which only 369 k (16.3 %) had ever created an [AccessTreeInterner]. The gates in [internImpl] + * - one intern attempt in [INTERN_RATE], and only once some tree here has reached + * [MIN_SIZE_TO_INTERN] - mean the other 83.7 % never had a use for it. + * + * Written only by the storage's own writer, like [edges] and [maxTreeSize] beside it. + */ + private var interner: AccessTreeSoftInterner? = null + private var operationsBeforeIntern = INTERN_RATE private var maxTreeSize = 0L + 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( + fun intern(idx: Int): Unit = internImpl( manager.cancellation, lastUpdated = edges[idx], size = edges.size, @@ -24,12 +39,17 @@ open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { updateMaxNodeSize = { maxTreeSize = it }, decOperations = { operationsBeforeIntern-- }, resetOperation = { operationsBeforeIntern = INTERN_RATE }, + getInterner = { interner() }, getNode = { edges[it] }, setNode = { i, n -> edges[i] = n } ) companion object { - inline fun AccessTreeSoftInterner.internImpl( + /** + * `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 +57,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 +67,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)) From ea7cc4414cd688a96a81e83ed2107a5335fbcd71 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:24:47 +0000 Subject: [PATCH 2/4] perf(dataflow): allocate a premise trie node's children map on the first child Every AccessBasedStorage node built a ConcurrentReadSafeInt2ObjectMap in its constructor. Measured on the same ThingsBoard dump: of the 1,652,208 IF2FFStorage nodes, 85.51 % have no children at all and 94.21 % have at most one - mean 0.947, max 194. The children maps are 610.6 MiB of a 9.70 GiB live heap and the empty ones alone are ~345 MiB (map object + two 17-slot tables = ~256 B each). The field is now nullable and installed on the first getOrCreateChild, under a double-checked monitor so racing writers cannot each install a table. @Volatile is load-bearing here rather than decorative. As a `val` the map got final-field publication for free; a plain `var` assigned after construction would let a lock-free reader observe a non-null map whose key/value tables are not yet visible. The map's seqlock cannot repair that - it guards mutations of a published map, not the publication of the map itself. The volatile write is the release that pairs with the reader's acquiring load, once per node. Representation only: the same children, reachable the same way. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 276d2d995f3c20e9bae6a0ba8755d758e8d881cf) (cherry picked from commit ea2524b64d657580ae454bd285369054dac98058) --- .../ap/ifds/access/tree/AccessBasedStorage.kt | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) 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 ->") From 9c17a2189200ef365e0001e526afaf89aa49aca6 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:27:50 +0000 Subject: [PATCH 3/4] perf(dataflow): key a premise's per-instruction facts sparsely TreeSetWithCompression gave every storage an arrayOfNulls(maxInstIdx + 1) per column, sized to the whole method whether or not the premise reached those instructions. Measured on a ThingsBoard heap dump (-Xmx12g, ssrf, 300 s, dumped at t=251 s), across the 1,652,208 EdgeNonUniverseExclusionMergingStorage instances: exclusions/edges array length : mean 29.65 (58.0 % are 3, 8.8 % exceed 64, max 1573) non-null slots : mean 2.64 (84.9 % use <= 2, max 377) 8.9 % occupancy, and the two dense arrays are 434.0 MiB of a 9.70 GiB live heap. The non-null distributions of exclusions and edges are byte-identical, because add() writes both at the same index - so one table carries both as two columns. Instructions now live in an ascending key array beside a value array of `columns` slots per key, the two wrapped in an immutable Row published through a single @Volatile field. Lookup scans linearly up to eight keys (93.8 % of storages) and binary-searches above that. Growth copies; with a mean of 2.64 rows the copies are noise. This is safer than what it replaces, not merely smaller: a reader takes one acquiring load and cannot pair a resized key array with a stale value array, where two plain arrays gave no ordering at all. Writes into an existing row are still plain stores, exactly as before, and access is still stored before the exclusion that marks the row populated. Representation only: same facts, same instructions, same merge order. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit a603820e8d017f291b3aea780f36f622e3e5ce21) (cherry picked from commit 0e305bbfd88ac7aeaadd6125d5d6ea6708cac60b) --- .../access/tree/MethodEdgesFinalTreeApSet.kt | 27 ++-- .../MethodEdgesInitialToFinalTreeApSet.kt | 52 ++++--- .../access/tree/TreeSetWithCompression.kt | 130 ++++++++++++++---- 3 files changed, 158 insertions(+), 51 deletions(-) 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/TreeSetWithCompression.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/access/tree/TreeSetWithCompression.kt index 0a03658c3..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,28 +1,81 @@ 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) + + @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 + } /** - * Allocated on the first intern that actually reaches the interner, not on construction. - * - * There is one of these storages per premise trie node, and the analyzer builds millions of - * them: an eagerly allocated interner was 2.27 M live objects in a ThingsBoard heap dump, of - * which only 369 k (16.3 %) had ever created an [AccessTreeInterner]. The gates in [internImpl] - * - one intern attempt in [INTERN_RATE], and only once some tree here has reached - * [MIN_SIZE_TO_INTERN] - mean the other 83.7 % never had a use for it. - * - * Written only by the storage's own writer, like [edges] and [maxTreeSize] beside it. + * 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. */ - private var interner: AccessTreeSoftInterner? = null + 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 } @@ -31,20 +84,46 @@ open class TreeSetWithCompression(maxInstIdx: Int, val manager: TreeApManager) { return interner().intern(node) } - fun intern(idx: Int): Unit = internImpl( - manager.cancellation, - lastUpdated = edges[idx], - size = edges.size, - maxNodeSize = maxTreeSize, - updateMaxNodeSize = { maxTreeSize = it }, - decOperations = { operationsBeforeIntern-- }, - resetOperation = { operationsBeforeIntern = INTERN_RATE }, - getInterner = { interner() }, - 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 { + 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. @@ -78,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 } } From 3617ae9fc717f1a2a75d9bb945dd38e845bfd88d Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:29:21 +0000 Subject: [PATCH 4/4] perf(dataflow): hold the access-tree index sparsely instead of a BitSet per node AccessTreeIndexImpl gave every trie node a java.util.BitSet over all indexed items. A BitSet's backing long[] is sized by the largest index it holds, not by how many, and the indices here run to tens of thousands. Measured on a ThingsBoard heap dump (-Xmx12g, 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 % at most three 1545.7 MiB - 15.8 % of a 9.70 GiB live heap, and 97 % of every long[] byte in it, to store 1.9 M bits. A node now keeps an ascending IntArray of item indices and promotes to a BitSet only past SPARSE_LIMIT. At the mean 7.46 items that is ~48 B rather than 6.2 KiB. The limit is set at 512 to bound the O(n) insert, not because a BitSet becomes cheaper there - at 512 items sparse is still 2 KiB against dense 6.2 KiB, since the dense cost tracks the maximum index rather than the count. findStartsWith returned the node's BitSet, so both call sites now iterate instead: AccessTreeIndex.forEachStartsWith, and DefaultNDF2FSubStorage's relevantStorageIndices becomes forEachRelevantStorageIndex (automata and cactus iterate the BitSet they already build). Materialising a BitSet at the boundary would have cost more than the set it describes. Representation only: same indices, same ascending order. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 0a564b83fb12c4bf88569aac2b32230ff242ae4e) (cherry picked from commit 77a97b6876843de78f0626f760ab1bd7e8972400) --- .../MethodAutomataAccessPathSubscription.kt | 4 +- .../MethodCactusAccessPathSubscription.kt | 5 +- .../common/ndf2f/DefaultNDF2FSubStorage.kt | 11 +- .../tree/MethodTreeAccessPathSubscription.kt | 108 +++++++++++++++--- 4 files changed, 104 insertions(+), 24 deletions(-) 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/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(