กรณีศึกษา

R8 ช่วยให้ Kotlin Coroutines ใน Android เร็วขึ้น 2 เท่าได้อย่างไร

ใช้เวลาอ่าน 7 นาที

ตั้งแต่ AGP 9.2.0 เป็นต้นไป R8 จะเพิ่มประสิทธิภาพการเรียกใช้ Atomic*FieldUpdater ส่วนใหญ่ให้เป็นตัวแปร Unsafe ซึ่งทำงานได้ดีขึ้น 2-4 เท่าในการดำเนินการทั่วไป การดำเนินการนี้ส่งผลอย่างมากต่อไลบรารี kotlinx.atomicfu ที่ใช้ Atomic สำหรับ kotlinx.coroutines ซึ่งทำให้การเปิดใช้และการยกเลิก Coroutines เร็วขึ้นสูงสุด 2 เท่า หากต้องการรับสิทธิประโยชน์ ให้อัปเดต AGP เป็น 9.2.0 ขึ้นไป

เนื่องจากแอป Android ส่วนใหญ่เลือกใช้ Kotlin เป็นภาษาหลัก kotlinx.coroutines จึงกลายเป็นมาตรฐานโดยพฤตินัยสำหรับการเขียนโปรแกรมแบบอะซิงโครนัส ไลบรารีนี้มีวิธีจัดการโฟลว์พร้อมกันที่ออกแบบมาอย่างดีและมีโครงสร้าง ซึ่งเป็นฟีเจอร์ที่มีอยู่ใน Kotlin Jetpack Compose ก็เช่นกัน โดยใช้ Coroutines เพื่อจัดการเหตุการณ์ตัวชี้ ภาพเคลื่อนไหว และการโต้ตอบอื่นๆ ในขณะที่เขียนบทความนี้ API พร้อมกันส่วนใหญ่ใน Compose จะเรียกใช้ฟังก์ชัน suspend เบื้องหลัง และเปิดใช้และ/หรือยกเลิก Coroutines เพื่อจัดการการอัปเดต

เมื่อทีม Compose เริ่มตรวจสอบประสิทธิภาพ ก็พบว่า โครูทีน เป็น จุดคอขวด สำหรับการดำเนินการจำนวนมากที่เกิดขึ้นนอก Composition ตัวอย่างเช่น 80% ของเวลาที่ใช้ในการสร้างและอัปเดต Modifier.clickable ถูกใช้ไปกับการเปิดใช้และยกเลิก Coroutines ภายในที่จัดการการอัปเดต InteractionSource จากข้อสังเกตดังกล่าว งานด้านประสิทธิภาพในช่วงแรกๆ จึงมุ่งเน้นไปที่การนำ Coroutines ออกจากเส้นทางเริ่มต้นและชะลอการเริ่มต้นจนกว่าจะจำเป็น

ค่าใช้จ่ายของ Coroutine

วิธีที่ง่ายที่สุดในการวิเคราะห์ลักษณะการทำงานภายในของฟังก์ชันใน Android คือการบันทึกร่องรอยเมธอด Android Runtime (ART) ร่องรอยเมธอด ART เป็นเครื่องมือที่บันทึกโฟลว์การดำเนินการของแอป โดยแสดงเมธอดที่เรียกใช้ ลำดับ และเวลาที่ใช้ในแต่ละเมธอดอย่างแม่นยำ ซึ่งช่วยให้นักพัฒนาแอปสามารถระบุจุดคอขวดด้านประสิทธิภาพได้ สำหรับการเรียกใช้ LaunchedEffect { } ที่ว่างเปล่า การติดตามเมธอดจะมีลักษณะดังนี้

pic01_enhanced.png
การติดตามเมธอด LaunchedEffect ที่แสดงเป็นภาพใน UI ของ Perfetto

ร่องรอยเมธอดด้านบนสามารถแบ่งออกเป็น 3 ส่วน ได้แก่

  • การเริ่มต้น Coroutine ใหม่
  • การเริ่ม Coroutine
  • การสิ้นสุด Coroutine (เนื่องจากออกจากระบบทันที)

การยกเลิก LaunchedEffect คล้ายกับการสิ้นสุดตามปกติ ยกเว้นว่าจะสร้าง CancellationException ด้วย

จากโปรไฟล์ด้านบน สิ่งหนึ่งที่น่าสงสัยทันทีคือการเรียกใช้ java.util.concurrent.AtomicReferenceFieldUpdater บ่อยครั้ง (กล่องสีม่วงหรือสีเขียวที่มีป้ายกำกับ j…) แม้ว่าการเรียกใช้แต่ละครั้งจะค่อนข้างเร็ว แต่ความถี่ก็เป็นเรื่องที่น่ากังวล ค่าใช้จ่ายที่ไม่เล็กน้อยซึ่งกระจายอยู่ในการเรียกใช้หลายครั้งอาจรวมกันเป็นค่าใช้จ่ายที่เพิ่มขึ้นอย่างเห็นได้ชัด เมื่อซูมดูการเรียกใช้ จะพบว่าเวลาส่วนใหญ่ใช้ไปกับการตรวจสอบการสะท้อน

