Cách Viết Code Nhanh Nhất Với BenchmarkDotNet trong C# ở Kỷ Nguyên AI

Ở kỷ nguyên AI, giống như các bài kiểm tra là những “chân” để một AI agent thực hiện vòng lặp của nó, profiler cũng quan trọng không kém vì chúng cung cấp tư liệu thô để tư duy. Theo truyền thống, chúng ta chủ yếu chỉ xem Mean và Allocated trong BenchmarkDotNet, nhưng nếu AI sẽ đọc kết quả, càng nhiều thông tin cung cấp cho nó càng tốt — vì vậy hãy thêm cả JIT Disassembler và BranchMispredictions/Op vào.
Mặc định. Đó là nội dung chính của bài viết này. Ở phần cuối, tôi sẽ cho một ví dụ về cải thiện việc ghi số nguyên trong MessagePack cho C#: dựa trên dữ liệu assembly và branch-prediction, Fable tạo ra code vượt xa những gì một con người (tôi) có thể viết.

↑ Hóa ra còn có khả năng tăng tốc gấp 4 lần.

Rốt cuộc, loại phân tích này chính là thứ AI giỏi nhất! Và hơn cả thế mạnh, vấn đề là: để hiểu tại sao một đoạn code trở nên nhanh hơn, code gốc cộng với Mean và Allocated đơn giản không đủ bằng chứng. Không đủ bằng chứng, mọi giải thích chỉ là suy đoán, và suy đoán có thể sai. Khi sai, vòng cải thiện bị đình trệ — điều này đúng với cả người kinh nghiệm lẫn AI.

Trong thời đại chỉ có con người, chúng ta thường chấp nhận lý luận mơ hồ, tay vẩy vẩy rồi cho qua. Nhưng đối với AI, chi phí phân tích mã máy là bằng không, vì vậy chúng ta nên nhồi nhét cho nó càng nhiều ngữ cảnh càng tốt.

Khi giải thích phương pháp, tôi chủ ý không viết về các file Skill hay AGENTS.md — tuổi thọ của chúng quá ngắn để đáng ghi lại. Mỗi khi một mô hình mới ra đời, nó thay đổi cục diện, kết thúc trò chơi, và mọi người lật ngược ý kiến — có ý nghĩa gì? Có nhiều kỹ năng thông minh và harness ở ngoài kia, nhưng theo tôi chúng đều ngắn ngủi. Ngược lại với những gì đám hype AI nói, không có “nắm vững AI ngay bây giờ hoặc bị bỏ lại phía sau”. Rõ ràng nó dễ sử dụng hơn một năm trước — không, sáu tháng trước — vì vậy thay vì dành thời gian lật lọng, bạn sống nghiêm túc ở hiện tại sẽ tốt hơn nhiều…!

Fable 5 vừa ra mắt, và như hướng dẫn prompt Fable 5 chính thức của Anthropic cũng nói, prompt ngắn gọn tập trung vào tinh yếu và định hướng phù hợp đánh bại hướng dẫn quá mức. Và điều này có nghĩa trò chơi đã trở nên dễ hơn nhiều so với việc dồn tâm hồn vào prompt engineering hay harness building.

Nhưng đưa ra định hướng chính xác đòi hỏi hiểu biết chính xác — đối với sản phẩm, đó là kiến thức chuyên sâu về domain và nền tảng; đối với thư viện, đó là hiểu biết ngôn ngữ và môi trường runtime. Ngay cả khi AI biết nhiều hơn bạn, nếu bạn không thể chỉ nó đúng hướng, đạt được điều bạn thực sự muốn giống như cầu nguyện rút được vé trúng thưởng từ gacha ngẫu nhiên — điều đó sẽ không xảy ra.

Tổng Nhanh Nhất

Hãy bắt đầu với khung benchmark. Tôi đưa ra hướng dẫn đơn giản và để nó xây dựng một benchmark:

Sử dụng BenchmarkDotNet trong C#, tôi muốn xây dựng một hệ thống trong đó AI kiểm tra bản cài đặt nào của hàm nhỏ là nhanh nhất, đọc kết quả, và tìm code tối ưu. Ngoài tốc độ thực thi, cũng thực hiện disassembly mã máy JIT-compiled và phân tích nó.

Là ví dụ khởi đầu, AI tạo ra benchmark Sum. Cả Opus và Fable đều làm vậy, và đây là ví dụ dễ hiểu, nên tôi nghĩ đây là ví dụ tốt. Nếu nó không tạo ra cho bạn, chỉ cần thêm “tạo một benchmark Sum” vào prompt.

Code cơ bản là vòng for đơn giản cộng mảng:

[Benchmark(Baseline = true)]
public int ForLoop()
{
    var d = data;
    int sum = 0;
    for (int i = 0; i < d.Length; i++)
    {
        sum += d[i];
    }
    return sum;
}

Dưới đây là kết quả:

Phương pháp Mean
ForLoop 211.31 ns
ForEachLoop 211.44 ns
LinqSum 59.42 ns
VectorSimd 36.42 ns
TensorPrimitivesSum 13.54 ns
VectorRef 32.44 ns
Vector512Unrolled 22.05 ns
Vector512Final 18.36 ns

