Skip to content

Commit

Permalink
Upstream #2205 performance improvements into 3.x. (#2247)
Browse files Browse the repository at this point in the history
* Improve RealImageLoader.execute to properly call async request (#2205)

* Improve RealImageLoader.execute to properly call async request

* Add experiments to get information about performance

* Revert fix

* Add comment what's blocking

* Fix spotless

* Tweak benchmarks + add traces

Update benchmarks

Experiment removing mutableState

Before:
AsyncImagePainter.onRememberedAverageMs        min   0.1,   median   0.1,   max   0.1
AsyncImagePainter.onRememberedCount            min 130.0,   median 139.0,   max 146.0
AsyncImagePainter.onRememberedMs               min   9.3,   median  12.5,   max  16.3
frameDurationCpuMs                             P50    4.1,   P90    9.1,   P95   20.0,   P99   99.3

After:
AsyncImagePainter.onRememberedAverageMs        min   0.1,   median   0.1,   max   0.1
AsyncImagePainter.onRememberedCount            min 130.0,   median 141.0,   max 148.0
AsyncImagePainter.onRememberedMs               min   8.3,   median  10.5,   max  15.8
frameDurationCpuMs                             P50    4.4,   P90    8.6,   P95   12.0,   P99   95.1

Don't launch job for compose

before
AsyncImagePainter.onRememberedAverage_µs       min     77.3,   median    101.5,   max    116.7
AsyncImagePainter.onRememberedCount            min    130.0,   median    130.0,   max    146.0
AsyncImagePainter.onRemembered_µs              min 10,048.4,   median 13,880.2,   max 16,900.0
timeToInitialDisplayMs                         min    256.8,   median    281.7,   max    358.9
frameDurationCpuMs                             P50       5.1,   P90       9.2,   P95      13.3,   P99     117.0

after
AsyncImagePainter.onRememberedAverage_µs       min     68.9,   median     78.7,   max     91.1
AsyncImagePainter.onRememberedCount            min    128.0,   median    141.0,   max    146.0
AsyncImagePainter.onRemembered_µs              min  8,956.1,   median 11,163.1,   max 13,111.9
timeToInitialDisplayMs                         min    234.1,   median    276.5,   max    325.7
frameDurationCpuMs                             P50       4.5,   P90       8.9,   P95      15.5,   P99     120.3

* Experiment removing Compose State

Before:
AsyncImagePainter.onRememberedAverageMs        min   0.1,   median   0.1,   max   0.1
AsyncImagePainter.onRememberedCount            min 130.0,   median 139.0,   max 146.0
AsyncImagePainter.onRememberedMs               min   9.3,   median  12.5,   max  16.3
frameDurationCpuMs                             P50    4.1,   P90    9.1,   P95   20.0,   P99   99.3

After:
AsyncImagePainter.onRememberedAverageMs        min   0.1,   median   0.1,   max   0.1
AsyncImagePainter.onRememberedCount            min 130.0,   median 141.0,   max 148.0
AsyncImagePainter.onRememberedMs               min   8.3,   median  10.5,   max  15.8
frameDurationCpuMs                             P50    4.4,   P90    8.6,   P95   12.0,   P99   95.1

* Don't launch job for compose

before
AsyncImagePainter.onRememberedAverage_µs       min     77.3,   median    101.5,   max    116.7
AsyncImagePainter.onRememberedCount            min    130.0,   median    130.0,   max    146.0
AsyncImagePainter.onRemembered_µs              min 10,048.4,   median 13,880.2,   max 16,900.0
timeToInitialDisplayMs                         min    256.8,   median    281.7,   max    358.9
frameDurationCpuMs                             P50       5.1,   P90       9.2,   P95      13.3,   P99     117.0

after
AsyncImagePainter.onRememberedAverage_µs       min     68.9,   median     78.7,   max     91.1
AsyncImagePainter.onRememberedCount            min    128.0,   median    141.0,   max    146.0
AsyncImagePainter.onRemembered_µs              min  8,956.1,   median 11,163.1,   max 13,111.9
timeToInitialDisplayMs                         min    234.1,   median    276.5,   max    325.7
frameDurationCpuMs                             P50       4.5,   P90       8.9,   P95      15.5,   P99     120.3

* Cleanup

* Fix dependency with version catalog

* Cleanup

* Revert API changes

* Remove debug code.

* Formatting.

---------

Co-authored-by: Tomáš Mlynarič <mlynarict@gmail.com>
  • Loading branch information
colinrtwhite and mlykotom committed May 10, 2024
1 parent a567208 commit 8a272d7
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 coil3.Image
import coil3.ImageLoader
import coil3.PlatformContext
Expand Down Expand Up @@ -159,7 +160,7 @@ private fun rememberAsyncImagePainter(
onState: ((State) -> Unit)?,
contentScale: ContentScale,
filterQuality: FilterQuality,
): AsyncImagePainter {
): AsyncImagePainter = trace("rememberAsyncImagePainter") {
val request = requestOf(state.model)
validateRequest(request)

Expand Down Expand Up @@ -244,9 +245,9 @@ class AsyncImagePainter internal constructor(
}

@OptIn(ExperimentalCoroutinesApi::class)
override fun onRemembered() {
override fun onRemembered() = trace("AsyncImagePainter.onRemembered") {
// 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 @@ -260,7 +261,7 @@ class AsyncImagePainter internal constructor(
val request = request.newBuilder().defaults(imageLoader.defaults).build()
val painter = request.placeholder()?.toPainter(request.context, filterQuality)
updateState(State.Loading(painter))
return
return@trace
}

// Observe the current request and execute any emissions.
Expand Down
4 changes: 4 additions & 0 deletions coil-core/src/androidMain/kotlin/coil3/RealImageLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import coil3.util.awaitStarted
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.sync.Semaphore

internal actual fun skipCreatingDisposable(
request: ImageRequest,
) = request.target !is ViewTarget<*>

internal actual fun getDisposable(
request: ImageRequest,
job: Deferred<ImageResult>,
Expand Down
27 changes: 20 additions & 7 deletions coil-core/src/commonMain/kotlin/coil3/RealImageLoader.common.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,23 @@ internal class RealImageLoader(
return getDisposable(request, job)
}

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)
}
override suspend fun execute(request: ImageRequest): ImageResult {
if (skipCreatingDisposable(request)) {
// Avoid creating the disposable to optimize performance.
return withContext(Dispatchers.Main.immediate) {
executeMain(request, REQUEST_TYPE_EXECUTE)
}
} else {
return 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.
return@coroutineScope getDisposable(request, job).job.await()
// Update the current request attached to the view and await the result.
getDisposable(request, job).job.await()
}
}
}

@MainThread
Expand Down Expand Up @@ -227,6 +236,10 @@ private fun CoroutineScope(logger: Logger?): CoroutineScope {
return CoroutineScope(context)
}

internal expect fun skipCreatingDisposable(
request: ImageRequest,
): Boolean

internal expect fun getDisposable(
request: ImageRequest,
job: Deferred<ImageResult>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import coil3.request.OneShotDisposable
import coil3.target.Target
import kotlinx.coroutines.Deferred

internal actual fun skipCreatingDisposable(
request: ImageRequest,
) = true

internal actual fun getDisposable(
request: ImageRequest,
job: Deferred<ImageResult>,
Expand Down
7 changes: 6 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
androidx-activity = "1.9.0"
androidx-benchmark = "1.3.0-alpha04"
androidx-lifecycle = "2.7.0"
androidx-tracing-perfetto = "1.0.0"
androidx-runtime-tracing = "1.0.0-beta01"
coroutines = "1.8.0"
jetbrains-compose = "1.6.10-rc01"
kotlin = "2.0.0-RC3"
Expand Down Expand Up @@ -35,7 +37,8 @@ androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-appcompat-resources = "androidx.appcompat:appcompat-resources:1.6.1"
androidx-annotation = "androidx.annotation:annotation:1.7.1"
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" }
androidx-benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.4"
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-runtime-tracing" }
androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
androidx-core = "androidx.core:core-ktx:1.13.1"
androidx-exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
Expand All @@ -50,6 +53,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.3.0"
androidx-tracing-perfetto = { module = "androidx.tracing:tracing-perfetto", version.ref = "androidx-tracing-perfetto" }
androidx-tracing-perfetto-binary = { module = "androidx.tracing:tracing-perfetto-binary", version.ref = "androidx-tracing-perfetto" }
androidx-vectordrawable-animated = "androidx.vectordrawable:vectordrawable-animated:1.2.0"

coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
Expand Down
8 changes: 8 additions & 0 deletions internal/benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ androidTest(name = "coil3.benchmark", config = true) {
defaultConfig {
minSdk = 28
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 timing
// testInstrumentationRunnerArguments["androidx.benchmark.profiling.mode"] = "MethodTracing"
}
buildTypes {
create("benchmark") {
Expand Down Expand Up @@ -46,4 +52,6 @@ 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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package coil3.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, ExperimentalPerfettoTraceProcessorApi::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
}

@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,
),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package coil3.benchmark

import android.graphics.Point
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 coil3.benchmark.BuildConfig.PROJECT
import coil3.benchmark.MicrosTraceSectionMetric.Mode.Average
import coil3.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()

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

0 comments on commit 8a272d7

Please sign in to comment.