Skip to content

Commit 20c07f8

Browse files
authored
Merge pull request #1754 from hoangmaihuy/perf/analysis-intern
[2.x] Intern analysis values while deserializing
2 parents 57b581e + be5354d commit 20c07f8

9 files changed

Lines changed: 598 additions & 44 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Zinc - The incremental compiler for Scala.
3+
* Copyright Scala Center, Lightbend, and Mark Harrah
4+
*
5+
* Licensed under Apache License 2.0
6+
* SPDX-License-Identifier: Apache-2.0
7+
*
8+
* See the NOTICE file distributed with this work for
9+
* additional information regarding copyright ownership.
10+
*/
11+
12+
package sbt.internal.inc
13+
14+
import java.util.{ EnumSet => JEnumSet }
15+
16+
import xsbti.UseScope
17+
18+
/**
19+
* The process-wide canonicalizer for strings and `UsedName`s: fresh
20+
* (compiler-produced) and persisted (deserialized) analyses both route through
21+
* it, so co-resident analyses share a single instance per distinct value.
22+
*
23+
* Strings are canonicalized through a weak interner: canonical instances are
24+
* held only through weak references, so once every analysis referencing a
25+
* string is released the canonical instance becomes eligible for GC.
26+
*
27+
* `UsedName`s are canonicalized without constructing a candidate first: a
28+
* `UsedName` is fully determined by its (already canonical) name string and one
29+
* of the eight possible `UseScope` combinations, so each combination gets a
30+
* weak-valued name-keyed pool. A pool entry is removed once the canonical
31+
* `UsedName` is no longer referenced by any analysis. On a pool hit no
32+
* `UsedName` is constructed.
33+
*
34+
* Both pools make interning leak-free across the lifetime of a long-running
35+
* build server. Benchmarks that need an uninterned baseline compare against a
36+
* stock zinc checkout; production code always interns.
37+
*/
38+
object AnalysisInterner {
39+
40+
private[inc] final val DEFAULT_SCOPE = 1
41+
private[inc] final val IMPLICIT_SCOPE = 2
42+
private[inc] final val PAT_MAT_TARGET_SCOPE = 4
43+
private final val SCOPE_COMBINATIONS = 8 // every subset of the three scopes above
44+
45+
private val stringPool = new WeakInterner[String]
46+
47+
// One weak-valued pool per UseScope combination, keyed by the canonical name.
48+
private val usedNamePools: Array[WeakValuePool[String, UsedName]] =
49+
Array.fill(SCOPE_COMBINATIONS)(new WeakValuePool[String, UsedName])
50+
51+
def internString(s: String): String = stringPool.intern(s)
52+
53+
/**
54+
* The canonical `UsedName` for `name` used in the scope combination
55+
* `scopeBits`, an or-ing of `DEFAULT_SCOPE`, `IMPLICIT_SCOPE` and
56+
* `PAT_MAT_TARGET_SCOPE`. Constructs a `UsedName` only when the value is not
57+
* pooled yet.
58+
*/
59+
def usedName(name: String, scopeBits: Int): UsedName = {
60+
// UsedName stores the escaped name, so the pool must be probed with it too;
61+
// escaping is a no-op unless the name contains control characters.
62+
val escaped = UsedName.escapeControlChars(name)
63+
val pool = usedNamePools(scopeBits)
64+
val existing = pool.get(escaped)
65+
if (existing != null) existing
66+
else {
67+
val fresh = UsedName.make(escaped, scopeSets(scopeBits))
68+
val prev = pool.putIfAbsent(fresh.name, fresh)
69+
if (prev == null) fresh else prev
70+
}
71+
}
72+
73+
/**
74+
* The eight possible scope sets, indexed by scope bits. Shared by every
75+
* UsedName and treated as immutable -- they must never be mutated.
76+
*/
77+
private[inc] val scopeSets: Array[JEnumSet[UseScope]] =
78+
Array.tabulate(SCOPE_COMBINATIONS) { bits =>
79+
val scopes = JEnumSet.noneOf(classOf[UseScope])
80+
if ((bits & DEFAULT_SCOPE) != 0) scopes.add(UseScope.Default)
81+
if ((bits & IMPLICIT_SCOPE) != 0) scopes.add(UseScope.Implicit)
82+
if ((bits & PAT_MAT_TARGET_SCOPE) != 0) scopes.add(UseScope.PatMatTarget)
83+
scopes
84+
}
85+
86+
/** The inverse of `scopeSets`: also the encoding used by the persisted format. */
87+
private[inc] def scopeBits(scopes: JEnumSet[UseScope]): Int =
88+
(if (scopes.contains(UseScope.Default)) DEFAULT_SCOPE else 0) |
89+
(if (scopes.contains(UseScope.Implicit)) IMPLICIT_SCOPE else 0) |
90+
(if (scopes.contains(UseScope.PatMatTarget)) PAT_MAT_TARGET_SCOPE else 0)
91+
}

