Java bị mang tiếng chậm suốt nhiều năm, và tiếng ấy đến từ những phép đo chạy một lần rồi bấm giờ. Bài này về lý do phép đo kiểu đó vô nghĩa.

Đườ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, vài lần sau được C1 biên dịch nhanh, rồi từ khoảng lần thứ 50 trở đi C2 vào cuộc 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.

Thử ba mươi giây

Chạy chương trình của bạn với:

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.

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.