4 điểm bởi GN⁺ 2024-03-20 | 1 bình luận | Chia sẻ qua WhatsApp
  • Công cụ JIT thử nghiệm pg-copyjit tập trung tạo ra mã “đủ nhanh” cho cả các truy vấn PostgreSQL ngắn với chi phí sinh mã thấp hơn LLVM
  • Ước lượng cost của PostgreSQL không khớp trực tiếp với thời gian thực thi thực tế, nên LLVM JIT có chi phí tối ưu hóa lớn đôi khi lại gây bất lợi cho các truy vấn ngắn
  • Cách tiếp cận copy-and-patch biên dịch sẵn các stencil viết bằng C, rồi khi chạy sẽ sao chép và vá các mảnh cần thiết để thực thi như một hàm mới
  • pg-copyjit tích hợp qua giao diện JIT provider của PostgreSQL, và khi gặp opcode chưa được triển khai sẽ tự động fallback sang trình thông dịch PostgreSQL
  • Hiện tại đây mới là proof of concept chạy trên PostgreSQL 16 và AMD64; thời gian sinh mã ở mức vài trăm micro giây, nhưng build, tài liệu và hỗ trợ vẫn chưa sẵn sàng

Mục tiêu mà pg-copyjit hướng tới

  • pg-copyjit là một công cụ JIT thử nghiệm nhằm tăng tốc máy chủ PostgreSQL
  • Mã hiện tại gần với mức để các hacker chuyên sâu thử nghiệm hơn là dùng trên máy chủ production
  • Loại phản hồi được kỳ vọng là các kết quả thử nghiệm thú vị, các trường hợp cải thiện hiệu năng, và ý tưởng triển khai; chưa phải giai đoạn chấp nhận downtime cho các ứng dụng cốt lõi trong công việc

Vì sao LLVM JIT gây gánh nặng trong PostgreSQL

  • PostgreSQL đã có sẵn trình biên dịch JIT dựa trên LLVM do Andres Freund đưa vào
  • LLVM có thể tạo ra mã hiệu quả, nhưng trong cách PostgreSQL sử dụng thì chi phí biên dịch JIT và tối ưu hóa khá lớn
  • Nếu không dùng optimizer thì còn có thể tệ hơn cả không biên dịch, còn nếu dùng optimizer thì chi phí có thể lớn hơn nữa
  • Việc quyết định có áp dụng JIT hay không dùng query cost estimation thông thường
    • Cost của PostgreSQL là giá trị để so sánh giữa các truy vấn, không có nghĩa là thời gian thực thi thực tế
    • Một truy vấn cost 100 có thể mất 1 giây, còn truy vấn cost 1000 có thể xong trong 100ms
  • Nếu truy vấn chỉ nhanh hơn 10ms nhưng tối ưu hóa mất 50ms thì tổng thời gian thực thi sẽ bị lỗ
  • Một cách để LLVM JIT hữu ích hơn là cache và tái sử dụng các truy vấn đã biên dịch, nhưng việc triển khai không phải là một việc nhỏ

Cấu trúc của cách tiếp cận copy-and-patch

  • copy-and-patch là một cách xây dựng trình biên dịch JIT được giới thiệu trong một bài báo năm 2021, và cũng được dùng trong JIT engine của Python 3.13
  • Đơn vị cốt lõi là stencil viết bằng C
    • Stencil là một hàm có các lỗ trống
    • Nó được biên dịch sẵn bằng clang
    • Hỗ trợ gcc hiện vẫn đang tạm hoãn
  • Tại thời điểm biên dịch, các stencil cần thiết được nối lại với nhau, các lỗ trống được điền vào, rồi nhảy sang hàm “đã biên dịch” mới tạo
  • Luồng cơ bản khá đơn giản
    • Sao chép stencil vào một vùng nhớ mới
    • Vá các giá trị cần thiết
    • Thực thi đoạn mã kết quả
  • Vẫn còn không gian cho tối ưu hóa bổ sung
    • Tính trước các giá trị có thể xác định ngay khi biên dịch
    • Chia vòng lặp thành nhiều stencil để unroll
    • Gộp nhiều stencil thành meta-stencil để tối ưu cùng lúc