“Nhanh hơn với SIMD” là phần dự kiến, nhưng rõ ràng nó đã đi xa hơn nhiều. Nguyên tắc là AI đã chạy ba vòng tối ưu hóa trong khi đọc assembly:

Vòng Bản cài đặt Mean Khuyết điểm tìm thấy trong asm → bước tiếp
1 ForLoop (cơ sở) 203 ns Không vector hóa
1 VectorSimd 33 ns Kiểm tra bounds Slice tồn tại mỗi vòng lặp → chuyển sang truy cập dựa trên ref
2 Vector512Unrolled 19 ns movsxd được chèn trước mỗi lệnh load, phần dư xử lý bằng scalar → chuyển sang nuint + masked tail
3 Vector512Final 18 ns Vòng lặp chính là vpaddd zmm×4 thuần túy, phần dư xử lý qua thanh ghi mask AVX-512. Đạt dạng lý tưởng viết tay
— TensorPrimitives.Sum 13 ns Tối thượng (điều chỉnh căn chỉnh 64B + mở rộng 8-way)

Kết quả này là tối thượng — không còn gì để nói. Tình cờ, Opus không đạt đến mức này, điều này khẳng định với tôi Fable mạnh đến đâu.

Bây giờ hãy xem code. Việc chuyển đổi SIMD tự nó đơn giản — bạn viết khoảng đó bằng tay và thông thường gọi là đủ ở đây:

[Benchmark]
public int VectorSimd()
{
    var d = data.AsSpan();
    var acc = Vector<int>.Zero;
    int i = 0;
    for (; i <= d.Length - Vector<int>.Count; i += Vector<int>.Count)
    {
        acc += new Vector<int>(d.Slice(i));
    }
    int sum = Vector.Sum(acc);
    for (; i < d.Length; i++)
    {
        sum += d[i];
    }
    return sum;
}

Nhưng trong lần chạy này nó đã đẩy xa hơn nhiều, và dạng cuối cùng ở Vòng 3 đã dồn toàn lực vào mở rộng:

// Round 3: nuint indexing (kills movsxd) + masked vector tail (kills scalar remainder loop)
[Benchmark]
public int Vector512Final()
{
    ref int p = ref MemoryMarshal.GetArrayDataReference(data);
    nuint length = (nuint)data.Length;
    nuint vc = (nuint)Vector512<int>.Count;
    if (!Vector512.IsHardwareAccelerated || length < vc)
    {
        int s = 0;
        for (nuint j = 0; j < length; j++)
        {
            s += Unsafe.Add(ref p, j);
        }
        return s;
    }
    var acc0 = Vector512<int>.Zero;
    var acc1 = Vector512<int>.Zero;
    var acc2 = Vector512<int>.Zero;
    var acc3 = Vector512<int>.Zero;
    nuint i = 0;
    if (length >= vc * 4)
    {
        nuint lastBlock = length - vc * 4;
        for (; i <= lastBlock; i += vc * 4)
        {
            acc0 += Vector512.LoadUnsafe(ref p, i);
            acc1 += Vector512.LoadUnsafe(ref p, i + vc);
            acc2 += Vector512.LoadUnsafe(ref p, i + vc * 2);
            acc3 += Vector512.LoadUnsafe(ref p, i + vc * 3);
        }
    }
    for (nuint lastVector = length - vc; i <= lastVector; i += vc)
    {
        acc0 += Vector512.LoadUnsafe(ref p, i);
    }
    if (i < length)
    {
        // overlapping load of the final vector, masking out already-summed lanes
        nuint offset = length - vc;
        var lane = Vector512<int>.Indices + Vector512.Create((int)offset);
        var mask = Vector512.GreaterThanOrEqual(lane, Vector512.Create((int)i));
        acc1 += mask & Vector512.LoadUnsafe(ref p, offset);
    }
    return Vector512.Sum((acc0 + acc1) + (acc2 + acc3));
}

Điều khiến sự tiến triển vòng này trở nên khả thi là DisassemblyDiagnoser của BenchmarkDotNet, lấy asm từ các lần chạy benchmark:

var config = ManualConfig.Create(DefaultConfig.Instance)
    .AddJob(Job.ShortRun) // 3 warmup + 3 iterations, fast feedback loop for AI iteration
    .AddDiagnoser(MemoryDiagnoser.Default)
    .AddDiagnoser(new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig(
        maxDepth: 3,
        printSource: true,
        exportGithubMarkdown: true,
        exportCombinedDisassemblyReport: true)))
    .AddExporter(JsonExporter.Full);

Với thiết lập này, bắt đầu từ output vòng for đơn giản —

; SumBenchmark.ForLoop()
; var d = data;
; ^^^^^^^^^^^^^
; int sum = 0;
; ^^^^^^^^^^^^
; for (int i = 0; i < d.Length; i++)
; ^^^^^^^^^
; sum += d[i];
; ^^^^^^^^^^^^
; return sum;
; ^^^^^^^^^^^
 mov rax,[rcx+8]
 xor ecx,ecx
 mov edx,[rax+8]
 test edx,edx
 jle short M00_L01
 add rax,10
