Skip to content

Commit 07a719a

Browse files
committed
fix(util): compare sign bits when measuring ulp distance
Comparing the sign of the values rather than of their representations treated -0.0 as non-negative, since -0.0 < 0 is false. Its representation is Long.MinValue, so a value of the opposite sign fell into the same branch and the subtraction overflowed. Representations that share a sign bit lie in the same half of the long range, so comparing those instead is both correct and overflow-free. Also avoids allocating an eligibility predicate per call. maxWithIndex and minWithIndex sit on the consensus calling hot path, where the previous Double => Boolean argument meant a lambda allocation for every base of every consensus read. The tie scan now takes allowNegativeInfinity directly and stops at the second match rather than counting every entry.
1 parent 8c3c910 commit 07a719a

2 files changed

Lines changed: 60 additions & 30 deletions

File tree

src/main/scala/com/fulcrumgenomics/util/MathUtil.scala

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,47 @@ object MathUtil {
5555
if (a == b) 0L
5656
else if (java.lang.Double.isNaN(a) || java.lang.Double.isNaN(b)) Long.MaxValue
5757
else if (a.isInfinite || b.isInfinite) Long.MaxValue
58-
else if ((a < 0) != (b < 0)) Long.MaxValue
59-
else Math.abs(java.lang.Double.doubleToLongBits(a) - java.lang.Double.doubleToLongBits(b))
58+
else {
59+
val aBits = java.lang.Double.doubleToLongBits(a)
60+
val bBits = java.lang.Double.doubleToLongBits(b)
61+
// The sign of the *representation* is compared rather than the sign of the value: -0.0 is not `< 0`, but its
62+
// representation is Long.MinValue, so treating it as non-negative would overflow the subtraction below.
63+
// Representations that share a sign bit lie in the same half of the long range, so the subtraction is safe.
64+
if ((aBits < 0) != (bBits < 0)) Long.MaxValue else Math.abs(aBits - bBits)
65+
}
6066
}
6167

6268
/** True if `a` and `b` are within either an absolute `epsilon` or `maxUlps` ulps of one another. */
6369
private def approximatelyEqual(a: Double, b: Double, epsilon: Double, maxUlps: Int): Boolean = {
6470
Math.abs(a - b) <= epsilon || ulpsBetween(a, b) <= maxUlps
6571
}
6672

