Toán tử AND logic (&&
) sử dụng đánh giá ngắn mạch, có nghĩa là thử nghiệm thứ hai chỉ được thực hiện nếu so sánh đầu tiên đánh giá là đúng. Điều này thường chính xác là ngữ nghĩa mà bạn yêu cầu. Ví dụ, hãy xem xét mã sau đây:
if ((p != nullptr) && (p->first > 0))
Bạn phải đảm bảo rằng con trỏ không rỗng trước khi bạn hủy đăng ký nó. Nếu điều này không phải là một đánh giá ngắn mạch, bạn sẽ có hành vi không xác định vì bạn sẽ hủy bỏ một con trỏ rỗng.
Cũng có thể đánh giá ngắn mạch mang lại hiệu suất đạt được trong trường hợp đánh giá các điều kiện là một quá trình tốn kém. Ví dụ:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
Nếu DoLengthyCheck1
thất bại, không có điểm nào trong cuộc gọiDoLengthyCheck2
.
Tuy nhiên, trong nhị phân kết quả, một hoạt động ngắn mạch thường dẫn đến hai nhánh, vì đây là cách dễ nhất để trình biên dịch bảo tồn các ngữ nghĩa này. (Đó là lý do tại sao, ở phía bên kia của đồng xu, việc đánh giá ngắn mạch đôi khi có thể ức chế tiềm năng tối ưu hóa.) Bạn có thể thấy điều này bằng cách xem phần mã có liên quan được tạo cho if
câu lệnh của bạn bằng GCC 5.4:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L5
cmp ax, 478 ; (l[i + shift] < 479)
ja .L5
add r8d, 1 ; nontopOverlap++
Bạn thấy ở đây hai so sánh ( cmp
hướng dẫn) ở đây, mỗi lần theo sau là một bước nhảy / nhánh có điều kiện riêng ( ja
hoặc nhảy nếu ở trên).
Đó là một quy tắc chung của ngón tay cái rằng các nhánh chậm và do đó phải tránh trong các vòng lặp chặt chẽ. Điều này đúng với hầu hết tất cả các bộ xử lý x86, từ 8088 khiêm tốn (có thời gian tìm nạp chậm và hàng đợi tìm nạp cực nhỏ [có thể so sánh với bộ đệm hướng dẫn], kết hợp với việc thiếu dự đoán nhánh, có nghĩa là các nhánh bị mất yêu cầu phải xóa bộ đệm. ) cho đến các triển khai hiện đại (có đường ống dài làm cho các nhánh bị dự đoán sai tương tự đắt tiền). Lưu ý cảnh báo nhỏ mà tôi trượt trong đó. Bộ xử lý hiện đại kể từ Pentium Pro có các công cụ dự đoán chi nhánh tiên tiến được thiết kế để giảm thiểu chi phí cho các chi nhánh. Nếu hướng của chi nhánh có thể được dự đoán đúng, chi phí là tối thiểu. Hầu hết thời gian, điều này hoạt động tốt, nhưng nếu bạn gặp phải các trường hợp bệnh lý trong đó công cụ dự đoán nhánh không đứng về phía bạn,mã của bạn có thể rất chậm . Đây có lẽ là nơi bạn đang ở đây, vì bạn nói rằng mảng của bạn chưa được sắp xếp.
Bạn nói rằng các điểm chuẩn đã xác nhận rằng việc thay thế &&
bằng một *
làm cho mã nhanh hơn đáng kể. Lý do cho điều này là hiển nhiên khi chúng ta so sánh phần có liên quan của mã đối tượng:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
xor r15d, r15d ; (curr[i] < 479)
cmp r13w, 478
setbe r15b
xor r14d, r14d ; (l[i + shift] < 479)
cmp ax, 478
setbe r14b
imul r14d, r15d ; meld results of the two comparisons
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Có một chút phản trực giác rằng điều này có thể nhanh hơn, vì có nhiều hướng dẫn hơn ở đây, nhưng đó là cách tối ưu hóa đôi khi hoạt động. Bạn thấy các phép so sánh tương tự ( cmp
) đang được thực hiện ở đây, nhưng bây giờ, mỗi cái được đi trước bởi một xor
và theo sau là a setbe
. XOR chỉ là một mẹo tiêu chuẩn để xóa sổ đăng ký. Lệnh setbe
x86 đặt một bit dựa trên giá trị của cờ và thường được sử dụng để triển khai mã không phân nhánh. Ở đây, setbe
là nghịch đảo của ja
. Nó đặt thanh ghi đích của nó thành 1 nếu so sánh là dưới hoặc bằng (vì thanh ghi được đặt trước 0, nó sẽ là 0 nếu không), trong khi ja
phân nhánh nếu so sánh ở trên. Một khi hai giá trị này đã đạt được trongr15b
vàr14b
đăng ký, chúng được nhân với nhau bằng cách sử dụng imul
. Truyền thống là một hoạt động tương đối chậm, nhưng nó rất nhanh trên các bộ xử lý hiện đại và điều này sẽ đặc biệt nhanh, bởi vì nó chỉ nhân hai giá trị kích thước byte.
Bạn có thể dễ dàng thay thế phép nhân bằng toán tử bitwise AND ( &
), không thực hiện đánh giá ngắn mạch. Điều này làm cho mã rõ ràng hơn nhiều và là một mẫu mà trình biên dịch thường nhận ra. Nhưng khi bạn làm điều này với mã của mình và biên dịch nó với GCC 5.4, nó sẽ tiếp tục phát ra nhánh đầu tiên:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L4
cmp ax, 478 ; (l[i + shift] < 479)
setbe r14b
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Không có lý do kỹ thuật nào mà nó phải phát mã theo cách này, nhưng vì một số lý do, các heuristic bên trong của nó đang nói với nó rằng điều này nhanh hơn. Nó sẽ có thể được nhanh hơn nếu các yếu tố dự báo chi nhánh là về phía bạn, nhưng nó có khả năng sẽ chậm hơn nếu dự đoán rẽ nhánh không thường xuyên hơn nó thành công.
Các thế hệ trình biên dịch mới hơn (và các trình biên dịch khác, như Clang) biết quy tắc này, và đôi khi sẽ sử dụng nó để tạo cùng một mã mà bạn đã tìm kiếm bằng cách tối ưu hóa bằng tay. Tôi thường xuyên thấy Clang dịch các &&
biểu thức sang cùng một mã sẽ được phát ra nếu tôi đã sử dụng &
. Sau đây là đầu ra có liên quan từ GCC 6.2 với mã của bạn bằng &&
toán tử thông thường :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L7
xor r14d, r14d ; (l[i + shift] < 479)
cmp eax, 478
setle r14b
add esi, r14d ; nontopOverlap++
Lưu ý làm thế nào thông minh này là! Nó đang sử dụng các điều kiện đã ký ( jg
và setle
) trái ngược với các điều kiện không dấu ( ja
và setbe
), nhưng điều này không quan trọng. Bạn có thể thấy rằng nó vẫn thực hiện so sánh và phân nhánh cho điều kiện đầu tiên như phiên bản cũ hơn và sử dụng cùng một setCC
hướng dẫn để tạo mã không phân nhánh cho điều kiện thứ hai, nhưng nó đã hiệu quả hơn rất nhiều trong cách tăng . Thay vì thực hiện so sánh thứ hai, dự phòng để đặt cờ cho một sbb
thao tác, nó sử dụng kiến thức r14d
sẽ là 1 hoặc 0 để thêm giá trị này vô điều kiện vào nontopOverlap
. Nếu r14d
là 0, thì phép cộng là không có op; mặt khác, nó thêm 1, chính xác như nó được cho là phải làm.
GCC 6.2 thực sự tạo ra mã hiệu quả hơn khi bạn sử dụng &&
toán tử ngắn mạch so với toán &
tử bitwise :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L6
cmp eax, 478 ; (l[i + shift] < 479)
setle r14b
cmp r14b, 1 ; nontopOverlap++
sbb esi, -1
Chi nhánh và tập hợp điều kiện vẫn còn đó, nhưng bây giờ nó trở lại cách tăng ít thông minh hơn nontopOverlap
. Đây là một bài học quan trọng tại sao bạn nên cẩn thận khi cố gắng vượt qua trình biên dịch của mình!
Nhưng nếu bạn có thể chứng minh với điểm chuẩn rằng mã phân nhánh thực sự chậm hơn, thì có thể phải trả tiền để thử và vượt qua trình biên dịch của bạn. Bạn chỉ cần làm như vậy với việc kiểm tra cẩn thận bộ tháo gỡ và chuẩn bị để đánh giá lại các quyết định của bạn khi bạn nâng cấp lên phiên bản mới hơn của trình biên dịch. Ví dụ: mã bạn có thể được viết lại thành:
nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));
Không có if
tuyên bố nào ở đây cả, và đại đa số các trình biên dịch sẽ không bao giờ nghĩ về việc phát ra mã phân nhánh cho việc này. GCC cũng không ngoại lệ; tất cả các phiên bản tạo ra một cái gì đó giống như sau:
movzx r14d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r14d, 478 ; (curr[i] < 479)
setle r15b
xor r13d, r13d ; (l[i + shift] < 479)
cmp eax, 478
setle r13b
and r13d, r15d ; meld results of the two comparisons
add esi, r13d ; nontopOverlap++
Nếu bạn đã theo dõi cùng với các ví dụ trước, điều này sẽ rất quen thuộc với bạn. Cả hai so sánh được thực hiện theo cách không phân nhánh, các kết quả trung gian được kết and
hợp với nhau và sau đó kết quả này (sẽ là 0 hoặc 1) được chỉnh add
sửa nontopOverlap
. Nếu bạn muốn mã không phân nhánh, điều này hầu như sẽ đảm bảo rằng bạn nhận được mã.
GCC 7 đã trở nên thông minh hơn. Bây giờ nó tạo mã gần như giống hệt nhau (ngoại trừ một số sắp xếp lại các hướng dẫn) cho thủ thuật trên như mã gốc. Vì vậy, câu trả lời cho câu hỏi của bạn, "Tại sao trình biên dịch lại hành xử theo cách này?" , có lẽ là vì chúng không hoàn hảo! Họ cố gắng sử dụng phương pháp phỏng đoán để tạo mã tối ưu nhất có thể, nhưng họ không luôn đưa ra quyết định tốt nhất. Nhưng ít nhất họ có thể trở nên thông minh hơn theo thời gian!
Một cách để xem xét tình huống này là mã phân nhánh có hiệu suất trường hợp tốt nhất . Nếu dự đoán chi nhánh thành công, bỏ qua các hoạt động không cần thiết sẽ dẫn đến thời gian chạy nhanh hơn một chút. Tuy nhiên, mã không phân nhánh có hiệu suất trường hợp xấu nhất tốt hơn . Nếu dự đoán chi nhánh thất bại, thực hiện một vài hướng dẫn bổ sung khi cần thiết để tránh một chi nhánh chắc chắn sẽ nhanh hơn một chi nhánh dự đoán sai. Ngay cả những trình biên dịch thông minh và thông minh nhất cũng sẽ gặp khó khăn khi đưa ra lựa chọn này.
Và đối với câu hỏi của bạn về việc liệu đây có phải là thứ mà các lập trình viên cần chú ý hay không, câu trả lời gần như chắc chắn là không, ngoại trừ trong các vòng lặp nóng nhất định mà bạn đang cố gắng tăng tốc thông qua tối ưu hóa vi mô. Sau đó, bạn ngồi xuống với việc tháo gỡ và tìm cách tinh chỉnh nó. Và, như tôi đã nói trước đây, hãy chuẩn bị xem xét lại các quyết định đó khi bạn cập nhật lên phiên bản mới hơn của trình biên dịch, bởi vì nó có thể làm điều gì đó ngu ngốc với mã khó hiểu của bạn, hoặc nó có thể thay đổi heuristic tối ưu hóa đủ để bạn có thể quay lại để sử dụng mã gốc của bạn. Nhận xét kỹ lưỡng!