pic02-enhanced.png
การดูร่องรอยเมธอดของ AtomicReferenceFieldUpdater.get อย่างใกล้ชิดระหว่างการเริ่มต้น LaunchedEffect

Coroutines ใช้โครงสร้างต้นไม้แบบไม่มีการล็อกสำหรับความสัมพันธ์หลัก-ย่อย ซึ่งทำให้เกิดการทำงานพร้อมกันแบบมีโครงสร้าง ปรากฏว่าไลบรารี kotlinx.atomicfu ใช้การดำเนินการ Atomic แบบไม่มีการล็อกโดยใช้ Primitive JVM ที่รู้จักกันดีอย่าง AtomicReferenceFieldUpdater Updater ใช้การอ้างอิงคลาสและชื่อฟิลด์เพื่อดำเนินการ Atomic ในรันไทม์ และต้องเรียกใช้การตรวจสอบความปลอดภัยแบบสะท้อนหลายครั้งเพื่อให้แน่ใจว่าฟิลด์มีอยู่และเข้าถึงได้ การดำเนินการแต่ละอย่างใน Coroutines (การเริ่มต้น การระงับ การยกเลิก การสิ้นสุด) จะเรียกใช้การดำเนินการ Atomic อย่างน้อย 1 รายการ ดังนั้นหากการดำเนินการ Atomic ช้า Coroutines ก็จะทำงานได้ไม่ดี

การตรวจสอบ AtomicReferenceFieldUpdater

แต่เราจะยังไม่พูดถึงเรื่องนี้AtomicReferenceFieldUpdater ได้รับการเพิ่มประสิทธิภาพอย่างดีใน JVM มานานกว่า 10 ปีแล้ว และการติดตามเมธอดอาจบันทึกค่าใช้จ่ายที่การเพิ่มประสิทธิภาพระดับ VM เช่น การคอมไพล์แบบ Just-In-Time (JIT) หรือ Ahead-Of-Time (AOT) นำออกไปโดยสมบูรณ์ หากต้องการยืนยันประสิทธิภาพ ให้เขียนการทดสอบประสิทธิภาพ 2-3 รายการเพื่อวัดความแตกต่างระหว่างการอ้างอิง Atomic จาก kotlinx.atomicfu กับ java.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 ภายใน ซึ่งจะดำเนินการ Atomic พื้นฐาน ในกรณีส่วนใหญ่ ตัวเริ่มต้น Updater จะเป็นแบบคงที่ และสามารถพิสูจน์ได้ว่าถูกต้องเสมอตามโครงสร้างของคลาสโดยรอบ ดังนั้น จึงสามารถวิเคราะห์การใช้งาน AtomicReferenceFieldUpdater ส่วนใหญ่แบบคงที่และแทนที่ด้วยตัวแปร Unsafe ภายในระหว่างการคอมไพล์ นอกจากนี้ Toolchain การสร้าง Android ยังมีคอมไพเลอร์เพิ่มประสิทธิภาพของตัวเองที่ทำเช่นนั้นได้

การเพิ่มประสิทธิภาพด้วย R8

คลาส Atomic*FieldUpdater รองรับการใช้งานแบบละเอียด แบบไดนามิก และแบบสะท้อน แต่มักใช้ในรูปแบบที่เห็นได้ชัดแบบคงที่ ซึ่งอธิบายได้ทั้งประสิทธิภาพพื้นฐานที่ช้าและความต้องการการเพิ่มประสิทธิภาพ R8 เป็นคอมไพเลอร์เพิ่มประสิทธิภาพแบบเต็มโปรแกรมและเหมาะอย่างยิ่งที่จะมองเห็นรูปแบบที่ง่ายกว่าเพื่อลดค่าใช้จ่ายของการตรวจสอบความปลอดภัยแบบสะท้อน R8 จะรับไบต์โค้ด JVM หลังจากคอมไพเลอร์ Java หรือ Kotlin แต่เพื่อให้ตัวอย่างอ่านง่าย เราจึงนำเสนอตัวอย่างเหล่านี้ในไวยากรณ์ Java นี่คือเหตุผลที่ไม่มีอาร์กิวเมนต์ประเภทสำหรับ AtomicReferenceFieldUpdater

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

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

