Tại sao việc giới thiệu các lệnh MOV vô dụng lại tăng tốc vòng lặp chặt chẽ trong lắp ráp x86_64?


222

Lý lịch:

Trong khi tối ưu hóa một số mã Pascal với ngôn ngữ lắp ráp nhúng, tôi nhận thấy một MOVlệnh không cần thiết và đã loại bỏ nó.

Thật ngạc nhiên, loại bỏ các hướng dẫn không cần thiết khiến chương trình của tôi bị chậm lại .

Tôi thấy rằng việc thêm các MOVhướng dẫn tùy ý, vô dụng làm tăng hiệu suất hơn nữa.

Hiệu ứng là thất thường và thay đổi dựa trên thứ tự thực hiện: các lệnh rác tương tự được chuyển lên hoặc xuống bởi một dòng duy nhất tạo ra sự chậm lại .

Tôi hiểu rằng CPU thực hiện tất cả các loại tối ưu hóa và hợp lý hóa, nhưng, điều này có vẻ giống như ma thuật đen.

Dữ liệu:

Một phiên bản mã của tôi có điều kiện biên dịch ba hoạt động rác ở giữa một vòng lặp chạy 2**20==1048576thời gian. (Chương trình xung quanh chỉ tính băm SHA-256 ).

Kết quả trên máy khá cũ của tôi (Intel (R) Core (TM) 2 CPU 6400 @ 2.13 GHz):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

Các chương trình được chạy 25 lần trong một vòng lặp, với thứ tự chạy thay đổi ngẫu nhiên mỗi lần.

Trích đoạn:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Hãy tự thử:

Mã này đang trực tuyến tại GitHub nếu bạn muốn tự mình dùng thử.

Những câu hỏi của tôi:

  • Tại sao sao chép vô dụng nội dung của người đăng ký vào RAM sẽ tăng hiệu suất?
  • Tại sao cùng một hướng dẫn vô dụng cung cấp một sự tăng tốc trên một số dòng và làm chậm lại những người khác?
  • Là hành vi này một cái gì đó có thể được khai thác dự đoán bởi một trình biên dịch?

7
Có tất cả các loại hướng dẫn 'vô dụng' thực sự có thể phục vụ để phá vỡ chuỗi phụ thuộc, đánh dấu các thanh ghi vật lý là đã nghỉ hưu, v.v. Khai thác các hoạt động này đòi hỏi một số kiến ​​thức về kiến trúc vi mô . Câu hỏi của bạn nên cung cấp một chuỗi các hướng dẫn ngắn như một ví dụ tối thiểu, thay vì hướng mọi người đến github.
Brett Hale

1
@BrettHale điểm tốt, cảm ơn. Tôi đã thêm một đoạn trích mã với một số bình luận. Sao chép giá trị của một người đăng ký để ram đánh dấu đăng ký là đã nghỉ hưu, ngay cả khi giá trị trong đó được sử dụng sau này?
tangentstorm

9
Bạn có thể đặt độ lệch chuẩn trên các trung bình đó không? Không có dấu hiệu thực tế trong bài viết này rằng có một sự khác biệt thực sự.
bắt đầu

2
Bạn có thể vui lòng thử định thời gian cho các hướng dẫn bằng cách sử dụng lệnh ndtscp và kiểm tra chu kỳ đồng hồ cho cả hai phiên bản không?
jakobbotsch

2
Nó cũng có thể là do căn chỉnh bộ nhớ? Tôi đã không tự mình làm toán (lười biếng: P) nhưng thêm một số hướng dẫn giả có thể khiến mã của bạn được căn chỉnh theo bộ nhớ ...
Lorenzo Dematté

Câu trả lời:


144