M00_L00:
 add ecx,[rax]
 add rax,4
 dec edx
 jne short M00_L00
M00_L01:
 mov eax,ecx
 ret
; Total bytes of code 30

— AI tự chủ lặp lại các cải tiến, đọc các lệnh máy cho đến:

; SumBenchmark.Vector512Final()
; ref int p = ref MemoryMarshal.GetArrayDataReference(data);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; nuint length = (nuint)data.Length;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; nuint vc = (nuint)Vector512<int>.Count;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; if (!Vector512.IsHardwareAccelerated || length < vc)
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; int s = 0;
; ^^^^^^^^^^
; for (nuint j = 0; j < length; j++)
; ^^^^^^^^^^^
; s += Unsafe.Add(ref p, j);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^
; return s;
; ^^^^^^^^^
; var acc0 = Vector512<int>.Zero;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; var acc1 = Vector512<int>.Zero;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; var acc2 = Vector512<int>.Zero;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; var acc3 = Vector512<int>.Zero;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; nuint i = 0;
; ^^^^^^^^^^^^
; if (length >= vc * 4)
; ^^^^^^^^^^^^^^^^^^^^^
; nuint lastBlock = length - vc * 4;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc0 += Vector512.LoadUnsafe(ref p, i);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc1 += Vector512.LoadUnsafe(ref p, i + vc);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc2 += Vector512.LoadUnsafe(ref p, i + vc * 2);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc3 += Vector512.LoadUnsafe(ref p, i + vc * 3);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; for (; i <= lastBlock; i += vc * 4)
; ^^^^^^^^^^^
; for (nuint lastVector = length - vc; i <= lastVector; i += vc)
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc0 += Vector512.LoadUnsafe(ref p, i);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; if (i < length)
; ^^^^^^^^^^^^^^^
; nuint offset = length - vc;
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^
; var lane = Vector512<int>.Indices + Vector512.Create((int)offset);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; acc1 += mask & Vector512.LoadUnsafe(ref p, offset);
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; return Vector512.Sum((acc0 + acc1) + (acc2 + acc3));
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 mov rax,[rcx+8]
 mov rcx,rax
 cmp [rcx],cl
 add rcx,10
 mov edx,[rax+8]
 cmp rdx,10
 jb near ptr M00_L05
 vxorps ymm0,ymm0,ymm0
 vxorps ymm1,ymm1,ymm1
 vxorps ymm2,ymm2,ymm2
 vxorps ymm3,ymm3,ymm3
 xor eax,eax
 cmp rdx,40
 jb short M00_L01
 lea r8,[rdx-40]
M00_L00:
 vpaddd zmm0,zmm0,[rcx+rax*4]
 vpaddd zmm1,zmm1,[rcx+rax*4+40]
 vpaddd zmm2,zmm2,[rcx+rax*4+80]
 vpaddd zmm3,zmm3,[rcx+rax*4+0 C0]
 add rax,40
 cmp rax,r8
 jbe short M00_L00
M00_L01:
 mov r8,rdx
 sub r8,10
 mov r10,r8
 cmp rax,r10
 ja short M00_L03
M00_L02:
 vpaddd zmm0,zmm0,[rcx+rax*4]
 add rax,10
 cmp rax,r10
 jbe short M00_L02
M00_L03:
 cmp rax,rdx
 jae short M00_L04
 vpbroadcastd zmm4,r8d
 vpaddd zmm4,zmm4,[7F FB6613A480]
 vpbroadcastd zmm5,eax
 vpcmpnltd k1,zmm4,zmm5
 vpmovm2d zmm4,k1
 vpandd zmm4,zmm4,[rcx+r8*4]
 vpaddd zmm1,zmm4,zmm1
M00_L04:
 vpaddd zmm0,zmm0,zmm1
 vpaddd zmm1,zmm2,zmm3
 vpaddd zmm0,zmm1,zmm0
 vmovaps zmm1,zmm0
 vextracti32x8 ymm0,zmm0,1
 vpaddd ymm0,ymm0,zmm1
 vmovaps ymm1,ymm0
 vextracti128 xmm0,ymm0,1
 vpaddd xmm0,xmm0,xmm1
 vpsrldq xmm1,xmm0,8
 vpaddd xmm0,xmm1,xmm0
 vpsrldq xmm1,xmm0,4
 vpaddd xmm0,xmm1,xmm0
 vmovd eax,xmm0
 vzeroupper
 ret
M00_L05:
 xor eax,eax
 xor r8d,r8d
 inc rdx
 jmp short M00_L07
M00_L06:
 add eax,[rcx+r8]
 add r8,4
M00_L07:
 dec rdx
 jne short M00_L06
 vzeroupper
 ret
; Total bytes of code 280

Với con người, đọc kỹ để phân tích là công việc vất vả, và chạy phân tích cẩn thận trên mỗi benchmark là không thực tế. Nhưng đưa cho AI thì không tốn gì và trở thành vũ khí mạnh mẽ. Việc đưa disassembly vào microbenchmark chạy nên được coi là常识 trong thời đại mới.

