1 điểm bởi GN⁺ 2024-08-20 | 1 bình luận | Chia sẻ qua WhatsApp
  • Khi musttail được thêm vào Clang, các ngôn ngữ họ C cũng có thể tận dụng tail call được bảo đảm, và áp dụng điều này vào bộ phân tích protobuf để trình diễn hiệu năng trên 2GB/s
  • Cốt lõi là biến lời gọi hàm thành thứ gần với jmp thay vì call, từ đó giảm mức sử dụng stack của các lời gọi liên tiếp từ O(n) xuống O(1) và xử lý chúng giống như một vòng lặp
  • Wire format của protobuf phải giải mã thẻ/giá trị và rẽ nhánh theo các field có thể xuất hiện theo thứ tự tùy ý, nên cấu trúc while + switch truyền thống gặp bài toán tối ưu rất giống opcode dispatch của trình thông dịch
  • Bộ phân tích thử nghiệm của upb không dùng một hàm lớn duy nhất mà nối các hàm phân tích nhỏ bằng tail call, nhờ đó tránh được dùng stack, register spill, prologue/epilogue trên fast path
  • Cách làm này bị ràng buộc bởi việc chất lượng mã sẽ giảm mạnh nếu trộn lẫn non-tail call, và musttail là một mở rộng không chuẩn, nên để triển khai parser tốc độ cao ngoài thực tế cần quy tắc gọi hàm chặt chẽ và phương án đối phó về tính di động

Phân tích protobuf tốc độ cao nhờ Clang musttail

  • Thuộc tính câu lệnh [[clang::musttail]] / __attribute__((musttail)) đã được thêm vào nhánh main của Clang, cho phép C, C++, Objective-C có bảo đảm tail call
  • Ở đây, tail call không được dùng như một kỹ thuật của lập trình hàm, mà như một công cụ tối ưu để giảm chi phí rẽ nhánh trong parser và interpreter
  • Khi áp dụng kỹ thuật này vào phân tích protobuf, upb pull/310 đã trình diễn hiệu năng phân tích trên 2GB/s
    • Kết quả này được giới thiệu là nhanh hơn hơn gấp đôi so với mức tốt nhất trước đó
    • Vì nhiều kỹ thuật cùng đóng góp nên không thể hiểu là “chỉ nhờ tail call mà nhanh gấp đôi”
    • Tail call là một trong những yếu tố cốt lõi làm nên mức tăng hiệu năng này
  • Các thay đổi sau đó được đề cập trong A Tail Calling Interpreter For Python (And Other Updates)

Vì sao tail call hoạt động như cấu trúc lặp

  • Tail call là lời gọi hàm cuối cùng được thực hiện ngay trước khi một hàm trả về
  • Khi tối ưu tail call được áp dụng, compiler sẽ tạo lệnh jmp thay cho call
    • Bỏ qua việc tạo stack frame mới và lưu địa chỉ trả về
    • Hàm gọi f() sẽ nhảy trực tiếp sang hàm được gọi g()
    • g() sẽ trả về thẳng cho hàm đã gọi f()
  • Nhờ tính chất này, tail call có thể thay thế cấu trúc lặp
    • Ngay cả với n tail call liên tiếp, lượng stack sử dụng cũng giảm từ O(n) xuống O(1)
    • Không còn overhead của call, nên có thể xem lời gọi hàm như một nhánh thông thường
  • Ý tưởng này không mới, có thể lần ngược về bài báo năm 1977 của Guy Steele và các “Lambda Papers” giai đoạn 1975~1980
  • Clang vốn đã có thể tối ưu tail call trong các bản build tối ưu như -O2, nhưng hành vi trước đây chỉ gần như best-effort
    • Trong bản build không tối ưu, nó rất dễ bị biên dịch thành call thực sự
    • Nếu muốn dùng tail call như một cấu trúc lặp an toàn thì cần bảo đảm tối ưu trong mọi chế độ build
    • musttail cung cấp chính sự bảo đảm đó

