Kể từ AGP 9.2.0, R8 tối ưu hoá hầu hết các lệnh gọi Atomic*FieldUpdater thành các biến thể Unsafe có hiệu suất từ 2 đến 4 lần cho các thao tác phổ biến. Điều này có tác động đặc biệt lớn đến thư viện kotlinx.atomicfu triển khai các thao tác nguyên tử cho kotlinx.coroutines, giúp việc chạy và huỷ coroutine nhanh hơn gấp 2 lần. Để hưởng các lợi ích này, hãy cập nhật AGP lên phiên bản 9.2.0 trở lên.
Khi phần lớn các ứng dụng Android chọn Kotlin làm ngôn ngữ chính, kotlinx.coroutines đã trở thành tiêu chuẩn thực tế cho việc lập trình không đồng bộ. Thư viện này cung cấp một cách thức có cấu trúc và được thiết kế hợp lý để quản lý các luồng đồng thời, vốn là một phần không thể thiếu của Kotlin. Jetpack Compose cũng không ngoại lệ, sử dụng coroutine để quản lý các sự kiện con trỏ, ảnh động và các hoạt động tương tác khác. Tại thời điểm viết bài này, hầu hết các API đồng thời trong Compose đều gọi các hàm suspend ở chế độ nền, đồng thời khởi chạy và/hoặc huỷ các coroutine để xử lý nội dung cập nhật.
Khi nhóm Compose bắt đầu điều tra hiệu suất, họ nhận thấy các coroutine là một điểm tắc nghẽn đối với nhiều thao tác diễn ra bên ngoài thành phần. Ví dụ: 80% thời gian dành cho việc tạo và cập nhật Modifier.clickable được dùng để khởi chạy và huỷ các coroutine nội bộ xử lý nội dung cập nhật InteractionSource. Dựa trên những quan sát đó, phần lớn công việc cải thiện hiệu suất ban đầu tập trung vào việc xoá các coroutine khỏi đường dẫn mặc định và trì hoãn quá trình khởi chạy cho đến khi cần thiết.
Chi phí của một coroutine
Cách dễ nhất để phân tích hành vi nội bộ của một hàm trên Android là ghi lại dấu vết phương thức Android Runtime (ART). Dấu vết phương thức ART là một công cụ ghi lại quy trình thực thi của một ứng dụng, cho biết chính xác những phương thức nào được gọi, thứ tự của chúng và thời gian dành cho mỗi phương thức, cho phép nhà phát triển xác định các điểm nghẽn về hiệu suất. Đối với một lệnh gọi LaunchedEffect { } trống, lệnh gọi này sẽ có dạng như sau:
Dấu vết phương thức ở trên có thể được chia thành 3 phần:
- Khởi chạy một coroutine mới
- Bắt đầu coroutine
- Hoàn tất coroutine (vì coroutine thoát ngay lập tức)
Huỷ LaunchedEffect cũng tương tự như hoàn tất bình thường, ngoại trừ việc thao tác này cũng tạo ra một CancellationException.
Trong hồ sơ trên, một điều đáng ngờ ngay lập tức là các lệnh gọi thường xuyên vào java.util.concurrent.AtomicReferenceFieldUpdater (các hộp màu tím hoặc màu xanh lục có nhãn j…). Mặc dù mỗi lệnh gọi đều tương đối nhanh, nhưng tần suất lại đáng lo ngại; mọi chi phí không đáng kể được trải đều trên nhiều lệnh gọi có thể cộng lại thành một mức giảm đáng kể. Khi phóng to một lệnh gọi, bạn sẽ thấy rằng hầu hết thời gian đều dành cho việc kiểm tra phản chiếu?
Các coroutine triển khai cấu trúc cây không khoá cho mối quan hệ mẹ con, giúp cơ chế xử lý đồng thời có cấu trúc trở nên khả thi. Hoá ra, thư viện kotlinx.atomicfu triển khai các thao tác nguyên tử không khoá bằng cách sử dụng một phương thức gốc JVM nổi tiếng, AtomicReferenceFieldUpdater. Trình cập nhật sử dụng một tham chiếu lớp và tên trường để thực hiện các thao tác nguyên tử trong thời gian chạy, đồng thời phải chạy một số quy trình kiểm tra an toàn phản chiếu để đảm bảo trường tồn tại và có thể truy cập. Mỗi thao tác trong coroutine (bắt đầu, tạm ngưng, huỷ, hoàn tất) sẽ gọi ít nhất một thao tác nguyên tử. Vì vậy, nếu thao tác này diễn ra chậm, coroutine sẽ không hoạt động hiệu quả.
Điều tra AtomicReferenceFieldUpdater
Nhưng đừng vội vàng. AtomicReferenceFieldUpdater thực sự được tối ưu hoá tốt trên JVM trong hơn 10 năm qua và dấu vết phương thức có thể ghi lại chi phí phát sinh hoàn toàn bị loại bỏ bằng cách tối ưu hoá cấp VM: biên dịch trong khi thực thi (JIT) hoặc biên dịch trước khi chạy (AOT). Để xác minh hiệu suất, hãy viết một vài điểm chuẩn để đo lường sự khác biệt giữa các tham chiếu nguyên tử từ kotlinx.atomicfu và 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 */ }
Khi chạy phép đo điểm chuẩn này trên Pixel 5 (trong khi đảm bảo AtomicReferenceFieldUpdater#compareAndSet được biên dịch JIT trong quá trình khởi động), bạn sẽ nhận được kết quả sau trên Pixel 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
Các phép đo xác nhận khoảng cách này, với phiên bản kotlinx.atomicfu rõ ràng chậm hơn khoảng 2,7 lần. Điều này xác nhận rằng ART không thực hiện bất kỳ hoạt động tối ưu hoá ẩn nào và các quy trình kiểm tra quyền truy cập phản chiếu sẽ làm tăng mức hao tổn thực trong thời gian chạy.
Nhìn lại dấu vết phương thức ban đầu, công việc có ý nghĩa duy nhất mà AtomicReferenceFieldUpdater thực hiện là lệnh gọi nội bộ vào Unsafe.getObjectVolatile. Lệnh gọi này thực sự thực thi thao tác nguyên tử cơ bản. Trong hầu hết các trường hợp, trình khởi tạo trình cập nhật là tĩnh và có thể chứng minh là luôn chính xác dựa trên cấu trúc của lớp xung quanh. Do đó, người ta có thể phân tích tĩnh hầu hết các cách sử dụng AtomicReferenceFieldUpdater và thay thế chúng bằng một biến thể Unsafe nội bộ trong quá trình biên dịch. Ngoài ra, chuỗi công cụ tạo Android có trình biên dịch tối ưu hoá riêng có thể thực hiện chính xác điều đó.
Tối ưu hoá bằng R8
Các lớp Atomic*FieldUpdater hỗ trợ việc sử dụng tinh tế, linh hoạt và dựa trên phản xạ, nhưng thường được dùng trong các mẫu tĩnh rõ ràng. Điều này vừa giải thích hiệu suất cơ sở chậm, vừa cho thấy nhu cầu tối ưu hoá. R8 là một trình biên dịch tối ưu hoá toàn bộ chương trình và rất phù hợp để xem xét các mẫu đơn giản hơn nhằm giảm bớt chi phí của các quy trình kiểm tra an toàn phản chiếu. R8 nhận mã byte JVM sau trình biên dịch Java hoặc Kotlin, nhưng để dễ đọc, các ví dụ này được trình bày bằng cú pháp Java. Đây là lý do không có đối số kiểu cho AtomicReferenceFieldUpdater.
class Example { volatile String data = ""; static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data"); void example() { // ... updater.compareAndSet(this, "", "new"); // ... } }
Ví dụ cơ bản tạo một trình cập nhật cuối cùng tĩnh, truy cập vào một trường biến động bằng các đối số hằng số đơn giản cho trình giữ chỗ, loại và tên của trường. Hình phản chiếu được dùng hoàn toàn trong suốt. Rõ ràng là trình cập nhật này tham chiếu đến một trường hợp lệ và trang web tạo trình cập nhật có quyền truy cập hợp lệ vào trường đó.
Về bản chất, Atomic*FieldUpdater là một trình bao bọc xung quanh độ lệch trường và các lệnh gọi đến Unsafe. Trường hợp tối ưu nhất cho việc tối ưu hoá là thay thế trường trình cập nhật bằng một trường độ lệch và thay thế các lệnh gọi trình cập nhật bằng các lệnh gọi đến Unsafe.
Tối ưu hoá Atomic*FieldUpdater
Quy trình tối ưu hoá được triển khai thành 3 phần: Đo lường, Thay thế và Dọn dẹp.
Khả năng đo lường
Bước đầu tiên là giới thiệu các trường bù bên cạnh trường trình cập nhật để tạo điều kiện truy cập trực tiếp thông qua lệnh gọi Unsafe .
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
Trường này được truy cập thông qua tính năng phản chiếu và Unsafe được dùng để trích xuất độ lệch trường trên lớp. Mã này biểu thị các thành phần bên trong của Atomic*FieldUpdater nếu bạn bỏ qua quy trình xác thực phản chiếu. Thay vào đó, loại trình giữ chỗ của trình cập nhật và loại trường của trường biến động được theo dõi tĩnh trong trình biên dịch.
Xin lưu ý rằng trường ban đầu và quá trình khởi tạo của trường này vẫn giữ nguyên. Quy trình tối ưu hoá sẽ hỗ trợ và tối ưu hoá việc sử dụng một cách lạc quan, sau đó dọn dẹp. Đây là một phương pháp triển khai đơn giản nhưng cũng cho phép tối ưu hoá một phần các trường của trình cập nhật, trong đó một số trường được giữ nguyên trong khi những trường khác được tối ưu hoá.
Thay thế
Tại thời điểm này trong trình biên dịch, sau một điểm kết hợp đồng thời phù hợp, chúng ta có một danh sách các trường trình cập nhật được đo lường. Điều này có nghĩa là chúng ta có thể tối ưu hoá từng trang web gọi riêng lẻ dựa trên một số điều kiện. Hãy xem xét một lệnh gọi ví dụ:
updater.compareAndSet(holder, expectedValue, newValue);
Atomic*FieldUpdater yêu cầu các điều kiện sau:
updatercó phải đến từ một trường được đo lường không? Tức là liệu quy trình phân tích tĩnh có thể theo dõi giá trị của đối tượng trở lại một trường đọc của trình cập nhật được đo lường hay không?holdercó phải là cùng một lớp hay một lớp con của loại phần giữ chỗ được xác định ban đầu không?newValuecó phải là cùng một lớp hay một lớp con của loại trường được xác định ban đầu không?
Nếu tất cả các điều kiện đều được đáp ứng, thì lệnh gọi sẽ được thay thế bằng một lệnh gọi đến Unsafe mà không cần bất kỳ quy trình kiểm tra phản chiếu nào.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
Lệnh gọi mới này nhanh hơn và đơn giản hơn, nhưng khác với lệnh gọi ban đầu về cách xử lý các giá trị rỗng trong updater và holder. Trừ phi được loại trừ tĩnh, các lệnh kiểm tra giá trị rỗng sẽ được chèn cho cả hai.
Dọn dẹp
Tại thời điểm này, lớp giữ chỗ có trường trình cập nhật ban đầu và trường độ lệch mới cùng với các vị trí gọi có thể sử dụng một trong hai trường. Nếu không có vị trí nào được tối ưu hoá, thì bạn nên xoá trường offset. Nếu tất cả vị trí đều được tối ưu hoá, thì bạn nên xoá trường updater. Trong cả hai trường hợp, bạn cũng nên xoá lệnh gọi khởi tạo. Việc xoá các trường không dùng đến và loại bỏ mã không dùng đến đã được thực hiện trong trình biên dịch, nhưng việc xoá mã khởi tạo ở đây cần thêm một vài thủ thuật.
Cả lệnh gọi đến newUpdater và getDeclaredField đều có thể có tác dụng phụ vì chúng có thể tạo ra các trường hợp ngoại lệ (và việc triển khai của chúng cũng không xác định được vì tuỳ thuộc vào phiên bản API). Điều này có nghĩa là bạn không thể xoá các tệp này một cách an toàn bằng cách tối ưu hoá chung. Vì vậy, quá trình dọn dẹp này đòi hỏi phải xem xét rõ ràng các trường được đo lường, vì những trường đó được biết là không có ngoại lệ.
Cuối cùng, ví dụ về trình cập nhật đơn giản ở trên sẽ có dạng như sau sau khi tối ưu hoá:
Kết quả
Sau những bước tối ưu hoá này, kotlinx.atomicfu và hầu hết các cách sử dụng rõ ràng của AtomicInt/Long/ReferenceFieldUpdater hiện khớp với hiệu suất AtomicReference khi áp dụng R8. Trên thực tế, nó còn nhanh hơn trong một số điểm chuẩn; kotlinx.atomicfu có một trình bổ trợ trình biên dịch có thể nội tuyến các thực thể atomic vào các trường, giảm số lượng phân bổ cần thiết để tạo một trường được cập nhật nguyên tử.
Jetpack Compose là đối tượng hưởng lợi chính từ hoạt động này. Thời gian chạy Compose có một số phép đo điểm chuẩn vi mô theo dõi hiệu suất coroutine rất chặt chẽ để phát hiện sớm các trường hợp hiệu suất giảm. Khi các điểm chuẩn được cập nhật lên phiên bản R8 mới, chúng tôi nhận thấy mức cải thiện gấp 2 lần khi khởi chạy và huỷ coroutine trong LaunchedEffect!
Ngoài ra, nhóm ART đang triển khai các hoạt động tối ưu hoá này một cách tự nhiên ở cấp VM. Nếu ứng dụng của bạn nhắm đến API 36 và đang chạy trên một phiên bản Android gần đây, thì có thể thiết bị của bạn đã tối ưu hoá các coroutine theo cách tương tự. Các điểm chuẩn coroutine ở trên cho thấy hiệu suất được cải thiện khoảng 15% sau khi cập nhật JIT trong các phiên bản gần đây của ART.
Theo mặc định, ứng dụng của bạn sẽ nhận được hoạt động tối ưu hoá này khi nâng cấp lên AGP 9.2.0 hoặc bằng cách sử dụng trực tiếp R8 9.2.0. Để biết thêm thông tin, hãy xem phần Trình chuyển đổi dex D8 và trình rút gọn R8.
-
Nghiên cứu điển hìnhCác trường hợp hiệu suất giảm rất khó tái tạo, khiến các trường hợp này trở thành một điểm tắc nghẽn lớn đối với nhà phát triển di động.
Alice Yuan, Arti Arutiunov, Nikita Ogorodnikov • 4 phút đọc -
Nghiên cứu điển hìnhGần đây, FotMob đã ghi nhận mức tăng lớn nhất trong một ngày trên Wear OS trong số những người dùng đã cài đặt ứng dụng này trong 5 năm qua, với mức tăng gấp 2 đến 3 lần so với mức trung bình hằng ngày. Bí quyết là gì? Quy trình cài đặt đơn giản trên nhiều thiết bị giúp người dùng khám phá ứng dụng Wear OS của họ ngay trên điện thoại.
Garan Jenkin • Đọc trong 3 phút -
Nghiên cứu điển hìnhỨng dụng chánh niệm Gratitude khuyến khích người dùng duy trì thói quen thông qua việc ghi nhật ký hằng ngày, lời tự động viên và bảng mục tiêu. Ứng dụng này có hơn 6 triệu lượt tải xuống, 150.000 lượt xếp hạng 5 sao và 100 triệu mục nhật ký đã ghi.
Amrit Sanjeev, Ash Nohe • Đọc trong 3 phút
Nhận thông tin chi tiết mới nhất về hoạt động phát triển trên Android trong hộp thư đến của bạn mỗi tuần.