Điểm nhấn, tình cờ, là lý thuyết TensorPrimitives.Sum của .NET 11. Một dòng duy nhất, và nó là nhanh nhất áp đảo!

[Benchmark]
public int TensorPrimitivesSum() => TensorPrimitives.Sum<int>(data);

Bạn có thể xem code trong TensorPrimitives.IAggregationOperator.Vectorized512, và nó gần với Vector512Final do AI viết. Đó là lớp code hardcore khác trên đó, điều này một cách nào đó xác nhận tính đúng đắn lý luận của AI. Tất nhiên, hoàn toàn có thể Fable đơn giản “biết câu trả lời” ở đây, nên bài toán tổng quát như vậy không thể tự đo khả năng lý luận và khái quát hóa thực sự.

Cải Thiệu Hiệu Suất Ghi Của MessagePack

Vì tôi cũng muốn có ví dụ về chẩn đoán branch-prediction, hãy xem phần Write của MessagePack. Read khó hơn và thú vị hơn để xem, nhưng Write đơn giản hơn, nên nó tạo ví dụ tốt hơn.

Là benchmark tiếp theo, tôi muốn cải thiện hiệu suất của MessagePack encoder. Theo mô hình của BinaryPrimitives.TryWriteInt32LittleEndian(), đề xuất các ứng viên triển khai cho TryWriteInt32() và lặp lại cải thiện hiệu suất của chúng.

public static class MessagePackPrimitives
{
    public static bool TryWriteInt32(Span<byte> destination, int value, out int bytesWritten)
}

Tôi cũng muốn đo chính xác branch prediction, vì vậy thêm HardwareCounter.BranchMispredictions và HardwareCounter.BranchInstructions vào diagnostics. Đây là Windows, khởi chạy với đặc quyền quản trị viên, nên phép đo sẽ hoạt động.

Lần này tôi biết có branching liên quan, vì vậy tôi đưa vào hướng dẫn ngay từ đầu; nếu không, bạn có thể để nó xem code trước và thêm counter sau. Dù bằng cách nào, nó (có lẽ) sẽ không thêm chúng mà không có yêu cầu rõ ràng từ bạn — gọi là “kỹ năng hướng dẫn trong thời đại AI”, tôi đoán vậy.

Lưu ý rằng HardwareCounter yêu cầu đặc quyền quản trị viên để chạy. Cursor truyền đặc quyền nâng cao xuống tiến trình con, nhưng shell của Claude Code không thừa kế. Claude (Fable) giải quyết bằng cách “chạy qua UAC (yêu cầu người dùng chấp thuận UAC)”, vì vậy nó không hoàn toàn tự động (bạn phải nhấp vào lời nhắc UAC), nhưng nó đã giải quyết vấn đề mà không gặp sự cố.

Dưới đây là code cơ bản:

// Candidate 1 (baseline): straightforward if-else cascade, MessagePackWriter.WriteInt32 style
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryWriteInt32Cascade(Span<byte> destination, int value, out int bytesWritten)
{
    if (value >= 0)
    {
        if (value <= 127)
        {
            if (destination.Length < 1) goto Fail;
            destination[0] = (byte)value;
            bytesWritten = 1;
            return true;
        }
        if (value <= 255)
        {
            if (destination.Length < 2) goto Fail;
            destination[0] = UInt8Code;
            destination[1] = (byte)value;
            bytesWritten = 2;
            return true;
        }
        if (value <= 65535)
        {
            if (destination.Length < 3) goto Fail;
            destination[0] = UInt16Code;
            BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(1), (ushort)value);
            bytesWritten = 3;
            return true;
        }
        if (destination.Length < 5) goto Fail;
        destination[0] = UInt32Code;
        BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(1), (uint)value);
        bytesWritten = 5;
        return true;
    }
    else
    {
        if (value >= -32)
        {
            if (destination.Length < 1) goto Fail;
            destination[0] = unchecked((byte)value);
            bytesWritten = 1;
            return true;
        }
        if (value >= sbyte.MinValue)
        {
            if (destination.Length < 2) goto Fail;
            destination[0] = Int8Code;
            destination[1] = unchecked((byte)value);
            bytesWritten = 2;
            return true;
        }
        if (value >= short.MinValue)
        {
            if (destination.Length < 3) goto Fail;
            destination[0] = Int16Code;
            BinaryPrimitives.WriteInt16BigEndian(destination.Slice(1), (short)value);
            bytesWritten = 3;
            return true;
        }
        if (destination.Length < 5) goto Fail;
        destination[0] = Int32Code;
        BinaryPrimitives.WriteInt32BigEndian(destination.Slice(1), value);
        bytesWritten = 5;
        return true;
    }
Fail:
    bytesWritten = 0;
    return false;
}

Nó làm gì đơn giản: chỉ là một loạt if. Theo thông số MessagePack, int (4 byte) được lưu dưới dạng “1 byte type-code + giá trị”, sử dụng 1–5 byte tùy theo độ lớn. -32 đến 127 vừa 1 byte (positive fixint, negative fixint), lên đến 255 mất 2 byte (uint8 tag + giá trị), lên đến 65535 mất… và tương tự, với branch ghi từng trường hợp.