Nguyên nhân rất có thể của việc cải thiện tốc độ là:

  • chèn MOV sẽ chuyển các hướng dẫn tiếp theo sang các địa chỉ bộ nhớ khác nhau
  • một trong những hướng dẫn di chuyển là một nhánh có điều kiện quan trọng
  • nhánh đó đã được dự đoán không chính xác do răng cưa trong bảng dự đoán nhánh
  • di chuyển nhánh đã loại bỏ bí danh và cho phép nhánh được dự đoán chính xác

Core2 của bạn không giữ một bản ghi lịch sử riêng cho mỗi lần nhảy có điều kiện. Thay vào đó, nó giữ một lịch sử chia sẻ của tất cả các bước nhảy có điều kiện. Một nhược điểm của dự đoán chi nhánh toàn cầu là lịch sử bị pha loãng bởi thông tin không liên quan nếu các bước nhảy có điều kiện khác nhau không được thông báo.

Hướng dẫn dự đoán nhánh nhỏ này cho thấy bộ đệm dự đoán nhánh hoạt động như thế nào. Bộ đệm bộ đệm được lập chỉ mục bởi phần dưới của địa chỉ của lệnh rẽ nhánh. Điều này hoạt động tốt trừ khi hai nhánh không quan trọng chia sẻ cùng một bit thấp hơn. Trong trường hợp đó, bạn kết thúc với việc khử răng cưa, điều này gây ra nhiều nhánh bị dự đoán sai (làm trì hoãn đường ống chỉ dẫn và làm chậm chương trình của bạn).

Nếu bạn muốn hiểu cách đánh giá sai chi nhánh ảnh hưởng đến hiệu suất, hãy xem câu trả lời tuyệt vời này: https://stackoverflow.com/a/11227902/1001643

Trình biên dịch thường không có đủ thông tin để biết các nhánh nào sẽ bí danh và liệu các bí danh đó có đáng kể hay không. Tuy nhiên, thông tin đó có thể được xác định trong thời gian chạy bằng các công cụ như CachegrindVTune .


2
Hừm. Điều này nghe có vẻ hứa hẹn. Các nhánh có điều kiện duy nhất trong việc thực hiện sha256 này là các kiểm tra cho sự kết thúc của các vòng lặp FOR. Vào thời điểm đó, tôi đã gắn thẻ bản sửa đổi này là một sự kỳ quặc trong git và tiếp tục tối ưu hóa. Một trong những bước tiếp theo của tôi là tự viết lại vòng lặp FOR pascal trong tập hợp, tại thời điểm này các hướng dẫn bổ sung này không còn có tác dụng tích cực. Có lẽ mã được tạo ra của pascal miễn phí khó cho bộ xử lý dự đoán hơn bộ đếm đơn giản mà tôi đã thay thế.
tiếp tuyến

1
@tangentstorm Nghe có vẻ như là một bản tóm tắt hay. Bảng dự đoán chi nhánh không lớn lắm, vì vậy một mục nhập bảng có thể đề cập đến nhiều hơn một chi nhánh. Điều này có thể khiến một số dự đoán vô dụng. Vấn đề dễ dàng được khắc phục nếu một trong các nhánh xung đột di chuyển sang một phần khác của bảng. Hầu như bất kỳ thay đổi nhỏ nào cũng có thể khiến điều này xảy ra :-)
Raymond Hettinger

1
Tôi nghĩ rằng đây là lời giải thích hợp lý nhất về hành vi cụ thể mà tôi đã quan sát, vì vậy tôi sẽ đánh dấu đây là câu trả lời. Cảm ơn. :)
tiếp tuyến

3
Có một cuộc thảo luận hoàn toàn tuyệt vời của một vấn đề tương tự của các thành viên đóng góp để Bochs ran vào, bạn có thể muốn thêm video này vào câu trả lời của bạn: emulators.com/docs/nx25_nostradamus.htm
leander

