Bài viết được dịch và chuyển ngữ từ bài gốc của Marek Fiser.
Việc thực thi code C# trên runtime Mono của Unity rất chậm theo tiêu chuẩn ngày nay, chậm hơn nhiều so với bạn có thể mong đợi! Game của chúng tôi chạy nhanh hơn 2-3 lần trên .NET hiện đại so với Mono của Unity, và trong một số benchmark nhỏ, tôi đã đo được tốc độ tăng lên tới 15 lần. Tôi đã dành thời gian nghiên cứu điều gì đang xảy ra và trong bài viết này, tôi sẽ trình bày những phát hiện của mình và lý do tại sao mọi người nên muốn quá trình hiện đại hóa .NET của Unity trở nên sẵn sàng cho sản xuất càng sớm càng tốt.
Mục lục
Chúng ta đã đến đây như thế nào
Unity sử dụng framework Mono để chạy các chương trình C# và vào năm 2006, đây là một trong những nền tảng triển khai .NET đa nền tảng khả thi duy nhất. Mono cũng là mã nguồn mở, cho phép Unity thực hiện một số tinh chỉnh để phù hợp hơn với phát triển game.
Một bước ngoặt thú vị đã xảy ra gần 10 năm sau đó. Vào năm 2014, Microsoft bắt đầu mở mã nguồn .NET (đáng chú ý là .NET Core vào cuối năm đó) và vào tháng 6 năm 2016, .NET Core 1.0 được phát hành với hỗ trợ đa nền tảng chính thức. Kể từ đó, hệ sinh thái .NET đã đạt được đà phát triển và rất nhiều cải tiến đã được thực hiện, bao gồm nền tảng trình biên dịch Roslyn, JIT (trình biên dịch just-in-time) mới, cải thiện hiệu năng, nhiều tính năng hơn, v.v.
Vào năm 2018, các kỹ sư của Unity đã thảo luận rằng họ đang làm việc để chuyển engine sang .NET CoreCLR, phiên bản đa nền tảng của Common Language Runtime (CLR) – một thành phần chạy các chương trình .NET. Động lực chính của họ đằng sau dự án này là hiệu năng và sự hội tụ. Trong bài viết của mình, họ cho biết:
…CoreCLR có thể rất tuyệt vời cho các nhà phát triển game Unity, vì nó sẽ cung cấp một sự cải thiện đáng kể về hiệu năng, với mức tăng từ 2x đến 5x so với runtime Mono, đôi khi lên tới 10x trên một số khối lượng công việc!
Thật không may, hiện tại là cuối năm 2025 và chúng ta vẫn không thể chạy game trên CoreCLR.
Khoảng cách hiệu năng
Chúng ta không nghe nhiều về khoảng cách hiệu năng giữa Mono và .NET, có lẽ vì không thể chạy các game được viết cho Unity trên .NET hiện đại. Nhưng chúng ta vẫn có thể so sánh trực tiếp với code không phụ thuộc trực tiếp vào Unity.
Game của chúng tôi có một kiến trúc độc đáo – chúng tôi tách biệt hoàn toàn code mô phỏng game (business logic) khỏi rendering. Đến mức code mô phỏng không phụ thuộc vào các thư viện của Unity và có thể được biên dịch và chạy dưới bất kỳ phiên bản .NET nào.
Một ngày nọ, tôi đang gỡ lỗi một vấn đề trong quá trình tạo bản đồ và việc này rất tốn thời gian vì mất hơn 2 phút để bắt đầu một game. Để việc gỡ lỗi nhanh hơn, tôi đã viết một unit test, hy vọng giảm thời gian quay vòng vì Unity mất 15+ giây chỉ để xử lý các DLL mới và tải lại domain trước khi game có thể khởi chạy, và nó cũng khởi tạo các thứ liên quan đến rendering mà tôi không quan tâm. Khi tôi chạy test, nó hoàn thành trong 40 giây. Tôi khá ngạc nhiên vì nó nhanh hơn 3 lần, vì vậy tôi bắt đầu tìm hiểu sâu hơn.
Nói ngắn gọn, Hình 1 cho thấy dữ liệu từ profiler cho thấy sự khác biệt giữa việc khởi chạy game trong Unity chạy Mono so với unit test chạy trên .NET.
Lưu ý rằng tất cả các benchmark được hiển thị đều sử dụng Unity 6.0 hoặc .NET 10.
- Unity editor biên dịch ở chế độ Debug: 100 giây.
- .NET unit test biên dịch ở chế độ Debug: 38 giây.
Hình 1: Dấu vết khởi chạy game trong Unity và .NET, chế độ Debug.
Vì vậy, benchmark của chúng tôi cho thấy việc tải file save, tạo bản đồ và khởi tạo mô phỏng mất 100 giây trong Unity/Mono nhưng chỉ 38 giây trong .NET. Kết quả này một mình đã đủ để khiến người ta phải ngạc nhiên và có những hậu quả thực tế về cách bạn có thể tiếp cận việc gỡ lỗi và kiểm thử.
Tôi cũng biết từ kinh nghiệm với Unity rằng chế độ Release chạy dưới dạng standalone executable (không có Unity editor) nhanh hơn nhiều, vì vậy tôi quyết định kiểm tra điều đó tiếp theo.
.NET so với Mono ở chế độ Standalone Release
Sự chậm chạp ở chế độ Debug không phải là tuyệt vời, nhưng ngay cả code C++ không được tối ưu hóa cũng có thể chậm. Để so sánh khoảng cách hiệu năng thực sự giữa Mono và .NET, hãy chạy cùng benchmark như trên nhưng ở chế độ release, standalone executable.
Đầu tiên: Unity. Tôi đã chạy script triển khai để có được executable đã được tối ưu hóa và chạy trực tiếp. Không ngạc nhiên, standalone executable đã được tối ưu hóa đánh bại Unity editor với một biên độ lớn, nhanh hơn hơn 3 lần. Tiếp theo, cùng code đó chạy dưới .NET ở chế độ Release. Hình 2 cho thấy kết quả.
- Mono dưới dạng standalone executable ở chế độ Release: 30 giây.
- .NET unit test ở chế độ Release: 12 giây.
Hình 2: Dấu vết khởi chạy game trong Unity và .NET, chế độ Release
Đúng vậy. 12 giây. Thực sự rất khó tin khi thấy lượng công việc được thực hiện trong 12 giây này và khi tôi thấy điều này lần đầu tiên, tôi không chỉ sốc mà còn ấn tượng. Để bạn biết, một bản đồ 4k × 4k đang được tạo bằng cách sử dụng tất cả các luồng có sẵn từ hàng trăm hàm noise kết hợp trong khoảng 3 giây. Hình 3 cho thấy dấu vết được mở rộng.
Hình 3: Dấu vết mở rộng của lần chạy 12 giây ở trên. Các hộp màu xanh sau khu vực được đánh dấu là unit test thực tế, đang chạy game.
Kết luận
Như bạn có thể thấy từ các benchmark được trình bày, Mono tụt hậu rất xa so với .NET về mặt hiệu năng. Điều này chủ yếu là do sự khác biệt trong các tối ưu hóa runtime và JIT tạo ra assembly không được tối ưu. Tốc độ tăng thực tế chắc chắn phụ thuộc vào code, nhưng từ nghiên cứu của tôi, tốc độ tăng 1.5-3x cho việc thực thi C# là rất khả thi đối với hầu hết các dự án.
Nếu bạn là một nhà phát triển game sử dụng Unity, hoặc thậm chí là một người chơi, bây giờ bạn có thể hiểu rằng CoreCLR sẽ là một sự thúc đẩy lớn cho hiệu năng của game và thậm chí cả Unity editor. Thật không may, trong 8 năm qua, ban lãnh đạo Unity đã quan tâm nhiều hơn đến “những thứ khác” và không dành cho việc hiện đại hóa .NET sự chú ý xứng đáng.
Một số người coi hiện đại hóa .NET là hỗ trợ các tính năng ngôn ngữ mới trong C#, nhưng đó chỉ là phần ngọn. C# mới thêm một số tính năng tiện lợi, nhưng JIT mới có thể mang lại tốc độ tăng gấp nhiều lần.
Tại hội nghị Unite năm nay, Unity đã thông báo rằng CoreCLR vẫn đang được phát triển nhưng sẽ không sẵn sàng cho sản xuất vào năm 2026. Tin tốt là hiện tại nó dường như đã có trong lộ trình Unity 6.x, và không bị để lại cho các phiên bản sau như đã được gợi ý trong bài thuyết trình Unite 2024.
Hình 4: Slide từ Unite 2025 cho thấy .NET Modernization được lên kế hoạch cho bản phát hành 6.x với ngày chưa được công bố.
Hơn nữa, CoreCLR không chỉ là JIT mới và C# mới, nó mở khóa hỗ trợ rộng hơn và được tối ưu hóa tốt hơn cho các API kiểu Span<T>, hardware intrinsics và các đường dẫn SIMD mới hơn mà các dev không thể sử dụng ngày nay. Những tính năng này có thể thêm một hệ số nhân nữa cho lợi ích hiệu năng đối với một số loại code. Ví dụ, trình tạo bản đồ của chúng tôi sử dụng nhiều simplex noise 2D và 3D. Tôi cá rằng việc có quyền truy cập vào các tính năng runtime mới trong CoreCLR có thể tăng tốc độ tạo bản đồ thêm 2 lần nữa.
Unity có một công nghệ gọi là Burst tự động chuyển đổi các phương thức C# được đánh dấu thành assembly gốc đã được tối ưu hóa thông qua trình biên dịch LLVM. Điều này nghe có vẻ hay vì nó có thể tránh được hiệu năng JIT kém, nhưng nhược điểm là Burst có những giới hạn nghiêm ngặt về những gì có thể được chuyển đổi và chỉ hỗ trợ một tập con của C#. Tôi tin rằng CoreCLR với JIT hiện đại sẽ có đặc tính hiệu năng rất giống với Burst. Tôi tò mò không biết điều gì sẽ xảy ra trong một vũ trụ mà Unity đã đầu tư tất cả thời gian và công sức vào hỗ trợ CoreCLR và C# hiệu năng cao, thay vì phát triển và duy trì Burst.
Một hệ quả thú vị khác của hỗ trợ CoreCLR là khả năng tiền biên dịch assembly trung gian .NET thành mã máy bằng cách sử dụng ahead-of-time compilation (AOT). AOT có thể cải thiện thêm thời gian khởi động và rất cần thiết trên các nền tảng nơi JIT bị hạn chế (đặc biệt là iOS). Ngày nay, Unity giải quyết vấn đề này bằng IL2CPP, lấy mã trung gian và biên dịch nó thành C++, sau đó được tối ưu hóa và biên dịch thành assembly gốc. Tuy nhiên, theo RichardFine (nhân viên Unity), việc sử dụng CoreCLR AOT không được lên kế hoạch và IL2CPP sẽ vẫn tồn tại:
AOT cho IL2CPP hoàn toàn độc lập với AOT cho CoreCLR (mà chúng tôi không có kế hoạch áp dụng). Hành vi GC trên IL2CPP sẽ được cải thiện khi chúng tôi nâng cấp GC ở đó, nó không thực sự bị ảnh hưởng bởi CoreCLR chút nào.
Tóm lại, CoreCLR sẽ không kỳ diệu khắc phục mọi nút thắt cổ chai trong game Unity, nhưng nó khắc phục nhiều sự kém hiệu quả trong việc tạo mã và cho phép viết code hiệu năng cao hơn. Benchmark được trình bày trong bài viết này nhằm minh họa rằng .NET hiện đại đã dành nhiều năm để ép nhiều công việc hơn vào ít chu kỳ CPU hơn, và người dùng Unity phần lớn bị khóa khỏi những lợi ích đó ngày hôm nay.
Nếu Unity có thể cung cấp hỗ trợ CoreCLR sẵn sàng cho sản xuất, nó sẽ không chỉ có nghĩa là “C# mới hơn”. Nó sẽ có nghĩa là hiệu năng runtime nhanh hơn, thời gian lặp lại nhanh hơn, nhiều khoảng trống hiệu năng hơn, không cần tải lại domain, hành vi GC tốt hơn và thậm chí có thể nhiều mã quản lý hơn và ít mã gốc hơn. Cho đến lúc đó, khoảng cách sẽ vẫn là một khoản thuế vô hình trên mọi dự án Unity dựa vào mã quản lý.
Tôi cổ vũ cho các bạn, các dev Unity, CoreCLR sẽ chiến thắng!

