Skip to content

Commit 0c066bd

Browse files
Merge pull request #155 from stephentyrone/complex-log1p
Augmented arithmetic implementations of log and log(onePlus:) for enhanced accuracy close to the circle where the real part of the result vanishes.
2 parents 754cbd9 + 2b4c670 commit 0c066bd

File tree

8 files changed

+414
-156
lines changed

8 files changed

+414
-156
lines changed

Package.swift

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ let package = Package(
2121
.library(name: "RealModule", targets: ["RealModule"]),
2222
],
2323

24-
dependencies: [
25-
.package(url: "https://github.com/apple/swift-argument-parser", from: "0.1.0"),
26-
],
27-
2824
targets: [
2925
// User-facing modules
3026
.target(name: "ComplexModule", dependencies: ["RealModule"]),
@@ -40,13 +36,7 @@ let package = Package(
4036
.testTarget(name: "RealTests", dependencies: ["_TestSupport"]),
4137

4238
// Test executables
43-
.target(
44-
name: "ComplexElementaryFunctions",
45-
dependencies: [
46-
"Numerics", "_TestSupport",
47-
.product(name: "ArgumentParser", package: "swift-argument-parser"),
48-
],
49-
path: "Tests/Executable"
50-
)
39+
.target(name: "ComplexLog", dependencies: ["Numerics", "_TestSupport"], path: "Tests/Executable/ComplexLog"),
40+
.target(name: "ComplexLog1p", dependencies: ["Numerics", "_TestSupport"], path: "Tests/Executable/ComplexLog1p")
5141
]
5242
)

Sources/ComplexModule/ElementaryFunctions.swift