Dưới đây là kết quả benchmark:

Phương pháp Phân bố Mean BranchMispredictions/Op BranchInstructions/Op Kích thước Code
Cascade Large 4.1720 ns 0 9 426 B
CascadeUnsafe Large 3.9454 ns 0 8 331 B
FixintFirst Large 5.3299 ns 0 11 371 B
BranchlessTable Large 1.7388 ns 0 4 459 B
Branchless2 Large 1.7022 ns 0 3 442 B
Hybrid Large 1.8189 ns 0 4 471 B
Cascade Mixed 8.5328 ns 1 8 439 B
CascadeUnsafe Mixed 8.2713 ns 1 8 336 B
FixintFirst Mixed 9.2350 ns 1 10 384 B
BranchlessTable Mixed 1.7210 ns 0 4 459 B
Branchless2 Mixed 1.6376 ns 0 3 442 B
Hybrid Mixed 2.5156 ns 0 4 474 B
Cascade Small 1.9931 ns 0 5 404 B
CascadeUnsafe Small 1.3937 ns 0 5 309 B
FixintFirst Small 0.6945 ns 0 4 171 B
BranchlessTable Small 1.7197 ns 0 4 459 B
Branchless2 Small 1.6388 ns 0 3 442 B
Hybrid Small 0.7017 ns 0 4 450 B

Vì vậy nó đã tạo ra kết quả tốt hơn code đơn giản (Cascade) ở trên.

Bây giờ, bất cứ khi nào bạn kiểm tra kết quả benchmark, việc kiểm tra code benchmark tự nó là bắt buộc. Kết quả benchmark thay đổi đáng kể tùy thuộc vào giá trị đầu vào. Ví dụ, khi kiểm tra hiệu suất JsonSerializer, đầu vào chỉ ASCII so với đầu vào hỗn hợp với tiếng Nhật tạo ra những con số hoàn toàn khác nhau (đường dẫn nhanh UTF8 encoding được nhập khác nhau). Vì vậy xác minh phạm vi phủ sóng đúng là quan trọng.

Khi con người viết benchmark, phạm vi phủ sóng tham số có xu hướng sơ sài — ít nhất tôi thường thoát tội không chỉ phủ sóng kém mà còn chỉ một tham số. Đây là thời đại AI, vì vậy hãy để AI tạo benchmark với phạm vi phủ sóng đúng. Ngược lại, AI yếu hơn có thể thất bại trong việc phủ sóng trường hợp, vì vậy đây là nơi con người nên xác minh “chúng ta đang kiểm tra chính xác điều gì?” Đừng nói “ai còn đọc code trong thời đại AI”.

Ở đây, giá trị int xác định có bao nhiêu if được thực thi, chuyển thành sự khác biệt hiệu suất, vì vậy các mô hình được tạo thành Small, Large và Mixed:

// Small: all fixint (perfectly predictable branch)
// Mixed: format class chosen at random per element (worst case for branch prediction)
// Large: all 5-byte int32/uint32
[Params("Small", "Mixed", "Large")]
public string Distribution = "Mixed";

[GlobalSetup]
public void Setup()
{
    var rand = new Random(42);
    values = new int[Count];
    buffer = new byte[Count * 5];
    for (int i = 0; i < Count; i++)
    {
        values[i] = Distribution switch
        {
            "Small" => rand.Next(-32, 128),
            "Large" => rand.Next(2) == 0 ? rand.Next(65536, int.MaxValue) : rand.Next(int.MinValue, -32768),
            _ => rand.Next(8) switch
            {
                0 => rand.Next(0, 128),
                1 => rand.Next(-32, 0),
                2 => rand.Next(128, 256),
                3 => rand.Next(-128, -32),
                4 => rand.Next(256, 65536),
                5 => rand.Next(-32768, -128),
                6 => rand.Next(65536, int.MaxValue),
                _ => rand.Next(int.MinValue, -32768),
            },
        };
    }
}

Giá trị nhỏ (vừa trong -32 đến 127) chắc chắn phổ biến trong thực tế, nhưng trường hợp Mixed cũng rất phổ biến, vì vậy chúng ta muốn cân bằng giữa chúng. FixintFirst đặt if phạm vi fixint lên đầu, biến nó thành đường dẫn nhanh đảm bảo thắng cho Small:

// Candidate 3: single-compare fast path for fixint [-32, 127] (the dominant case in typical
// msgpack payloads), everything else in a non-inlined slow path
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryWriteInt32FixintFirst(Span<byte> destination, int value, out int bytesWritten)
{
    if ((uint)(value + 32) <= 159 && destination.Length >= 1)
    {
        MemoryMarshal.GetReference(destination) = unchecked((byte)value);
        bytesWritten = 1;
        return true;
    }
    return TryWriteInt32MultiByte(destination, value, out bytesWritten);
}

