Closed alexjpwalker closed 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?
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
.
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?
@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).
Thanks @smallshen -- what was the fix? @alexjpwalker can you test the above?
@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.
@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.
@haikalpribadi The code being slow because of massive state updates.
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.
@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.
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.
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?
@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?
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.
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.
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.
Let's wait to hear what @igordmn finds, instead of coming up with hypotheticals @kirill-grouchnikov.
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.
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?
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.
Thanks for confirming that @alexjpwalker, and thanks for the input @smallshen @kirill-grouchnikov @igordmn . We should close this issue now as we can proceed.
Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.
Calls to
drawRect
(and other drawing methods) in aCanvas
are significantly slower in Compose1.1.1
than they were in1.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, while1.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 to1.0.0-alpha4-build385
.