Skip to content

Commit 06f5c53

Browse files
authored
perf: improve TimingAnimation performance (#780)
## 📜 Description Optimized `TimingAnimation.timingAt` method by `10x`. ## 💡 Motivation and Context ### 1️⃣ Main optimization (x10 boost) The key optimization here is that instead of `bisection` -> (`findTForX`/`bezierY`) pipeline we simply use `findTForY`/`bezierX`. Thus we get rid off outer loop and as a result performance got improved by 10x: from 2.57s to 0.26s! > Technically performance was improved from _log(n) ^ log(n)_ to _log(n)_, but concrete benchmarks shows `x10` boost 🔥 Other performance optimization was made for `valueAt` - I removed unneeded math operations + casting and it gave a little bit boost (like 5%). ### 2️⃣ Don't loose 30% because of closures To keep follow DRY principles I created common `findT` function. Initially I wanted to pass a closure (for `findTForX`/`findTForY`), but turned out it makes perf worse by ~30%: ```swift private func findTForX(xTarget: CGFloat, epsilon: CGFloat = 0.0001) -> CGFloat { findT( target: xTarget, valueFunction: { [weak self] t in self?.bezierX(t: t) ?? 0 }, derivativeFunction: { [weak self] t in self?.bezierXDerivative(t: t) ?? 0 }, epsilon: epsilon ) } ``` To overcome performance degradation I decided to switch to `enum`-based approach. Good comparison of speed: <img width="606" alt="image" src="https://github.com/user-attachments/assets/149d21e7-03fd-4c66-a6d6-a64519e70655" /> Explanation where we loose 30%: <img width="772" alt="image" src="https://github.com/user-attachments/assets/3f3669eb-eb9e-4443-8486-2d9f63bdb02e" /> And low level stuff: <img width="690" alt="image" src="https://github.com/user-attachments/assets/097dd481-c246-4264-b583-74286b6057d6" /> ### 3️⃣ Don't loose 10% I also noticed that code `max(0, min(t, 1))` - it's a simple clamping. And I created an `extension` for that, but immediately noticed `10%` worse performance 🤷‍♂️ I think it happens because of frequent implicit casting - so for now i decided to have such statement in two places of the code for the sake of performance. ### 4️⃣ Another potential 5% performance improvements We also can combine `derivative` and `value` computation from common values, i. e.: ```swift private func calculateComponents(t: CGFloat, component: BezierComponent) -> (value: CGFloat, derivative: CGFloat) { let u = 1 - t let tt = t * t let uu = u * u let tu = t * u switch component { case .x: let value = 3 * uu * t * p1.x + 3 * u * tt * p2.x + tt * t let derivative = (3 * uu - 6 * tu) * p1.x + (6 * tu - 3 * tt) * p2.x + 3 * tt return (value, derivative) case .y: let value = 3 * uu * t * p1.y + 3 * u * tt * p2.y + tt * t let derivative = (3 * uu - 6 * tu) * p1.y + (6 * tu - 3 * tt) * p2.y + 3 * tt return (value, derivative) } } ``` But it gives up to 1-5% of boost, and in my opinion we loose code attractiveness with such approach, so for now i'll stick with closures and shared methods (later on I always can revisit the approach). ### 5️⃣ What should we do with default bisect method? This is the question I don't have the answer at the moment. Just technically it is used only in `SpringAnimation`, but can be potentially handy to use it in other animations (`DecayAnimation` if it will be ever implemented, for example). So for now I think we can use it as a main method for finding a `t` by `x`, but if we can use more optimized version (as in `TimingAnimation`), then we can always override it. The reason why I don't want to use Newton-Rhapson method in `SpringAnimation` is because it's slower, than plain bisect search. So, to sum-it-up: bisect for `SpringAnimation` (and by default for all animations), Newton-Rhapson for `TimingAnimation` (since `valueAt` is based on it, so better not to increase complexity of the method with adding `bisect` on top of it and simply re-use Newton-Rhapson just for different coordinate value). > [!NOTE] > Last, but not least, if we increase precision by x10 (i. e. from `0.0001` to `0.00001` then computation will consume only 5% resources more vs 10% with previous approach 😎 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### iOS - added unit tests for `timingAt` method for `TimingAnimation` class; - updated performance metrics for `TimingAnimation` tests; - override `timingAt` method and use Newton Raphson method to find a given value; - create common `findT` method; - create `findTForX`/`findTForY` methods (via `enum`); - create `calculateComponents` method (using `enum` + `switch`). ## 🤔 How Has This Been Tested? Tested via performance tests. ## 📸 Screenshots (if appropriate): |Real device|Simulator (slow animations)| |-----------|----------------------------| |<video src="https://github.com/user-attachments/assets/c4bca15b-30a6-4e32-9bd1-71c00c4dc285">|<video src="https://github.com/user-attachments/assets/f8db2df0-e35f-460b-8b77-321b3b8faefa">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 1449971 commit 06f5c53

File tree

3 files changed

+94
-40
lines changed

3 files changed

+94
-40
lines changed

Diff for: ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/xcshareddata/xcbaselines/0873ED612BB6B7390004F3A4.xcbaseline/AE1417BE-2A84-4C63-BD95-0B7DE93E2975.plist

+8-8
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,28 @@
7676
<key>com.apple.dt.XCTMetric_CPU.cycles</key>
7777
<dict>
7878
<key>baselineAverage</key>
79-
<real>7640000.000000</real>
79+
<real>727000.000000</real>
8080
<key>baselineIntegrationDisplayName</key>
8181
<string>Local Baseline</string>
8282
</dict>
8383
<key>com.apple.dt.XCTMetric_CPU.instructions_retired</key>
8484
<dict>
8585
<key>baselineAverage</key>
86-
<real>37300000.000000</real>
86+
<real>3370000.000000</real>
8787
<key>baselineIntegrationDisplayName</key>
8888
<string>Local Baseline</string>
8989
</dict>
9090
<key>com.apple.dt.XCTMetric_CPU.time</key>
9191
<dict>
9292
<key>baselineAverage</key>
93-
<real>2.563817</real>
93+
<real>0.267000</real>
9494
<key>baselineIntegrationDisplayName</key>
9595
<string>Local Baseline</string>
9696
</dict>
9797
<key>com.apple.dt.XCTMetric_Clock.time.monotonic</key>
9898
<dict>
9999
<key>baselineAverage</key>
100-
<real>2.564888</real>
100+
<real>0.267000</real>
101101
<key>baselineIntegrationDisplayName</key>
102102
<string>Local Baseline</string>
103103
</dict>
@@ -107,28 +107,28 @@
107107
<key>com.apple.dt.XCTMetric_CPU.cycles</key>
108108
<dict>
109109
<key>baselineAverage</key>
110-
<real>299000.000000</real>
110+
<real>303000.000000</real>
111111
<key>baselineIntegrationDisplayName</key>
112112
<string>Local Baseline</string>
113113
</dict>
114114
<key>com.apple.dt.XCTMetric_CPU.instructions_retired</key>
115115
<dict>
116116
<key>baselineAverage</key>
117-
<real>1390000.000000</real>
117+
<real>1400000.000000</real>
118118
<key>baselineIntegrationDisplayName</key>
119119
<string>Local Baseline</string>
120120
</dict>
121121
<key>com.apple.dt.XCTMetric_CPU.time</key>
122122
<dict>
123123
<key>baselineAverage</key>
124-
<real>0.110000</real>
124+
<real>0.112000</real>
125125
<key>baselineIntegrationDisplayName</key>
126126
<string>Local Baseline</string>
127127
</dict>
128128
<key>com.apple.dt.XCTMetric_Clock.time.monotonic</key>
129129
<dict>
130130
<key>baselineAverage</key>
131-
<real>0.109000</real>
131+
<real>0.111000</real>
132132
<key>baselineIntegrationDisplayName</key>
133133
<string>Local Baseline</string>
134134
</dict>

Diff for: ios/KeyboardControllerNative/KeyboardControllerNativeTests/TimingAnimationPerformanceTest.swift

+9
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ final class TimingAnimationPerformanceTest: XCTestCase {
4343
XCTAssertEqual(animation.valueAt(time: 0.143954), 290.97699741147824)
4444
}
4545

46+
func testTimingAnimationTimingAt() throws {
47+
XCTAssertEqual(animation.timingAt(value: 123.56118966540286), 0.010046876612582212)
48+
XCTAssertEqual(animation.timingAt(value: 163.8067164607386), 0.050004940820975737)
49+
XCTAssertEqual(animation.timingAt(value: 122.0), 0.0008425121366836072)
50+
XCTAssertEqual(animation.timingAt(value: 284.31306189738245), 0.12478092602978305)
51+
XCTAssertEqual(animation.timingAt(value: 290.97699741147824), 0.14381945731075155)
52+
XCTAssertEqual(animation.timingAt(value: 291.0), 0.14434649124006554)
53+
}
54+
4655
func testValueAtPerformance() throws {
4756
measure(metrics: [XCTCPUMetric(), XCTClockMetric()], options: options) {
4857
for time in stride(from: 0.0, through: TimingAnimationPerformanceTest.duration, by: 0.000002) {

Diff for: ios/animations/TimingAnimation.swift

+77-32
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,38 @@ public final class TimingAnimation: KeyboardAnimation {
3333
super.init(fromValue: fromValue, toValue: toValue, animation: animation)
3434
}
3535

36-
// public functions
36+
// MARK: public functions
37+
3738
override func valueAt(time: Double) -> Double {
38-
let x = time * speed
39-
let frames = (animation?.duration ?? 0.0) * Double(speed)
40-
let fraction = min(x / frames, 1)
41-
let t = findTForX(xTarget: fraction)
39+
let duration = animation?.duration ?? 0.0
40+
guard duration > 0 else { return toValue }
4241

42+
let fraction = min(time / duration, 1.0)
43+
let t = findTForX(xTarget: fraction)
4344
let progress = bezierY(t: t)
4445

45-
return fromValue + (toValue - fromValue) * CGFloat(progress)
46+
return fromValue + (toValue - fromValue) * progress
47+
}
48+
49+
override func timingAt(value: Double) -> Double {
50+
guard (toValue - fromValue) != 0 else { return 0 }
51+
52+
let targetY = (value - fromValue) / (toValue - fromValue)
53+
let clampedY = max(min(targetY, 1.0), 0.0)
54+
55+
let t = findTForY(yTarget: clampedY)
56+
let x = bezierX(t: t)
57+
58+
let duration = animation?.duration ?? 0.0
59+
let time = x * duration / speed
60+
61+
return time
4662
}
4763

4864
// private functions
65+
66+
// MARK: Bézier
67+
4968
private func bezier(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
5069
let u = 1 - t
5170
let tt = t * t
@@ -70,39 +89,65 @@ public final class TimingAnimation: KeyboardAnimation {
7089
return bezier(t: t) { $0.x }
7190
}
7291

73-
private func findTForX(xTarget: CGFloat, epsilon: CGFloat = 0.0001, maxIterations: Int = 100) -> CGFloat {
74-
var t: CGFloat = 0.5 // Start with an initial guess of t = 0.5
75-
for _ in 0 ..< maxIterations {
76-
let currentX = bezierX(t: t) // Compute the x-coordinate at t
77-
let derivativeX = bezierXDerivative(t: t) // Compute the derivative at t
78-
let xError = currentX - xTarget
79-
if abs(xError) < epsilon {
80-
return t
81-
}
82-
t -= xError / derivativeX // Newton-Raphson step
83-
t = max(min(t, 1), 0) // Ensure t stays within bounds
92+
private func bezierDerivative(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
93+
let u = 1 - t
94+
let term1 = (3 * u * u - 6 * t * u) * valueForPoint(p1)
95+
let term2 = (6 * t * u - 3 * t * t) * valueForPoint(p2)
96+
let term3 = 3 * t * t
97+
return term1 + term2 + term3
98+
}
99+
100+
private func bezierXDerivative(t: CGFloat) -> CGFloat {
101+
bezierDerivative(t: t) { $0.x }
102+
}
103+
104+
private func bezierYDerivative(t: CGFloat) -> CGFloat {
105+
bezierDerivative(t: t) { $0.y }
106+
}
107+
108+
private enum BezierComponent {
109+
case x
110+
case y
111+
}
112+
113+
private func calculateComponents(t: CGFloat, component: BezierComponent) -> (value: CGFloat, derivative: CGFloat) {
114+
switch component {
115+
case .x:
116+
return (bezierX(t: t), bezierXDerivative(t: t))
117+
case .y:
118+
return (bezierY(t: t), bezierYDerivative(t: t))
84119
}
85-
return t // Return the approximation of t
86120
}
87121

88-
private func bezierDerivative(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
89-
let u = 1 - t
90-
let uu = u * u
91-
let tt = t * t
92-
let tu = t * u
122+
// MARK: Newton Raphson
93123

94-
// term0 is evaluated as `0`, because P0(0, 0)
95-
// let term0 = -3 * uu * valueForPoint(p0)
96-
let term1 = (3 * uu - 6 * tu) * valueForPoint(p1)
97-
let term2 = (6 * tu - 3 * tt) * valueForPoint(p2)
98-
let term3 = 3 * tt // * valueForPoint(p3), because P3(1, 1)
124+
private func findT(
125+
target: CGFloat,
126+
component: BezierComponent,
127+
epsilon: CGFloat = 0.0001,
128+
maxIterations: Int = 100
129+
) -> CGFloat {
130+
var t: CGFloat = 0.5
131+
for _ in 0 ..< maxIterations {
132+
let (value, derivative) = calculateComponents(t: t, component: component)
133+
let error = value - target
99134

100-
// Sum all terms to get the derivative
101-
return term1 + term2 + term3
135+
if abs(error) < epsilon {
136+
break
137+
}
138+
139+
t -= error / derivative
140+
t = max(0, min(t, 1))
141+
}
142+
return t
102143
}
103144

104-
private func bezierXDerivative(t: CGFloat) -> CGFloat {
105-
return bezierDerivative(t: t) { $0.x }
145+
private func findTForX(xTarget: CGFloat) -> CGFloat {
146+
findT(target: xTarget, component: .x)
147+
}
148+
149+
private func findTForY(yTarget: CGFloat) -> CGFloat {
150+
findT(target: yTarget, component: .y)
106151
}
107152
}
108153

0 commit comments

Comments
 (0)