Hình dung một nhân viên mới. Ngày đầu, việc gì họ cũng phải mở sổ tay đọc từng bước — chậm rề. Làm vài lần thì thuộc, nhanh hơn hẳn. Làm tới hàng trăm lần thì họ tìm ra lối tắt, sắp lại cả quy trình cho gọn. Đánh giá tốc độ của họ vào đúng ngày đầu rồi kết luận "người này chậm" là sai lầm hiển nhiên — nhưng đó chính xác là điều mọi phép đo Java chạy-một-lần-rồi-bấm-giờ đã làm suốt nhiều năm, và là lý do Java mang tiếng chậm. JVM đúng là cái nhân viên đó: nó quan sát mã nào chạy nhiều rồi viết lại cho tối ưu. Bài này về chuyện đó. (Số đo dưới đây trên máy tôi; tỷ lệ mới là thứ đáng đọc.)

Đường cong khởi động

Cùng một hàm, đo qua 2000 lần chạy:

  lần |  micro giây | so với lần đầu
     1 |     1185 us |    1,0 lần
     2 |      246 us |    4,8 lần
     5 |       92 us |   12,9 lần
    50 |       65 us |   18,2 lần
   500 |       53 us |   22,4 lần
  2000 |       57 us |   20,8 lần

Hai mươi hai lần. Không sửa một dòng mã nào.

Ba giai đoạn nhìn thấy rõ: lần đầu chạy bằng trình thông dịch (đọc sổ tay), vài lần sau được C1 biên dịch nhanh (đã thuộc việc), rồi từ khoảng lần thứ 50 trở đi C2 vào cuộc (tìm ra lối tắt) và đường cong phẳng lại.

Đây là lý do mọi phép đo hiệu năng Java phải có giai đoạn làm nóng. Bỏ qua nó là bạn đang đo tốc độ trình thông dịch, và kết luận rút ra sẽ sai gấp hai mươi lần.

Ba chế độ

  chỉ thông dịch (-Xint)             : 3648 ms
  chỉ C1 (-XX:TieredStopAtLevel=1)   :  168 ms
  đầy đủ C1 + C2                     :  158 ms

Trình thông dịch chậm hơn 23 lần. Đó là mức mà Java thực sự chậm — và không ai chạy sản xuất ở chế độ đó.

Chú ý số liệu thứ hai: chỉ C1 đã đạt 168 ms, gần bằng đầy đủ 158 ms. Với tải này, C2 chỉ thêm được 6%.

Điều đó khiến -XX:TieredStopAtLevel=1 là lựa chọn đáng cân nhắc cho những chương trình chạy ngắn: công cụ dòng lệnh, hàm serverless, script build. Chúng chết trước khi C2 kịp trả công cho thời gian nó bỏ ra để biên dịch. Với máy chủ chạy nhiều ngày thì ngược lại — hãy để C2 làm việc.

Đọc log biên dịch

java -XX:+PrintCompilation MyApp
  35   26       3       Jit2::bien (12 bytes)
  35   27       4       Jit2::bien (12 bytes)
  35   26       3       Jit2::bien (12 bytes)   made not entrant
  36   28 %     3       Jit2::vong @ 4 (32 bytes)
  48  153 %     4       Jit2::vong
  49   28 %     3       Jit2::vong @ 4 (32 bytes)   made not entrant

Bốn cột: thời điểm (ms từ lúc khởi động), số thứ tự bản biên dịch, cờ, tầng, rồi tên hàm.

Năm tầng, và biết chúng thì đọc log dễ hẳn:

Tầng 0 — thông dịch.

Tầng 1 — C1 không thu thập số liệu. Dùng cho hàm nhỏ, tầm thường, không cần tối ưu thêm.

Tầng 2, 3 — C1 có thu thập số liệu. Tầng 3 là nơi phần lớn mã đi qua: chạy nhanh vừa, đồng thời đếm xem nhánh nào hay chạy, kiểu thật của đối tượng là gì.

Tầng 4 — C2. Chậm khi biên dịch, nhưng dùng số liệu tầng 3 để tối ưu rất sâu.

Hai ký hiệu cần biết:

% nghĩa là thay thế trên ngăn xếp. Một hàm đang chạy dở trong vòng lặp dài có thể được thay bằng bản đã biên dịch ngay giữa chừng, không phải chờ lần gọi sau. Đây là lý do một vòng lặp duy nhất chạy vài giây vẫn nhanh dần lên.

