個案研究

R8 如何讓 Android 上的 Kotlin 協同程式速度提升 2 倍

7 分鐘小故事

從 AGP 9.2.0 開始,R8 會將大多數的 Atomic*FieldUpdater 呼叫最佳化為 Unsafe 變數,在常見作業中效能提升 2 到 4 倍。這對實作 kotlinx.coroutines 原子性的 kotlinx.atomicfu 程式庫影響特別大,可讓啟動和取消協同程式的速度提升 2 倍。如要享有這些福利,請將 AGP 更新至 9.2.0 以上版本。

由於大多數 Android 應用程式都採用 Kotlin 做為主要語言,kotlinx.coroutines 已成為非同步程式設計的事實標準。這個程式庫提供設計完善的結構化方式,可管理 Kotlin 原生的並行流程。Jetpack Compose 也不例外,採用協同程式來管理指標事件、動畫和其他互動。撰寫本文時,Compose 中的大多數並行 API 都會在幕後呼叫 suspend 函式,並啟動及/或取消協同程式來處理更新。

Compose 團隊開始調查效能時,發現許多在組合以外發生的作業,都會受到協同程式的瓶頸影響。舉例來說,在建立及更新 Modifier.clickable 時,有 80% 的時間都用於啟動及取消處理 InteractionSource 更新的內部協同程式。根據這些觀察結果,早期效能工作大多著重於從預設路徑移除協同程式,並延遲初始化作業,直到必要時才執行。

協同程式的成本

如要在 Android 上分析函式的內部行為,最簡單的方法是擷取 Android 執行階段 (ART) 方法追蹤記錄。ART 方法追蹤記錄工具會記錄應用程式的執行流程,顯示呼叫的方法、順序,以及每個方法所花費的時間,方便開發人員找出效能瓶頸。如果是空白的 LaunchedEffect { } 呼叫,程式碼應如下所示:

pic01_enhanced.png
在 Perfetto UI 中顯示 LaunchedEffect 方法追蹤記錄

上述方法追蹤記錄可分為三部分:

  • 正在初始化新的協同程式
  • 啟動協同程式
  • 完成協同程式 (因為會立即結束)

取消 LaunchedEffect 的程序與正常完成類似,但也會建立 CancellationException

從上述設定檔中,最可疑的一點是頻繁呼叫 java.util.concurrent.AtomicReferenceFieldUpdater (帶有 j… 標籤的紫色或綠色方塊)。雖然每次呼叫都相對快速,但頻率令人擔憂;如果多個呼叫之間分散著任何不可忽略的額外負荷,可能會累積成明顯的迴歸。放大通話畫面後,您會發現大部分時間都花在... 反射檢查?

pic02-enhanced.png
在 LaunchedEffect 初始化期間,近距離查看 AtomicReferenceFieldUpdater.get 的方法追蹤記錄

協同程式會實作父項-子項關係的無鎖定樹狀結構,藉此實現結構化並行。結果發現,kotlinx.atomicfu 程式庫使用廣為人知的 JVM 基元 AtomicReferenceFieldUpdater,實作無鎖定原子作業。更新工具會使用類別參照和欄位名稱,在執行階段執行不可分割的作業,且必須執行多項反射安全檢查,確保欄位存在且可存取。協同程式中的每項作業 (啟動、暫停、取消、完成) 至少會呼叫一項不可部分完成的作業,因此如果速度緩慢,協同程式的效能就會不佳。

調查 AtomicReferenceFieldUpdater

但我們別得意忘形。AtomicReferenceFieldUpdater 實際上在 JVM 上已最佳化超過 10 年,方法追蹤可能會擷取 VM 層級最佳化完全移除的額外負荷:即時 (JIT) 或預先 (AOT) 編譯。為驗證效能,我們來編寫幾個基準,測量 kotlinx.atomicfujava.util.concurrent.atomic 的原子參照之間的差異。

@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    private val atomicReference = java.util.concurrent.atomic.AtomicReference(false)
    private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false)
    
    @Test
    fun atomicReference_compareAndSet() {
        benchmarkRule.measureRepeated { 
            atomicReference.compareAndSet(true, false)
            atomicReference.compareAndSet(false, true)
        }
    }

     @Test
    fun atomicRef_compareAndSet() {
        benchmarkRule.measureRepeated {
            atomicRef.compareAndSet(true, false)
            atomicRef.compareAndSet(false, true)
        }
    }

    /* measuring other methods from the method traces above */
}

