Phát triển trình biên dịch JIT mới cho PostgreSQL
(pinaraf.info)- 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
.socung cấp một hàm_PG_jit_provider_init, và trong hàm này khởi tạo ba callbackcompile_exprrelease_contextreset_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
evalfunctừ trình thông dịch PostgreSQL sang mã được tạo
- Đầu vào là con trỏ biểu thức
- 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
CONSTkhai báooplàExprEvalStepbê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 đề
wherechứ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 Debian và PostgreSQL 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
Các ý kiến trên Hacker News
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 đó
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
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
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ầnNgoà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
Khoảng năm 2010, khi học lập trình, đặc biệt là interpreter, tôi nghĩ việc có thể
memcpycá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
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
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
Xem phần “notes” trong https://www.postgresql.org/docs/current/sql-prepare.html
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
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ịxtrong bảngTô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 đó
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ũ