# Tạo Ra Ruby Nhanh Hơn Ruby Chính Nó
Nếu bạn là một lập trình viên Ruby, bạn chắc chắn sẽ quen thuộc với các mẫu ERB và cú pháp đặc biệt nơi bạn trộn HTML thông thường với các đoạn mã Ruby để nhúng các giá trị động vào HTML.
Gần đây tôi đã viết về P2, một thư viện mẫu HTML mới cho Ruby, trong đó HTML được biểu diễn bằng Ruby thuần túy. Điều này không hề mới hay độc đáo. Có rất nhiều gem Ruby khác cho phép bạn làm điều đó: Phlex, (của riêng tôi) Papercraft và Ruby2html.
Điều làm P2 khác biệt là mã nguồn mẫu luôn được biên dịch thành mã Ruby hiệu quả tạo ra HTML. Nói cách khác, mã bạn viết bên trong mẫu P2 thực tế không bao giờ được thực thi, nó chỉ đóng vai trò là mô tả những gì bạn thực sự muốn làm.
Mặc dù đã có một số nỗ lực trước đây sử dụng kỹ thuật này để tăng tốc tạo mẫu, cụ thể là Phlex và Papercraft, nhưng theo hiểu biết của tôi, P2 là gem Ruby đầu tiên thực sự sử dụng độc quyền kỹ thuật này.
Trong bài viết này, tôi sẽ thảo luận về cách tôi đã đưa hiệu suất tạo mẫu của P2 từ “OK” lên “Xuất sắc”. Trên hành trình đó, tôi đã nhận được sự giúp đỡ từ Jean Boussier, a.k.a. byroot, người không chỉ cho tôi thấy P2 còn phải đi bao xa về mặt hiệu suất, mà còn đưa ra một số hướng có thể khám phá.
## Cách Hoạt Động Của Mẫu P2
Đây là giải thích ngắn gọn về cách P2 biên dịch mã mẫu. Trong P2, các mẫu HTML được biểu diễn dưới dạng Ruby Procs, ví dụ:
ruby
->(title:) { html { body { h1 title } } }.render(title: ‘Hello from P2’) # “
“`
Gọi phương thức #render sẽ tự động biên dịch và chạy mã được tạo ra, trông sẽ như sau:
“`ruby
->(__buffer__, title:) {
__buffer__ << "
”
__buffer__ << ERB::Escape.html_escape((title).to_s)
__buffer__ << "
”
__buffer__
}
“`
Như bạn thấy, trong khi mã nguồn gốc được tạo từ các khối lồng nhau, mã được tạo ra lấy thêm tham số __buffer__ và đẩy các đoạn HTML vào đó. Bất kỳ giá trị động nào cũng được đẩy riêng biệt sau khi được thoát thích hợp.
Hãy cùng xem nhanh kỹ thuật chuyển đổi mã này được thực hiện như thế nào. Đầu tiên, P2 xác định tệp nguồn nơi mẫu được định nghĩa, và phân tích mã nguồn của mẫu (sử dụng một gem nhỏ tôi viết叫做 Sirop) thành Prism AST. Đây là một phần của AST cho ví dụ trên, cho thấy lời gọi body với h1 lồng nhau (các phần không liên quan đã được loại bỏ):
“`
@ CallNode (location: (6,4)-(8,5))
├── receiver: ∅
├── name: :body
├── arguments: ∅
└── block:
@ BlockNode (location: (6,9)-(8,5))
├── locals: []
├── parameters: ∅
└── body:
@ StatementsNode (location: (7,6)-(7,14))
└── body: (length: 1)
└── @ CallNode (location: (7,6)-(7,14))
├── receiver: ∅
├── name: :h1
├── arguments:
@ ArgumentsNode (location: (7,9)-(7,14))
└── arguments: (length: 1)
└── @ LocalVariableReadNode (location: (7,9)-(7,14))
├── name: :title
└── depth: 2
└── block: ∅
“`
(Bạn có thể xem AST của bất kỳ proc nào bằng cách gọi Sirop.to_ast(my_proc) hoặc my_proc.ast.)
Bây giờ nếu chúng ta nhìn vào DSL trên, chúng ta có thể thấy các lời gọi html, body và h1 được biểu diễn dưới dạng nút loại CallNode, và các nút này có người nhận được đặt là nil (vì không có người nhận), và tên thẻ HTML được lưu trong name. Vì vậy, bước đầu tiên trong việc chuyển đổi mã là dịch mỗi CallNode thành loại nút tùy chỉnh có thể được sử dụng sau này để tạo các đoạn HTML sẽ được thêm vào bộ đệm HTML. Việc dịch được thực hiện bởi lớp TagTranslator, tìm kiếm các mẫu cụ thể và khi một mẫu được khớp, thay thế nút đã cho bằng nút tùy chỉnh. Hãy xem TagTranslator#visit_call_node:
“`ruby
class TagTranslator < Prism::MutationCompiler
...
def visit_call_node(node, dont_translate: false)
return super(node) if dont_translate
match_builtin(node) || match_extension(node) || match_const_tag(node) || match_block_call(node) || match_tag(node) || super(node)
end
...
end
```
Một Prism::MutationCompiler là một lớp trả về AST đã được sửa đổi dựa trên giá trị trả về của mỗi phương thức #visit_xxx. Vì vậy, #visit_call_node, như tên gọi cho thấy, truy cập các nút loại CallNode và giá trị trả về được sử dụng để biến đổi AST. Nếu chúng ta xem phương thức #match_tag, chúng ta sẽ thấy cách nút lời gọi được chuyển đổi:
```ruby
def match_tag(node)
return if node.receiver
TagNode.new(node, self)
end
```
Vì vậy, điều xảy ra là đối với các thẻ HTML thông thường, phương thức #match_tag sẽ trả về một TagNode tùy chỉnh. Sau khi toàn bộ AST được duyệt qua, chúng ta sẽ có một AST đã biến đổi trong đó tất cả các lời gọi liên quan đã được dịch thành các thực thể của TagNode (có các lớp nút tùy chỉnh khác tương ứng với các phần khác của DSL P2).
Bước tiếp theo là biến đổi AST đã biến đổi trở lại nguồn. Công việc nặng nhọc được thực hiện bởi gem Sirop, với lớp Sourcifier, cho phép chúng ta biến đổi một AST nhất định thành mã nguồn Ruby. Nhưng Sirop sourcifier không biết gì về các loại nút tùy chỉnh của P2 như TagNode, vì vậy chúng ta cần giúp nó một chút. Chúng ta làm điều này bằng cách kế thừa nó và thêm một số mã để xử lý tất cả các nút tùy chỉnh đó:
```ruby
def visit_tag_node(node)
tag = node.tag
is_void = is_void_element?(tag)
# emit open tag
emit_html(node.tag_location, format_html_tag_open(tag, node.attributes))
return if is_void
# emit nested block
case node.block
when Prism::BlockNode
visit(node.block.body)
when Prism::BlockArgumentNode
flush_html_parts!
adjust_whitespace(node.block)
emit("; #{format_code(node.block.expression)}.compiled_proc.(__buffer__)")
end
# emit inner text if node.inner_text
if node.inner_text
if is_static_node?(node.inner_text)
emit_html(node.location, ERB::Escape.html_escape(format_literal(node.inner_text)))
else
to_s = is_string_type_node?(node.inner_text) ? '' : '.to_s'
emit_html(node.location, interpolated("ERB::Escape.html_escape((#{format_code(node.inner_text)})#{to_s})"))
end
end
# emit close tag
emit_html(node.location, format_html_tag_close(tag))
end
```
Khi HTML được phát ra, mã tương ứng không được tạo ngay lập tức. Thay vào đó, mỗi phần HTML được đẩy vào một các phần HTML đang chờ. Khi đến lúc làm sạch các phần HTML đang chờ và tạo mã cho chúng, chúng tôi nối tất cả các chuỗi tĩnh lại với nhau thành một lần đẩy bộ đệm, trong khi mỗi phần động được thoát và đẩy riêng biệt.
Trình biên dịch P2 làm việc tương tự để xử lý các phần khác của DSL P2, chẳng hạn như thành phần mẫu, thực thi trì hoãn, thẻ mở rộng v.v. Ngoài ra còn có khá nhiều công việc xung quanh việc tạo một bản đồ nguồn ánh xạ các dòng từ mã biên dịch đến các dòng trong mã nguồn gốc. Khi một ngoại lệ được đưa ra trong khi tạo mẫu, P2 sử dụng các bản đồ nguồn này để dịch backtrace của ngoại lệ sao cho nó sẽ trỏ đến mã nguồn gốc.
## Vậy Làm Thế Nào Để Chúng Ta Có Thể Làm Ruby Nhanh Hơn Ruby?
Bây giờ chúng ta đã có ý tưởng về cách P2 hoạt động, hãy xem cách tôi đã đưa hiệu suất của P2 từ OK lên xuất sắc. Khi tôi lần đầu tiên phát hành P2, tôi khá hài lòng với hiệu suất của nó, vì nó nhanh hơn đáng kể so với Papercraft, và benchmark tôi viết đã so sánh nó với ERB. Nhưng tôi đã không xem xét thực tế là tôi biết quá ít về ERB, đặc biệt là về cách đạt được hiệu suất tốt nhất từ các mẫu ERB.
May mắn là, ngay sau khi lần đầu tiên xuất kho lưu trữ, tôi đã nhận được một PR đẹp từ byroot cho thấy P2 không nhanh như tôi nghĩ. Mặc dù cuộc thảo luận trên cho thấy cách P2 tạo mã bây giờ, nhưng vào thời điểm đó nó đang tạo ra mã không phải là tốt nhất. Đây là cách mã P2 tạo ra vào thời điểm đó trông như thế nào (cho cùng một mẫu ví dụ được hiển thị ở trên):
```ruby
->(__buffer__, title:) do
__buffer__ << "
#{CGI.escape_html((title).to_s)}
”
__buffer__
rescue => e
P2.translate_backtrace(e)
raise e
end
“`
Bây giờ có một vài điều trong mã trên ngăn nó nhanh như mã biên dịch ERB (sử dụng các gem ERB hoặc ERubi):
Việc đẩy một chuỗi nội suy vào bộ đệm chậm hơn việc đẩy mỗi phần riêng biệt. Điều này khá rõ ràng khi bạn xem xét thực tế là với một chuỗi nội suy, bạn trước tiên cần tạo một chuỗi nhận các phần tĩnh và động của chuỗi nội suy, và sau đó đẩy chuỗi đó vào __buffer__.
Mệnh đề rescue thêm một số chi phí bổ sung. Điều này có thể đặc biệt tốn kém khi bạn có các mẫu lồng nhau. Nếu mỗi phần có riêng khối rescue của nó, điều đó có thể nhanh chóng cộng thành một chi phí đáng kể.
Như byroot đã chỉ ra, khi mã được tạo ra được eval thành một Proc, các chuỗi ký tự theo mặc định sẽ không bị đóng băng, điều này thêm chi phí phân bổ và áp lực GC.
Như mrinterweb đã chỉ ra trong một PR riêng biệt, việc thoát HTML với ERB::Escape.html_escape nhanh hơn CGI.escape_html (chỉ một vài phần trăm nhưng vẫn vậy)
Vì vậy, xem xét tất cả lời khuyên này, tôi đã viết lại mã trình biên dịch để làm những việc sau:
Tách biệt việc tạo mã HTML, sao cho các chuỗi HTML tĩnh được nối lại và đẩy vào bộ đệm HTML một lần, và bất kỳ phần động nào được đẩy riêng biệt.
Loại bỏ mệnh đề rescue và thay vào đó chỉ dịch backtrace một lần trong Proc#render.
Thêm bình luận ma thuật # frozen_string_literal: true ở đầu mã biên dịch, để tất cả nội dung HTML tĩnh được làm từ các chuỗi bị đóng băng, điều này giảm chi phí phân bổ và áp lực GC. BTW, khi nào chúng ta sẽ có chuỗi ký tự ký tự bị đóng băng theo mặc định?
Chuyển từ sử dụng CGI.escape_html sang ERB::Escape.html_escape.
Khi byroot làm PR của mình, benchmark trông như thế này:
“`
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24]
Warming up ————————————–
erb 31.381k i/100ms
p2 65.312k i/100ms
erubi 179.937k i/100ms
Calculating ————————————-
erb 314.436k (± 1.3%) i/s (3.18 μs/i) – 1.600M in 5.090675s
p2 669.849k (± 1.1%) i/s (1.49 μs/i) – 3.396M in 5.070805s
erubi 1.869M (± 2.3%) i/s (535.01 ns/i) – 9.357M in 5.008685s
Comparison:
erb: 314436.3 i/s
erubi: 1869118.6 i/s – 5.94x faster
p2: 669849.2 i/s – 2.13x faster
“`
Điều này cho thấy P2 vẫn còn nhiều điều để cải thiện, vì nó chậm gần 3 lần so với ERubi. (Sau này tôi cũng tìm ra cách làm cho ERB biên dịch các mẫu của nó, hiệu suất biên dịch của nó cơ bản giống với ERubi đã biên dịch.) Sau những thay đổi tôi đã thực hiện ở đây là kết quả benchmark cập nhật:
“`
ruby 3.4.5 (2025-07-16 revision 20cda200d3) +YJIT +PRISM [x86_64-linux]
Warming up ————————————–
p2 128.815k i/100ms
papercraft 17.480k i/100ms
phlex 15.620k i/100ms
erb 159.678k i/100ms
erubi 154.085k i/100ms
Calculating ————————————-
p2 1.454M (± 2.4%) i/s (687.59 ns/i) – 7.342M in 5.051705s
papercraft 173.686k (± 2.7%) i/s (5.76 μs/i) – 874.000k in 5.035996s
phlex 155.211k (± 2.5%) i/s (6.44 μs/i) – 781.000k in 5.035369s
erb 1.567M (± 4.2%) i/s (637.97 ns/i) – 7.824M in 5.000791s
erubi 1.498M (± 4.2%) i/s (667.45 ns/i) – 7.550M in 5.048427s
Comparison:
p2: 1454360.2 i/s
erb: 1567482.7 i/s – 1.08x faster
erubi: 1498238.7 i/s – same-ish: difference falls within error
papercraft: 173686.1 i/s – 8.37x slower
phlex: 155211.0 i/s – 9.37x slower
“`
Benchmark cho thấy P2 hiện ngang bằng với ERB và ERubi về hiệu suất của các mẫu đã biên dịch (và cơ bản, mã được tạo ra cho cả ba là giống hệt nhau.) Tôi cũng đã thêm Papercraft và Phlex để cho thấy sự khác biệt mà việc biên dịch tạo ra, đặc biệt vì P2 thực sự là một nhánh của Papercraft, và DSL trong P2 và Papercraft gần như giống hệt nhau. (Phlex cũng đã có một số công việc về biên dịch mẫu, nhưng tôi không biết nó đã tiến xa đến đâu.)
Như bạn có thể thấy, phương pháp biên dịch có thể nhanh khoảng 10X so với phương pháp không biên dịch. Tất nhiên, vẫn có những lời cảnh báo thông thường về benchmark: đó là một mẫu rất đơn giản với chỉ hai phần và không nhiều phần động, nhưng đây là chỉ số về loại hiệu suất bạn có thể mong đợi từ P2. Theo tôi biết, P2 là DSL tạo HTML Ruby đầu tiên cung cấp hiệu suất tương tự như ERB/ERubi đã biên dịch.
## Kết Luận
Điều tôi thấy thú vị nhất về những thay đổi tôi đã thực hiện đối với việc tạo mã trong P2 là mã hiện được biên dịch nhanh hơn gấp đôi so với khi P2 lần đầu tiên ra mắt, điều này chỉ cho thấy thực tế là Ruby không chậm, nó thực tế khá nhanh, bạn chỉ cần biết cách viết mã nhanh! (Và tôi đoán điều này đúng với bất kỳ ngôn ngữ lập trình nào.)
Hy vọng rằng, kỹ thuật biên dịch Ruby-to-Ruby được thảo luận ở trên sẽ được áp dụng cho các mục đích sử dụng khác, và cho nhiều DSL hơn nữa. Tôi đã có một vài ý tưởng đang xoay quanh trong đầu tôi…