Nút thắt cổ chai chung của vòng lặp interpreter và parser protobuf

  • Mike Pall của LuaJIT đã viết interpreter LuaJIT 2.x bằng assembly thay vì C, và xem đó là một lý do chính giúp interpreter chạy nhanh
  • Compiler C gặp hai vấn đề nổi bật trong vòng lặp chính của interpreter
    • Càng nhiều hàm lớn và luồng điều khiển phức tạp, register allocator càng khó giữ dữ liệu quan trọng trong thanh ghi
    • Khi fast path và slow path bị trộn trong cùng một hàm, slow path còn làm suy giảm chất lượng mã của cả fast path
  • Wire format của protobuf cũng có cấu trúc tương tự interpreter
    • Wire format là một chuỗi cặp thẻ/giá trị
    • Thẻ chứa số field và wire type
    • Thẻ hoạt động gần giống một opcode cho biết dữ liệu của field đó phải được phân tích như thế nào
    • Vì số field có thể đến theo thứ tự tùy ý, mã phải luôn sẵn sàng dispatch tới bất kỳ phần nào tương ứng
  • Parser protobuf truyền thống thường dùng switch bên trong vòng lặp while, và trong phần lớn thời gian tồn tại của protobuf đây là cách tiếp cận tốt nhất
  • Trong quá trình phân tích thực tế, các ngoại lệ như sai wire type, dữ liệu hỏng, hoặc chạm cuối buffer có thể xảy ra ở gần như mọi bước
    • Fast path phải được giữ ngắn và ổn định nhất có thể
    • Các trường hợp khó cần mã fallback lớn và phức tạp hơn, đôi khi còn phải gọi hàm out-of-line

Thiết kế parser upb dựa trên tail call

  • Parser thử nghiệm của upb không dùng một hàm phân tích lớn duy nhất mà tách từng thao tác thành một hàm nhỏ
  • Mỗi hàm sẽ gọi thao tác kế tiếp bằng tail call
    • Nhờ calling convention của x86-64, các tham số phân tích dùng chung được truyền qua thanh ghi
    • Mọi hàm phân tích đều dùng cùng một tập tham số để giảm việc di chuyển giá trị giữa các lần gọi
  • Hàm parser mẫu cho field fixed-width 4 byte hoạt động theo luồng sau
    • Giải mã thông tin field từ data
    • Nếu wire type không khớp thì MUSTTAIL return sang fallback()
    • Bỏ qua tag và ghi dữ liệu vào message
    • Đọc tag tiếp theo rồi tail call dispatch() để rẽ sang parser field phù hợp
  • Assembly do Clang tạo ra trên fast path không có prologue, epilogue, register spill hay dùng stack
    • Điểm thoát chỉ là các lệnh jmp sang fallback hoặc dispatch
    • Vì tham số đã nằm sẵn trong đúng thanh ghi nên cũng không cần mã truyền tham số riêng
  • Về mặt khái niệm, cấu trúc này vẫn xem vòng lặp interpreter lớn là một hàm phức tạp, nhưng ở mức triển khai thực tế nó được chia nhỏ thành các hàm cấp basic block và chuyển luồng điều khiển bằng tail call
  • Khi tách fast path và slow path thành các hàm riêng, các thay đổi trong mã fallback sẽ ít có khả năng làm dao động chất lượng mã của fast path hơn
    • Có thể dùng noinline để ngăn inline khi cần
    • Trình tự assembly của fast path gần như có thể được cố định

Chất lượng mã sinh ra từ C qua ví dụ LuaJIT

  • Nếu áp dụng cùng mẫu này vào ví dụ LuaJIT, có thể thu được từ mã C kết quả khá gần với assembly viết tay
  • Hàm ADDVN minh họa thực hiện các bước sau
    • Trích xuất thanh ghi và chỉ số hằng số từ lệnh
    • Nếu kiểm tra kiểu thất bại thì chuyển sang fallback
    • Cộng hằng số vào giá trị trong thanh ghi
    • Đọc opcode tiếp theo rồi tail call tới hàm tương ứng trong bảng opcode
  • Các điểm còn có thể cải thiện trong assembly sinh ra là tương đối nhỏ
    • Có thêm một jmp riêng sau nhánh điều kiện
    • Dùng jmp rax sau khi nạp vào rax, thay vì jmp qword ptr [rsi + 8*rax]
  • Những điểm như vậy được xem là các vấn đề nhỏ về sinh mã mà Clang có thể tiếp tục cải thiện

