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
27 changes: 17 additions & 10 deletions coil-base/src/main/java/coil/RealImageLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,25 @@ 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)
}

// Update the current request attached to the view and await the result.
override suspend fun execute(request: ImageRequest) =
if (request.target is ViewTarget<*>) {
request.target.view.requestManager.getDisposable(job)
// 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.
coroutineScope { // 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
private suspend fun executeMain(initialRequest: ImageRequest, type: Int): ImageResult {
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)
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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 +198,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 Down Expand Up @@ -253,7 +254,7 @@ class AsyncImagePainter internal constructor(
private set

/** The current [ImageRequest]. */
var request: ImageRequest by mutableStateOf(request)
var request by mutableStateOf(request)
internal set

/** The current [ImageLoader]. */
Expand Down Expand Up @@ -282,9 +283,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 @@ -297,7 +298,7 @@ class AsyncImagePainter internal constructor(
if (isPreview) {
val request = request.newBuilder().defaults(imageLoader.defaults).build()
updateState(State.Loading(request.placeholder?.toPainter()))
return
return@trace
}

// Observe the current request and execute any emissions.
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