3
Liên kết nội bộ quan trọng cho nhiều hơn là chỉ mục tiêu chi nhánh. Giải mã các nút cổ chai là một vấn đề lớn đối với Core2 và Nehalem: nó thường gặp khó khăn trong việc giữ cho các đơn vị thực thi của nó bận rộn. Việc giới thiệu bộ đệm uop của Sandybridge đã tăng lượng thông lượng giao diện rất lớn. Sắp xếp các mục tiêu chi nhánh được thực hiện vấn đề này, nhưng nó ảnh hưởng đến tất cả các mã.
Peter Cordes

80

Bạn có thể muốn đọc http://research.google.com/pub/pub37077.html

TL; DR: chèn ngẫu nhiên các hướng dẫn nop trong các chương trình có thể dễ dàng tăng hiệu suất từ ​​5% trở lên và không, trình biên dịch không thể dễ dàng khai thác điều này. Nó thường là sự kết hợp của bộ dự báo nhánh và hành vi bộ đệm, nhưng nó cũng có thể là ví dụ như một trạm đặt phòng (ngay cả trong trường hợp không có chuỗi phụ thuộc nào bị hỏng hoặc đăng ký tài nguyên rõ ràng quá mức).


1
Hấp dẫn. Nhưng liệu bộ xử lý (hoặc FPC) có đủ thông minh để thấy rằng ghi vào ram là NOP trong trường hợp này không?
tangentstorm

8
Trình biên dịch không được tối ưu hóa.
Marco van de Voort

5
Trình biên dịch có thể khai thác nó bằng cách thực hiện các tối ưu hóa cực kỳ tốn kém như liên tục xây dựng và định hình và sau đó thay đổi đầu ra của trình biên dịch bằng thuật toán ủ hoặc mô phỏng di truyền. Tôi đã đọc về một số công việc trong khu vực đó. Nhưng chúng ta đang nói tối thiểu 5-10 phút CPU 100% để biên dịch, và tối ưu hóa kết quả có thể sẽ là mô hình lõi CPU và thậm chí là sửa đổi lõi hoặc vi mã cụ thể.
AdamIerymenko

Tôi sẽ không gọi nó là NOP ngẫu nhiên, họ giải thích tại sao NOP có thể có tác động tích cực đến hiệu suất (tl; dr: stackoverflow.com/a/5901856/357198 ) và việc chèn NOP ngẫu nhiên đã dẫn đến suy giảm hiệu suất. Điều thú vị của bài báo là việc loại bỏ 'chiến lược' NOP của GCC không có ảnh hưởng gì đến hiệu suất tổng thể!
PuercoPop

15

Tôi tin vào các CPU hiện đại, các hướng dẫn lắp ráp, trong khi là lớp hiển thị cuối cùng cho một lập trình viên để cung cấp các hướng dẫn thực thi cho CPU, thực tế là một số lớp từ thực thi thực tế của CPU.

Các CPU hiện đại là các giống lai RISC / CISC dịch các hướng dẫn CISC x86 thành các hướng dẫn nội bộ có nhiều RISC hơn trong hành vi. Ngoài ra, còn có các máy phân tích thực hiện không theo thứ tự, các bộ dự đoán nhánh, "phản ứng tổng hợp vi mô" của Intel cố gắng nhóm các hướng dẫn thành các lô công việc đồng thời lớn hơn (giống như titanic VLIW / Itanium ). Thậm chí còn có các ranh giới bộ đệm có thể làm cho mã chạy nhanh hơn để biết được lý do tại sao nếu nó lớn hơn (có thể bộ điều khiển bộ đệm tạo khe thông minh hơn hoặc giữ nó lâu hơn).

CISC luôn có một lớp dịch mã từ lắp ráp sang vi mã, nhưng vấn đề là với các CPU hiện đại, mọi thứ phức tạp hơn nhiều. Với tất cả các bất động sản bóng bán dẫn bổ sung trong các nhà máy chế tạo chất bán dẫn hiện đại, CPU có thể có thể áp dụng song song một số phương pháp tối ưu hóa và sau đó chọn một phương pháp ở cuối cung cấp tốc độ tốt nhất. Các hướng dẫn bổ sung có thể thiên vị CPU để sử dụng một đường dẫn tối ưu hóa tốt hơn các đường dẫn khác.

