AudioKitUI icon indicating copy to clipboard operation
AudioKitUI copied to clipboard

FFTView poor performance as time passes

Open alexdmotoc opened this issue 1 year ago • 4 comments

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

alexdmotoc avatar Jul 15 '24 19:07 alexdmotoc

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.

mahal avatar Aug 03 '24 10:08 mahal

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;
}
image

mahal avatar Aug 03 '24 11:08 mahal

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 avatar Aug 03 '24 12:08 mahal

@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
        }
    }

alexdmotoc avatar Aug 03 '24 12:08 alexdmotoc