internal/zinc-core/src/main/scala/sbt/internal/inc/Incremental.scala

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,9 +920,18 @@ private final class AnalysisCallback(
920920
}
921921

922922
def usedName(className: String, name: String, useScopes: EnumSet[UseScope]) = {
923+
// Canonicalize freshly-produced used names and their strings into the
924+
// process-wide pools, so a just-compiled analysis shares them with every
925+
// other resident analysis. (Api tree nodes are deduped only when an
926+
// analysis is deserialized, not on this fresh-compile path.)
923927
usedNames
924928
.getOrElseUpdate(className, ConcurrentHashMap.newKeySet[UsedName].asScala)
925-
.add(UsedName.make(name, useScopes))
929+
.add(
930+
AnalysisInterner.usedName(
931+
AnalysisInterner.internString(name),
932+
AnalysisInterner.scopeBits(useScopes)
933+
)
934+
)
926935
()
927936
}
928937

internal/zinc-core/src/main/scala/sbt/internal/inc/UsedName.scala

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,23 @@ package sbt.internal.inc
1313

1414
import java.{ util => ju }
1515
import scala.{ collection => sc }
16+
import scala.util.hashing.MurmurHash3
1617
import xsbti.compile.{ UsedName => XUsedName }
1718
import xsbti.UseScope
1819

20+
/**
21+
* `scopes` must never be mutated after construction: instances may share one
22+
* scope set (`make` uses the set it is given, and interning aliases equal
23+
* instances), and the hash below is computed once.
24+
*/
1925
case class UsedName private (name: String, scopes: ju.EnumSet[UseScope]) extends XUsedName {
2026
override def getName: String = name
2127
override def getScopes: ju.EnumSet[UseScope] = scopes
28+
29+
// A canonical (interned) instance is inserted into one name-set per class that
30+
// uses it, and EnumSet.hashCode iterates its elements; caching makes each
31+
// re-hash a field read. Fits in the object's existing alignment padding.
32+
override val hashCode: Int = MurmurHash3.caseClassHash(this)
2233
}
2334