Cách gắn vào PostgreSQL

  • JIT của PostgreSQL có cấu trúc provider có thể mở rộng
  • File .so cung cấp một hàm _PG_jit_provider_init, và trong hàm này khởi tạo ba callback
    • compile_expr
    • release_context
    • reset_after_error
  • Callback cốt lõi là compile_expr
    • Đầu vào là con trỏ biểu thức ExprState* gồm các opcode
    • Nó biên dịch các opcode theo cách mong muốn
    • Đánh dấu mã được tạo là có thể thực thi
    • Đổi evalfunc từ trình thông dịch PostgreSQL sang mã được tạo
  • Khi gặp opcode chưa được triển khai, có thể tự động fallback sang trình thông dịch PostgreSQL

Quy trình biên dịch của pg-copyjit

  • Thuật toán copy-and-patch của pg-copyjit hiện là một dạng đơn giản chỉ gồm vài tối ưu hóa nhỏ
  • Với mỗi opcode, trình biên dịch sẽ kiểm tra bộ sưu tập stencil
    • Nếu có stencil tương ứng với opcode, nó sẽ gắn vào đoạn mã đang tạo
    • Nếu không có stencil, việc biên dịch sẽ dừng lại và trình thông dịch PostgreSQL sẽ tiếp quản thực thi
    • Sau khi gắn stencil, nó vá từng lỗ trống bằng các giá trị cần thiết
  • Stencil cho opcode CONST khai báo opExprEvalStep bên ngoài, và trong file .o đã biên dịch sẽ để lại một lỗ trống cho địa chỉ op
  • Bộ sưu tập stencil giữ lại thông tin relocation này, và trình biên dịch JIT điền địa chỉ cấu trúc opcode hiện tại để tạo mã có thể thực thi
  • Quy trình build trước hết tạo các stencil thành một file .o, rồi trích xuất mã assembly và relocation từ đó để chuyển thành các cấu trúc dùng trong C

Tình trạng triển khai và hiệu năng

  • Ban đầu tác giả trích xuất mã assembly thủ công để làm cho 3 opcode cần cho SELECT 42; hoạt động
  • Sau đó, tác giả viết một script DirtyPython để tự động hóa việc trích xuất mã assembly và trong vài giờ đã bổ sung các tính năng sau
    • Gọi hàm
    • Truy vấn một bảng đơn
    • Các kiểu dữ liệu phức tạp hơn
    • Một vài tối ưu hóa
  • Tình trạng hiện đã xác nhận như sau
    • Chạy được trên PostgreSQL 16
    • Dự kiến các bản phát hành trước đó cũng ổn, nhưng môi trường đã xác nhận là PostgreSQL 16
    • Kiến trúc được hỗ trợ hiện chỉ có AMD64
    • Có kế hoạch bổ sung hỗ trợ ARM64
    • Các mục tiêu như POWER64 và S390x cũng được quan tâm, nhưng có thể cần vá compiler và quyền truy cập vào các máy đó
  • Các con số hiệu năng hiện vẫn là kết quả trong trạng thái gần như chưa tối ưu
    • Thời gian sinh mã hoàn tất trong vài trăm micro giây
    • Đủ mức để dùng cả cho các truy vấn ngắn
    • Với SELECT 42;, no JIT mất 0.3ms, copyjit 0.6ms, LLVM không tối ưu hóa 1.6ms, và LLVM có tối ưu hóa 6.6ms
  • LLVM có thể tạo ra mã rất nhanh, nhưng mục tiêu của pg-copyjit là nhanh chóng tạo ra mã đủ nhanh, nên khó so sánh trực tiếp hai công cụ
  • Tác giả đã benchmark hai truy vấn trên một bảng đơn giản không có chỉ mục với 90k hàng; khi có mệnh đề where chứa xử lý CPU thì hiệu năng tốt hơn so với trình thông dịch
  • Benchmark được thực hiện trên laptop nên độ tin cậy còn hạn chế; sau này tác giả dự định benchmark phù hợp hơn trên desktop
  • Dù số opcode đã triển khai còn ít, bất kỳ truy vấn nào vẫn có thể chạy
    • JIT engine sẽ in thông báo cho các phần chưa được triển khai
    • Việc thực thi thực tế sẽ do trình thông dịch đảm nhiệm

Công bố mã nguồn và các việc còn lại

  • Mã nguồn đã được công khai trên GitHub tại pg-copyjit
  • Hiện tác giả vẫn tập trung vào bản thân mã nguồn hơn là dọn dẹp git history hay viết tài liệu
  • Để build, trước tiên cần chạy thủ công file build-stencils.sh
  • Trong trạng thái hiện tại chưa thể cung cấp hỗ trợ nên cũng chưa có tài liệu
  • Các việc còn lại tương đối rõ ràng
    • Triển khai thêm nhiều opcode hơn
    • Tìm kiếm tối ưu hóa
    • Cải thiện sự thuận tiện khi build
    • Sắp xếp lại để có thể đóng gói
  • Script build hiện được chuyên biệt cho DebianPostgreSQL 16