Tình cờ, việc đóng gói kiểm tra phạm vi -32-to-127 vào if duy nhất duyên dáng đó — if ((uint)(value + 32) <= 159 ...) — là một chạm âm thầm đáng chú ý. Khi nó lồng những thứ như vậy mà không ồn ào, bạn không thể không ấn tượng.

Con số tôi quan tâm nhất ở đây là Mixed, vì vậy hãy xem lại từng vòng:

Vòng Bản cài đặt Mean Khuyết điểm tìm thấy trong asm / counter → bước tiếp
1 Cascade (cơ sở) 8.53 ns chuỗi if-else. 8 branches/op, 1 miss/op — hình phạt misprediction (≈13 cycle) chi phối → bản thân các branch phải bị loại bỏ
1 CascadeUnsafe 8.27 ns Gộp Slice/lưu riêng biệt thành ref + lưu không căn chỉnh (code 426→331B). Nhưng cấu trúc branch vẫn giữ nguyên, vẫn 1 miss/op → xác nhận: giảm số lệnh sẽ không giúp
1 FixintFirst 9.24 ns fixint xử lý trước với 1 compare (0.69 ns trên Small). Trên Mixed, chính branch dẫn đầu là nguồn miss → phụ thuộc phân bố cực kỳ
1 BranchlessTable 1.72 ns Branchless qua lzcnt + bảng 66 mục, 0 miss. Nhưng asm vẫn hiển thị kiểm tra bounds bảng (cmp r11d,42; jae RNGCHKFAIL) và nhân cho sign*33 → chuyển sang bảng 64 mục + Unsafe
Sửa đo (tất cả) — Với Count=1000, Mispredictions/Op=0 ngay cả trên Predictor Zen 5 đã ghi nhớ toàn bộ mô hình branch của mảng cố định → tăng lên Count=100,000
2 Branchless2 1.64 ns Kiểm tra bounds biến mất, tính chỉ số giảm xuống shr+and+or. 3 branches/op trên đường dẫn nóng (tất cả dự đoán được). Còn lại là chuỗi phụ thuộc tuần tự lzcnt→table load→movbe, vốn có ở bộ mã độ dài biến đổi → đã hội tụ
2 Hybrid 2.52 ns Branchless2 đi trước bởi kiểm tra fixint 1 compare (Small 0.70 ns). Trên Mixed, branch dẫn đầu tốn ~0.4 misses/op → chỉ chọn khi bạn biết phân bố là số nguyên nhỏ chiếm ưu thế

Điều đầu tiên khiến tôi ấn tượng: benchmark ban đầu sử dụng Count = 1000, nhưng phép đo trả về Mispredictions/Op = 0, vì vậy nó đáp ứng bằng “Predictor Zen 5 đã ghi nhớ toàn bộ mô hình branch của mảng cố định → tăng lên Count=100,000”.

// large enough that the branch predictor cannot memorize the repeating sequence
// (with 1000 values, Zen 5 learned the whole pattern: BranchMispredictions/Op was 0 even for Mixed)
const int Count = 100_000;

[Benchmark(OperationsPerInvoke = Count)]
public int Cascade()

Nó có thể nhận ra và xử lý nhanh chóng và tự động chính vì phép đo Mispredictions/Op đã có mặt — nếu không, vòng lặp có thể đã bị đình trệ.

Chuyển sang đánh giá code. Khi tối ưu hóa thủ công các if branch, thành thật không có nhiều bạn có thể làm, nhưng AI đã tạo ra hai biến thể không branch. Hãy xem nhanh nhất, Branchless2:

// Round 2: significant bits of a non-negative int are 0..31, never 32, so a 64-entry table
// indexed by ((value >>> 26) & 32) | bits works — single OR instead of imul, and the
// power-of-two size lets us drop the bounds check via Unsafe
static ReadOnlySpan<uint> Formats64 =>
[
    // non-negative: bits 0..7 fixint, 8 uint8, 9..16 uint16, 17..31 uint32
    Fix1, Fix1, Fix1, Fix1, Fix1, Fix1, Fix1, Fix1,
    U8,
    U16, U16, U16, U16, U16, U16, U16, U16,
    U32, U32, U32, U32, U32, U32, U32, U32, U32, U32, U32, U32, U32, U32, U32,
    // negative (bits of ~value): 0..5 fixint, 6..7 int8, 8..15 int16, 16..31 int32
    Fix1, Fix1, Fix1, Fix1, Fix1, Fix1,
    I8, I8,
    I16, I16, I16, I16, I16, I16, I16, I16,
    I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32, I32,
];

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryWriteInt32Branchless2(Span<byte> destination, int value, out int bytesWritten)
{
    if (destination.Length >= 5)
    {
        int x = value ^ (value >> 31);
        int bits = 32 - BitOperations.LeadingZeroCount((uint)x); // 0..31
        int idx = ((value >>> 26) & 32) | bits;
        uint e = Unsafe.Add(ref MemoryMarshal.GetReference(Formats64), idx);
        int len = (int)(e & 0xff);
        ref byte d = ref MemoryMarshal.GetReference(destination);
        d = (byte)((e >> 8) | ((uint)value & (e >> 16)));
        Unsafe.WriteUnaligned(ref Unsafe.Add(ref d, 1), BinaryPrimitives.ReverseEndianness((uint)value << ((5 - len) * 8)));
        bytesWritten = len;
        return true;
    }
    return TryWriteInt32Cascade(destination, value, out bytesWritten);
}

