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

How we boosted ExoPlayer performance by 30%

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Alexey Bykov Alexey Bykov
September 20, 2024
160

How we boosted ExoPlayer performance by 30%

Avatar for Alexey Bykov

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