Trường hợp sử dụng dự kiến và mở rộng kiến trúc

  • Mục tiêu là đóng gói an toàn đến mức có thể triển khai trên máy chủ production của riêng mình
  • Cũng có ý tưởng chia cách dùng JIT theo từng máy chủ
    • Với các máy chủ GIS, nơi truy vấn xứng đáng chịu chi phí tối ưu hóa, dùng LLVM JIT
    • Với cơ sở dữ liệu ứng dụng web, nơi thời gian truy vấn ngắn là quan trọng, dùng pg-copyjit
  • Việc port sang các kiến trúc khác cũng là một mục tiêu nghiêm túc
  • Tác giả nhớ thời kỳ có nhiều kiến trúc đa dạng như Alpha, Itanium, Sparc, M68k, và không muốn góp phần vào vấn đề monoculture xoay quanh một kiến trúc duy nhất

1 bình luận

 
GN⁺ 2024-03-20
Các ý kiến trên Hacker News
  • Bộ nhớ đệm và tái sử dụng truy vấn đã biên dịch, một cách giúp trình biên dịch JIT LLVM dễ dùng hơn, thực ra đã được triển khai trong LLVM từ nhiều năm trước rồi :) https://github.com/llvm/llvm-project/commit/a98546ebcd2a692e...
    • Đúng, lẽ ra tôi nên nói rõ hơn: vấn đề nằm ở PostgreSQL, không phải LLVM
      Trình biên dịch JIT phải chèn trực tiếp địa chỉ bộ nhớ, nên mã được sinh ra bị gắn với truy vấn và tiến trình đó
    • PG dùng một tiến trình cho mỗi kết nối, còn mã LLVM JIT thì cố định theo từng tiến trình, nên mọi kết nối trong DB không thể chia sẻ mã với nhau
      Kế hoạch thực thi cũng gặp cùng vấn đề, vì nó không phải là cấu trúc dữ liệu trong bộ nhớ được thiết kế để chia sẻ giữa các tiến trình khác nhau
      Các DB như MSSQL dùng kiến trúc một tiến trình với các luồng nên không gặp vấn đề này, và đó cũng là một lý do chúng xử lý được nhiều kết nối đồng thời hơn mà không cần pooler bên ngoài
      MSSQL cũng có thể tuần tự hóa kế hoạch thực thi thành một biểu diễn không bị gắn với tiến trình rồi lưu vào DB, nên có thể dùng cho các tính năng như cố định kế hoạch thực thi
  • Tôi là tác giả bài viết. Cảm ơn đã đăng bài lên Hacker News; nếu có câu hỏi, tôi sẽ cố gắng trả lời hết mức có thể
    • Tôi tò mò liệu có khác biệt căn bản nào giữa copy-and-patch nhắm tới C và những gì trình biên dịch làm khi nhắm tới biểu diễn trung gian hay không
      Cách biên dịch truyền thống cũng trông giống như “copy-and-patch” dùng một ngôn ngữ trung gian khác thay vì C
    • Lúc nào cũng vui khi thấy cải thiện hiệu năng cho Postgres. Tôi tò mò thời gian tốn cho LLVM trong các truy vấn thực tế là bao nhiêu, và cấu hình LLVM như thế nào
      Ví dụ như dùng những pass nào, tối ưu hóa backend nào
      Theo kinh nghiệm của chúng tôi [1], nếu tắt tối ưu hóa và tinh chỉnh theo thời gian biên dịch với pipeline backend -O0, LLVM cũng có thể khá nhanh, nhưng vẫn chậm hơn các cách tiếp cận khác 10–20 lần
      Ngoài ra, chúng tôi có kinh nghiệm rằng mã sinh bằng copy-and-patch chạy khá chậm và khó tối ưu. Chúng tôi đã thử vài cách [2; Sec. 5], nhưng khoảng cách vẫn còn lớn. Xem Fig. 3 để biết kết quả đánh giá trên cơ sở dữ liệu
      Tôi cũng tò mò liệu có số liệu về mức giảm thời gian chạy so với LLVM không, và liệu có kế hoạch triển khai JIT đa tầng để chuyển động từ mã biên dịch nhanh sang mã đã tối ưu bằng LLVM hay không
      [1]: https://home.in.tum.de/~engelke/pubs/2403-cgo.pdf
      [2]: https://home.in.tum.de/~engelke/pubs/2403-cc.pdf
    • Tôi tự hỏi copy-and-patch có thật sự là ý tưởng mới không, hay chỉ là tên mới cho một ý tưởng cũ
      Khoảng năm 2010, khi học lập trình, đặc biệt là interpreter, tôi nghĩ việc có thể memcpy các đoạn mã thực thi do trình biên dịch tạo ra nếu dùng cẩn thận là điều đã được biết rõ
      Cái bẫy lớn khi đó là bit NX mới bắt đầu phổ biến. Ngay cả trên Linux, phần lớn mọi người vẫn mặc nhiên nghĩ đến các bản phân phối 32-bit, và có người còn ngạc nhiên khi CPU hỗ trợ 64-bit
      Sau này tôi thậm chí còn dùng một netbook hoàn toàn không hỗ trợ mã 64-bit
      Tiếc là tôi đã dành quá nhiều thời gian cho phần mã còn lại, nên không đào sâu đủ để làm ra thứ gì dùng được
    • Có vẻ đây sẽ là chủ đề rất hay để nói ở pgconf.eu vào tháng 6. pgcon đã chuyển sang Vancouver
      Tiếc là đợt nhận đề xuất bài trình bày đã kết thúc, nhưng vẫn có phần “unconference”. Tuy nhiên chủ đề sẽ được quyết định tại sự kiện nên không có gì đảm bảo
  • Tôi vẫn ngạc nhiên là PostgreSQL vẫn chưa có bộ nhớ đệm truy vấn/kế hoạch thực thi
    Nếu có cache, các truy vấn chạy thường xuyên sẽ được lưu đệm và tối ưu mạnh tay hơn, có vẻ hai cách tiếp cận này có thể phối hợp tốt bằng cách bù lại chi phí biên dịch
    Tất nhiên, bản thân nó cũng sẽ thêm vào cả một lớp phức tạp và rắc rối hoàn toàn mới
    • PG cũng có một dạng caching kế hoạch thực thi. Với prepared statement, nếu PG đánh giá rằng giá trị tham số thực tế không ảnh hưởng lớn đến kế hoạch thực thi, nó dùng “generic plan” và tái sử dụng cùng một kế hoạch thực thi cho mọi lần chạy của cùng prepared statement
      Xem phần “notes” trong https://www.postgresql.org/docs/current/sql-prepare.html
    • Ban đầu tôi cũng nghĩ như vậy, nhưng sau khi tự làm nó, tôi hiểu ra là việc này sẽ rất khó
      Nói cực kỳ đơn giản thì các con trỏ trỏ tới một phần truy vấn bị rò rỉ khắp nơi trong execution engine
      Để loại bỏ chúng sẽ cần một cuộc đại tu đáng kể, bao gồm execution engine, planner, và cả những phần khác nữa
      Ngay cả trong một session đơn lẻ, hai truy vấn đã biên dịch cũng sẽ có mã biên dịch khác nhau vì lý do này. Cả LLVM lẫn copyjit của tôi đều phải chèn địa chỉ của nhiều struct vào mã assembly
    • Bộ nhớ đệm kế hoạch thực thi của Oracle trước đây từng khá đau đầu khi optimizer không thể nhìn vào tham số của prepared statement
      Thường phải tự vào thêm hint hoặc ép dùng một kế hoạch thực thi
      Ngay cả một truy vấn đơn giản như SELECT * FROM t WHERE x = TRUE; cũng có thể trở thành ác mộng tùy theo phân bố giá trị x trong bảng
      Tôi hầu như không gặp vấn đề như vậy với Postgres, nhưng phải thừa nhận là tôi chưa từng dùng Postgres với prepared statement
      Tôi từng thấy các truy vấn có thời gian lập kế hoạch chậm (trên 100ms), nên cache có thể đã hữu ích, nhưng tôi không nhớ trường hợp nào thật sự cần tối ưu hóa đến mức đó
  • Thời 386 có khái niệm mã tự sửa đổi trong assembly
    Nó giống với stencil được nêu ở đây, nhưng vì mã thường chỉ có một instance duy nhất nên hiếm khi tạo bản sao
    Ví dụ Doom trên DOS cũng dùng kỹ thuật tối ưu này. Nó cần thiết để vắt đủ hiệu năng cho vòng lặp render căng trên các CPU cũ