Việc đạt được hiệu suất cao nhất từ mã C++ của bạn có thể khó khăn, đòi hỏi phải lập hồ sơ tỉ mỉ, điều chỉnh quyền truy cập bộ nhớ phức tạp và tối ưu hóa bộ đệm. Có mẹo nào để đơn giản hóa việc này một chút không?? May mắn thay, có một con đường tắt để đạt được mức tăng hiệu suất vượt trội với nỗ lực tối thiểu — miễn là bạn có thông tin chi tiết phù hợp và biết mình đang làm gì. Nhập các tối ưu hóa trình biên dịch có thể nâng cao đáng kể hiệu suất mã của bạn.
Các trình biên dịch hiện đại đóng vai trò là đồng minh không thể thiếu trong hành trình hướng tới hiệu suất tối ưu, đặc biệt là trong quá trình song song hóa tự động. Những công cụ phức tạp này có khả năng xem xét kỹ lưỡng các mẫu mã phức tạp, đặc biệt là trong các vòng lặp và thực hiện tối ưu hóa một cách liền mạch.
Bài viết này nhằm mục đích làm nổi bật tiềm năng của việc tối ưu hóa trình biên dịch, tập trung vào trình biên dịch Intel C++ — nổi tiếng về tính phổ biến và được sử dụng rộng rãi.
Trong câu chuyện này, chúng ta làm sáng tỏ các lớp phép thuật của trình biên dịch có thể biến mã của bạn thành một kiệt tác hiệu suất cao, đòi hỏi ít sự can thiệp thủ công hơn bạn nghĩ.
Điểm nổi bật: Tối ưu hóa trình biên dịch là gì? | -Bật | Kiến trúc được nhắm mục tiêu | Tối ưu hóa liên thủ tục | -fno-bí danh | Báo cáo Tối ưu hóa trình biên dịch
Tối ưu hóa trình biên dịch bao gồm các kỹ thuật và biến đổi khác nhau mà trình biên dịch áp dụng cho mã nguồn trong quá trình biên dịch. Nhưng tại sao? Để nâng cao hiệu suất, hiệu quả và trong một số trường hợp là kích thước của mã máy thu được. Những tối ưu hóa này có vai trò then chốt trong việc ảnh hưởng đến các khía cạnh khác nhau của việc thực thi mã, bao gồm tốc độ, mức sử dụng bộ nhớ và mức tiêu thụ năng lượng.
Bất kỳ trình biên dịch nào cũng thực hiện một loạt các bước để chuyển đổi mã nguồn cấp cao sang mã máy cấp thấp. Chúng liên quan đến phân tích từ vựng, phân tích cú pháp, phân tích ngữ nghĩa, tạo mã trung gian (hoặc IR), tối ưu hóa và tạo mã.
Trong giai đoạn tối ưu hóa, trình biên dịch tìm kiếm một cách tỉ mỉ các cách để chuyển đổi chương trình, nhằm đạt được đầu ra tương đương về mặt ngữ nghĩa, sử dụng ít tài nguyên hơn hoặc thực thi nhanh hơn. Các kỹ thuật được sử dụng trong quy trình này bao gồm nhưng không giới hạn ở việc gấp liên tục, tối ưu hóa vòng lặp, nội tuyến chức năng và loại bỏ mã chết .
Tôi sẽ không thảo luận về tất cả các tùy chọn có sẵn mà thảo luận về cách chúng ta có thể hướng dẫn trình biên dịch thực hiện tối ưu hóa cụ thể để có thể cải thiện hiệu suất mã. Vậy giải pháp là gì???? Cờ trình biên dịch.
Các nhà phát triển có thể chỉ định một bộ cờ trình biên dịch trong quá trình biên dịch, một cách làm quen thuộc với những người sử dụng các tùy chọn như “ -g” hoặc “-pg” với GCC để gỡ lỗi và lập hồ sơ thông tin. Khi tiếp tục, chúng ta sẽ thảo luận về các cờ trình biên dịch tương tự mà chúng ta có thể sử dụng khi biên dịch ứng dụng của mình bằng trình biên dịch Intel C++. Những điều này có thể giúp bạn cải thiện hiệu quả và hiệu suất của mã.
Tôi sẽ không đi sâu vào lý thuyết khô khan hoặc làm bạn ngập trong tài liệu tẻ nhạt liệt kê mọi cờ của trình biên dịch. Thay vào đó, chúng ta hãy cố gắng hiểu lý do và cách thức hoạt động của những lá cờ này.
Chúng ta đạt được điều này như thế nào???
Chúng ta sẽ sử dụng một hàm C++ chưa được tối ưu hóa chịu trách nhiệm tính toán phép lặp Jacobi và từng bước một, chúng ta sẽ làm sáng tỏ tác động của từng cờ trình biên dịch. Trong quá trình khám phá này, chúng tôi sẽ đo lường mức độ tăng tốc bằng cách so sánh một cách có hệ thống từng lần lặp với phiên bản cơ sở — bắt đầu không có cờ tối ưu hóa (-O0).
Tốc độ tăng tốc (hoặc thời gian thực hiện) được đo trên máy xử lý Intel® Xeon® Platinum 8174 . Ở đây, phương pháp Jacobi giải phương trình vi phân từng phần 2D (phương trình Poisson) để mô hình hóa sự phân bố nhiệt trên lưới hình chữ nhật.
u(x,y,t) là nhiệt độ tại điểm (x,y) tại thời điểm t.
Chúng tôi giải quyết trạng thái ổn định khi phân phối không thay đổi nữa:
Một tập hợp các điều kiện biên Dirichlet đã được áp dụng tại biên.
Về cơ bản, chúng tôi có mã hóa C++ thực hiện các lần lặp Jacobi trên các lưới có kích thước thay đổi (mà chúng tôi gọi là độ phân giải). Về cơ bản, kích thước lưới 500 có nghĩa là giải quyết ma trận có kích thước 500x500, v.v.
Hàm thực hiện một lần lặp Jacobi như sau:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
Chúng tôi tiếp tục thực hiện phép lặp Jacobi cho đến khi phần dư đạt giá trị ngưỡng (bên trong vòng lặp). Việc tính toán phần dư và đánh giá ngưỡng được thực hiện bên ngoài hàm này và không được quan tâm ở đây. Vì vậy, bây giờ chúng ta hãy nói về con voi trong phòng!
Không tối ưu hóa (-O0), chúng tôi nhận được kết quả sau:
Ở đây, chúng tôi đo hiệu suất theo MFLOP/s. Đây sẽ là cơ sở để chúng tôi so sánh.
MFLOP/s là viết tắt của “Triệu phép tính dấu phẩy động mỗi giây”. Nó là một đơn vị đo lường được sử dụng để định lượng hiệu suất của máy tính hoặc bộ xử lý dưới dạng các phép toán dấu phẩy động. Các phép toán dấu phẩy động bao gồm các phép tính toán học với số thập phân hoặc số thực được biểu diễn dưới dạng dấu phẩy động.
MFLOP/s thường được sử dụng làm thước đo chuẩn hoặc hiệu suất, đặc biệt là trong các ứng dụng khoa học và kỹ thuật, nơi phổ biến các phép tính toán học phức tạp. Giá trị MFLOP/s càng cao thì hệ thống hoặc bộ xử lý thực hiện các thao tác dấu phẩy động càng nhanh.
Lưu ý 1: Để cung cấp kết quả ổn định, tôi chạy tệp thực thi 5 lần cho mỗi độ phân giải và lấy giá trị trung bình của các giá trị MFLOP/s.
Lưu ý 2: Điều quan trọng cần lưu ý là tối ưu hóa mặc định trên trình biên dịch Intel C++ là -O2. Vì vậy, điều quan trọng là phải chỉ định -O0 khi biên dịch mã nguồn.
Hãy tiếp tục và xem thời gian chạy này sẽ thay đổi như thế nào khi chúng ta thử các cờ trình biên dịch khác nhau!
Đây là một số cờ trình biên dịch được sử dụng phổ biến nhất khi bắt đầu tối ưu hóa trình biên dịch. Trong trường hợp lý tưởng, hiệu suất của Ofast > O3 > O2 > O1 > O0 . Tuy nhiên, điều này không nhất thiết phải xảy ra. Điểm quan trọng của các tùy chọn này như sau:
-O1:
-O2:
-O3:
-Ofast:
Hướng dẫn chính thức nói chi tiết về chính xác những tối ưu hóa mà các tùy chọn này cung cấp.
Khi sử dụng các tùy chọn này trên mã Jacobi, chúng tôi nhận được thời gian chạy thực thi sau:
Rõ ràng là tất cả những tối ưu hóa này đều nhanh hơn nhiều so với mã cơ sở của chúng tôi (với “-O0”). Thời gian chạy thực thi thấp hơn 2–3 lần so với trường hợp cơ bản. Còn MFLOP/s thì sao??
Chà, đó là một cái gì đó !!!
Có sự khác biệt lớn giữa MFLOP/s của trường hợp cơ bản và những trường hợp được tối ưu hóa.
Nhìn chung, mặc dù chỉ một chút nhưng “-O3” hoạt động tốt nhất.
Các cờ bổ sung được sử dụng bởi “- Ofast ” (“ -no-prec-div -fp-model fast=2 ”) không mang lại bất kỳ sự tăng tốc bổ sung nào.
Kiến trúc của máy nổi bật như một yếu tố then chốt ảnh hưởng đến việc tối ưu hóa trình biên dịch. Nó có thể nâng cao đáng kể hiệu suất khi trình biên dịch biết các tập lệnh có sẵn và các tối ưu hóa được phần cứng hỗ trợ (như vector hóa và SIMD).
Ví dụ: máy Skylake của tôi có 3 đơn vị SIMD: 1 đơn vị AVX 512 và 2 đơn vị AVX-2.
Tôi thực sự có thể làm được điều gì đó với kiến thức này không???
Câu trả lời nằm ở các cờ biên dịch chiến lược. Thử nghiệm với các tùy chọn như “ -xHost ” và chính xác hơn là “ -xCORE-AVX512 ” có thể cho phép chúng tôi khai thác toàn bộ tiềm năng của máy và điều chỉnh tối ưu hóa để có hiệu suất tối ưu.
Dưới đây là mô tả nhanh về ý nghĩa của những lá cờ này:
-xHost:
-xCORE-AVX512:
Mục tiêu: Hướng dẫn rõ ràng trình biên dịch tạo mã sử dụng bộ hướng dẫn Intel Advanced Vector Extensions 512 (AVX-512).
Các tính năng chính: AVX-512 là bộ lệnh SIMD (Một lệnh, Nhiều dữ liệu) nâng cao cung cấp các thanh ghi vectơ rộng hơn và các hoạt động bổ sung so với các phiên bản trước như AVX2. Việc bật cờ này cho phép trình biên dịch tận dụng các tính năng nâng cao này để có hiệu suất được tối ưu hóa.
Cân nhắc: Tính di động một lần nữa là thủ phạm ở đây. Các tệp nhị phân được tạo bằng lệnh AVX-512 có thể không chạy tối ưu trên các bộ xử lý không hỗ trợ bộ lệnh này. Chúng có thể không hoạt động chút nào!
Tập lệnh AVX-512 sử dụng các thanh ghi Zmm, là một tập hợp các thanh ghi rộng 512 bit. Những thanh ghi này đóng vai trò là nền tảng cho việc xử lý vector.
Theo mặc định, “ -xCORE-AVX512 ” giả định rằng chương trình sẽ không được hưởng lợi từ việc sử dụng thanh ghi zmm. Trình biên dịch tránh sử dụng các thanh ghi zmm trừ khi đảm bảo đạt được hiệu suất.
Nếu một người có kế hoạch sử dụng các thanh ghi zmm mà không bị hạn chế, “ -qopt-zmm-usage ” có thể được đặt ở mức cao. Đó cũng là điều chúng tôi sẽ làm.
Đừng quên kiểm tra hướng dẫn chính thức để được hướng dẫn chi tiết.
Hãy xem những lá cờ này hoạt động như thế nào đối với mã của chúng ta:
Tuyệt vời!
Bây giờ chúng tôi đã vượt qua mốc 1200 MFLOP/s cho độ phân giải nhỏ nhất. Giá trị MFLOP/s cho các độ phân giải khác cũng tăng lên.
Điều đáng chú ý là chúng tôi đã đạt được những kết quả này mà không cần bất kỳ sự can thiệp thủ công đáng kể nào — chỉ bằng cách kết hợp một số cờ trình biên dịch trong quá trình biên dịch ứng dụng.
Tuy nhiên, cần nhấn mạnh rằng tệp thực thi được biên dịch sẽ chỉ tương thích với máy sử dụng cùng một tập lệnh.
Sự đánh đổi giữa tối ưu hóa và tính di động là hiển nhiên, vì mã được tối ưu hóa cho một tập lệnh cụ thể có thể hy sinh tính di động trên các cấu hình phần cứng khác nhau. Vì vậy, hãy chắc chắn rằng bạn biết bạn đang làm gì!!
Lưu ý: Đừng lo lắng nếu phần cứng của bạn không hỗ trợ AVX-512. Trình biên dịch Intel C++ hỗ trợ tối ưu hóa cho AVX, AVX-2 và thậm chí cả SSE. Tài liệu có mọi thứ bạn cần biết!
Tối ưu hóa liên thủ tục bao gồm việc phân tích và chuyển đổi mã trên nhiều chức năng hoặc thủ tục, nhìn xa hơn phạm vi của các chức năng riêng lẻ.
IPO là một quy trình gồm nhiều bước tập trung vào sự tương tác giữa các chức năng hoặc quy trình khác nhau trong một chương trình. IPO có thể bao gồm nhiều loại tối ưu hóa khác nhau, bao gồm Thay thế chuyển tiếp, Chuyển đổi cuộc gọi gián tiếp và Nội tuyến.
Trình biên dịch Intel hỗ trợ hai loại IPO phổ biến: Biên dịch một tệp và biên dịch nhiều tệp (Tối ưu hóa toàn bộ chương trình) [ 3 ]. Có hai cờ biên dịch phổ biến thực hiện từng cờ đó:
-ipo:
Mục tiêu: Cho phép tối ưu hóa liên thủ tục, cho phép trình biên dịch phân tích và tối ưu hóa toàn bộ chương trình, ngoài các tệp nguồn riêng lẻ, trong quá trình biên dịch.
Các tính năng chính:- Tối ưu hóa toàn bộ chương trình: “ -ipo ” thực hiện phân tích và tối ưu hóa trên tất cả các tệp nguồn, xem xét sự tương tác giữa các chức năng và quy trình trong toàn bộ chương trình.- Tối ưu hóa chức năng chéo và mô-đun chéo: Cờ tạo điều kiện cho các chức năng nội tuyến, đồng bộ hóa tối ưu hóa và phân tích luồng dữ liệu trên các phần chương trình khác nhau.
Cân nhắc: Nó yêu cầu một bước liên kết riêng. Sau khi biên dịch bằng “ -ipo ”, cần có một bước liên kết cụ thể để tạo tệp thực thi cuối cùng. Trình biên dịch thực hiện các tối ưu hóa bổ sung dựa trên toàn bộ chế độ xem chương trình trong quá trình liên kết.
-ip:
Mục tiêu: Cho phép truyền bá phân tích liên thủ tục, cho phép trình biên dịch thực hiện một số tối ưu hóa liên thủ tục mà không yêu cầu bước liên kết riêng.
Các tính năng chính:- Phân tích và truyền bá: “ -ip ” cho phép trình biên dịch thực hiện nghiên cứu và truyền dữ liệu qua các chức năng và mô-đun khác nhau trong quá trình biên dịch. Tuy nhiên, nó không thực hiện tất cả các tối ưu hóa yêu cầu xem toàn bộ chương trình.- Biên dịch nhanh hơn: Không giống như “ -ipo ”, “ -ip ” không cần một bước liên kết riêng biệt, dẫn đến thời gian biên dịch nhanh hơn. Điều này có thể có lợi trong quá trình phát triển khi phản hồi nhanh là cần thiết.
Cân nhắc: Chỉ xảy ra một số tối ưu hóa liên thủ tục có giới hạn, bao gồm cả nội tuyến hàm.
-ipo thường cung cấp khả năng tối ưu hóa liên thủ tục rộng rãi hơn vì nó bao gồm một bước liên kết riêng biệt nhưng phải trả giá bằng thời gian biên dịch lâu hơn. [ 4 ]
-ip là một giải pháp thay thế nhanh hơn, thực hiện một số tối ưu hóa liên thủ tục mà không yêu cầu bước liên kết riêng, giúp nó phù hợp cho các giai đoạn phát triển và thử nghiệm.[ 5 ]
Vì chúng tôi chỉ nói về hiệu suất và các cách tối ưu hóa khác nhau, thời gian biên dịch hoặc kích thước của tệp thực thi không phải là mối quan tâm của chúng tôi nên chúng tôi sẽ tập trung vào “ -ipo ”.
Tất cả những tối ưu hóa ở trên phụ thuộc vào mức độ hiểu biết về phần cứng của bạn và mức độ bạn sẽ thử nghiệm. Nhưng đó không phải là tất cả. Nếu chúng tôi cố gắng xác định cách trình biên dịch nhìn thấy mã của chúng tôi, chúng tôi có thể xác định các cách tối ưu hóa tiềm năng khác.
Chúng ta hãy xem lại mã của chúng tôi:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
Hàm jacobi() lấy một vài con trỏ để nhân đôi làm tham số và sau đó thực hiện điều gì đó bên trong các vòng lặp for lồng nhau. Khi bất kỳ trình biên dịch nào nhìn thấy hàm này trong tệp nguồn, nó phải hết sức cẩn thận.
Tại sao??
Biểu thức để tính unew bằng u bao gồm giá trị trung bình của 4 giá trị u lân cận. Điều gì sẽ xảy ra nếu cả bạn và unew đều trỏ đến cùng một vị trí? Điều này sẽ trở thành vấn đề cổ điển của con trỏ bí danh [ 7 ].
Các trình biên dịch hiện đại rất thông minh và để đảm bảo an toàn, họ cho rằng có thể tạo ra bí danh. Và đối với những tình huống như thế này, họ tránh mọi tối ưu hóa có thể ảnh hưởng đến ngữ nghĩa và đầu ra của mã.
Trong trường hợp của chúng tôi, chúng tôi biết rằng u và unew là các vị trí bộ nhớ khác nhau và có mục đích lưu trữ các giá trị khác nhau. Vì vậy, chúng ta có thể dễ dàng cho trình biên dịch biết rằng sẽ không có bất kỳ bí danh nào ở đây.
làm sao chúng ta làm việc đó bây giờ?
Có hai phương pháp. Đầu tiên là từ khóa C “ hạn chế ” . Nhưng nó đòi hỏi phải thay đổi mã. Chúng tôi không muốn điều đó vào lúc này.
Có gì đơn giản không? Hãy thử “ -fno-alias ”.
-fno-bí danh:
Mục tiêu: Hướng dẫn trình biên dịch không giả định bí danh trong chương trình.
Các tính năng chính: Giả sử không có bí danh, trình biên dịch có thể tối ưu hóa mã một cách tự do hơn, có khả năng cải thiện hiệu suất.
Cân nhắc: Nhà phát triển phải cẩn thận khi sử dụng cờ này vì trong trường hợp có bất kỳ bí danh không chính đáng nào, chương trình có thể đưa ra kết quả đầu ra không mong muốn.
Thông tin chi tiết có thể được tìm thấy trong tài liệu chính thức .
Điều này thực hiện như thế nào đối với mã của chúng tôi?
Chà, bây giờ chúng ta có một cái gì đó !!!
Ở đây, chúng tôi đã đạt được mức tăng tốc đáng chú ý, gần gấp 3 lần so với mức tối ưu hóa trước đó. Bí mật đằng sau sự thúc đẩy này là gì?
Bằng cách hướng dẫn trình biên dịch không sử dụng bí danh, chúng tôi đã cho phép trình biên dịch tự do thực hiện các tính năng tối ưu hóa vòng lặp mạnh mẽ.
Việc kiểm tra kỹ hơn mã hợp ngữ (mặc dù không được chia sẻ ở đây) và báo cáo tối ưu hóa biên dịch được tạo (xem bên dưới ) cho thấy ứng dụng hiểu biết của trình biên dịch về trao đổi vòng lặp và hủy kiểm soát vòng lặp . Những chuyển đổi này góp phần mang lại hiệu suất được tối ưu hóa cao, cho thấy tác động đáng kể của các chỉ thị của trình biên dịch đối với hiệu quả của mã.
Đây là cách tất cả các tối ưu hóa hoạt động với nhau:
Trình biên dịch Intel C++ cung cấp một tính năng có giá trị cho phép người dùng tạo báo cáo tối ưu hóa tóm tắt tất cả các điều chỉnh được thực hiện cho mục đích tối ưu hóa [ 8 ]. Báo cáo toàn diện này được lưu ở định dạng tệp YAML, trình bày danh sách chi tiết các tối ưu hóa được trình biên dịch áp dụng trong mã. Để biết mô tả chi tiết, hãy xem tài liệu chính thức về “ -qopt-report ”.
Chúng ta đã thảo luận về một số cờ trình biên dịch có thể cải thiện đáng kể hiệu suất của mã mà không cần thực sự phải làm gì nhiều. Điều kiện tiên quyết duy nhất: đừng làm bất cứ điều gì một cách mù quáng; hãy chắc chắn rằng bạn biết bạn đang làm gì!!
Có hàng trăm cờ biên dịch như vậy và câu chuyện này nói về một số ít. Vì vậy, bạn nên xem hướng dẫn biên dịch chính thức của trình biên dịch ưa thích của mình (đặc biệt là tài liệu liên quan đến tối ưu hóa).
Ngoài các cờ trình biên dịch này, còn có rất nhiều kỹ thuật như Vectorization, nội tại SIMD, Tối ưu hóa hướng dẫn hồ sơ và Tự động song song có hướng dẫn , có thể cải thiện hiệu suất mã của bạn một cách đáng kinh ngạc.
Tương tự, các trình biên dịch Intel C++ (và tất cả các trình biên dịch phổ biến) cũng hỗ trợ các lệnh pragma, đây là những tính năng rất hay. Bạn nên kiểm tra một số pragma như ivdep, Parallel, simd, vector, v.v. trên Intel-Specific Pragma Reference .
[1] Tối ưu hóa và lập trình (intel.com)
[2] Máy tính hiệu năng cao với “Elwetritsch” tại Đại học Kaiserslautern-Landau (rptu.de)
[3] Tối ưu hóa liên thủ tục (intel.com)
[6] Trình biên dịch Intel, Tối ưu hóa và các cờ khác để SPEChpc sử dụng
[8] Báo cáo tối ưu hóa trình biên dịch Intel®
Ảnh nổi bật của Igor Omilaev trên Bapt .
Cũng được xuất bản ở đây .