2435
object UsedName {
@@ -33,7 +44,7 @@ object UsedName {
3344
new UsedName(escapedName, useScopes)
3445
}
3546

36-
private def escapeControlChars(name: String) = {
47+
private[inc] def escapeControlChars(name: String): String = {
3748
if (name.indexOf('\n') > 0) // optimize for common case to regex overhead
3849
name.replace("\n", "\u26680A")
3950
else
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Zinc - The incremental compiler for Scala.
3+
* Copyright Scala Center, Lightbend, and Mark Harrah
4+
*
5+
* Licensed under Apache License 2.0
6+
* SPDX-License-Identifier: Apache-2.0
7+
*
8+
* See the NOTICE file distributed with this work for
9+
* additional information regarding copyright ownership.
10+
*/
11+
12+
package sbt.internal.inc
13+
14+
import java.lang.ref.{ ReferenceQueue, WeakReference }
15+
import java.util.concurrent.ConcurrentHashMap
16+
17+
import scala.annotation.tailrec
18+
19+
/**
20+
* Canonicalizing pool: `intern` returns one instance per distinct value
21+
* (`equals`/`hashCode`), and holds it only through a weak reference, so a value
22+
* becomes collectable as soon as the last caller drops it. Dead entries are
23+
* expunged by the next pool operation.
24+
*/
25+
private[inc] final class WeakInterner[A <: AnyRef] {
26+
private val stale = new ReferenceQueue[A]
27+
private val pool = new ConcurrentHashMap[WeakValue[A], WeakValue[A]]
28+
29+
def intern(a: A): A = {
30+
expunge()
31+
val candidate = new WeakValue(a, stale)
32+
@tailrec def publish(): A = pool.putIfAbsent(candidate, candidate) match {
33+
case null => a
34+
case existing =>
35+
existing.get match {
36+
case null => // collected since it matched: drop the dead entry and retry
37+
pool.remove(existing, existing)
38+
publish()
39+
case canonical =>
40+
candidate.clear() // never enqueue a reference that was not pooled
41+
canonical
42+
}
43+
}
44+
publish()
45+
}
46+
47+
@tailrec private def expunge(): Unit = stale.poll() match {
48+
case null => ()
49+
case dead =>
50+
pool.remove(dead, dead)
51+
expunge()
52+
}
53+
}
54+
55+
/**
56+
* Map that holds its values only through weak references: an entry disappears
57+
* once its value is unreachable, releasing the strong reference to its key with
58+
* it. Unlike an interner it is keyed by something cheaper than the value, so
59+
* callers can probe the pool before constructing a candidate.
60+
*/
61+
private[inc] final class WeakValuePool[K, V <: AnyRef] {
62+
private val stale = new ReferenceQueue[V]
63+
private val pool = new ConcurrentHashMap[K, KeyedWeakValue[K, V]]
64+
65+
/** The pooled value for `key`, or `null` if there is none. */
66+
def get(key: K): V = {
67+
expunge()
68+
pool.get(key) match {
69+
case null => null.asInstanceOf[V]
70+
case entry => entry.get
71+
}
72+
}
73+
74+
/** Pools `value` under `key`, returning `null`, or the value already pooled. */
75+
@tailrec def putIfAbsent(key: K, value: V): V = {
76+
val candidate = new KeyedWeakValue(key, value, stale)
77+
pool.putIfAbsent(key, candidate) match {
78+
case null => null.asInstanceOf[V]
79+
case existing =>
80+
existing.get match {
81+
case null => // collected since it was published: replace the dead entry
82+
pool.remove(key, existing)
83+
putIfAbsent(key, value)
84+
case pooled =>
85+
candidate.clear() // never enqueue a reference that was not pooled
86+
pooled
87+
}
88+
}
89+
}
90+
91+
@tailrec private def expunge(): Unit = stale.poll() match {
92+
case null => ()
93+
case dead =>
94+
pool.remove(dead.asInstanceOf[KeyedWeakValue[K, V]].key, dead)
95+
expunge()
96+
}
97+
}
98+
99+
/** Weak reference that hashes and compares by the value of its referent. */
100+
private final class WeakValue[A <: AnyRef](a: A, stale: ReferenceQueue[A])
101+
extends WeakReference[A](a, stale) {
102+
private val hash: Int = a.hashCode
103+
104+
override def hashCode(): Int = hash
105+
override def equals(other: Any): Boolean = other match {
106+
case that: WeakValue[?] =>
107+
(this `eq` that) || {
108+
val value = get
109+
value != null && value == that.get
110+
}
111+
case _ => false
112+
}
113+
}
114+
115+
/** Weak reference that remembers the key its value was pooled under. */
116+
private final class KeyedWeakValue[K, V <: AnyRef](val key: K, v: V, stale: ReferenceQueue[V])
117+
extends WeakReference[V](v, stale)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Zinc - The incremental compiler for Scala.
3+
* Copyright Scala Center, Lightbend, and Mark Harrah
4+
*
5+
* Licensed under Apache License 2.0
6+
* SPDX-License-Identifier: Apache-2.0
7+
*
8+
* See the NOTICE file distributed with this work for
9+
* additional information regarding copyright ownership.
10+
*/
11+
12+
package sbt.internal.inc
13+
14+
import java.lang.ref.WeakReference
15+
import java.util.concurrent.{ Callable, CountDownLatch, Executors, TimeUnit }
16+
import xsbti.UseScope
17+
18+
class AnalysisInternerSpec extends UnitSpec {
19+
20+
behavior of "AnalysisInterner"
21+
22+
it should "canonicalize equal strings to one instance" in {
23+
val s1 = new String("com.example.Foo") // force distinct heap instances
24+
val s2 = new String("com.example.Foo")
25+
assert(s1 `ne` s2)
26+
assert(AnalysisInterner.internString(s1) `eq` AnalysisInterner.internString(s2))
27+
}
28+
29+
it should "return one UsedName instance per (name, scope) without construction on hit" in {
30+
val a = AnalysisInterner.usedName("map", 1)
31+
val b = AnalysisInterner.usedName(new String("map"), 1)
32+
assert(a `eq` b)
33+
assert(a.name == "map")
34+
assert(a.scopes.contains(UseScope.Default))
35+
assert(!a.scopes.contains(UseScope.Implicit))
36+
}
37+
38+
it should "distinguish scope combinations for the same name" in {
39+
val default = AnalysisInterner.usedName("map", 1)
40+
val implicitScope = AnalysisInterner.usedName("map", 2)
41+
val patMat = AnalysisInterner.usedName("map", 4)
42+
assert(default `ne` implicitScope)
43+
assert(default `ne` patMat)
44+
assert(implicitScope.scopes.contains(UseScope.Implicit))
45+
assert(patMat.scopes.contains(UseScope.PatMatTarget))
46+
}
47+
48+
it should "pool names containing control characters under their escaped form" in {
49+
val a = AnalysisInterner.usedName("weird\nname", 1)
50+
val b = AnalysisInterner.usedName("weird\nname", 1)
51+
assert(a `eq` b)
52+
assert(a.name == "weird♨0Aname") // stored escaped, exactly as UsedName.make does
53+
}
54+
55+
it should "converge on one canonical instance under concurrent interning" in {
56+
// Parallel compilation interns from many threads at once. Every thread must
57+
// observe the same canonical reference, and the pools must never deadlock.
58+
val threads = 16
59+
val start = new CountDownLatch(1)
60+
val pool = Executors.newFixedThreadPool(threads)
61+
try {
62+
val futures = (1 to threads).map { _ =>
63+
pool.submit(new Callable[(String, UsedName)] {
64+
def call(): (String, UsedName) = {
65+
start.await() // release all threads together to maximize contention
66+
(
67+
AnalysisInterner.internString(new String("concurrent.value")),
68+
AnalysisInterner.usedName(new String("concurrent.value"), 5)
69+
)
70+
}
71+
})
72+
}
73+
start.countDown()
74+
val results = futures.map(_.get(30, TimeUnit.SECONDS)).toList // deadlock => timeout
75+
assert(results.forall(_._1 `eq` results.head._1))
76+
assert(results.forall(_._2 `eq` results.head._2))
77+
} finally pool.shutdownNow()
78+
}
79+
80+
it should "release canonical instances once no analysis references them" in {
81+
// The leak-free requirement: canonical instances are held only weakly, so
82+
// once every analysis referencing a value is dropped it becomes GC-eligible.
83+
val stringRef =
84+
new WeakReference[String](AnalysisInterner.internString(new String("ephemeral.str")))
85+
val usedNameRef =
86+
new WeakReference[UsedName](AnalysisInterner.usedName("ephemeral.used", 1))
87+
val released = (1 to 100).exists { _ =>
88+
System.gc()
89+
Thread.sleep(10)
90+
stringRef.get() == null && usedNameRef.get() == null
91+
}
92+
assert(released, "weak pools must not retain values after they become unreachable")
93+
}
94+
95+
it should "keep pooled values while an analysis still references them" in {
96+
val held = AnalysisInterner.usedName("held", 1)
97+
System.gc()
98+
Thread.sleep(10)
99+
assert(AnalysisInterner.usedName("held", 1) `eq` held)
100+
}
101+
}

0 commit comments

Comments
 (0)