JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
16.3k stars 1.18k forks source link

Performance regression in modifying `MutableState` #2005

Closed alexjpwalker closed 2 years ago

alexjpwalker commented 2 years ago

Calls to drawRect (and other drawing methods) in a Canvas are significantly slower in Compose 1.1.1 than they were in 1.0.0-alpha3.

EDIT: We now know that the actual performance regression is in the management of the MutableState objects - not the drawing of the rectangles.

I've bisected the releases, and found that the regression happened in 1.0.0-alpha4-build396 (that is, 1.0.0-alpha4-build396 has the worse performance, while 1.0.0-alpha4-build385 is OK)

Environment

OS: MacOS 12.1 (Monterey) Compose version: 1.1.1

Reproducible example

The following code creates a Window, renders 2000 rectangles, and gradually moves them across the screen, from left to right.

On my MacBook it runs approximately 4-5x slower in Compose 1.0.0-alpha4-build396, compared to 1.0.0-alpha4-build385.

package com.vaticle.compose.perf

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState

fun main() {
    application {
        Window(title = "Boxes", onCloseRequest = { exitApplication() }, state = rememberWindowState(WindowPlacement.Maximized)) {
            val boxes = rememberBoxes()
            Canvas(Modifier.fillMaxSize()) {
                boxes.forEach {
                    drawRect(Color.Red, Offset(it.x, it.y), Size(18f, 6f))
                }
            }
            LaunchedEffect(Unit) {
                while (true) {
                    withFrameNanos { boxes.forEach { box -> box.x++ } }
                }
            }
        }
    }
}

@Composable
fun rememberBoxes(): List<Block> {
    val boxes = mutableListOf<Block>()
    for (i in 0 until 100) {
        for (j in 0 until 20) { boxes += Block(x = (j * 20).toFloat(), y = (i * 8).toFloat()) }
    }
    return remember { boxes }
}

class Block(x: Float, y: Float) {
    var x by mutableStateOf(x)
    var y by mutableStateOf(y)
}
haikalpribadi commented 2 years ago

This is a critical issue for us, Compose team, as our application's man feature is a data "graph visualiser" we rely on the drawing functionality scaling properly. We're planning the major release to come out next week. Any chance this can get some attention ASAP?

igordmn commented 2 years ago

Thanks!

I have tested this example on MacBook x86:

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kotlin.math.roundToInt

val fpsCounter = FPSCounter()

fun main() {
    System.setProperty("skiko.vsync.enabled", "false")
    application {
        Window(title = "Boxes", onCloseRequest = { exitApplication() }, state = rememberWindowState(WindowPlacement.Maximized)) {
            val boxes = rememberBoxes()
            Canvas(Modifier.fillMaxSize()) {
                boxes.forEach {
                    drawRect(Color.Red, Offset(it.x, it.y), Size(18f, 6f))
                }
            }
            LaunchedEffect(Unit) {
                while (true) {
                    withFrameNanos {
                        boxes.forEach { box -> box.x++ }
                        fpsCounter.tick()
                        println(fpsCounter.average)
                    }
                }
            }
        }
    }
}

@Composable
fun rememberBoxes(): List<Block> {
    val boxes = mutableListOf<Block>()
    for (i in 0 until 200) {
        for (j in 0 until 90) { boxes += Block(x = (j * 20).toFloat(), y = (i * 8).toFloat()) }
    }
    return remember { boxes }
}

class Block(x: Float, y: Float) {
    var x by mutableStateOf(x)
    var y by mutableStateOf(y)
}

class FPSCounter(
    private val periodSeconds: Double = 2.0
) {
    private val times = mutableListOf<Long>()
    private var lastLogTime = System.nanoTime()
    private var lastTime = System.nanoTime()

    var average = 0
        private set

    fun tick() {
        val time = System.nanoTime()
        val frameTime = time - lastTime
        lastTime = time

        times.add(frameTime)

        if ((time - lastLogTime) > periodSeconds.secondsToNanos() && times.isNotEmpty()) {
            average = (nanosPerSecond / times.average()).roundToInt()
            times.clear()
            lastLogTime = time
        }
    }

    private val nanosPerSecond = 1_000_000_000.0
    private fun Double.secondsToNanos(): Long = (this * nanosPerSecond).toLong()
}

And have these results: 1.0.0-alpha4-build385 - 15 FPS 1.0.0-alpha4-build396 - 13 FPS 1.0.1 - 13 FPS 1.1.0-alpha03 - 13 FPS 1.1.0-alpha04 - 4 FPS

So it looks like significant degradation was between 1.1.0-alpha03 and 1.1.0-alpha04.

haikalpribadi commented 2 years ago