Ràng buộc về non-tail call và tính di động

  • Điều cần lưu ý lớn nhất của cách này là nếu trong hàm có non-tail call, chất lượng assembly sẽ xấu đi đáng kể
    • Chỉ một non-tail call cũng buộc phải tạo stack frame
    • Nhiều dữ liệu có thể bị spill xuống stack
  • Để tránh điều đó, cần một kỷ luật là hoặc inline các lời gọi khác, hoặc chỉ thực hiện chúng dưới dạng tail call
  • Trong phân tích protobuf, xử lý varint là ví dụ điển hình cho điểm khó
    • Trường hợp phổ biến và nhanh là varint 1 byte
    • Varint dài hơn không phải lỗi, nhưng là trường hợp ít gặp
    • Nếu inline phần xử lý ngoại lệ này thì chất lượng mã của fast path có thể giảm
    • Nếu tail call sang hàm fallback thì sau khi xử lý xong sẽ không dễ quay lại tiếp tục thao tác ban đầu, nên fallback phải xử lý nốt toàn bộ thao tác
    • Kết quả là phát sinh lặp mã và độ phức tạp
  • Bản cập nhật 2025-01-27 bổ sung cách giảm nhẹ vấn đề này bằng calling convention
    • __attribute__((preserve_most)) là calling convention có thể dùng cho hàm fallback, chuyển trách nhiệm bảo toàn gần như mọi thanh ghi sang callee, nhờ đó dồn chi phí spill về phía fallback
    • Bug khiến Clang bị crash liên quan đến thuộc tính này đã được sửa vào năm 2023
    • __attribute__((preserve_none)) là calling convention có thể dùng cho các hàm tail-calling, loại bỏ gánh nặng bảo toàn thanh ghi và cho phép dùng nhiều thanh ghi hơn cho tham số
    • Trong hai cách, preserve_none được đánh giá là ít xâm lấn hơn nên là lựa chọn tốt hơn
  • Một ràng buộc khác là musttailmột mở rộng compiler không chuẩn
    • Có kỳ vọng nó sẽ lan sang GCC, Visual C++ và được chuẩn hóa, nhưng điều đó chưa thể xảy ra sớm
    • Khi không có musttail, mỗi vòng lặp khái niệm ít nhất sẽ cần một return thực sự
    • upb vẫn chưa triển khai phần fallback này, và có lẽ sẽ cần một macro để tùy theo việc có dùng được musttail hay không mà hoặc tail call sang dispatch, hoặc đơn giản là trả về

Tình trạng áp dụng trong upb và khả năng mở rộng

  • Parser hơn 2GB/s đã được gửi vào upb, một thư viện protobuf nhỏ viết bằng C
  • Mã tương ứng đã hoạt động đầy đủ và vượt qua toàn bộ protobuf conformance test, nhưng tại thời điểm bài viết vẫn chưa được rollout ở đâu
  • Phiên bản protobuf viết bằng C++ chưa triển khai thiết kế này
  • Sau đó, khi upb được cập nhật để dùng musttail, một rào cản lớn để đưa parser nhanh vào production đã được gỡ bỏ
  • Cùng kỹ thuật này cũng có thể mang lại lợi ích hiệu năng đáng kể cho các interpreter lớn viết bằng C như Python, Ruby, PHP, Lua

