FFTView poor performance as time passes
macOS Version(s) Used to Build
macOS 13 Ventura
Xcode Version(s)
Xcode 14
Description
I observe a decrease in frame rate for the FTTView as time passes. This can be observed in the cookbook example.
I attached a recording of the view performance after about 2 minutes of running. You can see the lag. Any ideas how to fix?
Crash Logs, Screenshots or Other Attachments (if applicable)
https://github.com/user-attachments/assets/4fcb597d-402f-41cc-a74b-03e5e29a4496
The bug is fully reproducible. Leaving the App open for some more minutes even leads to a crash. I'll have a quick look into the crash to gather more information.
Findings without looking at the code:
- View uses 13% CPU on my blue iPhone even when no sound is playing. Partly to AURemoteIO and partly on main thread.
- View uses roughly 50% CPU when sound is playing: Partly in SwiftUI.AsyncRenderer
- When sound is playing: Memory footprint constantly increasest at a rate of 100KB per second
- CPU usage constantly increases, to 100% in a about a minute. On main thread
- Memory Graph:
- There's a UICollectionView List with about 1200 Mutable Dictionaries. These dictionaries contain some runtime information (see below)
- There are more than 3000 CFStrings lingering around, showing a orange icond with a drop. The contain all the same value
eyJEQkd [...] Ijp0cnVlfQ== - The Mutable Dictionaires are inited by the CFPrefs and by a CGPath (moveto, quadto, lineto, ...)
- View Hierarchy with clipped content shows a grotesque amount of ZStacks (see attached screenshot)
- Xcode shows an Optimization Opportunity: "The layer is using dynamic shadows which are expensive to render. If possible try setting
shadowPath, or pre-rendering the shadow into an image and putting it under the layer." - Pausing and continuing the app with Debugger: Memory clears, CPU usage dropts to 40% and starts increasing again.
It looks like a memory leak or some silly code that creates elements in a tight loop and garbage collector / reference counter can't act quick enough to free the memory again.
Starting the app with attached debugger will not lead to a crash, even after running for more than an hour (on CPU 100%).
Next step: Look into the code to understand where all these dicts come from.
{
"ALU instruction count" = 24;
"Branch instruction count" = 0;
"Compilation time in milliseconds" = "3.817916";
"Constant calculation phase present" = 0;
"Constant calculation temporary register count" = 0;
"Device atomic instruction count" = 0;
"Device load instruction count" = 0;
"Device store instruction count" = 0;
"FP16 instruction count" = 15;
"FP32 instruction count" = 8;
FragmentBufferPrefetch = (
promoted
);
"INT16 instruction count" = 0;
"INT32 instruction count" = 0;
"Instruction count" = 34;
Remarks = "--- !Analysis\nPass: prologepilog\nName: StackSize\nFunction: agc.main\nArgs:\n - NumStackBytes: '0'\n - String: ' stack bytes in function'\n...\n--- !Analysis\nPass: asm-printer\nName: InstructionMix\nFunction: agc.main\nArgs:\n - String: 'BasicBlock: '\n - BasicBlock: wrapper_exit\n - String: \"\\n\"\n - String: ''\n - String: ': '\n - INST_: '34'\n - String: \"\\n\"\n...\n--- !Analysis\nPass: asm-printer\nName: InstructionCount\nFunction: agc.main\nArgs:\n - NumInstructions: '34'\n - String: ' instructions in function'\n...\n";
"Spilled bytes" = 0;
"Telemetry Statistics" = {
};
"Temporary register count" = 8;
"Texture reads instruction count" = 0;
"Texture writes instruction count" = 0;
"Thread invariant spilled bytes" = 0;
"Threadgroup atomic instruction count" = 0;
"Threadgroup load instruction count" = 0;
"Threadgroup memory" = 32;
"Threadgroup store instruction count" = 0;
"Uniform register count" = 8;
"Wait instruction count" = 4;
}
Code analysis:
- No Strings and dicts are explicitely created. Could it be due to some debugging stuff? will a release build also have the same problems?
- Fresh FFT results might come too quick
- .animation of AmplitudeBar and Cap View might interfere with fresh FFT data: the animation is running until the end even when fresh data is already shown
- .drawingGroup (Metal rendering) is on HStack, it might be better on each bar (because of List View)
- GeometryReader in a thight loop (ForEach fresh amplitude reading), this is discouraged by Apple. The width and height could be read once (on appear and on change of orientation / layout) and the passed to the other views in @Environment or as a member
@mahal appreciate your insight on this! To me it seems SwiftUI rendering is messing something up, as you showed the huge amount of views rendered one on top of another... I bet that's the key area to look in.
In the mean time I just replace the rendering with Charts (iOS 16+) and it works perfect
var body: some View {
GeometryReader { geometry in
ZStack {
Chart(Array(0 ..< barCount), id: \.self) { index in
makeBar(
index: index,
amplitude: amplitude(at: index),
viewHeight: geometry.size.height
)
}
.chartYScale(domain: 0 ... 0.9)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
}
}
.onAppear {
fft.maxAmplitude = self.maxAmplitude
fft.minAmplitude = self.minAmplitude
callbacks.update = fft.update(buffer:)
callbacks.start = fft.start
callbacks.stop = fft.stop
}
.background(backgroundColor)
}
private func makeBar(index: Int, amplitude: Float, viewHeight: CGFloat) -> some ChartContent {
BarMark(
x: .value("Frequency", String(index)),
y: .value("Amplitude", amplitude)
)
.foregroundStyle(
linearGradient.in(CGRect(x: 0, y: 0, width: 0, height: viewHeight))
)
}
private func amplitude(at index: Int) -> Float {
if index < fft.amplitudes.count {
return fft.amplitudes[index]
} else {
return Constants.silence
}
}