Thanks, @igordmn ! Great that we've narrowed it down. Have you got an idea on timeline as to when we can get a patch for this?

smallshen commented 2 years ago

@haikalpribadi can you try this?

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kotlin.math.roundToInt

val fpsCounter = FPSCounter()

val boxes = boxes()

fun main() {
    application {
        Window(
            title = "Boxes",
            onCloseRequest = { exitApplication() },
            state = rememberWindowState(WindowPlacement.Maximized)
        ) {
            var frame by remember { mutableStateOf(0L) }
            key(frame) {
                MassiveBlock()
            }
            LaunchedEffect(Unit) {
                while (true) {
                    withFrameNanos {
                        frame++
                        boxes.forEach { box -> box.x++ }
                        fpsCounter.tick()
                        println(fpsCounter.average)
                    }
                }
            }
        }
    }
}

@Composable
fun MassiveBlock() {

    Canvas(Modifier.fillMaxSize()) {
        drawStuff()
    }
}

fun DrawScope.drawStuff() {
    boxes.forEach { drawBlock(it) }
}

fun DrawScope.drawBlock(block: Block) {
    drawRect(Color.Red, Offset(block.x, block.y), Size(18f, 6f))
}

fun boxes(): List<Block> {
    val boxes = mutableListOf<Block>()
    for (i in 0 until 200) {
        for (j in 0 until 90) {
            boxes += Block(x = (j * 20).toFloat(), y = (i * 8).toFloat())
        }
    }
    return boxes
}

class Block(x: Float, y: Float) {
    var x = x
    var y = y
}

class FPSCounter(
    private val periodSeconds: Double = 2.0
) {
    private val times = mutableListOf<Long>()
    private var lastLogTime = System.nanoTime()
    private var lastTime = System.nanoTime()

    var average = 0
        private set

    fun tick() {
        val time = System.nanoTime()
        val frameTime = time - lastTime
        lastTime = time

        times.add(frameTime)

        if ((time - lastLogTime) > periodSeconds.secondsToNanos() && times.isNotEmpty()) {
            average = (nanosPerSecond / times.average()).roundToInt()
            times.clear()
            lastLogTime = time
        }
    }

    private val nanosPerSecond = 1_000_000_000.0
    private fun Double.secondsToNanos(): Long = (this * nanosPerSecond).toLong()
}

