The Missing Pillar: C# Native AOT as a Headless Core for Mobile
TL;DR
Bài viết đề xuất sử dụng C# Native AOT để làm các headless core library trên edge device, với Android làm ví dụ điển hình thông qua dynamic library được build thành file .so.
Luận điểm chính: C# Native AOT cho hiệu suất tiệm cận và developer experience (DX) vượt trội so với Cpp/Rust truyền thống, với cái giá phải đánh đổi chỉ là tăng khoảng 3MB binary file và 20MB RAM.
Để chứng minh luận điểm, bài viết xây dựng một bộ benchmark so sánh trực tiếp C# với Cpp và Rust trên cùng một C-ABI surface. Kết quả mở ra một lối thoát cho việc viết headless core lib, vốn trước đây gần như bị giới hạn vào Cpp và gần đây là Rust. Từ đó, bài viết đề xuất một framework hoàn toàn mới giúp C# có chỗ đứng ngay cả trên thị trường mobile app.
I. Bối cảnh & Phương trình Đánh đổi (The Core Problem)
1.1. Nỗi đau Core Lib hiện tại
Thế giới mobile app chia làm hai nửa. Nửa đầu tiên là việc dùng các framework tiện dụng như Capacitor để làm app, ưu tiên TTM tới mức tối đa, code rất dễ học và dễ viết, nhưng thứ đánh đổi lại chính là hiệu suất, bất cứ gì dùng JS làm lõi đều có hiệu suất yếu hơn kha khá và có trần hiệu suất tối đa khá nông.
Chính vì vậy, nửa còn lại gồm những ứng dụng mà bản thân nó đã là một đống tính toán nặng, và vì thế ta chọn một ngôn ngữ cho phép vắt kiệt hiệu suất của thiết bị như Cpp hay Rust. Tuy nhiên, điểm yếu của nó cũng chính là điểm mạnh của nó: ta đều biết rằng Cpp và Rust non-GC nên nó nhanh, và cũng vì non-GC nên viết trên hai ngôn ngữ này rất khổ. Tầng UI phát triển vũ bão, nhưng tầng lõi vẫn dậm chân tại chỗ, vì ngôn ngữ khó, DX thấp, maintain cực khổ.
Ngày nay, ta chỉ nghĩ tới việc viết core lib khi mà hiệu suất tổng thể của ứng dụng đã quá bết bát, chứ chưa bao giờ đó là suy nghĩ đầu tiên của ta khi bắt đầu viết một ứng dụng. Điều này tới từ một hậu quả tất yếu của việc ta chỉ có lựa chọn sử dụng một ngôn ngữ low-level để làm ngôn ngữ code. Và vì nó khó viết, ta luôn ở thế bị động khi thực hiện viết core lib, cũng như khi đã viết core lib, ta tốn vô số tài nguyên chỉ để khiến nó chạy được và khai thác phần hiệu năng bị bỏ trống này. Thực tế thế giới lập trình hiện nay chỉ có đúng hai ngôn ngữ này có thể build dlib mà lại nhanh. Tôi sẽ tạm bỏ qua Go, vì bản chất Go không nhanh bằng Rust, DX cũng không vượt trội khi so với Rust, và thực tế chẳng ai dùng Go để làm app cả.
1.2. Lựa chọn thứ 3: C# Native AOT
C# Native AOT xuất hiện như một ngôn ngữ có dev DX vượt trội và đã được tối ưu hằng chục năm, trong khi có khả năng build dlib với trade-off tối thiểu. Đưa ra thẳng Phương trình Đánh đổi:
- Filesize tăng 3MB.
- RAM usage tốn thêm 20MB.
- Hiệu suất đạt tới 90–95% so với việc code bằng Cpp/Rust.
Và điểm ta cần làm rõ ở đây: với một codebase đủ lớn, khả năng maintain codebase của C# vượt trội hơn hẳn Cpp/Rust. Và với một business logic đủ phức tạp, việc không cần phải quản lý bộ nhớ giảm tải gánh nặng nhận thức của chúng ta lớn tới mức thậm chí trong real world perf, C# có thể còn lớn hơn cả Cpp/Rust, nhờ việc tối ưu thuật toán và compiler PGO tối ưu.
II. Cơ Sở Vi Kiến Trúc (Tại sao C# làm được?)
2.1. Gọt bỏ Runtime
C# Native AOT ra đời để loại bỏ bộ framework cồng kềnh của mình, hỗ trợ khả năng startup siêu nhanh cũng như memory footprint tối thiểu. Sau .NET 7 và đặc biệt hiện nay tới .NET 10, hiệu năng của .NET Native AOT thực sự rất đáng nể, và đã được chứng minh hiệu quả và tối ưu bằng hằng tỉ request mỗi ngày tại môi trường cloud computing.
Dù sinh ra cho cloud, chúng ta đang quên mất một ứng dụng vô cùng mạnh mẽ của nó: nhờ khả năng build thành dynamic lib (dlib) ở vô số runtime khác nhau, và quan trọng nhất là arm64-android và arm64-ios, đã mở ra cơ hội để ứng dụng C# vào môi trường mobile app.
2.2. Điểm nghẽn C-ABI & Phân bổ bộ nhớ
Thực tế thì cả Rust lẫn C# ngay tại thời điểm hiện nay đều sử dụng qua cầu nối C ABI, và vì vậy đều sinh ra vô số boilerplate trong quá trình sử dụng. Ta luôn phải chấp nhận magic strings và một đống quy ước "mồm" để core lib có thể được sử dụng.
Điều quan trọng cần nhấn mạnh: dù bạn chọn ngôn ngữ nào, trải nghiệm C ABI vẫn y hệt nhau. Vì vậy, nếu đó là lí do để không dùng C#, thì cũng không nên viết headless core lib.
Vậy câu hỏi còn lại cần đặt ra chỉ là: liệu thực sự hiệu năng C# tới đâu? Và nó sẽ được giải đáp ngay ở phần sau.
III. Hồ Sơ Thực Chứng (Direct Benchmark — Trái tim của bài viết)
Các bài benchmark này không mang tính phổ quát, mà chỉ để kể nên một câu chuyện, một luận điểm nào đó đã được tôi chắt lọc qua nhiều benchmark độc lập và chọn lọc. Để hiểu tại sao chỉ còn từng này benchmark, bạn có thể đọc phụ lục sau khi đã đọc hết bài viết.
3.1. Methodology
Để xác định rõ khả năng thực sự của từng ngôn ngữ, ta chia việc benchmark này thành nhiều nhóm benchmark độc lập để có thể phủ được hầu hết mọi trường hợp có thể xảy ra và đưa ra được một quyết định công tâm nhất.
Benchmark sẽ yêu cầu một ứng dụng Android, và tôi sẽ sử dụng Capacitor làm giao diện chính. Sẽ có 3 project tương ứng với 3 ngôn ngữ được đo đạc: C#, Rust và Cpp.
Bộ test sẽ được chạy trên cùng một máy Android duy nhất là chiếc Galaxy S25U của tôi, đang còn đúng 100% pin và đang cắm ADB.
Toàn bộ việc đo đạc được gọi thông qua một lib Cpp trung gian duy nhất.
Nhờ thiết lập trên, ta sẽ loại bỏ được biến số của thiết bị hay cách gọi, và chỉ nhìn vào hiệu năng thuần túy của ngôn ngữ. Các thông số sẽ đo lần lượt là: avg (ms), min (ms), max (ms), p99 (ms), p999 (ms), op/s, ns/op, gflops, avg temp, peak temp, avg cpu, peak cpu.
3.2. Ping — When C# Loss but still win
Benchmark đầu tiên chúng ta đi qua sẽ là một benchmark rất đơn giản, khi nó chỉ đơn giản là một hàm Ping. Hàm này chả làm gì cả, được gọi, rồi return, chỉ vậy thôi. Và ta sẽ có một kết quả sơ bộ như sau:
WhySharp · Ping
testId: ping · chế độ: time (giây) · params: seconds=60
| lib | ops/s | ns/op | temp avg | clock avg |
|---|---|---|---|---|
| csharp | 117,293,723 | 8.53 | 60.9 | 2371 |
| cpp | 190,948,805 | 5.24 | 50.4 | 2242 |
| rust | 242,441,713 | 4.12 | 53.1 | 2089 |
Từ benchmark trên ta có thể thấy rõ hạn chế của C# so với Rust/Cpp trong việc gọi hàm. Để gọi một hàm qua C-ABI, C# chậm hơn, và có thể là chậm hơn gấp đôi Rust. Tuy nhiên, dù có chậm hơn nhiều như vậy, tốc độ đó cũng vô cùng nhỏ. Kể cả nhân lên 100 lần là 800ns/op thì bản chất tốc độ đó vẫn chấp nhận được trong thực tế. Thẳng thắn thừa nhận thì không ai cần tận 200M ops/s cả, và con số 1M ops/s đã là quá thừa thãi để làm core lib rồi.
Điều đó cũng đúng với bất kể tác vụ nào có thời gian xử lý ít, kể cả reverse callback cũng gặp vấn đề tương tự, nhưng bản chất nó không phải là một vấn đề mà là một đánh đổi chấp nhận được.
3.3. ProcessTreeNodes — When C# is better
Benchmark tiếp theo sẽ là một bài toán được thiết kế để cho C# thắng, đó là bài toán tính toán trên cây nhị phân. Ta có thể cùng nhìn vào kết quả sau:
WhySharp · Tree
testId: tree · chế độ: time (giây) · params: depth=10, count=50, seconds=60
| lib | avg (ms) | ops/s | temp avg | clock avg |
|---|---|---|---|---|
| csharp | 1.465 | 682.5 | 62.5 | 2883 |
| rust | 6.056 | 165.1 | 59.0 | 2997 |
| cpp | 6.002 | 166.6 | 60.8 | 3075 |
Ta có thể thấy C# nhanh gấp 6 lần Rust/Cpp trong trường hợp này. Chính vì cơ chế quản lý bộ nhớ qua GC mà ta luôn chê khi code hiệu suất cao, giờ nó chính là điểm khác biệt lớn nhất giúp ta có kết quả này.
(Note: clock avg của C# là thấp nhất mà hiệu năng nó lại top đầu, bản thân vì lượt chạy của nó muộn nhất nên máy vốn đã nóng rồi, chứ trong các bài test tổng hợp khác, con số còn kinh khủng hơn nhiều.)
Task ProcessTreeNodes này rất nặng về cấp phát bộ nhớ: mỗi lần gọi, chương trình dựng một cây nhị phân đầy đủ với hàng nghìn node, mỗi node là một vùng nhớ được cấp phát riêng biệt, sau đó duyệt cây để tính tổng rồi bỏ đi toàn bộ. Lặp lại như vậy liên tục. Đây là kịch bản "tạo rác dồn dập" kinh điển.
Với C#, các node là object nằm trên heap do GC quản lý. Việc cấp phát trong Gen 0 chỉ là nhích một con trỏ (ptr += size), chi phí gần như bằng 0. Khi toàn bộ cây thành rác, GC thu dọn cả cụm cùng lúc theo batch, không phải trả từng node một. Ngược lại, Cpp/Rust phải gọi malloc/free cho từng node riêng lẻ, mỗi lần đều đi qua tra bảng quản lý vùng nhớ, kèm tranh chấp khóa trên heap khi chạy đa luồng. Cái nhược điểm mang tiếng xấu của GC lại hóa ra là thế mạnh lớn nhất ở tác vụ này.
Và từ chính benchmark này cũng cho ta một insight khá thú vị: đôi khi có GC còn đem lại hiệu suất tổng thể cao hơn cả việc không có GC, vì bản thân việc cấp phát bộ nhớ cũng cần tính toán, và đôi khi thứ nặng nề nhất lại là cấp phát bộ nhớ. Ta cũng cần phải thẳng thắn: Rust/Cpp hoàn toàn có thể viết ra cơ chế cấp phát bộ nhớ tương tự cách C# làm, và đôi khi là tốt hơn. Nhưng làm vậy sẽ phức tạp hơn rất rất nhiều, trong khi lợi ích cũng chỉ như một đoạn code bất kỳ của C#.
3.4. JsonParseFilter — When C# become bare-metal
Tại benchmark này, chúng ta sẽ xử lý một kịch bản mà gần như bất kể chương trình nào cũng cần xử lý, một hot path thực sự trong thực tế, đó chính là xử lý JSON. Chính vì vậy, ta luôn cần hiệu năng tối đa của hàm này. Và lần này tôi sẽ code C# bằng unsafe, bằng những đoạn code tối ưu nhất có thể làm được trên C#. Ta sẽ có kết quả benchmark như sau:
WhySharp · JsonParseFilter
testId: json · chế độ: time (giây) · params: count=2000, seconds=60
| lib | avg (ms) | ops/s | temp avg | clock avg |
|---|---|---|---|---|
| csharp | 0.040 | 25,191 | 54.2 | 2920 |
| rust | 0.038 | 26,272 | 56.2 | 3107 |
| cpp | 0.043 | 23,320 | 58.0 | 3080 |
Ta có thể thấy rõ, C# xấp xỉ bằng Rust lẫn Cpp. Điều này đạt được nhờ việc tối ưu hóa dòng lệnh tới tận SIMD. Bạn không nghe nhầm đâu, C# cho phép gọi trực tiếp SIMD. Và công cụ này giúp trần tối ưu của C# lớn tới mức có thể tạm gọi nó chính là bare-metal. Chính benchmark này là ví dụ rõ ràng nhất của điều đó. Tôi đã tối ưu cả ba ngôn ngữ tới tận tầng SIMD, và kết quả cũng chỉ ra rằng bản chất ba ngôn ngữ này cho ra cùng một kết quả, sai số chỉ là do OS scheduler hoặc một vài yếu tố ngẫu nhiên nào đó thôi.
IV. Kết Luận: Lối Thoát Cho Tầng Lõi Mobile (The Main Verdict)
4.1. C# đã sẵn sàng đoạt ngôi
C# hoàn toàn đủ khả năng để dùng làm headless core lib cho ứng dụng di động. Trần hiệu năng của C# có thể coi là bare-metal, và trong bối cảnh ưu tiên DX hơn thì hiệu năng của C# vẫn thừa đủ cho business logic phổ biến. Trong source code của một headless core lib C#, phần lớn thời gian ta sẽ code như một project high level thông thường, trải nghiệm lập trình sẽ vô cùng dễ dàng trong khi hiệu suất managed của C# cũng không hề tệ. Rồi tới một ngày đẹp trời để tối ưu, ta sẽ sửa lại các hàm hot path bằng code low level mà không cần phải tạo thêm bất cứ một project phức tạp hay học thêm một ngôn ngữ nào. Thậm chí khi dùng managed code, đôi khi còn nhanh hơn cả unsafe code trong một số trường hợp nhờ cơ chế quản lý bộ nhớ thông minh của C#.
Cpp vẫn sẽ có chỗ đứng của riêng nó, chỉ khác là hầu hết mọi thứ Cpp làm được thì C# làm được y hệt. Còn với Rust cũng vậy, chỉ khác là C# không hỗ trợ cơ chế bộ nhớ kiểu borrow checker, nên có những thứ thực sự C# không thể thay thế được Rust.
Tuy C# không phải là không có điểm yếu, dễ thấy nhất khi nhìn vào benchmark của bài test Ping: dù thua thiệt tận 4 lần so với Rust/Cpp, nhưng tổng thể vẫn có 50M+ Op/s được thực thi với độ trễ trung bình chỉ là 20ns. Nó là quá đủ với phần lớn, nếu không muốn nói là toàn bộ các ứng dụng mobile. Thực sự thì dù bạn có làm game engine cũng chả cần gọi nhiều tới vậy đâu. Các bài test khác trên môi trường managed cũng cho thấy hiệu năng C# lúc cao hơn, lúc thấp hơn Rust/Cpp, nhưng kể cả trong trường hợp chậm hơn, hiệu năng đó vẫn là quá đủ để thực hiện những logic phức tạp. Và nếu nó vẫn không đủ? Viết lại là xong.
Chốt lại, mức chênh lệch hiệu năng là không đáng kể, trong khi khoản phí 20ns và 20MB RAM là cái giá quá rẻ.
4.2. Giải phóng nhân sự & Codebase (DX Win)
Tóm gọn lại, bạn nên dùng C# Native AOT để làm headless core lib thay thế cho Rust/Cpp để có thể có một trải nghiệm lập trình và tính an toàn tốt hơn với trade-off tối thiểu, và vẫn cho phép tối ưu hóa một cách tuyệt đối. Điều đó càng đúng với góc nhìn của một doanh nghiệp, khi codebase rõ ràng hơn đồng nghĩa với TTM ngắn hơn, ít lỗi hơn. Độ khó ngôn ngữ thấp hơn đồng nghĩa với việc lương nhân sự rẻ hơn và rủi ro sa thải thấp hơn. Và quan trọng nhất, bạn không phải trả lương cao chỉ để code những phần business logic đơn giản chỉ vì nó viết bằng một ngôn ngữ khó dùng.
Nhìn rộng hơn, điều này mang trải nghiệm Monorepo, tooling hiện đại và nguồn nhân lực C# khổng lồ vào thị trường Mobile Core Lib, thay vì phải khổ sở tuyển dụng và maintain code Rust/Cpp. Chi phí nhân sự giảm mạnh tỉ lệ thuận với độ khó của ngôn ngữ lập trình, trong khi TTM tăng mạnh và khả năng bảo trì codebase có một sự thay đổi lớn về chất, với một cái giá quá nhỏ về mặt hiệu năng. Và nên nhớ, nếu có những super hot path, ta hoàn toàn có thể tiếp tục quay lại dùng Cpp/Rust với một vài hàm và để tầng C# làm nơi gọi tới chúng luôn.
V. Thảo Luận Mở Rộng: Hình Hài Của Một Framework Tương Lai
Phần này rất quan trọng nhưng tôi lười nên chưa viết xong!
VI. Phụ lục
Phần phụ lục này không được biên tập để dễ hiểu, nó là hàng loạt những suy nghĩ của tôi cũng như góc nhìn về bài viết trong quá trình viết bài, nếu bạn không tính đào sâu tới tận cùng của vấn đề, tôi khuyên bạn không nên đọc vì nó sẽ rất hỗn loạn
6.1. Tôi là người lười
Thực tế bài viết có thể sẽ dài hơn rất nhiều. Cụ thể, trong kế hoạch ban đầu có tới 19 benchmark, trong đó có 8 benchmark micro, 8 benchmark business, và nguyên một benchmark là cherry-pick btree.c của SQLite ra để so sánh. Nhưng tôi đã tóm gọn nó lại thành những benchmark trong bài. Hơn cả 19 benchmark đó, vẫn còn thêm những benchmark tôi đã test và xóa do không mang nhiều ý nghĩa. Tại source code vẫn còn những logic đó, có thể nó không chạy được nữa, có thể nó chưa tối ưu, hoặc sai ý đồ thiết kế ban đầu, bạn có thể đọc thử.
Ngoài ra, chính cách thiết lập thí nghiệm cũng đã được tinh gọn. Ban đầu tôi tính sẽ có 3 watcher nằm tại 3 tầng khác nhau, gồm watcher từ tận tầng webview, watcher tại tầng native viết bằng Kotlin, rồi mới tới watcher viết độc lập bằng Cpp. Ban đầu tôi cũng dự tính dùng tới 10 máy, chạy trên 2 môi trường là giả lập và thiết bị thật, nhưng cuối cùng chỉ rút gọn còn một máy duy nhất. Phần lớn source code cũng được generate thay vì viết tay hoàn toàn, và tất cả tôi làm trong 3 ngày nên không tránh khỏi sai sót.
Các bạn có thể thử hoàn thiện toàn bộ các benchmark và watcher để có một góc nhìn kĩ lưỡng hơn nữa, nhưng theo tôi kết quả cũng không khác đâu. C# chỉ yếu ở C-ABI boundary, và ngay cả cái chúng ta gọi là yếu thì cũng chỉ là yếu hơn 2 ngôn ngữ kia do rào cản GC của chính C#. Và tôi cũng đã có một phụ lục bên dưới để nói về cách khắc phục nó, cũng như khiến C# còn nhanh hơn cả Rust.
Nếu bạn có code xong, xin hãy tag tôi vào, tôi muốn chứng kiến benchmark đó dù cho tôi hơi quá lười để hoàn thiện nó.
Đây là bản nháp chưa được hoàn thiện và trước khi được dịch sang tiếng anh. Nếu bạn có quan tâm tới chủ đề này hãy theo dõi để đọc khi nó hoàn thành.
All rights reserved