Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve RealImageLoader.execute to properly call async request #2205

Merged
20 changes: 14 additions & 6 deletions coil-base/src/main/java/coil/RealImageLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,24 @@ internal class RealImageLoader(
}

override suspend fun execute(request: ImageRequest) = coroutineScope {
// Start executing the request on the main thread.
val job = async(Dispatchers.Main.immediate) {
executeMain(request, REQUEST_TYPE_EXECUTE)
}
// We don't use the async call that returns the job for Compose to micro-optimize the performance.
// The job is only needed in case of the Views implementation.

// Update the current request attached to the view and await the result.
if (request.target is ViewTarget<*>) {
// Start executing the request on the main thread.
val job = async(Dispatchers.Main.immediate) {
executeMain(request, REQUEST_TYPE_EXECUTE)
}
// Update the current request attached to the view and await the result.
request.target.view.requestManager.getDisposable(job)

job.await()
} else {
// Start executing the request on the main thread.
withContext(Dispatchers.Main.immediate) {
executeMain(request, REQUEST_TYPE_EXECUTE)
}
}
return@coroutineScope job.await()
}

@MainThread
Expand Down
8 changes: 8 additions & 0 deletions coil-benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ setupTestModule(name = "coil.benchmark", config = true) {
defaultConfig {
minSdk = 23
buildConfigField("String", "PROJECT", "\"$targetProject\"")

// Enables Composition Tracing for benchmarks
// testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
// Enables Method tracing for benchmarks. Be aware this skews the performance results,
// so don't use it for measuring exact timinig
// testInstrumentationRunnerArguments["androidx.benchmark.profiling.mode"] = "MethodTracing"
}
buildTypes {
create("benchmark") {
Expand Down Expand Up @@ -39,6 +45,8 @@ dependencies {
implementation(libs.androidx.test.espresso)
implementation(libs.androidx.test.junit)
implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.tracing.perfetto)
implementation(libs.androidx.tracing.perfetto.binary)
}

androidComponents {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package coil.benchmark

import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.TraceMetric
import androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi
import androidx.benchmark.perfetto.PerfettoTraceProcessor

/**
* TraceSectionMetric to give average/sum in microseconds measurements.
*/
@OptIn(ExperimentalMetricApi::class)
class MicrosTraceSectionMetric(
private val sectionName: String,
private vararg val mode: Mode,
private val label: String = sectionName,
private val targetPackageOnly: Boolean = true,
) : TraceMetric() {

enum class Mode {
Sum,
Average
}

@ExperimentalPerfettoTraceProcessorApi
@Suppress("RestrictedApi")
override fun getResult(
captureInfo: CaptureInfo,
traceSession: PerfettoTraceProcessor.Session,
): List<Measurement> {
val slices = traceSession.querySlices(
sectionName,
packageName = if (targetPackageOnly) captureInfo.targetPackageName else null,
)

return mode.flatMap { m ->
when (m) {
Mode.Sum -> listOf(
Measurement(
name = sectionName + "_µs",
// note, this duration assumes non-reentrant slices
data = slices.sumOf { it.dur } / 1_000.0,
),
Measurement(
name = sectionName + "Count",
data = slices.size.toDouble(),
),
)

Mode.Average -> listOf(
Measurement(
name = label + "Average_µs",
data = slices.sumOf { it.dur } / 1_000.0 / slices.size,
),
)
}
}
}
}
66 changes: 66 additions & 0 deletions coil-benchmark/src/main/java/coil/benchmark/ScrollBenchmark.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package coil.benchmark

import android.graphics.Point
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import coil.benchmark.BuildConfig.PROJECT
import coil.benchmark.MicrosTraceSectionMetric.Mode.Average
import coil.benchmark.MicrosTraceSectionMetric.Mode.Sum
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ScrollBenchmark {

@get:Rule
val benchmarkRule = MacrobenchmarkRule()

@RequiresApi(Build.VERSION_CODES.N)
@Test
fun baselineProfile() {
benchmark(CompilationMode.Partial(BaselineProfileMode.Require))
}

@Test
fun fullCompilation() {
benchmark(CompilationMode.Full())
}

private fun benchmark(compilationMode: CompilationMode) {
benchmarkRule.measureRepeated(
packageName = "sample.$PROJECT",
metrics = listOf(
FrameTimingMetric(),
StartupTimingMetric(),
MicrosTraceSectionMetric(
"rememberAsyncImagePainter",
Sum, Average,
),
MicrosTraceSectionMetric(
"AsyncImagePainter.onRemembered",
Sum, Average,
),
),
iterations = 20,
startupMode = StartupMode.COLD,
compilationMode = compilationMode,
measureBlock = {
startActivityAndWait()
Thread.sleep(3_000)
val list = device.findObject(By.res("list"))
list.setGestureMargin(device.displayWidth / 5)
list.drag(Point(list.visibleBounds.centerX(), list.visibleBounds.top))
Thread.sleep(300)
},
)
}
}
2 changes: 1 addition & 1 deletion coil-compose-base/api/coil-compose-base.api
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public final class coil/compose/AsyncImagePainter : androidx/compose/ui/graphics
public static final field Companion Lcoil/compose/AsyncImagePainter$Companion;
public final fun getImageLoader ()Lcoil/ImageLoader;
public fun getIntrinsicSize-NH-jbRc ()J
public final fun getRequest ()Lcoil/request/ImageRequest;
public final fun getRequest (Landroidx/compose/runtime/Composer;I)Lcoil/request/ImageRequest;
public final fun getState ()Lcoil/compose/AsyncImagePainter$State;
public fun onAbandoned ()V
public fun onForgotten ()V
Expand Down
28 changes: 17 additions & 11 deletions coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package coil.compose

import android.annotation.SuppressLint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
Expand All @@ -9,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.ColorFilter
Expand All @@ -30,6 +31,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.util.trace
import coil.ImageLoader
import coil.compose.AsyncImagePainter.Companion.DefaultTransform
import coil.compose.AsyncImagePainter.State
Expand Down Expand Up @@ -197,7 +199,7 @@ private fun rememberAsyncImagePainter(
onState: ((State) -> Unit)?,
contentScale: ContentScale,
filterQuality: FilterQuality,
): AsyncImagePainter {
): AsyncImagePainter = trace("rememberAsyncImagePainter") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this trace add any overhead? Is it OK to ship to users?

Copy link
Contributor Author

@mlykotom mlykotom May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This trace doesn't add any overhead (~6ns per call) and it's fine to ship to production. Compose adds bunch of custom sections as well. These usually serve for better understanding what's going on.
You can even use these sections from the benchmarks without enabling composition tracing to keep track of how long they take.

val request = requestOf(state.model)
validateRequest(request)

Expand All @@ -208,7 +210,9 @@ private fun rememberAsyncImagePainter(
painter.filterQuality = filterQuality
painter.isPreview = LocalInspectionMode.current
painter.imageLoader = state.imageLoader
painter.request = request // Update request last so all other properties are up to date.
@SuppressLint("StateFlowValueCalledInComposition")
painter.requestInternal.value =
request // Update request last so all other properties are up to date.
painter.onRemembered() // Invoke this manually so `painter.state` is set to `Loading` immediately.
return painter
}
Expand Down Expand Up @@ -247,17 +251,19 @@ class AsyncImagePainter internal constructor(
internal var contentScale = ContentScale.Fit
internal var filterQuality = DefaultFilterQuality
internal var isPreview = false
internal val requestInternal = MutableStateFlow(request)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the a performance benefit to reading request from a MutableStateFlow vs. a mutableStateOf?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, yes, which is why I was trying a different options.

When mutableStateOf is used, in order to observe it you need the snapshotFlowOf, which adds bunch of coroutines overhead. The launch duration takes 17% of the onRemembers in comparison without it takes 9% (8% of the onRemembered difference).

image

image


/** The current [AsyncImagePainter.State]. */
var state: State by mutableStateOf(State.Empty)
private set

/** The current [ImageRequest]. */
var request: ImageRequest by mutableStateOf(request)
internal set
val request
@Composable
get() = requestInternal.collectAsState().value
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@colinrtwhite what do you think about this change? Technically this changes the API, because now the request is only available from a composable, which wasn't before. I'm not sure how this could be done in a backwards compatible way 😬

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep it as a non-Composable since it's then the same as the other exposed properties (imageLoader and state). Though if there's a strong performance case then we should consider changing all the exposed properties to be @Composable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends what strong is. I think with the coroutines optimizations + the async, we can squeeze ~0.2ms for each rememberAsyncImagePainter = grid of 20 images = ~4ms if they're all composed at once.

I don't want to just come and introduce breaking changes, which is why I try to have this discussion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mlykotom I think 0.2ms is probably a strong enough incentive to change the API. Though unfortunately, we can't make any breaking changes to the 2.x branch. We could make the changes on the 3.x (main) branch, however.


/** The current [ImageLoader]. */
var imageLoader: ImageLoader by mutableStateOf(imageLoader)
var imageLoader: ImageLoader = imageLoader
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to keep this as a mutableStateOf otherwise it violates @Stable's contract. It's possible to change the ImageLoader from rememberAsyncImagePainter and the composition should be notified.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling it doesn't have to. The readers of this are in the onRemembered function, but aren't observed with snapshotFlow. This means that whatever variable is set there, it will be used.

This state is only needed if someone would like to observe imageLoader in composition, which I kind of highly doubt, but I know it's an API change.

internal set

override val intrinsicSize: Size
Expand All @@ -282,9 +288,9 @@ class AsyncImagePainter internal constructor(
}

@OptIn(ExperimentalCoroutinesApi::class)
override fun onRemembered() {
override fun onRemembered() = trace("AsyncImagePainter.onRemembered") {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking whether the usage of onRemembered is used actually? This is manually called immediatelly in composition and then compose framework calls it again after the composition and it always just returns with the check on rememberScope != null.

I don't fully understand (yet) the child painter and it's relation to RememberObserver, but feels like it could call them differently as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too sure of Compose's behaviour, but I added this in on onRemembered since we want to stop any ongoing requests in onForgotten. This keeps the symmetry in case the painter is re-remembered after being forgotten.

The child painter is usually either a CrossfadePainter if crossfading is enabled or a DrawablePainter, which wraps the Drawable returned by the ImageLoader.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current way how it's used it doesn't really need to call this in onRemembered. It's called directly from the rememberAsyncImagePainter and sets the rememberScope, when Compose actually iterates over the RememberObservables after the composition in the apply phase, it will skip doing anything, because it's already created.

The onRemembered should be only called once, because you can't really re-remember. If it gets out of the composition completely, then the onForgotten is called and next time a new instance is created.

I think this could optimized into starting the imageLoader.execute(updateRequest(it)).toState() whenever you set a new request and it's different from the previous and it doesn't have to observe the flow. This way you optimize the coroutines overhead for many people, but also can keep the backwards-compatible way of having the state.

Anyway, I'll revert this so the API stays the same

// Short circuit if we're already remembered.
if (rememberScope != null) return
if (rememberScope != null) return@trace

// Create a new scope to observe state and execute requests while we're remembered.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
Expand All @@ -295,14 +301,14 @@ class AsyncImagePainter internal constructor(

// If we're in inspection mode skip the image request and set the state to loading.
if (isPreview) {
val request = request.newBuilder().defaults(imageLoader.defaults).build()
val request = requestInternal.value.newBuilder().defaults(imageLoader.defaults).build()
updateState(State.Loading(request.placeholder?.toPainter()))
return
return@trace
}

// Observe the current request and execute any emissions.
scope.launch {
snapshotFlow { request }
requestInternal
.mapLatest { imageLoader.execute(updateRequest(it)).toState() }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if this is actually just a var _request: ImageRequest?
The request is changed from outside right before calling onRemembered manually, which means it's actually up-to-date here.

I don't know what's the case for the nested painters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might need to read this as a state, but I can't remember the specifics. If I remember correctly there are cases were mutableStateOf(request) won't be equal to _request since it exists outside of the Composition. Also it's possible for request to change so we need to observe it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the _request is set everytime when the rememberAsyncImagePainter recomposes, it could use just setter and job?.cancel() without observing the flow. This could bring the benefit because most of the time the _request stays the same and therefore only needs one job that finishes and doesn't have to keep listening, but if it changes, then it just calls another job (or cancels it if existing).

.collect(::updateState)
}
Expand Down
1 change: 1 addition & 0 deletions coil-sample-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.compose.material)
implementation(libs.androidx.compose.runtime.tracing)
}
7 changes: 6 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ ktlint = "1.0.1"
okhttp = "4.12.0"
okio = "3.8.0"
roborazzi = "1.7.0"
perfetto = "1.0.0"
runtimeTracing = "1.0.0-beta01"

[plugins]
binaryCompatibility = "org.jetbrains.kotlinx.binary-compatibility-validator:0.14.0"
Expand All @@ -25,9 +27,10 @@ accompanist-drawablepainter = "com.google.accompanist:accompanist-drawablepainte

androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "runtimeTracing" }
androidx-appcompat-resources = "androidx.appcompat:appcompat-resources:1.6.1"
androidx-annotation = "androidx.annotation:annotation:1.7.1"
androidx-benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.3"
androidx-benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.4"
androidx-collection = "androidx.collection:collection:1.4.0"
androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
androidx-core = "androidx.core:core-ktx:1.12.0"
Expand All @@ -43,6 +46,8 @@ androidx-test-junit = "androidx.test.ext:junit-ktx:1.1.5"
androidx-test-rules = "androidx.test:rules:1.5.0"
androidx-test-runner = "androidx.test:runner:1.5.2"
androidx-test-uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0"
androidx-tracing-perfetto = { module = "androidx.tracing:tracing-perfetto", version.ref = "perfetto" }
androidx-tracing-perfetto-binary = { module = "androidx.tracing:tracing-perfetto-binary", version.ref = "perfetto" }
androidx-vectordrawable-animated = "androidx.vectordrawable:vectordrawable-animated:1.1.0"

compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
Expand Down