I got 60 fps on a Mac, (same as monitor's max).

haikalpribadi commented 2 years ago

Thanks @smallshen -- what was the fix? @alexjpwalker can you test the above?

alexjpwalker commented 2 years ago

@haikalpribadi can you try this?

// snip

I got 60 fps on a Mac, (same as monitor's max).

@smallshen Sure enough, the FPSCounter does report high FPS (55 on my Mac) when running in later versions of Compose such as 1.1.0-alpha04. But when running in 1.0.0-alpha3, it reports 230 FPS which is indeed 4-5x more, and as you'd expect, the blocks slide across the screen about 4.5x faster.

Obviously, we wouldn't need 230 FPS in a real application - our real application is a physical force simulation which needs to perform computations between each frame render, which drags down the FPS as well; but it performs sharply worse in later versions of Compose, which appears to be caused by drawRect (specifically drawRoundRect) having become more expensive.

haikalpribadi commented 2 years ago

@smallshen I think we can say that your code snippet above doesn't really prove much, other than the fact that you can optimise the code for the specific instance above (which is not our real life use case anyways). As @alexjpwalker pointed out above, your code performs much faster in older versions of compose, which proves the performance degradation is still there. In lightweight computation like the above, it goes down from 230 FPS to 60 FPS. But in heavier-weight computation (our real life use case) it goes down from 6 FPS to 1 FPS. Irrespective of how optimal the application code is, the compose drawing API should not degrade in its performance.

smallshen commented 2 years ago

@haikalpribadi The code being slow because of massive state updates.

Screen Shot 2022-04-20 at 11 27 21 AM

If you know all the states will change on every frame, in this case is the x, we don't need to create 18000 state, we only need to create one state depends on the frame. Create one instead many of them.

For example, we create 10000 user entity.

class DatabaseUserEntity(username:String) {
   var displayName by mutableState(username + y.toString())
   var x by mutableStateOf(0)
   var y by mutableStateOf(0)
   var z by mutableStateOf(0)
}

If we increase 1 for all users' y, it creates 10000 state update. Also if we update displayName with their y value when the y change, it creates another 10000 state update. All of them will store in snapshot, and contains previous value (10000 more objects).

change class to

class DatabaseUserEntity(username:String) {
   var displayName = "$username$y"
   var x = 0
   var y = 0
   var z = 0
}

If xyz is offset on screen (dragging offsets), we can use key(frame) to update on each frame, thus, we only have 1 state instead of 3 * 10000 states.

@haikalpribadi you also mentioned about heavier-weight computation. The compose ui shouldn't lag because they are all composable functions, if you are doing computations they are side effects, which is not allowed in composable.

I use compose runtime as a small particle engine in a game. I used to use state I also faced performance issue, states are expensive compares to normal field. They create 2 objects(StateRecord, Listener), and it will hold previous, current, applied state objects. It is a lot more than a single primitive int field.

haikalpribadi commented 2 years ago

@smallshen your solution definitely pointed out a strategy that we haven't considered before: avoid making thousands of states and ensure recomposition using key(frame) { ... } -- that's definitely an optimisation we want to adopt, and it makes perfect sense! (cc @alexjpwalker ). I think it should speed things up a lot for us too.

However, that the function to drawRect() (which is the main topic of this issue) is still suffering from a regression, and I think we should look into how to fix that to the original performance.

kirill-grouchnikov commented 2 years ago

In your sample, you are mixing two things - state updates and drawing. Same in Igor's repro - where every rectangle manages its own state.

So it's not clear that there is a regression in the Canvas.drawRect - until you split your state updates into a single state object that is used to compute the positions of each of the rectangles.

In general, I would not view Compose at the moment as something that targets - or is optimized - to be run as a gaming / sprite / particle engine.

haikalpribadi commented 2 years ago

Hi @kirill-grouchnikov, using the @smallshen's example, where the heavy state updates were avoided, @alexjpwalker was still able to reproduce the regression.

We're not quite using Compose for a gaming/particle engine. We're building an IDE, and as part of that IDE there is a "graph visualisation" component. It's far, far from a particle engine. I think it would be fair to expect Compose to render drawRect for a few thousand vertices smoothly, no? It did so in 1.0.0-alpha-x and there's no reason for it to regress, no?

haikalpribadi commented 2 years ago

@kirill-grouchnikov Also, Compose Drawing library uses Skia (https://skia.org) which is a "2D Graphics Library". I think it should be fair to expect Compose Drawing library to be able to delivery what Skia provides under the hood, smoothly, right?

kirill-grouchnikov commented 2 years ago

If the native monitor framerate is 60fps, there is only a theoretical interest in doing something like "230fps updates". The monitor can't exceed its own refresh rate, so you are not really doing 230 updates every second on the screen in any case.

Skia performance is a separate topic. What would help here is to have simple, standalone samples that show performance regressions of a very specific part that you want to focus on, be it state management or rendering rectangles.

haikalpribadi commented 2 years ago

If the native monitor framerate is 60fps, there is only a theoretical interest in doing something like "230fps updates". The monitor can't exceed its own refresh rate, so you are not really doing 230 updates every second on the screen in any case.

LOL - of course we know that. You're missing the point @kirill-grouchnikov.

Even if 230fps is theoretical, upgrading the Compose version (without changing anything else) should not drop it to 55-60fps.

kirill-grouchnikov commented 2 years ago

It wasn't to 55-60 fps, at least in the example that was placed in the comment. It went down to exactly the framerate of the underlying hardware. This very well might have been an intentional change in Skiko to synchronize the framerate of the rendering pipeline with the capabilities of the hardware.

haikalpribadi commented 2 years ago

Let's wait to hear what @igordmn finds, instead of coming up with hypotheticals @kirill-grouchnikov.

alexjpwalker commented 2 years ago

Thanks for the feedback and the hint to use key(frame)! I'll work on creating a reproducible sample that uses only one MutableState object (the frame number) so we can proceed with this issue.

alexjpwalker commented 2 years ago

If you know all the states will change on every frame, in this case is the x, we don't need to create 18000 state, we only need to create one state depends on the frame. Create one instead many of them.

Thanks @smallshen - after further investigation it turns out the culprit is, indeed, the updates to MutableState objects - not the drawing of rectangles or any other shape. By refactoring our physics simulation to use a single MutableState object (a frame ID) to determine when to re-render the scene, the performance regression of our application is fixed.

Do we want to look into the reasons why MutableState updates are slower in the later versions of Compose?

kirill-grouchnikov commented 2 years ago

As said yesterday, Compose shouldn't be seen to be optimized to drive particle-like scenegraph with thousands+ objects where each object has its own state that changes on every frame.

However, if that is indeed a desired performance characteristic, it needs to be filed as a feature request in the Compose tracker, as state tracking is done in the core Compose modules / compiler.

haikalpribadi commented 2 years ago

Thanks for confirming that @alexjpwalker, and thanks for the input @smallshen @kirill-grouchnikov @igordmn . We should close this issue now as we can proceed.

okushnikov commented 2 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.