Lines changed: 112 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -224,78 +224,131 @@ extension Complex /*: ElementaryFunctions */ {
224224
// If z is zero or infinite, the phase is undefined, so the result is
225225
// the single exceptional value.
226226
guard z.isFinite && !z.isZero else { return .infinity }
227-
// Otherwise, try computing lengthSquared; if the result is normal,
228-
// we can just take its log to get the real part of the result.
229-
let r2 = z.lengthSquared
227+
// Having eliminated non-finite values and zero, the imaginary part is
228+
// easy; it's just the phase, which is always computed with good
229+
// relative accuracy via atan2.
230230
let θ = z.phase
231-
if r2.isNormal { return Complex(.log(r2)/2, θ) }
232-
// z is finite, but z.lengthSquared is not normal. Rescale and recompute.
233-
let w = z.divided(by: z.magnitude)
234-
return Complex(.log(z.magnitude) + .log(w.lengthSquared)/2, θ)
235-
}
236-
237-
@inlinable
238-
public static func log(onePlus z: Complex) -> Complex {
239-
// Nevin proposed the idea for this implementation on the Swift forums:
240-
// https://forums.swift.org/t/elementaryfunctions-compliance-for-complex/37903/3
231+
// The real part of the result is trickier. In exact arithmetic, the
232+
// real part is just log |z|--many implementations of complex functions
233+
// simply use this expression as is. However, there are two problems
234+
// lurking here:
241235
//
242-
// Here's a quick explainer on why it works: in exact arithmetic,
236+
// - length can overflow even when log(z) is finite.
243237
//
244-
// log(1+z) = (log |1+z|, atan2(y, 1+x))
238+
// - when length is close to 1, catastrophic cancellation is hidden
239+
// in this expression. Consider, e.g. z = 1 + δi for small δ.
245240
//
246-
// where x and y are the real and imaginary parts of z, respectively.
241+
// Because δ ≪ 1, |z| rounds to 1, and so log |z| produces zero.
242+
// We can expand using Taylor series to see that the result should
243+
// be:
247244
//
248-
// The first thing to note is that the expression for the imaginary
249-
// part works fine as is. If cancellation occurs (because x ≈ -1),
250-
// then 1+x is exact, and so we have good componentwise relative
251-
// accuracy. Otherwise, x is bounded away from -1 and 1+x has good
252-
// relative accuracy, and therefore so does atan2(y, 1+x).
245+
// log |z| = log √(1 + δ²)
246+
// = log(1 + δ²)/2
247+
// = δ²/2 + O(δ⁴)
253248
//
254-
// So the real part is the hard part (no surprise, just like expPlusOne).
255-
// Nevin's clever idea is simply to take advantage of the expansion:
249+
// So naively using log |z| results in a total loss of relative
250+
// accuracy for this case. Note that this is _not_ constrained near
251+
// a single point; it occurs everywhere close to the circle |z| = 1.
256252
//
257-
// Re(log 1+z) = (log 1+z + Conj(log 1+z))/2
253+
// Note that this case still _does_ deliver a result with acceptable
254+
// relative accuracy in the complex norm, because
258255
//
259-
// Log commutes with conjugation, so this becomes:
256+
// Im(log z) ≈ δ ≫ δ²/2 ≈ Re(log z).
260257
//
261-
// Re(log 1+z) = (log 1+z + log 1+z̅)/2
262-
// = log((1+z)(1+z̅)/2
263-
// = log(1+z+z̅+zz̅)/2
258+
// There are a number of ways to try to tackle this problem. I'll begin
259+
// with a simple one that solves the first issue, and _sometimes_ the
260+
// second, then analyze when it doesn't work for the second case.
264261
//
265-
// This behaves well close to zero, because the z+z̅ term dominates
266-
// and is computed exactly. Away from zero, cancellation occurs near
267-
// the circle x(x+2) + y^2 = 0, but everywhere along this curve we
268-
// have |Im(log 1+z)| >= π/2, so the relative error in the complex
269-
// norm is well-controlled. We can take advantage of FMA to further
270-
// reduce the cancellation error and recover a good error bound.
262+
// To handle very large arguments without overflow, the standard
263+
// approach is to _rescale_ the problem. We can do this by finding
264+
// whichever of x and y has greater magnitude, and dividing through
265+
// by it. You can think of this as changing coordinates by reflections
266+
// so that we get a new value w = u + iv with |w| = |z| (and hence
267+
// Re(log w) = Re(log z), and 0 ≤ u, 0 ≤ v ≤ u.
268+
let u = max(z.x.magnitude, z.y.magnitude)
269+
let v = min(z.x.magnitude, z.y.magnitude)
270+
// Now expand out log |w|:
271271
//
272-
// The other common implementation choice for log1p is Kahan's trick:
272+
// log |w| = log(u² + v²)/2
273+
// = log u + log(onePlus: (v/u)²)/2
273274
//
274-
// w := 1+z
275-
// return z/(w-1) * log(w)
275+
// This looks promising! It handles overflow well, because log(u) is
276+
// finite for every finite u, and we have 0 ≤ v/u ≤ 1, so the second
277+
// term is bounded by 0 ≤ log(1 + (v/u)²)/2 ≤ (log 2)/2. It also
278+
// handles the example I gave above well: we have u = 1, v = δ, and
276279
//
277-
// But this actually doesn't do as well as Nevin's approach does,
278-
// and requires a complex division, which we want to avoid when we
279-
// can do so.
280-
var a = 2*z.x
281-
// We want to add the larger term first (contra usual guidance for
282-
// floating-point error optimization), because we're optimizing for
283-
// the catastrophic cancellation case; when that happens adding the
284-
// larger term via FMA is always exact. When cancellation doesn't
285-
// happen, the simple relative error bound carries through the
286-
// rest of the computation.
287-
let large = max(z.x.magnitude, z.y.magnitude)
288-
let small = min(z.x.magnitude, z.y.magnitude)
289-
a.addProduct(large, large)
290-
a.addProduct(small, small)
291-
// If r2 overflowed, then |z| ≫ 1, and so log(1+z) = log(z).
292-
guard a.isFinite else { return log(z) }
293-
// Unlike log(z), we do not need to worry about what happens if a
294-
// underflows.
295-
return Complex(
296-
RealType.log(onePlus: a)/2,
297-
RealType.atan2(y: z.y, x: 1+z.x)
298-
)
280+
// log(1) + log(onePlus: δ²)/2 = 0 + δ²/2
281+
//
282+
// as expected.
283+
//
284+
// Unfortunately, it does not handle all points close to the unit
285+
// circle so well; it's easy to see why if we look at the two terms
286+
// that contribute to the result. Cancellation occurs when the result
287+
// is close to zero and the terms have opposing signs. By construction,
288+
// the second term is always positive, so the easiest observation is
289+
// that cancellation is only a problem for u < 1 (because otherwise
290+
// log u is also positive, and there can be no cancellation).
291+
//
292+
// We are not trying for sub-ulp accuracy, just a good relative error
293+
// bound, so for our purposes it suffices to have log u dominate the
294+
// result:
295+
if u >= 1 || u >= RealType._mulAdd(u,u,v*v) {
296+
let r = v / u
297+
return Complex(.log(u) + .log(onePlus: r*r)/2, θ)
298+
}
299+
// Here we're in the tricky case; cancellation is likely to occur.
300+
// Instead of the factorization used above, we will want to evaluate
301+
// log(onePlus: u² + v² - 1)/2. This all boils down to accurately
302+
// evaluating u² + v² - 1. To begin, calculate both squared terms
303+
// as exact head-tail products (u is guaranteed to be well scaled,
304+
// v may underflow, but if it does it doesn't matter, the u term is
305+
// all we need).
306+
let (a,b) = Augmented.twoProdFMA(u, u)
307+
let (c,d) = Augmented.twoProdFMA(v, v)
308+
// It would be nice if we could simply use a - 1, but unfortunately
309+
// we don't have a tight enough bound to guarantee that that expression
310+
// is exact; a may be as small as 1/4, so we could lose a single bit
311+
// to rounding if we did that.
312+
var (s,e) = Augmented.fastTwoSum(-1, a)
313+
// Now we are ready to assemble the result. If cancellation happens,
314+
// then |c| > |e| > |b|, |d|, so this assembly order is safe. It's
315+
// also possible that |c| and |d| are small, but if that happens then
316+
// there is no significant cancellation, and the exact assembly doesn't
317+
// matter.
318+
s = (s + c) + e + b + d
319+
return Complex(.log(onePlus: s)/2, θ)
320+
}
321+
322+
@inlinable
323+
public static func log(onePlus z: Complex) -> Complex {
324+
// If either |x| or |y| is bounded away from the origin, we don't need
325+
// any extra precision, and can just literally compute log(1+z). Note
326+
// that this includes part of the circle |1+z| = 1 where log(onePlus:)
327+
// vanishes (where x <= -0.5), but on this portion of the circle 1+x
328+
// is always exact by Sterbenz' lemma, so as long as log( ) produces
329+
// a good result, log(1+z) will too.
330+
guard 2*z.x.magnitude < 1 && z.y.magnitude < 1 else { return log(1+z) }
331+
// z is in (±0.5, ±1), so we need to evaluate more carefully.
332+
// The imaginary part is straightforward:
333+
let θ = z.phase
334+
// For the real part, we _could_ use the same approach that we do for
335+
// log( ), but we'd need an extra-precise (1+x)², which can potentially
336+
// be quite painful to calculate. Instead, we can use an approach that
337+
// NevinBR suggested on the Swift forums:
338+
//
339+
// Re(log 1+z) = (log 1+z + log 1+z̅)/2
340+
// = log((1+z)(1+z̅)/2
341+
// = log(1+z+z̅+zz̅)/2
342+
// = log((2+x)x + y²)/2
343+
//
344+
// So now we need to evaluate (2+x)x + y² accurately. To do this,
345+
// we employ augmented arithmetic; the key observation here is that
346+
// cancellation is only a problem when y² ≈ -(2+x)x
347+
let xp2 = Augmented.fastTwoSum(2, z.x) // Known that 2 > |x|.
348+
let a = Augmented.twoProdFMA(z.x, xp2.head)
349+
let y² = Augmented.twoProdFMA(z.y, z.y)
350+
let s = (a.head + y².head + a.tail + y².tail).addingProduct(z.x, xp2.tail)
351+
return Complex(.log(onePlus: s)/2, θ)
299352
}
300353