made not entrant nghĩa là bản biên dịch đó bị vô hiệu, không ai vào được nữa. Ở ví dụ trên nó hoàn toàn bình thường: bản tầng 3 bị bỏ khi bản tầng 4 sẵn sàng.

Ngưỡng

  Tier3InvocationThreshold = 200
  Tier4InvocationThreshold = 5000
  CICompilerCount          = 12

Khoảng 200 lời gọi để lên C1 có số liệu, khoảng 5000 để lên C2. Con số thật phức tạp hơn — JVM còn cộng số vòng lặp bên trong và cân nhắc hàng đợi biên dịch — nhưng bậc độ lớn là vậy.

CICompilerCount = 12 là số luồng dành cho việc biên dịch, chạy song song với ứng dụng. Đó là lý do chương trình vẫn chạy được trong lúc JIT làm việc, và cũng là lý do lúc khởi động máy chủ bạn thấy CPU cao hơn hẳn lúc chạy ổn định.

Nội tuyến hoá: vũ khí chính

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp
  @ 18   Jit2::bien (12 bytes)   inline
  @ 18   Jit2::bien (12 bytes)   inline (hot)

Nội tuyến hoá là chép thẳng thân hàm vào chỗ gọi. Nó không chỉ tiết kiệm một lời gọi — nó mở đường cho mọi tối ưu khác: khi thân hàm đã nằm tại chỗ, JIT thấy được toàn cảnh và có thể gấp hằng số, bỏ kiểm tra thừa, giữ giá trị trong thanh ghi.

Đây là lý do viết hàm nhỏ không làm chương trình chậm đi — lời khuyên "đừng tách hàm vì tốn lời gọi" đã sai từ rất lâu. Ngược lại: hàm nhỏ dễ nội tuyến hơn, nên thường lại nhanh hơn.

Giới hạn cần biết: JIT không nội tuyến hàm quá lớn (MaxInlineSize 35 byte cho hàm thường, FreqInlineSize 325 byte cho hàm nóng). Một hàm khổng lồ không bao giờ được nội tuyến, và nó cũng chặn luôn các tối ưu phía trong.

Khử tối ưu, bắt tận tay

C2 tối ưu dựa trên giả định rút ra từ số liệu thực tế. Nếu giả định sai, nó phải rút lui.

Tôi dựng một điểm gọi chỉ thấy đúng một kiểu, chạy 20.000 vòng, rồi cho kiểu thứ hai xuất hiện:

  giai đoạn 1: CHỈ có Vuong (điểm gọi đơn hình)
    16   17       4       Deopt::tong (42 bytes)          <- C2 biên dịch

  giai đoạn 2: xuất hiện thêm Tron
    43   17       4       Deopt::tong (42 bytes)   made not entrant   <- BỊ HUỶ
    44  136       4       Deopt::tong (42 bytes)                      <- biên dịch lại

Thấy rõ trong log: bản C2 bị vô hiệu đúng lúc lớp thứ hai xuất hiện, rồi được biên dịch lại.

Giả định bị phá là "chỗ này luôn gọi Vuong.dienTich". Với giả định đó, C2 bỏ hẳn phép tra bảng ảo và nội tuyến thẳng thân hàm. Khi Tron xuất hiện, mã ấy sai và phải vứt đi.

Nhưng đây là chỗ tôi phải nói thật về phép đo của mình:

  giai đoạn 1 (chỉ Vuong)                : 1327 micro giây / 2000 vòng
  giai đoạn 2 (hai kiểu)                 : 1277 micro giây / 2000 vòng
  đo lại trên mảng chỉ-Vuong             : 1285 micro giây / 2000 vòng

Không chậm đi. Sự kiện khử tối ưu có thật và ghi rõ trong log, nhưng tôi không đo được thiệt hại nào.

Lý do là JIT xử lý hai kiểu vẫn rất tốt: nó dùng bộ nhớ đệm lời gọi hai hình, kiểm một lần rồi rẽ nhánh, gần như miễn phí. Chi phí thật chỉ là một lần biên dịch lại, và nó tan biến trong 2000 vòng đo.

Nên kết luận đúng là: khử tối ưu là chuyện bình thường trong đời một ứng dụng, không phải thứ đáng lo. Nó chỉ thành vấn đề khi xảy ra liên tục — mã sinh động, thay lớp lúc chạy, hoặc một điểm gọi thấy hàng chục kiểu (đa hình lớn), khi đó JIT bỏ cuộc và quay về tra bảng thường.