73+
/** True if more than one eligible entry of `ds` is approximately equal to `target`.
74+
*
75+
* `NaN`s are always ineligible, and `-Infinity` is ineligible unless `allowNegativeInfinity` is set, mirroring
76+
* the entries that were candidates for the extremum in the first place. The scan stops as soon as a second
77+
* match is seen; these functions sit on the consensus calling hot path, so neither the predicate nor the count
78+
* is worth materialising.
79+
*/
80+
private def hasApproximateTie(ds: Array[Double],
81+
target: Double,
82+
epsilon: Double,
83+
maxUlps: Int,
84+
allowNegativeInfinity: Boolean): Boolean = {
85+
var seen = false
86+
var idx = 0
87+
while (idx < ds.length) {
88+
val v = ds(idx)
89+
if (!java.lang.Double.isNaN(v) && (Double.NegativeInfinity != v || allowNegativeInfinity) &&
90+
approximatelyEqual(v, target, epsilon, maxUlps)) {
91+
if (seen) return true
92+
seen = true
93+
}
94+
idx += 1
95+
}
96+
false
97+
}
98+
6799
/** Calculates the arithmetic mean of an array of bytes. The computation is performed
68100
* in integer space and the result will therefore always round down.
69101
*
@@ -104,18 +136,15 @@ object MathUtil {
104136
*/
105137
def minWithIndex(ds: Array[Double], allowNegativeInfinity: Boolean=false, requireUniqueMinimum: Boolean=false, epsilon: Double = MathUtil.epsilon, maxUlps: Int = MathUtil.maxUlps): (Double, Int) = {
106138
if (ds.length == 0) throw new NoSuchElementException("Cannot find the min of a zero length array.")
107-
def eligible(v: Double): Boolean = {
108-
!java.lang.Double.isNaN(v) && (Double.NegativeInfinity != v || allowNegativeInfinity)
109-
}
110-
111139
var min = Double.MaxValue
112140
var minIndex = -1
113141
var assigned = false
114142
val len = ds.length
115143
var idx = 0
116144
while (idx < len) {
117145
val v = ds(idx)
118-
if (eligible(v) && (!assigned || v < min)) {
146+
if (!java.lang.Double.isNaN(v) && (Double.NegativeInfinity != v || allowNegativeInfinity) &&
147+
(!assigned || v < min)) {
119148
min = v
120149
minIndex = idx
121150
assigned = true
@@ -126,28 +155,12 @@ object MathUtil {
126155

127156
if (!assigned) throw new NoSuchElementException("All values are NaNs or negative infinity.")
128157

129-
// Ties are counted against the final minimum, not a running one: a near-tie is a property of the values, and
158+
// Ties are detected against the final minimum, not a running one: a near-tie is a property of the values, and
130159
// comparing against a running minimum would only detect it when the tied value happens to appear later.
131-
if (requireUniqueMinimum && countApproximatelyEqual(ds, min, epsilon, maxUlps, eligible) > 1) (min, -1)
160+
if (requireUniqueMinimum && hasApproximateTie(ds, min, epsilon, maxUlps, allowNegativeInfinity)) (min, -1)
132161
else (min, minIndex)
133162
}
134163

135-
/** Counts the eligible entries of `ds` that are approximately equal to `target`. */
136-
private def countApproximatelyEqual(ds: Array[Double],
137-
target: Double,
138-
epsilon: Double,
139-
maxUlps: Int,
140-
eligible: Double => Boolean): Int = {
141-
var count = 0
142-
var idx = 0
143-
while (idx < ds.length) {
144-
val v = ds(idx)
145-
if (eligible(v) && approximatelyEqual(v, target, epsilon, maxUlps)) count += 1
146-
idx += 1
147-
}
148-
count
149-
}
150-
151164

152165
/**
153166
* Finds the maximum element in the array and returns it with its index.
@@ -167,16 +180,14 @@ object MathUtil {
167180
*/
168181
def maxWithIndex(ds: Array[Double], requireUniqueMaximum: Boolean=false, epsilon: Double = MathUtil.epsilon, maxUlps: Int = MathUtil.maxUlps): (Double,Int) = {
169182
if (ds.length == 0) throw new NoSuchElementException("Cannot find the max of a zero length array.")
170-
def eligible(v: Double): Boolean = !java.lang.Double.isNaN(v)
171-
172183
var max = Double.MinValue
173184
var maxIndex = -1
174185
var assigned = false
175186
val len = ds.length
176187
var idx = 0
177188
while (idx < len) {
178189
val v = ds(idx)
179-
if (eligible(v) && (!assigned || v > max)) {
190+
if (!java.lang.Double.isNaN(v) && (!assigned || v > max)) {
180191
max = v
181192
maxIndex = idx
182193
assigned = true
@@ -186,9 +197,10 @@ object MathUtil {
186197

187198
if (!assigned) throw new NoSuchElementException("Array contained only NaNs.")
188199

189-
// Ties are counted against the final maximum, not a running one: a near-tie is a property of the values, and
200+
// Ties are detected against the final maximum, not a running one: a near-tie is a property of the values, and
190201
// comparing against a running maximum would only detect it when the tied value happens to appear later.
191-
if (requireUniqueMaximum && countApproximatelyEqual(ds, max, epsilon, maxUlps, eligible) > 1) (max, -1)
202+
// Negative infinities are eligible here: unlike the minimum, they are never excluded from the maximum.
203+
if (requireUniqueMaximum && hasApproximateTie(ds, max, epsilon, maxUlps, allowNegativeInfinity=true)) (max, -1)
192204
else (max, maxIndex)
193205
}
194206
}

src/test/scala/com/fulcrumgenomics/util/MathUtilTest.scala

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ class MathUtilTest extends UnitSpec {
147147
}
148148
}
149149

150+
it should "honour an explicit maxUlps" in {
151+
val value = -500.0
152+
val oneUlp = Math.ulp(value)
153+
154+
// No ulps of tolerance: only exactly equal values tie, and the absolute epsilon is far too small to match here.
155+
maxWithIndex(Array(value, value - oneUlp, -1e9, -1e9), requireUniqueMaximum=true, maxUlps=0) shouldBe (value, 0)
156+
157+
// A wider tolerance ties values several ulps apart.
158+
maxWithIndex(Array(value, value - (oneUlp * 3), -1e9, -1e9), requireUniqueMaximum=true, maxUlps=8) shouldBe (value, -1)
159+
}
160+
161+
it should "not treat negative zero as tied with a distant value of the opposite sign" in {
162+
// -0.0 is not `< 0`, but its bit representation is Long.MinValue. Comparing the sign of the value rather than
163+
// of the representation would put these in the same branch and overflow the ulp subtraction.
164+
maxWithIndex(Array(1.0, -0.0), requireUniqueMaximum=true) shouldBe (1.0, 0)
165+
minWithIndex(Array(-0.0, 1.0), requireUniqueMinimum=true) shouldBe (-0.0, 0)
166+
}
167+
150168
it should "detect a near-tie regardless of order when finding the minimum" in {
151169
val value = 20.0
152170
val oneUlp = Math.ulp(value)

0 commit comments

Comments
 (0)