ตัวอย่างพื้นฐานจะสร้าง Updater แบบคงที่ขั้นสุดท้ายซึ่งเข้าถึงฟิลด์ Volatile ด้วยอาร์กิวเมนต์ค่าคงที่แบบง่ายสำหรับ Holder, ประเภท และชื่อฟิลด์ การสะท้อนที่ใช้นั้นโปร่งใสโดยสมบูรณ์ เห็นได้ชัดว่า Updater นี้อ้างอิงฟิลด์ที่ถูกต้อง และไซต์การสร้าง Updater มีสิทธิ์เข้าถึงฟิลด์ที่ถูกต้อง

โดยพื้นฐานแล้ว Atomic*FieldUpdater เป็น Wrapper รอบๆ ออฟเซ็ตฟิลด์และการเรียกใช้ Unsafe สถานการณ์ที่ดีที่สุดสำหรับการเพิ่มประสิทธิภาพคือการแทนที่ฟิลด์ Updater ด้วยฟิลด์ออฟเซ็ต และแทนที่การเรียกใช้ Updater ด้วยการเรียกใช้ Unsafe

การเพิ่มประสิทธิภาพ Atomic*FieldUpdater

การเพิ่มประสิทธิภาพจะดำเนินการใน 3 ส่วน ได้แก่ การใช้เครื่องมือ การแทนที่ และการจัดระเบียบ

การใช้เครื่องมือ

ขั้นตอนแรกคือการนำฟิลด์ออฟเซ็ตมาใช้ควบคู่กับฟิลด์ Updater เพื่อให้เข้าถึงได้โดยตรงผ่านการเรียกใช้ Unsafe

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

ระบบจะเข้าถึงฟิลด์ผ่านการสะท้อน และใช้ Unsafe เพื่อดึงออฟเซ็ตฟิลด์ในคลาส โค้ดนี้แสดงถึงส่วนประกอบภายในของ Atomic*FieldUpdater หากคุณไม่สนใจการตรวจสอบการสะท้อน แต่ระบบจะติดตามประเภท Holder ของ Updater และประเภทฟิลด์ของฟิลด์ Volatile แบบคงที่ในคอมไพเลอร์

 

โปรดทราบว่าระบบจะปล่อยให้ฟิลด์เดิมและการเริ่มต้นของฟิลด์เป็นไปตามเดิม กระบวนการเพิ่มประสิทธิภาพจะอำนวยความสะดวกและเพิ่มประสิทธิภาพการใช้งานอย่างเต็มที่ แล้วจึงจัดระเบียบในภายหลัง นี่เป็นแนวทางที่ง่ายในการใช้งาน แต่ยังช่วยให้เพิ่มประสิทธิภาพฟิลด์ Updater ได้บางส่วน โดยที่การใช้งานบางอย่างยังคงเป็นไปตามเดิมในขณะที่การใช้งานอื่นๆ ได้รับการเพิ่มประสิทธิภาพ

การแทนที่

ในจุดนี้ในคอมไพเลอร์ หลังจากจุดรวมการทำงานพร้อมกันที่เหมาะสมแล้ว เราจะมีรายการฟิลด์ Updater ที่ใช้เครื่องมือ ซึ่งหมายความว่าเราสามารถเพิ่มประสิทธิภาพแต่ละไซต์การเรียกใช้ได้ทีละรายการตามเงื่อนไข 2-3 ข้อ ลองดูตัวอย่างการเรียกใช้ต่อไปนี้

updater.compareAndSet(holder, expectedValue, newValue);

เงื่อนไขที่ Atomic*FieldUpdater กำหนดมีดังนี้

  • updater มาจากฟิลด์ที่ใช้เครื่องมือหรือไม่ กล่าวคือ การวิเคราะห์แบบคงที่สามารถติดตามค่าของออบเจ็กต์กลับไปเป็นการอ่านฟิลด์ของ Updater ที่ใช้เครื่องมือได้หรือไม่
  • holder เป็นคลาสเดียวกันหรือคลาสย่อยของประเภท Holder ที่กำหนดไว้เดิมหรือไม่
  • newValue เป็นคลาสเดียวกันหรือคลาสย่อยของประเภทฟิลด์ที่กำหนดไว้เดิมหรือไม่

หากตรงตามเงื่อนไขทั้งหมด ระบบจะแทนที่การเรียกใช้ด้วยการเรียกใช้ Unsafe โดยไม่มีการตรวจสอบการสะท้อน

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

การเรียกใช้ใหม่นี้เร็วกว่าและง่ายกว่า แต่แตกต่างจากการเรียกใช้เดิมในเรื่องการจัดการค่า Null ใน updater และ holder ระบบจะแทรกการตรวจสอบ Null สำหรับทั้ง 2 รายการ เว้นแต่จะมีการตัดออกแบบคงที่

การจัดระเบียบ