Phần bổ sung: So sánh assembly x86
Tôi đã thực sự đào sâu hơn nhiều vào các khía cạnh hiệu năng của Mono so với .NET nhưng để bài viết này không quá dài, đây là một bản tóm tắt ngắn gọn.
Code listing 1 cho thấy code kiểm thử. Nó thực hiện một số phép tính tổng cơ bản của các struct tùy chỉnh là wrapper xung quanh int. Đây là một ví dụ thú vị vì Mono rất tệ trong việc inline và đơn giản hóa các biểu thức, ngay cả những biểu thức hiển nhiên, và chúng tôi có rất nhiều struct như thế này trong codebase của mình (ví dụ: Quantity, MechPower, Tile2i, v.v.).
static class Program {
static void Main() {
Console.WriteLine(RunTest(int.MaxValue));
}
public static TestStruct RunTest(int iterations) {
TestStruct value1 = new TestStruct(iterations % 2);
TestStruct value2 = new TestStruct(iterations % 7);
TestStruct value3 = new TestStruct(iterations % 13);
TestStruct result = default;
for (int i = 0; i < iterations; ++i) {
result += value1 + value2;
result += value1 + value3;
}
return result;
}
}
readonly struct TestStruct {
public readonly int Value;
public TestStruct(int value) {
Value = value;
}
public static TestStruct operator +(TestStruct lhs, TestStruct rhs) {
return new TestStruct(lhs.Value + rhs.Value);
}
public override string ToString() => Value.ToString();
}
Code listing 1: Code benchmark đơn giản
Để lấy mã assembly, tôi đã biên dịch code ở chế độ Release và chạy nó dưới dạng standalone executable. Sau đó, tôi đính kèm một debugger vào tiến trình đang chạy. Một cách dễ dàng để tìm vòng lặp này là làm cho nó dài/vô hạn và chỉ cần ngắt chương trình bất cứ lúc nào, nó sẽ kết thúc trong vòng lặp đó.
Đầu tiên, hãy nhìn vào .NET. Đây là assembly x64 của phần vòng lặp for của code.
Code listing 2: Chỉ thị của vòng lặp for test chạy trong .NET 10 (x64 assembly)
add r8d,edx
add edx,r10d
00007FFDEC338E88:
mov r10d,r8d
add r9d,r10d
mov r10d,edx
add r9d,r10d
inc ecx
cmp ecx,eax
jl 00007FFDEC338E88
Trong cả hai trường hợp, vòng lặp đầy đủ với int.MaxValue lần lặp mất khoảng 750 ms trên máy của tôi.
Trông rất gọn gàng. Ngay cả khi bạn không đọc assembly, bạn có thể thấy rằng có hai lệnh add, một lệnh decrement và một lệnh jump. Có vẻ như JIT đã kéo các tổng bất biến a = value1 + value2 và b = value1 + value3 ra khỏi vòng lặp và sau đó chỉ tích lũy chúng.
Tôi cũng đã kiểm tra assembly x86 và nó trông rất giống:
Code listing 3: Chỉ thị của vòng lặp for test chạy trong .NET 10 (x86 assembly)
082E18D0:
lea ebx,[esi+edi]
add eax,ebx
lea ebx,[esi+edx]
add eax,ebx
dec ecx
jne 082E18D0
Điều thú vị là hướng của vòng lặp đã được đảo ngược, đếm ngược. Điều này tiết kiệm một lệnh vì so sánh với 0 và jump có điều kiện có thể được thực hiện như một lệnh.
Bây giờ hãy nhìn vào assembly x64 của Mono.
Code listing 4: Chỉ thị của vòng lặp for test chạy trong Unity 6.0 dưới Mono (x64 assembly)
1E87D2F3E20:
movsxd rax,dword ptr [rsp+0C0h]
mov dword ptr [rsp+40h],eax
movsxd rax,dword ptr [rsp+0B8h]
mov dword ptr [rsp+38h],eax
movsxd rax,dword ptr [rsp+40h]
mov dword ptr [rsp+0A0h],eax
movsxd rax,dword ptr [rsp+38h]
mov dword ptr [rsp+98h],eax
movsxd rax,dword ptr [rsp+0A0h]
movsxd rcx,dword ptr [rsp+98h]
add eax,ecx
mov dword ptr [rsp+90h],0
mov dword ptr [rsp+90h],eax
mov dword ptr [rsp+30h],eax
movsxd rax,dword ptr [rsp+0A8h]
mov dword ptr [rsp+88h],eax
movsxd rax,dword ptr [rsp+30h]
mov dword ptr [rsp+80h],eax
movsxd rax,dword ptr [rsp+88h]
movsxd rcx,dword ptr [rsp+80h]
add eax,ecx
mov dword ptr [rsp+78h],0
mov dword ptr [rsp+78h],eax
mov dword ptr [rsp+0A8h],eax
mov dword ptr [rsp+28h],eax
movsxd rax,dword ptr [rsp+0C0h]
mov dword ptr [rsp+20h],eax
movsxd rax,dword ptr [rsp+0B0h]
mov dword ptr [rsp+18h],eax
movsxd rax,dword ptr [rsp+20h]
mov dword ptr [rsp+70h],eax
movsxd rax,dword ptr [rsp+18h]
mov dword ptr [rsp+68h],eax
movsxd rax,dword ptr [rsp+70h]
movsxd rcx,dword ptr [rsp+68h]
add eax,ecx
mov dword ptr [rsp+60h],0
mov dword ptr [rsp+60h],eax
mov dword ptr [rsp+10h],eax
movsxd rax,dword ptr [rsp+28h]
mov dword ptr [rsp+58h],eax
movsxd rax,dword ptr [rsp+10h]
mov dword ptr [rsp+50h],eax
movsxd rax,dword ptr [rsp+58h]
movsxd rcx,dword ptr [rsp+50h]
add eax,ecx
mov dword ptr [rsp+48h],0
mov dword ptr [rsp+48h],eax
mov dword ptr [rsp+0A8h],eax
inc esi
cmp esi,7FFFFFFFh
jl 1E87D2F3E20
Như bạn có thể thấy chỉ từ số lượng lệnh, code này sẽ chạy chậm hơn rất nhiều. Vòng lặp đầy đủ với int.MaxValue lần lặp mất khoảng 11500 ms, chậm hơn ~15 lần.
Trong assembly, bạn có thể thấy bốn lệnh add trong vòng lặp, sự kết hợp “kém hiệu quả” giữa increment + so sánh + jump (thay vì decrement + conditional jump), và quan trọng nhất là một biển các lệnh mov, chỉ đơn giản là sao chép bộ nhớ từ việc inline không hiệu quả của các trường struct. Về cơ bản, Mono chỉ đang ném các giá trị xung quanh bộ nhớ.
Tôi cũng đã kiểm tra assembly được biên dịch ở chế độ Debug chạy trong Unity editor và nó còn tệ hơn. Vòng lặp đầy đủ mất 67 giây (67000 ms)! Trong Unity Editor, JIT có khả năng chuyển sang codegen ít tối ưu hơn và bao gồm các kiểm tra bổ sung/chi phí sequence-point, làm tăng thời gian chạy lên rất nhiều.
Bài học rút ra: JIT của .NET hiện đại có thể scalar hóa các kiểu giá trị nhỏ và kéo các công việc bất biến ra ngoài để vòng lặp nóng trở thành một số ít các thao tác thanh ghi, trong khi Mono thường thất bại trong việc này và kết thúc bằng việc xáo trộn các giá trị qua bộ nhớ, chính xác là loại khoảng cách thể hiện dưới dạng chậm lại trong code mô phỏng nặng thực tế.
Bài viết gốc: Marek’s Blog – Unity’s Mono problem: Why your C# code runs slower than it should