1 bình luận

 
GN⁺ 2024-08-20
Các ý kiến trên Hacker News
  • Trong đề xuất tiêu chuẩn C có cú pháp dành cho tail call, dạng là return goto (expression);
    Điểm tôi thích hơn so với [[musttail]] chuẩn là việc đảm bảo vòng đời của các đối tượng cục bộ kết thúc. Nhờ vậy có thể triển khai mà không cần escape analysis trên diện rộng
    [0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...

    • Tôi thắc mắc vì sao return goto lại dễ triển khai hơn. Nhìn qua thì [[musttail]] cũng có vẻ kết thúc vòng đời của đối tượng cục bộ
      Lướt nhanh thì thấy có nói rằng hàm được gọi ở vị trí đuôi phải có cùng kiểu với đích gọi. Đây là điều kiện để không cần chuyển đổi giá trị trả về, và để bảo đảm không gian truyền đối số cùng quy ước gọi được giữ nguyên
      Phàn nàn tôi thường thấy về [[musttail]] mà tôi từng triển khai trong Clang là ràng buộc này nghiêm ngặt một cách không cần thiết. Một số kiến trúc cho phép tail call ngay cả khi kiểu không khớp hoàn toàn: https://github.com/llvm/llvm-project/issues/54964
      Nói “vậy thì mã sẽ không portable” là đúng, nhưng bản thân tối ưu hóa tail call về bản chất đã không portable. Chẳng hạn một số target về cơ bản không hỗ trợ tối ưu hóa tail call, như WASM không có phần mở rộng tail call
    • Việc xu hướng bổ sung tính năng mới vào C lại mạnh lên vừa đáng kỳ vọng vừa khá đáng lo
      Có những thay đổi và bổ sung thực sự cần thiết, thậm chí có các ý tưởng cần được làm rõ, nên tôi thấy kỳ vọng; nhưng chu kỳ cập nhật mạnh tay của C++ rốt cuộc có vẻ đã trở thành kiểu đắp thêm u lên u
      Vấn đề đặc biệt là khi các tính năng tương tác xấu với nhau sớm hơn dự kiến rất nhiều. Tôi mong quá trình chuẩn hóa không chỉ dựa vào các tài liệu lập luận, mà hãy kiểm thử đầy đủ tính năng trên các codebase lớn và đa dạng, rồi lựa chọn thật bảo thủ
  • Nếu bạn quan tâm đến phía Rust, có một RFC cũ từng định thêm từ khóa become để cung cấp tối ưu hóa tail call được bảo đảm
    Ban đầu nó bị hoãn để tập trung vào mục tiêu của edition 2018, và quyết định đó là đúng, nhưng gần đây ý tưởng này đã được xem xét lại. Nó có thể quay trở lại
    [0]: https://github.com/rust-lang/rfcs/pull/1888
    [1]: https://github.com/rust-lang/rfcs/pull/3407

  • Cách các interpreter trong C++ thường đạt được mức tăng tốc như vậy là dùng computed goto. Khi đó trên đường đi từ opcode này sang opcode kế tiếp không có nhiễu liên quan đến quy ước gọi
    Lý do chính khiến cách dùng computed goto hoặc tail call nhanh hơn vòng lặp switch cổ điển là vì chúng giảm gánh nặng cho bộ dự đoán nhánh. Về mặt tĩnh, mỗi opcode có một nhánh gián tiếp, thay vì cấu trúc chỉ có duy nhất một nhánh gián tiếp về mặt tĩnh

    • Như bài viết cũng nói, ngay cả khi dùng computed goto, đồ thị luồng điều khiển của hàm vẫn quá phức tạp nên việc cấp phát thanh ghi cho các biến hay dùng rất mong manh
      Nếu mỗi hàm nhỏ và nhận các biến quan trọng làm đối số, việc cấp phát thanh ghi sẽ bớt mong manh hơn nhiều
    • Tôi tò mò về câu “về mặt tĩnh, mỗi opcode có một nhánh gián tiếp”. So với chỉ một nhánh gián tiếp thì nó nghĩa là gì, và đạt được bằng cách nào? Nếu giải thích chi tiết hơn một chút thì tốt
    • Tôi nghe nói cách này đã lỗi thời một thời gian rồi. Lý do là bộ dự đoán nhánh đã đủ tốt nên không còn cần nữa
      Tuy vậy tôi vẫn tò mò liệu điều đó có còn đúng khi interpreter lớn lên hay không
  • Vấn đề còn lại khi dùng tail call để chuyển đổi ngữ cảnh là ta dùng các hàm phải tuân theo quy ước gọi. Đáng tiếc là chúng lãng phí thanh ghi để khôi phục trạng thái khi hàm kết thúc
    Phân tích chi tiết hơn và phương án thay thế dùng trình biên dịch trung gian có trên blog làm lại LuaJIT: https://sillycross.github.io/2022/11/22/2022-11-22/

    • Trong vài năm qua tôi đã thấy một số ngôn ngữ bỏ tầng JIT rồi lại đưa nó trở lại. Một phần là do năng lực lập trình viên và các bài học rút ra, nhưng một phần cũng là do thay đổi về thế hệ CPU
      Như mọi thứ khác trong khoa học máy tính, khi cán cân chi phí giữa các loại phép tính thay đổi, thuật toán tốt nhất có thể quay lại với cách đã dùng từ 15, 20 năm trước. Vì thế lập trình có nhiều điểm trông giống thời trang. Việc hồi sinh một thứ không có nghĩa là vô cớ, nhưng quên mất vì sao lần trước nó không phải thuốc chữa bách bệnh vẫn là vấn đề
      Nếu JIT chính nhanh hơn hoặc chậm hơn, lợi ích so với chi phí thực thi sẽ thay đổi, và ngưỡng kích hoạt nó cũng được điều chỉnh. Khi đó lượng mã chạy ở các tầng khác thay đổi, và chi phí khấu hao của tầng đó cũng có thể xấu đi. Nó giống như cân bằng một con lắc đôi
      Nếu có thể làm tầng JIT đủ nhanh và thô, có thể bỏ qua hẳn interpreter. Nhìn từ bên ngoài, gánh nặng nhận thức khi cân đối sổ sách giữa interpreter và khoảng hai JIT khá lớn, nên có vẻ một số ngôn ngữ đã gác interpreter lại và dùng một JIT tối ưu cho thời gian biên dịch hơn là tốc độ đầu ra
      Tôi không nhớ đó là ngôn ngữ nào, nhưng theo tôi biết ít nhất một nhóm cuối cùng cũng đã loại bỏ cả trình biên dịch trung gian vì vấn đề cân bằng này. Tập trung vào hai thứ thì tốt hơn là xử lý cả ba
    • Gần đây Clang đã có một quy ước gọi mới khiến những tail call kiểu này rẻ hơn nhiều. Nó tránh việc caller phải bảo toàn một số thanh ghi
      Tôi lần nào cũng nhầm tên, nhưng có lẽ là preserve_all hoặc preserve_none. Vấn đề là góc nhìn về “bảo toàn” là theo phía nào
  • Theo tôi biết, thuộc tính musttail đang được bổ sung vào GCC. Bản vá đang được xem xét, và ngữ nghĩa tương thích với Clang

    • Tôi tò mò thuộc tính preserve_most thì sẽ ra sao. Liệu có khả năng một thứ tương tự được đưa vào GCC không? Nếu không có cái này, lời gọi không ở vị trí đuôi sẽ phá hỏng interpreter
    • Đây là vấn đề khó. Nhiều ABI không thể thực hiện tail call ngay cả trong những trường hợp rất cơ bản như gọi hàm bên ngoài có đối số và kiểu trả về khớp nhau
      Clang dường như có heuristic thay đổi chuỗi gọi đối với các lời gọi musttail. Ví dụ trên i686, nó đổi thành lời gọi noplt. Những nội dung này không có trong tài liệu Clang: https://clang.llvm.org/docs/AttributeReference.html#musttail
      Trên thực tế, điều khả thi chỉ là compiler phát ra thông báo chẩn đoán khi không thể sinh tail call. Với nhiều người dùng, mức đó có lẽ là đủ. Việc bảo đảm tail call như Scheme có vẻ khó xảy ra
    • GNUC có khá nhiều tính năng giống Scheme, nên việc tụt lại ở tính năng này khá bất ngờ
  • Hỗ trợ C++ cũng được nhắc đến, nhưng trong C++ có lẽ tail call sẽ rất ít
    Ví dụ foo() { auto a = SomeClassWithADestructor(); return bar(); } không phải là tail call, vì sau lời gọi bar() còn diễn ra việc hủy a

    • Nếu compiler có thể chứng minh rằng không có side effect tầm xa nào giữa các dòng đó, liệu nó có thể gọi destructor trước khi chạy bar không?
      Tôi tò mò liệu chuẩn C++ có bắt buộc destructor phải được gọi ở cuối block hay không, hay có thể gọi ngay khi biến không còn được dùng nữa
  • Có thể ví dụ quá đơn giản, nhưng có vẻ __attribute__((musttail)) không thật sự cần thiết để tạo mã tốt
    Nếu hàm xử lý lỗi là đường đi hiếm gặp, tốc độ gọi cũng có vẻ không quá quan trọng
    Cấu trúc như if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); } dường như tạo ra jump table tốt một cách khá ổn định

    • Dĩ nhiên compiler đã loại bỏ tail call từ lâu, nhưng với kỹ thuật này, “được sinh khá ổn định” là chưa đủ. Nó phải được bảo đảm, hoặc việc biên dịch phải thất bại
      Nếu không, cấu trúc này sẽ không hoạt động và stack sẽ nổ ngay. Điểm cốt lõi của [[musttail]] là việc loại bỏ tail call là bắt buộc. Compiler không có lựa chọn khác
    • Như phần disassembly trong bài cho thấy, lý do đường fallback là vấn đề không nằm ở bản thân lời gọi đó nhanh đến đâu. Chỉ sự tồn tại của lời gọi đó cũng có thể khiến compiler tạo stack frame cho toàn bộ hàm và đổ register vào đó. Cả đường nhanh cũng bị ảnh hưởng
      Tất nhiên, cách nói “ép buộc” có thể không hoàn toàn chính xác. Không có quy định rằng compiler phải có một cấu trúc stack frame duy nhất cho mọi đường thực thi của hàm, cũng không có quy định rằng các hàm liên kết nội bộ không bị lấy địa chỉ hoặc hàm trong namespace ẩn danh phải dùng ABI chuẩn. Nhưng mọi compiler tôi từng thấy, kể cả Clang, trên thực tế đều làm như vậy. Vì thế cần có cách nói với chúng rằng đừng lo về ABI và đừng phí thời gian bảo toàn register giữa các lời gọi
      Jump table tất nhiên được tạo rất tốt. Nhưng nếu chạy kết quả đó qua thứ như perf report và bytecode thử nghiệm không biểu diễn một vòng lặp ngắn, bạn sẽ thấy một trong hai điều: hoặc có branch misprediction ở mỗi lần dispatch, hoặc compiler nghĩ “có vẻ đang muốn viết interpreter” rồi chuyển indirect jump xuống cuối mỗi case. Tôi từng thấy điều này trong Clang. Dù là bên nào, phân bổ register của mã kết quả nhìn chung rất có khả năng là tệ
  • Tôi tò mò trampoline, tức cách trả về hàm kế tiếp dưới dạng function pointer rồi gọi trong vòng lặp bên ngoài, sẽ nhanh đến mức nào. Ưu điểm là đây là C có tính di động

    • C thường được dùng làm ngôn ngữ đích cho compiler của các ngôn ngữ bậc cao
      Ngôn ngữ lập trình Scheme yêu cầu mọi tail call không được làm stack lớn lên. Vì vậy các nhà triển khai đã khám phá nhiều kỹ thuật, bao gồm cả trampoline
      Tôi không có tài liệu để trích dẫn, nhưng có thể tìm câu trả lời trong các bài báo về việc biên dịch Scheme sang C. Nếu ngôn ngữ đích không bảo đảm tối ưu hóa tail call, chương trình được sinh ra sẽ chậm hơn
      Thêm nữa, đây cũng là lý do vì sao những người triển khai ngôn ngữ bậc cao đặc biệt không hài lòng khi tối ưu hóa tail call bị loại khỏi đặc tả JavaScript. Cũng có các giải pháp duy trì cả tối ưu hóa tail call lẫn stack check
      https://github.com/schemedoc/bibliography/blob/master/page8....
    • Tôi cho rằng lý do tối ưu hóa tail call nhanh là vì vòng lặp kết quả có thể dự đoán được, nên instruction prefetch của CPU và memory prefetch hoạt động tốt
      Nếu nhảy qua function pointer thì có lẽ sẽ không dự đoán được đến mức đó, và khó nhận được cùng lợi ích
      Tất nhiên phải đo mới biết, và tôi thì vẫn chưa thử
  • Tôi từng viết bộ giải mã/mã hóa Protobuf và trình phân tích cú pháp IML bằng C, cùng binding cho Python, và có vài điều muốn nói về việc đo tốc độ phân tích cú pháp
    Nếu thư viện này chỉ được cung cấp dưới dạng binding cho các ngôn ngữ quản lý bộ nhớ, thì sẽ xuất hiện thêm một biến số khiến nó áp đảo mọi thứ khác về mặt hiệu năng. Tôi không rõ Ruby hay PHP thì sao, nhưng trong Python, tôi đã thấy tốc độ tăng mạnh khi không dùng enumerator. Nếu chuyển enumerator của Protobuf thành enum của Python, mọi lợi ích có thể đạt được từ mã C đều bị thời gian tạo đủ loại đối tượng Python đè bẹp. Chênh lệch là ở mức nhiều bậc độ lớn. Xa hơn nữa, cũng có thể triển khai mọi cấu trúc dữ liệu phụ trợ bằng C và chỉ để lộ giao diện tối thiểu cho Python. Rất khó trả lời việc so sánh như vậy công bằng đến đâu với mã dùng các cấu trúc tích hợp sẵn của Python
    Trình phân tích cú pháp Protobuf cho Python của Google vẫn có thể “nhanh” hơn 2GB/s. Lý do là nó không phân tích cú pháp gì ngoài message cấp cao nhất. Cấu trúc bên trong message chỉ được phân tích khi cần. Nếu mã đọc ngay toàn bộ nội dung đã phân tích, nhiều khả năng nó sẽ chậm hơn 2GB/s, nhưng vấn đề là làm sao so sánh hai cách tiếp cận này một cách thực dụng. Kết quả thực tế thay đổi tùy theo tính chất ứng dụng, nên không có câu trả lời rõ ràng
    Trong trường hợp thông thường, phân tích cú pháp Protobuf không thể streaming vì việc xử lý trùng lặp. Trên thực tế, mã phân tích nội dung Protobuf bị nghẽn ở I/O. Đó là vì phải chờ đến cuối message trước khi bắt đầu phân tích. Riêng biệt, tùy theo các message Protobuf điển hình của ứng dụng, có thể song song hóa việc phân tích cú pháp, và khi đó nhiều khả năng sẽ vượt qua hầu hết các parser đơn luồng. Nhưng cũng như các ví dụ ở trên, không thể nói đây là chiến lược thắng trong mọi trường hợp
    Thông thường, kết hợp phân tích cú pháp với tạo đối tượng miền nghiệp vụ sẽ hiệu quả hơn nhiều. Ứng dụng gần như luôn phải đi qua bước này. Việc parser có thể tiếp cận chức năng này như thế nào trong nhiều trường hợp sẽ quyết định parser nào thắng
    Tóm lại, Protobuf, và có lẽ parser nói chung, không phải là đối tượng tốt để đo và so sánh tốc độ. Nó quá cấp thấp và thiết kế cũng không tốt, nên khó dùng làm chuẩn cho benchmark hiệu năng

    • Tôi không hiểu đoạn “trong trường hợp thông thường, phân tích cú pháp Protobuf không thể streaming vì việc xử lý trùng lặp”
      Sẽ tốt nếu có thể giải thích chi tiết quy tắc trường cuối cùng thắng ngăn cản phân tích cú pháp streaming như thế nào
  • GCC và Clang từ lâu đã có tùy chọn -foptimize-sibling-calls, nên có thể có tail call ngay cả trong bản build debug
    Tất nhiên, việc tính năng này được chuẩn hóa, được bảo đảm và có thể điều khiển ở cấp hàm là một cải tiến lớn
    [1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
    [2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...