Hiệu quả của các hướng dẫn bổ sung có thể phụ thuộc vào kiểu / thế hệ / nhà sản xuất CPU và không có khả năng dự đoán được. Tối ưu hóa ngôn ngữ lắp ráp theo cách này sẽ yêu cầu thực thi đối với nhiều thế hệ kiến ​​trúc CPU, có thể sử dụng các đường dẫn thực thi dành riêng cho CPU và chỉ mong muốn cho các phần mã thực sự quan trọng, mặc dù nếu bạn đang thực hiện lắp ráp, có lẽ bạn đã biết điều đó.


6
Câu trả lời của bạn là loại khó hiểu. Ở nhiều nơi có vẻ như bạn đang đoán, mặc dù hầu hết những gì bạn nói là chính xác.
alcuadrado

2
Có lẽ tôi nên làm rõ. Điều tôi cảm thấy khó hiểu là sự thiếu chắc chắn
alcuadrado

3
đoán điều đó có ý nghĩa và với lập luận tốt là hoàn toàn hợp lệ.
jturolla

7
Không ai có thể thực sự biết chắc chắn tại sao OP lại quan sát hành vi kỳ lạ này, trừ khi đó là một kỹ sư của Intel có quyền truy cập vào thiết bị chẩn đoán đặc biệt. Vì vậy, tất cả những người khác có thể làm là đoán. Đó không phải là lỗi của @ cowarldlydragon.
Alex D

2
Downvote; không có gì bạn nói giải thích hành vi mà OP đang thấy. Câu trả lời của bạn là vô ích.
fuz

0

Chuẩn bị bộ đệm

Di chuyển các hoạt động vào bộ nhớ có thể chuẩn bị bộ đệm và làm cho các hoạt động di chuyển tiếp theo nhanh hơn. Một CPU thường có hai đơn vị tải và một đơn vị lưu trữ. Một đơn vị tải có thể đọc từ bộ nhớ vào một thanh ghi (một lần đọc trong mỗi chu kỳ), một đơn vị lưu trữ lưu trữ từ thanh ghi đến bộ nhớ. Ngoài ra còn có các đơn vị khác thực hiện các hoạt động giữa các thanh ghi. Tất cả các đơn vị làm việc song song. Vì vậy, trên mỗi chu kỳ, chúng tôi có thể thực hiện một số thao tác cùng một lúc, nhưng không quá hai lần tải, một cửa hàng và một số thao tác đăng ký. Thông thường có tới 4 thao tác đơn giản với các thanh ghi đơn giản, tối đa 3 thao tác đơn giản với các thanh ghi XMM / YMM và 1-2 thao tác phức tạp với bất kỳ loại thanh ghi nào. Mã của bạn có rất nhiều hoạt động với các thanh ghi, vì vậy một thao tác lưu trữ bộ nhớ giả là miễn phí (vì dù sao cũng có hơn 4 thao tác đăng ký), nhưng nó chuẩn bị bộ nhớ cache cho hoạt động lưu trữ tiếp theo. Để tìm hiểu cách các cửa hàng bộ nhớ hoạt động, vui lòng tham khảoHướng dẫn tham khảo tối ưu hóa kiến ​​trúc Intel 64 và IA-32 .

Phá vỡ sự phụ thuộc sai

Mặc dù điều này không chính xác liên quan đến trường hợp của bạn, nhưng đôi khi sử dụng các hoạt động Mov 32 bit trong bộ xử lý 64 bit (như trong trường hợp của bạn) được sử dụng để xóa các bit cao hơn (32-63) và phá vỡ chuỗi phụ thuộc.