Thành thật mà nói, đánh giá liệu thứ như vậy có hợp lệ hay không thực sự khó, phải không? Tôi gần như đã ném nó cho Gemini và ChatGPT nữa, và nếu chúng nói ổn, thì nó ổn (chúng nói vậy, nên nó ổn).

Nhưng không — “tôi không hiểu, nhưng LGTM” là không chấp nhận được. Nếu bạn chấp nhận những gì được phục vụ, bạn cần hiểu nó từng dòng, hoặc các hộp đen nhân lên vô tận và mọi thứ sụp đổ. Vì vậy hãy thực sự hiểu nó. Và vâng, nó đứng vững. Đầu tiên, các kiểm tra như if (value <= 65535) — xác định phạm vi một số rơi vào — có thể được trả lời bằng Leading Zero Count.

Một int là 4 byte, tức 32 bit, vì vậy với 64 mục bảng bao gồm cả số dương và số âm, bạn có thể tìm loại hoàn toàn không có nhánh if. Để biến nó thành sign-agnostic, trước hết nó lấy giá trị tuyệt đối; LeadingZeroCount tự nó tạo ra 0..31, tức 5 bit, và gắn bit dấu lên trên (shift 26) tạo chỉ mục 6 bit vào bảng 64 mục. Mục bảng đóng gói nhiều thông tin vào một uint duy nhất — header tag (e >> 8), độ dài (e & 0xff), mask (e >> 16) — vì vậy một lần lookup bảng lấy mọi thứ cùng lúc.

Cuối cùng, tất cả giá trị 1–4 byte được ghi dưới dạng ghi 4 byte đồng nhất. Một biến đổi không tì vết từ code if-branch sang code branchless.

Kết quả: trong kịch bản Mixed, 8.53 ns trở thành 1.64 ns — một tốc độ tăng khổng lồ. Liệu có áp dụng nguyên vẹn hay không là vấn đề khác, nhưng đây là kết quả thực sự hấp dẫn. Tôi sẽ mang về nhà và cân nhắc kỹ (?)

Đối với counter phần cứng dựa trên ETW, bạn có thể thêm InstructionRetired, TotalCycles, CacheMisses và nhiều thứ khác. Nếu bạn muốn đi xa hơn, bước tiếp theo có thể xây dựng vòng lặp tích hợp profiler từ nhà cung cấp CPU (AMD uProf, Intel VTune).

Tuy nhiên, việc lấy dữ liệu này một cách dễ dàng qua ETW (Event Tracing for Windows), không cần thiết lập nặng, là vô giá.

Lập Trình Ở Thời Đại AI

Dù là Sum hay TryWriteInt của MessagePack, không có nghi ngờ đây vượt xa trình độ con người. Việc tối ưu hóa vòng for Sum không thể loại bỏ nghi ngờ “gian lận” (đã thấy câu trả lời), nhưng code Write branchless của MessagePack không tồn tại ở bất kỳ đâu trên thế giới, nên tôi có thể nói Fable thực sự suy luận nó.

Opus không tạo ra kết quả này, vì vậy cá nhân tôi cảm thấy Fable đã vượt qua ngưỡng. Cho đến nay tôi đã nghĩ: phân tích rõ ràng là thế mạnh của AI, nhưng khả năng viết của nó tầm thường. Với Fable, có lẽ vì sức mạnh phân tích đã tăng thêm, khả năng viết cũng tăng theo. Ít nhất trong lĩnh vực siêu nhỏ, hàm đơn này, khả năng lập trình của nó là áp đảo. Tất cả những gì con người cần làm là tạo không khí và quyết định mặt nào của sự đánh đổi được chấp thuận.

Tuy nhiên — nếu bạn đưa cho nó toàn bộ ứng dụng hoặc thư viện và hỏi liệu code thỏa mãn có quay lại, câu trả lời vẫn là không. Một phần là vấn đề ngữ cảnh, một phần độ phức tạp gấp bội. Nhưng ngay cả khi hiệu suất AI tăng vọt và vượt qua tất cả, liệu thiết kế tổng thể của thư viện có thỏa mãn hay không cuối cùng là vấn đề sở thích — lĩnh vực thích hay không, chấp nhận hay không.

Bất kể AI tiến hóa đến đâu, nó sẽ không bao giờ tạo ra thứ gì đó 100% theo sở thích cá nhân của bạn trong một lần. Vì vậy thay vì tham gia vào cuộc chiến vô vọng để uốn nắn nó theo sở thích của bạn, bạn có thể kết thúc với trái tim tan vỡ, tự chấp nhận code của AI “à, nó hoạt động”.

Nói cách khác, quyền sở hữu code đang trôi đi. Trong ngắn hạn, hoạt động là đủ — nhưng về trung-dài hạn điều này ảnh hưởng sâu sắc đến động lực, vì vậy tôi nghĩ việc chấp nhận thiếu phê phán thực sự khá nguy hiểm. Code bạn tự tạo và kiểm tra kỹ là ổn — tôi sẽ chấp nhận thứ như TryWriteInt32Branchless2 nếu đó là code tôi tạo ra.