Tôi giữ nguyên phép đo này trong bài vì nó chỉnh lại một câu hay được nhắc: "đa hình làm chậm chương trình". Hai kiểu thì không. Hàng chục kiểu ở cùng một điểm gọi thì có.

Hệ quả thực dụng

Đo hiệu năng phải làm nóng. Ít nhất vài nghìn vòng trước khi bấm giờ, hoặc dùng JMH — nó lo hộ chuyện này cùng nhiều cạm bẫy khác.

Ứng dụng chạy ngắn thì cân nhắc -XX:TieredStopAtLevel=1. Đo thử; với công cụ dòng lệnh nó thường thắng.

Đừng "tối ưu" bằng cách gộp hàm. Hàm nhỏ được nội tuyến; hàm khổng lồ thì không.

Đường chạy nóng nên ổn định. Cùng một điểm gọi mà thấy nhiều kiểu, hoặc nhánh if lúc đúng lúc sai ngẫu nhiên, đều làm JIT khó tối ưu.

Khởi động chậm là có thật. Máy chủ vừa lên có thể chậm hơn hàng chục lần trong vài giây đầu — hãy tính tới điều đó khi đặt cấu hình kiểm tra sức khoẻ và khi triển khai cuốn chiếu. Đây cũng là bài toán mà CDS, AOT và GraalVM Native Image sinh ra để giải.

Muốn tự soi trong ba mươi giây, chạy java -XX:+PrintCompilation MyApp 2>&1 | grep "made not entrant" | wc -l. Vài chục dòng là bình thường — đó là các bản tầng 3 nhường chỗ cho tầng 4. Hàng nghìn dòng và vẫn tăng sau khi ứng dụng đã chạy ổn định thì có chuyện: JIT đang biên dịch lại liên tục, và đó là dấu hiệu của đường chạy nóng không ổn định.

Mẫu số chung

Cái nhân viên học việc rồi tự tối ưu không phải của riêng Java — mọi runtime có JIT đều làm đúng vậy, và cách chúng khác nhau lại soi rõ Java. JavaScript trên V8 là ví dụ nổi tiếng nhất, và phân tầng còn sâu hơn: trình thông dịch Ignition, rồi Sparkplug, Maglev, tới TurboFan tối ưu sâu — cùng cả chuyện khử tối ưu khi giả định về "hidden class" bị phá, đúng cái bộ-nhớ-đệm-lời-gọi và đơn/đa hình ở trên. .NET cũng có biên dịch phân tầng: Tier 0 dịch nhanh rồi Tier 1 tối ưu, cộng ReadyToRun cho phần khởi động. PyPy là JIT truy vết cho Python, nhanh hơn CPython nhiều lần nhờ đúng cơ chế "quan sát rồi tối ưu đường nóng". Ở phía ngược lại, Go và Rust biên dịch sẵn (AOT): nhanh ngay từ lệnh đầu tiên, không có đường cong khởi động — nhưng đổi lại chúng không tối ưu được theo dữ liệu chạy thật, nên gần đây mới thêm PGO để bù.

Điểm chung, và là thứ đáng mang theo, gồm hai vế đối nhau. Vế JIT: bất kỳ runtime nào biên dịch lúc chạy đều phải trả một cái giá khởi động và đều tối ưu bằng phỏng đoán có thể sai — nên "làm nóng trước khi đo" là luật ở mọi ngôn ngữ JIT, và một phép đo bỏ qua nó đo nhầm tốc độ trình thông dịch, sai cả một bậc độ lớn. Vế AOT: ngôn ngữ biên dịch sẵn đổi khả năng thích nghi lúc chạy lấy tốc độ tức thì — không warmup, nhưng cũng không có cái đặc quyền của JIT là viết lại mã dựa trên những gì thật sự xảy ra. Và một hệ quả thiết kế đúng cho cả hai phe: đường chạy nóng nên ổn định — dù là trình tối ưu của JIT đang đặt cược vào tính đơn hình, hay trình biên dịch AOT đang nội tuyến, mã dễ đoán luôn chạy nhanh hơn mã lắt léo.

Ngày mai: bộ thu gom rác — các thế hệ, vì sao có "dừng cả thế giới", và cách đọc log GC để biết nó đang ăn bao nhiêu thời gian của bạn.