Người ta biết rằng dưới x86-64, sử dụng toán hạng 32 bit sẽ xóa các bit cao hơn của thanh ghi 64 bit. Xin vui lòng đọc phần có liên quan - 3.4.1.1 - của Hướng dẫn dành cho nhà phát triển phần mềm Intel® 64 và IA-32 Architectures Tập 1 :

Toán hạng 32 bit tạo ra kết quả 32 bit, không được mở rộng thành kết quả 64 bit trong thanh ghi mục đích chung

Vì vậy, các hướng dẫn Mov, có vẻ như vô dụng ngay từ cái nhìn đầu tiên, xóa các bit cao hơn của các thanh ghi thích hợp. Những gì nó mang lại cho chúng ta? Nó phá vỡ các chuỗi phụ thuộc và cho phép các hướng dẫn thực hiện song song, theo thứ tự ngẫu nhiên, bằng thuật toán Out-of-Order được thực hiện bởi các CPU kể từ Pentium Pro vào năm 1995.

Trích dẫn từ Hướng dẫn tham khảo tối ưu hóa kiến ​​trúc Intel® 64 và IA-32 , Phần 3.5.1.8:

Trình tự mã sửa đổi thanh ghi một phần có thể gặp một số độ trễ trong chuỗi phụ thuộc của nó, nhưng có thể tránh được bằng cách sử dụng các thành ngữ phá vỡ phụ thuộc. Trong các bộ xử lý dựa trên kiến ​​trúc vi mô Intel Core, một số hướng dẫn có thể giúp xóa phụ thuộc thực thi khi phần mềm sử dụng các hướng dẫn này để xóa nội dung đăng ký về không. Phá vỡ sự phụ thuộc vào các phần của các thanh ghi giữa các hướng dẫn bằng cách vận hành trên các thanh ghi 32 bit thay vì các thanh ghi một phần. Đối với di chuyển, điều này có thể được thực hiện bằng các động thái 32 bit hoặc bằng cách sử dụng MOVZX.

Quy tắc mã hóa hội / biên dịch 37. (M tác động, tổng quát MH) : Phá vỡ các phụ thuộc vào các phần của các thanh ghi giữa các lệnh bằng cách vận hành trên các thanh ghi 32 bit thay vì các thanh ghi một phần. Đối với di chuyển, điều này có thể được thực hiện bằng các động thái 32 bit hoặc bằng cách sử dụng MOVZX.

MOVZX và MOV với toán hạng 32 bit cho x64 là tương đương - tất cả chúng đều phá vỡ chuỗi phụ thuộc.

Đó là lý do tại sao mã của bạn thực thi nhanh hơn. Nếu không có phụ thuộc, CPU có thể đổi tên bên trong các thanh ghi, mặc dù ngay từ cái nhìn đầu tiên, có vẻ như lệnh thứ hai sửa đổi một thanh ghi được sử dụng bởi lệnh đầu tiên và cả hai không thể thực thi song song. Nhưng do đăng ký đổi tên họ có thể.

Đổi tên đăng ký là một kỹ thuật được sử dụng bên trong bởi CPU giúp loại bỏ các phụ thuộc dữ liệu sai phát sinh từ việc sử dụng lại các thanh ghi bằng các hướng dẫn liên tiếp không có bất kỳ phụ thuộc dữ liệu thực nào giữa chúng.

Tôi nghĩ rằng bây giờ bạn thấy rằng nó là quá rõ ràng.


Điều này hoàn toàn đúng, nhưng không liên quan gì đến mã được trình bày trong câu hỏi.
Cody Grey

@CodyGray - cảm ơn bạn đã phản hồi. Tôi đã chỉnh sửa câu trả lời và thêm một chương về vụ án - rằng Mov vào bộ nhớ được bao quanh bởi các hoạt động đăng ký chuẩn bị bộ đệm và nó hoàn toàn miễn phí vì đơn vị cửa hàng không hoạt động. Vì vậy, hoạt động cửa hàng tiếp theo sẽ nhanh hơn.
Maxim Masiutin

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.