301354
public static func acos(_ z: Complex) -> Complex {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//===--- AugmentedArithmetic.swift ----------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Numerics open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift Numerics project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
public enum Augmented { }
13+
14+
extension Augmented {
15+
@_transparent
16+
public static func twoProdFMA<T:Real>(_ a: T, _ b: T) -> (head: T, tail: T) {
17+
let head = a*b
18+
let tail = (-head).addingProduct(a, b)
19+
return (head, tail)
20+
}
21+
22+
@_transparent
23+
public static func fastTwoSum<T:Real>(_ a: T, _ b: T) -> (head: T, tail: T) {
24+
assert(!(b.magnitude > a.magnitude))
25+
let head = a + b
26+
let tail = a - head + b
27+
return (head, tail)
28+
}
29+
}

Tests/Executable/ComplexElementaryFunctions/Error.swift renamed to Sources/_TestSupport/Error.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,22 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13-
import Numerics
13+
import ComplexModule
1414

15-
func relativeError(_ tst: Float, _ ref: Double) -> Double {
15+
public func relativeError(_ tst: Float, _ ref: Double) -> Double {
1616
let scale = max(ref.magnitude, Double(Float.leastNormalMagnitude))
1717
let error = (Double(tst) - ref).magnitude
1818
return error / scale
1919
}
2020

21-
func componentwiseError(_ tst: Complex<Float>, _ ref: Complex<Double>) -> Double {
21+
public func componentwiseError(_ tst: Complex<Float>, _ ref: Complex<Double>) -> Double {
2222
return max(relativeError(tst.real, ref.real),
2323
relativeError(tst.imaginary, ref.imaginary))
2424
}
2525

26-
func relativeError(_ tst: Complex<Float>, _ ref: Complex<Double>) -> Double {
26+
public func relativeError(_ tst: Complex<Float>, _ ref: Complex<Double>) -> Double {
2727
let scale = max(ref.magnitude, Double(Float.leastNormalMagnitude))
28-
let error = (Complex<Double>(tst) - ref).magnitude
28+
let dtst = Complex(Double(tst.real), Double(tst.imaginary))
29+
let error = (dtst - ref).magnitude
2930
return error / scale
3031
}

Sources/_TestSupport/Interval.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//===--- Interval.swift ---------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
// A not-particularly-clever floating-point iterval that is iterable for the
14+
// purposes of testing.
15+
public struct Interval<Element>: Sequence where Element: FloatingPoint {
16+
17+
let lower: Element
18+
19+
let upper: Element
20+
21+
public init(from: Element, through: Element) {
22+
precondition(from <= through)
23+
lower = from
24+
upper = through
25+
}
26+
27+
public init(from: Element, to: Element) {
28+
self.init(from: from, through: to.nextDown)
29+
}
30+
31+
public func makeIterator() -> Iterator {
32+
Iterator(self)
33+
}
34+
35+
public struct Iterator: IteratorProtocol {
36+
let interval: Interval
37+
var nextOutput: Element?
38+
39+
init(_ interval: Interval) {
40+
self.interval = interval
41+
self.nextOutput = interval.lower
42+
}
43+
44+
public mutating func next() -> Element? {
45+
let result = nextOutput
46+
if nextOutput == interval.upper { nextOutput = nil }
47+
else { nextOutput = nextOutput?.nextUp }
48+
return result
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)