Swift Weak Reference Much Slower than Strong Reference

1k views Asked by At

I'm building a physics engine in Swift. After making some recent additions to the engine and running the benchmarking tests I noticed the performance was drastically slower. For example, in the screenshots below you can see how the FPS dropped from 60 to 3 FPS (FPS is in the bottom-right corner). Eventually, I traced the problem down to just a single line of code:

final class Shape {
    ...
    weak var body: Body! // This guy
    ...
}

At some point in my additions I added a weak reference from the Shape class to the Body class. This is to prevent a strong reference cycle, as Body also has a strong reference to Shape.

Unfortunately, it appears weak references have a significant overhead (I suppose the extra steps in nulling it out). I decided to investigate this further by building a massively simplified version of the physics engine below and benchmarking different reference types.


import Foundation

final class Body {
    let shape: Shape
    var position = CGPoint()
    init(shape: Shape) {
        self.shape = shape
        shape.body = self
        
    }
}

final class Shape {
    weak var body: Body! //****** This line is the problem ******
    var vertices: [CGPoint] = []
    init() {
        for _ in 0 ..< 8 {
            self.vertices.append( CGPoint(x:CGFloat.random(in: -10...10), y:CGFloat.random(in: -10...10) ))
        }
    }
}

var bodies: [Body] = []
for _ in 0 ..< 1000 {
    bodies.append(Body(shape: Shape()))
}

var pairs: [(Shape,Shape)] = []
for i in 0 ..< bodies.count {
    let a = bodies[i]
    for j in i + 1 ..< bodies.count {
        let b = bodies[j]
        pairs.append((a.shape,b.shape))
    }
}

/*
 Benchmarking some random computation performed on the pairs.
 Normally this would be collision detection, impulse resolution, etc.
 */
let startTime = CFAbsoluteTimeGetCurrent()
for (a,b) in pairs {
    var t: CGFloat = 0
    for v in a.vertices {
        t += v.x*v.x + v.y*v.y
    }
    for v in b.vertices {
        t += v.x*v.x + v.y*v.y
    }
    a.body.position.x += t
    a.body.position.y += t
    b.body.position.x -= t
    b.body.position.y -= t
}
let time = CFAbsoluteTimeGetCurrent() - startTime

print(time)

Results

Below are the benchmark times for each reference type. In each test, the body reference on the Shape class was changed. The code was built using release mode [-O] with Swift 5.1 targeting macOS 10.15.

weak var body: Body!: 0.1886 s

var body: Body!: 0.0167 s

unowned body: Body!: 0.0942 s

You can see using a strong reference in the computation above instead of a weak reference results in over 10x faster performance. Using unowned helps, but unfortunately it is still 5x slower. When running the code through the profiler, there appear to be additional runtime checks being performed resulting in much overhead.

So the question is, what are my options for having a simple back pointer to Body without incurring this ARC overhead. And furthermore why does this overhead seem so extreme? I suppose I could keep the strong reference cycle and break it manually. But I'm wondering if there is a better alternative?

Update: Based on the answer, here is the result for
unowned(unsafe) var body: Body!: 0.0160 s

Update2: As of Swift 5.2 (Xcode 11.4), I have noticed that unowned(unsafe) has much more overhead. Here is the result now for unowned(unsafe) var body: Body!: 0.0804 s

Note: This is still true as of Xcode 12/Swift 5.3

1

There are 1 answers

0
Epic Byte On BEST ANSWER

As I was writing up/investigating this issue, I eventually found a solution. To have a simple back pointer without the overhead checks of weak or unowned you can declare body as:

unowned(unsafe) var body: Body!

According to the Swift documentation:

Swift also provides unsafe unowned references for cases where you need to disable runtime safety checks—for example, for performance reasons. As with all unsafe operations, you take on the responsibility for checking that code for safety.

You indicate an unsafe unowned reference by writing unowned(unsafe). If you try to access an unsafe unowned reference after the instance that it refers to is deallocated, your program will try to access the memory location where the instance used to be, which is an unsafe operation

So it is clear these runtime checks can produce serious overhead in performance-critical code.

Update: As of Swift 5.2 (Xcode 11.4), I have noticed that unowned(unsafe) has much more overhead. I now simply use strong references and break retain cycles manually, or try to avoid them entirely in performance-critical code.

Note: This is still true as of Xcode 12/Swift 5.3