Nhưng khi nó đến dưới dạng PR — “đây là cải thiện hiệu suất tuyệt vời!” — tôi sẽ phản ứng thế nào? Có lẽ tôi sẽ từ chối. Và nếu tôi chấp nhận, tôi sẽ không bao giờ muốn nhìn phần codebase đó nữa.

Đối với sản phẩm công ty, ứng dụng, bạn phải buông bỏ sự gắn bó với code và đảm bảo quyền sở hữu bằng cách đối mặt với sản phẩm. Bạn thậm chí có thể nói đó là lý tưởng, phải không? (Cá nhân tôi nghĩ sản phẩm lớn thành công vì nhiều vai trò và nhiều lĩnh vực tập trung, vì vậy kỹ sư quan tâm sâu sắc đến code không phải là điều xấu.)

Trên thực tế, đối với ứng dụng cá nhân của tôi — tôi đã xây dựng và phát hành ứng dụng Android gần đây — code không quan trọng và tôi hầu như không nhìn; chất lượng ứng dụng, giao diện, mới là tất cả.

Nhưng thư viện OSS là thế giới 100% code. Vứt bỏ nó và không còn gì — không còn gì. Thật lòng, tôi bắt đầu cảm thấy sự trống rỗng len lỏi về điều này.

Ai đó liên hệ nói “chúng tôi đã xây dựng tính năng tuyệt vời!” và tôi nhìn — à, đây là pull request AI — và tôi không thể tự mình review. “Chúng tôi đã chạy kiểm tra bảo mật!” — tuyệt, phần đó có giá trị — nhưng đống code sửa chữa do AI tạo ra đi kèm thật khó khăn. Nó cắt đứt động lực của bạn, từng phần.

Vì vậy tôi mang cảm xúc rất hỗn hợp về tất cả.

Ngoại trừ điều đó — tôi muốn xây dựng gì? Tôi muốn xây dựng thư viện tối thượng, nơi mọi thứ được mài sắc đến mức hoàn hảo, và Fable rõ ràng là vũ khí siêu mạnh cho điều đó.

Nếu có gì, tôi bây giờ xấu hổ về mọi đoạn code tôi đã phát hành. Tôi muốn viết lại tất cả — tôi gần như có thể nói code quá khứ là sự xấu hổ tôi không muốn nhìn (?)

Kết Luận

Kết quả Fable ở trên rất tốt, để đo điểm cơ sở của các mô hình khác khi được gợi ý, tôi đưa hướng dẫn “viết một Write branchless MessagePack” cho Sonnet 5, Opus 4.8, Fable 5 và Gemini 3.1 Pro. Chỉ Fable 5 tạo ra đầu ra hợp lệ (điều gì đó gần code trên); đầu ra từ mọi mô hình khác không đáng bàn luận.

Không có khả năng tạo code nền tảng, không có lượng harness benchmark nào cho phép tự cải thiện. Có khoảng cách khổng lồ giữa tạo code chỉ hoạt động và tạo code ở mức tôi thực sự áp dụng. Và tôi tin Fable cuối cùng đã đạt đến mức đó.

Kết hợp với harness benchmark như được hiển thị trong bài viết này, code bóng bẩy tôi đã lâu mong muốn cuối cùng được tạo ra. Với tôi, năm thứ nhất thực sự của lập trình AI có thể đã đến.

Bài viết này được viết không có sự hỗ trợ AI nào — 100% hữu cơ, hoàn toàn bằng tay. Khi nói đến văn xuôi, tôi thực sự ghét viết dựa trên AI. Chắc chắn, vì tôi đã để Claude Code chạy benchmark, tôi có thể nói “hình thành thành blog post đẹp” — và nó có thể, chắc chắn có thể — nhưng kết quả sẽ là bài viết vô hồn với ít điều thú vị.

Có phải văn xuôi ồn ào, đầy tạp chất này bắt đầu tốt sau khi đi một vòng…!? Đừng tiêu thụ văn xuôi 99%-natri-chloride — thời đại đòi hỏi muối biển tự nhiên.

Vì vậy, kết luận: bước đầu tiên là phát hành một thư viện tối thượng. Cảm giác rằng nó thực sự khả thi đang cao, và tôi rất háo hước…! Việc hiện thực hóa đòi hỏi kiến trúc mới — sự kết hợp các cấu trúc cho phép AI hoạt động đầy đủ sức mạnh, hiệu suất runtime, và sự hoàn thiện API thân thiện với con người.

Vâng — và tầm nhìn cho nó đã hoàn toàn hình thành trong tâm trí tôi. Hãy mong đợi điều tiếp theo…! Tôi cũng cảm thấy một lần nữa tầm quan trọng của khả năng bảo trì trong OSS, vì vậy các dự án đã dormant trong năm qua — không, một năm rưỡi — sẽ có thể bắt đầu di chuyển lại. Tôi sẽ làm việc trên chúng nữa!