Điều tra và khắc phục sự cố hiệu năng 25% trên LLVM RISC-V
Bài viết này mô tả quá trình phân tích và sửa chữa một lỗi regression gây sụt giảm hiệu suất 25% trên LLVM khi biên dịch cho kiến trúc RISC-V. Vấn đề xuất phát từ một tối ưu hóa trong InstCombiner vô tình ngăn chặn việc chuyển đổi từ độ chính xác kép (double) sang độ chính xác đơn (float), dẫn đến việc sử dụng lệnh chia chậm hơn nhiều trên CPU SiFive P550.

Điều tra và khắc phục sự cố hiệu năng 25% trên LLVM RISC-V
Trong quá trình phân tích hiệu năng của LLVM so với GCC trên các mục tiêu RISC-V, tôi đã phát hiện một sự bất thường đáng lo ngại. Một benchmark cụ thể cho thấy LLVM đang hoạt động kém hiệu quả hơn đáng kể so với đối thủ. Bài viết này sẽ đi sâu vào quá trình điều tra, tìm ra nguyên nhân gốc rễ của một sự sụt giảm hiệu năng (regression) 25% và cách tôi đã gửi một bản vá (patch) để khắc phục vấn đề này.
Phát hiện vấn đề: LLVM chậm hơn GCC
Khi xem xét dữ liệu benchmark từ trang web của Igalia so sánh hiệu năng giữa LLVM và GCC trên kiến trúc RISC-V, tôi nhận thấy LLVM yêu cầu nhiều chu kỳ hơn khoảng 8% so với GCC cho một bài kiểm tra cụ thể trên CPU SiFive P550.
So sánh hiệu năng LLVM và GCC
Tôi đã xem xét mã assembly (mã máy) được tạo ra cho các khối cơ bản liên quan. Hầu hết thời gian xử lý đều được tiêu tốn trong vòng lặp chính. Dưới đây là đoạn mã assembly tương ứng từ cả hai trình biên dịch:
Mã assembly của LLVM
Mã assembly của GCC
Thoạt nhìn, hai đoạn mã này trông khá giống nhau. Tuy nhiên, có một sự khác biệt lớn mà tôi nhận thấy ngay lập tức: LLVM đang sử dụng lệnh fdiv.d, đây là lệnh chia với độ chính xác kép (double precision/f64). Trong khi đó, GCC sử dụng fdiv.s (độ chính xác đơn/f32).
Tại sao fdiv.d lại là vấn đề?
Trên kiến trúc RISC-V, lệnh fdiv.d có độ trễ (latency) lên tới 33 chu kỳ, trong khi fdiv.s chỉ mất 19 chu kỳ. Sự chênh lệch này là rất lớn và giải thích tại sao hiệu năng của LLVM lại thấp hơn.
Để xác minh xem đây có phải là một regression gần đây hay không, tôi đã sử dụng công cụ llvm-mca (Machine Code Analyzer) để phân tích mã máy tĩnh. Kết quả cho thấy trong các bản dựng cũ, LLVM thực sự đã tạo ra fdiv.s thay vì fdiv.d. Điều này khẳng định rằng một thay đổi nào đó trong quá khứ đã vô tình làm mất đi khả năng tối ưu hóa "thu hẹp" (narrowing) từ double sang float.
Tìm nguyên nhân trong pipeline tối ưu hóa
Tôi bắt đầu điều tra các commit gần đây liên quan đến RISC-V, nhưng không tìm thấy gì bất thường. Do đó, tôi chuyển sự chú ý sang "middle-end" (giai đoạn giữa của trình biên dịch), nơi các tối ưu hóa độc lập với kiến trúc phần cứng diễn ra.
Bằng cách so sánh LLVM IR (Intermediate Representation) ở đầu và cuối pipeline tối ưu hóa, tôi nhận thấy vấn đề nằm ở pass InstCombiner.
Ban đầu, mã IR trông như sau:
%conv = sitofp i64 %5 to float ; int -> float
%conv2 = fpext float %conv to double ; float -> double
%div3 = fdiv double %conv2, 7.438300e+04 ; fdiv.d
%conv4 = fptrunc double %div3 to float ; double -> float
Logic tối ưu hóa mong muốn là chuyển đổi toàn bộ phép tính sang độ chính xác đơn (float) vì kết quả cuối cùng cũng là float. Tuy nhiên, một commit gần đây đã cải thiện hàm isKnownExactCastIntToFP. Hàm này đã gộp chuỗi thao tác fpext(sitofp x to float) to double thành một lệnh chuyển đổi trực tiếp uitofp x to double.
Mặc dù tối ưu hóa này có vẻ tốt ở chỗ nó loại bỏ lệnh fpext, nhưng nó lại có một tác dụng phụ nghiêm trọng. Pass visitFPTrunc - vốn chịu trách nhiệm thu hẹp double về float - dựa vào sự hiện diện của lệnh fpext để nhận biết rằng phép tính có thể được thực hiện ở độ chính xác thấp hơn. Khi fpext bị loại bỏ sớm, visitFPTrunc không còn đủ thông tin để thực hiện tối ưu hóa thu hẹp, dẫn đến việc giữ lại phép chia double chậm chạp fdiv.d.
Giải pháp: Mở rộng phân tích phạm vi
Để khắc phục vấn đề, chúng ta cần dạy cho InstCombiner biết rằng nếu kết quả cuối cùng bị thu hẹp (truncated) sang float, thì các phép tính trung gian cũng nên được thực hiện ở float nếu có thể.
Giải pháp của tôi là mở rộng hàm getMinimumFPType để bao gồm cả phân tích phạm vi (range analysis) cho các lệnh chuyển đổi từ số nguyên sang số thực dấu chấm động. Cụ thể, tôi đã tách logic kiểm tra ra một hàm mới là canBeCastedExactlyIntToFP. Hàm này cho phép chúng ta kiểm tra xem một giá trị số nguyên có thể được chuyển đổi chính xác sang một kiểu thực cụ thể (ví dụ: float) mà không làm mất dữ liệu hay không.
Bản vá (patch) cuối cùng cho phép getMinimumFPType nhận diện mẫu fptrunc(uitofp x double) to float và chuyển đổi nó thành uitofp x to float ngay lập tức.
Kết quả
Sau khi bản vá được hợp nhất vào mã nguồn của LLVM, tôi đã chạy lại benchmark. Kết quả rất khả quan:
Kết quả phân tích llvm-mca
Lệnh fptrunc đã biến mất, và lệnh chuyển đổi uitofp giờ đây chuyển đổi trực tiếp sang float. Mã assembly tạo ra cuối cùng sử dụng fdiv.s thay vì fdiv.d.
Hiệu năng của benchmark đã được phục hồi hoàn toàn, thậm chí còn cải thiện khoảng 25% so với phiên bản bị lỗi, đưa LLVM ngang hàng với GCC cho bài kiểm tra này.
Kết luận
Đây là một ví dụ điển hình cho thấy việc tối ưu hóa trình biên dịch là một quá trình phức tạp và đầy thách thức. Một tối ưu hóa nhằm mục đích cải thiện mã ở một nơi có thể vô tình phá vỡ một tối ưu hóa quan trọng ở nơi khác. Việc sử dụng các công cụ như llvm-mca và kỹ thuật phân tích IR là rất quan trọng để chẩn đoán và giải quyết các vấn đề hiệu năng tinh tế này.
Tôi muốn gửi lời cảm ơn đến @SavchenkoValeriy đã hướng dẫn và review mã nguồn giúp tôi hoàn thiện bản vá này.