ในจุดนี้ คลาส Holding จะมีฟิลด์ Updater เดิมและฟิลด์ออฟเซ็ตใหม่ รวมถึงไซต์การเรียกใช้ที่อาจใช้ฟิลด์ใดฟิลด์หนึ่ง หากไม่มีการเพิ่มประสิทธิภาพไซต์การเรียกใช้ ระบบควรนำฟิลด์ออฟเซ็ตออก และหากมีการเพิ่มประสิทธิภาพไซต์การเรียกใช้ทั้งหมด ระบบควรนำฟิลด์ Updater ออก ในทั้ง 2 กรณี ระบบควรลบการเรียกใช้การเริ่มต้นด้วย คอมไพเลอร์จะลบฟิลด์ที่ไม่ได้ใช้และการนำโค้ดที่ไม่ได้ใช้แล้วออก แต่การนำโค้ดการเริ่มต้นออกในที่นี้ต้องใช้เทคนิคเพิ่มเติม

ทั้งการเรียกใช้ newUpdater และ getDeclaredField อาจมีผลข้างเคียงเนื่องจากอาจส่งข้อยกเว้น (และเราไม่ทราบการใช้งานเนื่องจากขึ้นอยู่กับเวอร์ชัน API) ซึ่งหมายความว่าการเพิ่มประสิทธิภาพทั่วไปไม่สามารถนำออกได้อย่างปลอดภัย ดังนั้น การจัดระเบียบนี้จึงต้องพิจารณาฟิลด์ที่ใช้เครื่องมืออย่างชัดเจน เนื่องจากทราบแบบคงที่ว่าฟิลด์เหล่านั้นไม่มีข้อยกเว้น

ในท้ายที่สุด ตัวอย่าง Updater แบบง่ายที่แสดงด้านบนจะมีลักษณะดังนี้หลังการเพิ่มประสิทธิภาพ

ผลลัพธ์

หลังจากการเพิ่มประสิทธิภาพเหล่านี้ kotlinx.atomicfu และการใช้งาน AtomicInt/Long/ReferenceFieldUpdater อย่างชัดเจนส่วนใหญ่จะตรงกับประสิทธิภาพของ AtomicReference เมื่อใช้ R8 ซึ่งในความเป็นจริงแล้วยังเร็วกว่าในการทดสอบประสิทธิภาพบางรายการ kotlinx.atomicfu มี ปลั๊กอินคอมไพเลอร์ ที่สามารถอินไลน์อินสแตนซ์ atomic ลงในฟิลด์ ซึ่งช่วยลดการจัดสรรที่จำเป็นในการสร้างฟิลด์ที่อัปเดตแบบ Atomic

Jetpack Compose เป็นผู้ได้รับประโยชน์หลักจากงานนี้ รันไทม์ของ Compose มีการทดสอบประสิทธิภาพขนาดเล็กจำนวนมากที่ติดตามประสิทธิภาพของ Coroutine อย่างใกล้ชิดเพื่อตรวจหาการลดลงของประสิทธิภาพตั้งแต่เนิ่นๆ เมื่ออัปเดตการทดสอบประสิทธิภาพเป็น R8 เวอร์ชันใหม่ เราพบว่าประสิทธิภาพดีขึ้น 2 เท่า เมื่อเปิดใช้และยกเลิก Coroutines ในLaunchedEffect!

pic03_enhanced.png
กราฟการทดสอบประสิทธิภาพที่แสดงเวลาที่ใช้เมื่อเริ่มและยกเลิก Coroutines ใน LaunchedEffect (ยิ่งต่ำยิ่งดี) การเปลี่ยนแปลงในกราฟสอดคล้องกับการอัปเดต R8 ซึ่งแสดงให้เห็นถึงประสิทธิภาพที่ดีขึ้น 2 เท่า

นอกจากนี้ ทีม ART ยังใช้การเพิ่มประสิทธิภาพเหล่านี้แบบเนทีฟในระดับ VM หากแอปกำหนดเป้าหมายเป็น API 36 และทำงานใน Android เวอร์ชันล่าสุด อุปกรณ์ของคุณอาจเพิ่มประสิทธิภาพ Coroutines ในลักษณะที่คล้ายกันอยู่แล้ว การทดสอบประสิทธิภาพ Coroutine ด้านบนพบว่าประสิทธิภาพดีขึ้น ~15% หลังจากการอัปเดต JIT ใน ART เวอร์ชันล่าสุด

แอปจะได้รับการเพิ่มประสิทธิภาพนี้โดยค่าเริ่มต้นเมื่ออัปเกรดเป็น AGP 9.2.0 หรือใช้ R8 9.2.0 โดยตรง โปรดดูข้อมูลเพิ่มเติมที่ D8 Dexer และ R8 Shrinker

เขียนโดย
อ่านต่อ