Upgrade to Pro — share decks privately, control downloads, hide ads and more …

How we boosted ExoPlayer performance by 30%

Alexey Bykov
September 20, 2024
43

How we boosted ExoPlayer performance by 30%

Alexey Bykov

September 20, 2024
Tweet

Transcript

  1. 9 Challenges ~ Performance: ~ Bandwidth instability: * TTFF, Rebuffering,

    Quality ~ Device capabilities: * 3g, 4g, 5g & WiFi * Decoders instances
  2. 10 Challenges ~ Performance: ~ Bandwidth instability: * TTFF, Rebuffering,

    Quality ~ Device capabilities: * 3g, 4g, 5g & WiFi * Decoders instances ~ Observability & analytics: * Trust & platforms implementation differences
  3. 11 Early 2024 In worst cases (P95), It took almost

    2 seconds to render a first frame ms
  4. 12 Agenda ~ ExoPlayer: under the hood ~ Cases study:

    ~ Delivery ~ Prefetching ~ Pools: Views, Players ~ http 2/0 & 3/0 ~ SurfaceView & TextureView ~ Performance metrics ~ Pre-warming ~ LoadControl
  5. 16 ABR (dash) Playback Backend Give me batch of videos

    CDN .mpd <Period duration="PT0H4M40.414S">
  6. 17 .mpd or Manifest <MPD xmlns="urn:mpeg:DASH:schema:MPD:2011"> <Period duration="PT0H4M40.414S"> <AdaptationSet mimeType="video/mp4">

    <Representation bandwidth="250000" id="video_lowest" /> <Representation bandwidth="500000" id="video_low" /> <Representation bandwidth="1000000" id="video_medium" /> <Representation bandwidth="2000000" id="video_high" /> </AdaptationSet> <AdaptationSet mimeType="audio/mp4"> <Representation bandwidth="64000" id="audio_low" /> <Representation bandwidth="128000" id="audio_high" /> </AdaptationSet> </Period> </MPD>
  7. 18 .mpd or Manifest <MPD xmlns="urn:mpeg:DASH:schema:MPD:2011"> <Period duration="PT0H4M40.414S"> <AdaptationSet mimeType="video/mp4">

    <Representation bandwidth="250000" id="video_lowest" /> <Representation bandwidth="500000" id="video_low" /> <Representation bandwidth="1000000" id="video_medium" /> <Representation bandwidth="2000000" id="video_high" /> </AdaptationSet> <AdaptationSet mimeType="audio/mp4"> <Representation bandwidth="64000" id="audio_low" /> <Representation bandwidth="128000" id="audio_high" /> </AdaptationSet> </Period> </MPD>
  8. 19 .mpd or Manifest <MPD xmlns="urn:mpeg:DASH:schema:MPD:2011"> <Period duration="PT0H4M40.414S"> <AdaptationSet mimeType="video/mp4">

    <Representation bandwidth="250000" id="video_lowest" /> <Representation bandwidth="500000" id="video_low" /> <Representation bandwidth="1000000" id="video_medium" /> <Representation bandwidth="2000000" id="video_high" /> </AdaptationSet> <AdaptationSet mimeType="audio/mp4"> <Representation bandwidth="64000" id="audio_low" /> <Representation bandwidth="128000" id="audio_high" /> </AdaptationSet> </Period> </MPD>
  9. 20 ABR (dash) Playback Backend Give me batch of videos

    CDN audio (selected quality) video (selected quality) .mpd <Period duration="PT0H4M40.414S"> decoding() initDecoders()
  10. 23

  11. 24 Can be: - Dash - HLS - SS -

    Progressive (Mp4)
  12. 25

  13. 26

  14. 27

  15. 29

  16. 30

  17. 32

  18. TS

  19. TS

  20. 66 >45 seconds Playback errors: Video view: -5% +1.7% Use

    dash/hls if: Use mp4 if: <45 seconds Mp4 vs Adaptive DO Video loading: +63% faster
  21. 68 Http version Exo uses by default: http/1 (1996) With

    OkHttp: http/2 (2015) With Cronet or Http engine: http/2 or http/3 (2022) https://www.linkedin.com/pulse/http-10-vs-11-20-30-swadhin-pattnaik/
  22. 71 DataSource CronetDataSource.Factory(engine, executor) fun createEngine(): CronetEngine { return CronetEngine.Builder(context)

    .setStoragePath(context.cacheDir("cronetHttp2")) .enableHttpCache(HTTP_CACHE_DISK, CACHE_SIZE) .enableHttp2(true) .build() }
  23. 72 API 34+ if (Build.VERSION.SDK_INT >= 34) { // An

    instance of this class can be created using Builder. val httpEngine = HttpEngine .Builder(applicationContext) .setEnableHttp2(true) .build() return HttpEngineDataSource.Factory(httpEngine, executor) }
  24. 79 2 flags: 2.5 60 52.5 Blocking buffering * bufferForPlaybackMs

    = 2.5s - First Frame - Playback stopped by user * bufferForPlaybackAfterRebuffer = 5s - Network latency problems
  25. 81 * bufferForPlaybackMs Load control: Recommendations Default: 2.5s ~1s —>

    * bufferForPlaybackAfterRebuffer Default: 5s ~1s —> * minBufferMs & maxBufferMs Default: 50s ~20s —> * setPrioritizeTimeOverSizeThresholds Default: false true —> Keep them equal (drip feeding technique) !
  26. 86 and me * Pre-fetching: Download file earlier —> cache

    hit * Pre-warming: Load data to memory, decode first segments —> immediate play Pre-fetching & Pre-warming
  27. 90 Caching pitfalls and me * SimpleCache hits disk in

    constructor * SimpleCache may clear other cache ¯\_(ツ)_/¯
  28. 95 Caching pitfalls and me * SimpleCache hits disk in

    constructor * SimpleCache may clear other cache * You should have only 1 instance of SimpleCache
  29. 96 Caching pitfalls and me * SimpleCache hits disk in

    constructor * SimpleCache may clear other cache * You should have only 1 instance of SimpleCache * Don’t forget about eviction: LeastRecentlyUsedCacheEvictor(maxCacheSize)
  30. 97 Caching pitfalls and me * SimpleCache hits disk in

    constructor * SimpleCache may clear other cache * You should have only 1 instance of SimpleCache * Don’t forget about eviction: LeastRecentlyUsedCacheEvictor(maxCacheSize) * Don’t forget that URL == cacheKey
  31. 106 Playback errors: The same +2% Load < 500ms: Video

    view: +1.7% Prefetching: Lazy Load < 250ms: The same
  32. 112 Prefetching: Aggressive +2.1% Load < 500ms: Load > 2

    sec: +2% Load < 250ms: Load > 1sec: -4% -4.8% Watch time: +1.2%
  33. 113 Prefetching: Aggressive +2.1% Load < 500ms: Load > 2

    sec: +2% Load < 250ms: Load > 1sec: -4% -4.8% TTI detail page latency: +2.5% Watch time: +1.2%
  34. 118 Prefetching: Mixed (3 videos) Load: Similar data compared to

    agressive TTI detail page latency: ~0.5% Better (but still slightly degraded)
  35. 119 Which approach to choose? Approach Load result Other requests

    latency Lazy Agressive Mixed Average Not affected Best Affected Good Affected, but just a bit
  36. 122 DownloadHelper Select a proper track Paired with DownloadManager You

    can’t define how much data you need to download ! https://github.com/google/ExoPlayer/issues/1497 Select track manually !
  37. 127 Before TextPost VideoPost TextPost if (visiblity >= 0.5){ val

    player = createExoPlayer() player.prepare("url") }
  38. 130 Now TextPost VideoPost TextPost TextPost val player = remember

    { createExoPlayer().apply { player.prepare(“url") player } }
  39. Pre-warming: Result Playback errors: +19% Load < 500ms: Load >

    2 sec: +16% Load < 250ms: Load > 1sec: -17% -14% +13% Watch time: +11%
  40. Pre-warming: Result Playback errors: +19% Load < 500ms: Load >

    2 sec: +16% Load < 250ms: Load > 1sec: -17% -14% +13% Watch time: +11% DO
  41. PreloadMediaSource Partial downloading Load decoded segments in runtime Easy to

    use Prefetching may not be that efficient ! Easier control of ExoPlayer instances
  42. 148 and me Don’t call player.stop() for the same playbacks!

    * Video decoder start: ~41ms * Audio decoder start: ~24ms
  43. 151 Implementation private val playerPool = LinkedHashMap<String, RedditVideoPlayer>() fun acquire(key:

    String): RedditVideoPlayer { val savedPlayer = playerPool[key] if (savedPlayer != null) { return savedPlayer } }
  44. 152 Implementation fun acquire(key: String): RedditVideoPlayer { val savedPlayer =

    playerPool[key] if (savedPlayer != null) { return savedPlayer } val idlePlayer = playerPool.firstNotNullOfOrNull { (key, value) -> if (!value.isAttached()) { key to value } else { null } } }
  45. 153 Implementation fun acquire(key: String): RedditVideoPlayer { val savedPlayer =

    playerPool[key] if (savedPlayer != null) { return savedPlayer } val idlePlayer = playerPool.firstNotNullOfOrNull { (key, value) -> if (!value.isAttached()) { key to value } else { null } } if (idlePlayer != null) { playerPool.remove(idlePlayer.first) playerPool[key] = idlePlayer.second idlePlayer.second.stop() return idlePlayer.second } }
  46. 154 Implementation fun acquire(key: String): RedditVideoPlayer { val savedPlayer =

    playerPool[key] if (savedPlayer != null) { return savedPlayer } val idlePlayer = playerPool.firstNotNullOfOrNull { (key, value) -> if (!value.isAttached()) { key to value } else { null } } if (idlePlayer != null) { playerPool.remove(idlePlayer.first) playerPool[key] = idlePlayer.second idlePlayer.second.stop() return idlePlayer.second } else { val player = createPlayer() playerPool += key to player return player }
  47. Player Pool: Results +1% Load < 500ms: Load > 2

    sec: +0.6% Load < 250ms: Load > 1sec: -1.1% -2% Watch time: +7%
  48. Player Pool: Results +1% Load < 500ms: Load > 2

    sec: +0.6% Load < 250ms: Load > 1sec: -1.1% -2% Watch time: +7% Number of frames > 700ms: -2%
  49. Player Pool: Results +1% Load < 500ms: Load > 2

    sec: +0.6% Load < 250ms: Load > 1sec: -1.1% -2% Watch time: +7% DO Number of frames > 700ms: -2%
  50. 162 Always have cached player and me URL IDLE *

    Can be reused IDLE * Can be reused
  51. Player Pool M2: Results +2% Load < 500ms: Load >

    2 sec: +0.4% Load < 250ms: Load > 1sec: Didn’t change Didn’t change Watch time: Didn’t change
  52. Player Pool M2: Results +2% Load < 500ms: Load >

    2 sec: +0.4% Load < 250ms: Load > 1sec: Didn’t change Didn’t change Watch time: Didn’t change Number of frames > 16ms: -1%
  53. Player Pool M2: Results +2% Load < 500ms: Load >

    2 sec: +0.4% Load < 250ms: Load > 1sec: Didn’t change Didn’t change Watch time: Didn’t change Number of frames > 16ms: -1% DO
  54. 169 Problem and me We still have to use TextureView

    or SurfaceView It may take 30+ms to inflate!
  55. 170 Potential solution and me AndroidView( onReset = { //

    Called before view is about to be reused }, onRelease = { view -> // Called when view won’t be reused }, modifier = Modifier, factory = { context -> // create View } )
  56. 171 Potential solution and me AndroidView( onReset = { //

    Called before view is about to be reused }, onRelease = { view -> // Called when view won’t be reused }, //… )
  57. 173 Problems with standard pool and me * Works good

    only if you have the same viewTypes
  58. 174 Problems with standard pool and me * Works good

    only if you have the same viewTypes onRelease is called too often ;(
  59. 175 Problems with standard pool and me * Works good

    only if you have the same viewTypes * Can’t control instances count
  60. 176 Problems with standard pool and me * Works good

    only if you have the same viewTypes * Can’t control instances count * Can’t pre-load them earlier
  61. 178 Implementation and me class OldFashionedViewPool(private val maxAllocations: Int) {

    val viewsCache = ArrayMap<String, LinkedList<View>>() }
  62. 179 Aquire and me inline fun <reified T : View>

    acquire(creator: () -> T): T { val clazzName = T::class.qualifiedName val typed = viewsCache[clazzName] val iterator = typed?.iterator() while (iterator?.hasNext() == true) { val view = iterator.next() iterator.remove() return view as T } return creator.invoke() }
  63. 180 Aquire and me inline fun <reified T : View>

    acquire(creator: () -> T): T { val clazzName = T::class.qualifiedName val typed = viewsCache[clazzName] val iterator = typed?.iterator() while (iterator?.hasNext() == true) { val view = iterator.next() iterator.remove() return view as T } return creator.invoke() }
  64. 181 Create if empty and me inline fun <reified T

    : View> acquire(creator: () -> T): T { val clazzName = T::class.qualifiedName val typed = viewsCache[clazzName] val iterator = typed?.iterator() while (iterator?.hasNext() == true) { val view = iterator.next() iterator.remove() return view as T } return creator.invoke() }
  65. 182 Release and me fun release(item: View) { val clazzName

    = item::class.qualifiedName val typed = viewsCache.getOrPut(clazzName) { LinkedList() } if (typed.size >= maxAllocations) { typed.remove() } typed.add(item) }
  66. 183 Eviction and me fun release(item: View) { val clazzName

    = item::class.qualifiedName val typed = viewsCache.getOrPut(clazzName) { LinkedList() } if (typed.size >= maxAllocations) { typed.remove() } typed.add(item) }
  67. 185 Integration and me // … AndroidView( factory = {

    context -> viewPool.acquire { } // …
  68. 188 and me Warnings * Use only onRelease with this,

    with no onReset * AndrodView::onRelease called later than onDispose
  69. 189 and me Warnings * Use only onRelease with this,

    with no onReset * AndrodView::onRelease called later than onDispose * Always implement your own controls!
  70. ViewPool: Results +4.3% Load < 500ms: Load > 2 sec:

    +3.1% Load < 250ms: Load > 1sec: -4% -1% Watch time: +4.3%
  71. ViewPool: Results +4.3% Load < 500ms: Load > 2 sec:

    +3.1% Load < 250ms: Load > 1sec: -4% -1% Watch time: 4.3% DO
  72. 193 Where to render? TextureView SurfaceView + Scaling/Rotation/Alpha + Performance

    + DRM support + Simple to use - Battery consumption - Performance - DRM Support + More accurate frame timing - Complexity - API 24+ (animations/scrolling)
  73. 202 SurfaceView results: Load time: Neutral Frame metrics: Neutral Battery

    metrics: Neutral Only if you optimised everything
  74. 219 Conclusions ~ Removing operation ~ Moving operation to IO

    ~ Pre-heating / Pre-warming ~ Caching & Reusability
  75. 220 Conclusions ~ Removing operation ~ Moving operation to IO

    ~ Pre-heating / Pre-warming ~ Caching & Reusability ~ Make it faster
  76. 221 Conclusions ~ Removing operation ~ Moving operation to IO

    ~ Pre-heating / Pre-warming ~ Caching & Reusability ~ Make it faster