在 Pixel 5 上執行這項基準測試 (同時確保 AtomicReferenceFieldUpdater#compareAndSet 在暖機期間經過 JIT 編譯),會在 Pixel 5 (API 33) 上產生下列結果:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

測量結果證實了這個差距,kotlinx.atomicfu 版本明顯慢了大約 2.7 倍。這項結果證實 ART 不會執行任何隱藏的最佳化作業,而反射存取檢查會在執行階段增加實際負荷。

回顧原始方法追蹤記錄,AtomicReferenceFieldUpdater 執行的唯一有意義工作是內部呼叫 Unsafe.getObjectVolatile,實際執行基礎不可分割的作業。在大多數情況下,更新程式初始設定是靜態的,且可根據周圍類別的結構證明一律正確。因此,在編譯期間,可以靜態分析大部分的 AtomicReferenceFieldUpdater 用法,並以內部 Unsafe 變數取代。此外,Android 建構工具鍊剛好有自己的最佳化編譯器,可以執行這項作業。

使用 R8 進行最佳化

Atomic*FieldUpdater 類別支援細微、動態和以反映為基礎的使用方式,但通常用於靜態明顯的模式。這說明瞭基準效能緩慢的原因,以及最佳化的必要性。R8 是全程式最佳化編譯器,非常適合用來查看較簡單的模式,略過反射安全檢查的額外負荷。R8 會在 Java 或 Kotlin 編譯器之後接收 JVM 位元碼,但為了方便閱讀,這些範例會以 Java 語法呈現。因此 AtomicReferenceFieldUpdater 沒有型別引數。

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

基本範例會建立靜態最終更新程式,並存取具有簡單常數引數的揮發性欄位,以供持有者、型別和欄位名稱使用。使用的反射效果完全透明。很明顯地,這個更新程式參照的是有效欄位,且更新程式建立網站具有該欄位的有效存取權。

本質上,Atomic*FieldUpdater 是欄位偏移和 Unsafe 呼叫的包裝函式。最佳做法是將更新程式欄位替換為偏移欄位,並將更新程式呼叫替換為對 Unsafe 的呼叫。

最佳化 Atomic*FieldUpdater

最佳化作業分為三個部分:插碼、取代和清除。

檢測設備測試

第一步是導入偏移欄位和更新程式欄位,方便透過Unsafe 呼叫直接存取。

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

系統會透過反射存取這個欄位,並使用 Unsafe 擷取類別中的欄位位移。如果您忽略反射驗證,這段程式碼代表 Atomic*FieldUpdater 的內部結構。而是由編譯器靜態追蹤更新程式的持有者型別和揮發性欄位的欄位型別。

 

請注意,原始欄位及其初始化作業會維持不變。最佳化程序會樂觀地促進及最佳化用途,然後再進行清理。這是簡單的實作方法,但也能部分最佳化更新工具欄位,也就是保留部分用途,同時最佳化其他用途。

替換

在編譯器中的這個時間點,我們會在適當的並行接合點後,取得已插碼的更新程式欄位清單。也就是說,我們可以根據幾個條件,個別最佳化每個通話網站。請參考以下呼叫範例:

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater 的條件如下:

  • updater 是否來自已插碼的欄位?也就是說,靜態分析是否能將物件的值追蹤回已插碼更新程式的欄位讀取作業?
  • holder 是否與原始定義的持有者型別相同或為其子類別?
  • newValue 是否與原始定義的欄位型別相同,或是其子類別?

如果符合所有條件,系統就會以對 Unsafe 的呼叫取代呼叫,且不會進行任何反射檢查。

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

這個新呼叫速度更快且更簡單,但與原始呼叫不同,因為它會處理 updaterholder 中的空值。除非靜態排除,否則系統會為兩者插入空值檢查。

清除

此時,保留類別會包含原始更新程式欄位和新的偏移欄位,以及可能使用其中一個欄位的呼叫網站。如果沒有任何通話網站經過最佳化,則應移除偏移欄位;如果所有通話網站都經過最佳化,則應移除更新者欄位。在這兩種情況下,也應刪除初始化呼叫。編譯器已刪除未使用的欄位和無效程式碼,但移除此處的初始化程式碼需要一些技巧。

呼叫 newUpdatergetDeclaredField 都可能有副作用,因為兩者可能會擲回例外狀況 (且實作方式不明,因為取決於 API 版本)。這表示無法透過一般最佳化安全地移除這些檔案。因此,這項清理作業需要明確考量已插碼的欄位,因為這些欄位靜態已知沒有例外狀況。

最後,經過最佳化後,上述簡單的更新工具範例如下所示:

結果

完成這些最佳化作業後,kotlinx.atomicfu和大多數明確使用 AtomicInt/Long/ReferenceFieldUpdater 的情況,現在都與套用 R8 的 AtomicReference 效能相符。事實上,在某些基準中,它甚至更快;kotlinx.atomicfu 有一個編譯器外掛程式,可將 atomic 執行個體內嵌至欄位,減少建立以原子方式更新欄位所需的分配量。

Jetpack Compose 是這項工作的主要受益者。Compose 執行階段有許多微基準,可密切追蹤協同程式效能,及早發現效能回歸。基準測試更新至新版 R8 後,我們發現啟動及取消 LaunchedEffect 中的協同程式時,效能提升了 2 倍

pic03_enhanced.png
基準圖表:說明在 LaunchedEffect 中啟動及取消協同程式所花費的時間 (越低越好)。圖表中的變化對應於 R8 更新,顯示效能提升 2 倍。

此外,ART 團隊正在 VM 層級以原生方式實作這些最佳化功能。如果您的應用程式以 API 36 為目標,且在最新版 Android 上執行,裝置可能已採用類似方式最佳化協同程式。在上述協同程式基準中,我們觀察到在近期版本的 ART 中進行 JIT 更新後,效能提升了約 15%。

升級至 AGP 9.2.0 或直接使用 R8 9.2.0 時,應用程式預設會進行這項最佳化。詳情請參閱「D8 dexer 和 R8 shrinker」。

繼續閱讀