Tại sao trình biên dịch nhấn mạnh vào việc sử dụng một thanh ghi lưu callee ở đây?


10

Hãy xem xét mã C này:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

Khi tôi biên dịch nó trên GCC 9,3 với một trong hai -O3hoặc -Os, tôi có được điều này:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

Đầu ra từ clang là giống hệt nhau ngoại trừ việc chọn rbxthay vì r12là thanh ghi lưu callee.

Tuy nhiên, tôi muốn / mong đợi để xem lắp ráp trông giống như thế này:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

Trong tiếng Anh, đây là những gì tôi thấy đang xảy ra:

  • Đẩy giá trị cũ của thanh ghi lưu trữ vào ngăn xếp
  • Di chuyển xvào thanh ghi lưu trữ đó
  • Gọi foo
  • Chuyển xtừ thanh ghi đã lưu callee sang thanh ghi giá trị trả về
  • Bật ngăn xếp để khôi phục giá trị cũ của thanh ghi đã lưu

Tại sao phải bận tâm với một đăng ký lưu callee ở tất cả? Tại sao không làm điều này thay thế? Nó dường như ngắn hơn, đơn giản hơn và có thể nhanh hơn:

  • Đẩy xvào ngăn xếp
  • Gọi foo
  • Pop xtừ ngăn xếp vào thanh ghi giá trị trả về

Là lắp ráp của tôi sai? Có phải nó bằng cách nào đó kém hiệu quả hơn so với việc đăng ký thêm? Nếu câu trả lời cho cả hai câu trả lời là "không", thì tại sao GCC hoặc clang không làm theo cách này?

Liên kết Godbolt .


Chỉnh sửa: Đây là một ví dụ ít tầm thường hơn, để hiển thị nó xảy ra ngay cả khi biến được sử dụng có ý nghĩa:

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

Tôi nhận được điều này:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

Tôi muốn có cái này:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

Lần này, chỉ có một hướng dẫn so với hai, nhưng khái niệm cốt lõi là như nhau.

Liên kết Godbolt .


4
Thú vị bỏ lỡ tối ưu hóa.
fuz

1
rất có thể giả định rằng tham số đã truyền sẽ được sử dụng để bạn muốn lưu một thanh ghi dễ bay hơi và giữ tham số đã truyền trong một thanh ghi không trên ngăn xếp vì các lần truy cập tiếp theo vào tham số đó nhanh hơn từ thanh ghi. vượt qua x để foo và bạn sẽ thấy điều này. vì vậy nó có thể chỉ là một phần chung của thiết lập khung stack của họ.
old_timer

chấp nhận tôi thấy rằng không có foo thì nó không sử dụng stack, vì vậy, đó là một tối ưu hóa bị bỏ qua nhưng một cái gì đó sẽ cần phải thêm, phân tích hàm và nếu giá trị không được sử dụng và không có xung đột với thanh ghi đó (nói chung là có Là).
old_timer

phụ trợ cánh tay làm điều này quá trên gcc. rất có thể không phải là phụ trợ
old_timer

clang 10 cùng một câu chuyện (cánh tay phụ).
old_timer

Câu trả lời:


5

TL: DR:

  • Trình biên dịch nội bộ có thể không được thiết lập để tìm kiếm tối ưu hóa này một cách dễ dàng và có lẽ nó chỉ hữu ích xung quanh các chức năng nhỏ, không phải bên trong các chức năng lớn giữa các cuộc gọi.
  • Nội tuyến để tạo các hàm lớn là một giải pháp tốt hơn hầu hết thời gian
  • Có thể có độ trễ so với đánh đổi thông lượng nếu fooxảy ra không lưu / khôi phục RBX.

Trình biên dịch là phần phức tạp của máy móc. Chúng không "thông minh" như con người và các thuật toán đắt tiền để tìm mọi tối ưu hóa có thể thường không đáng giá trong thời gian biên dịch thêm.

Tôi đã báo cáo đây là lỗi GCC 69986 - mã nhỏ hơn có thể với -Os bằng cách sử dụng đẩy / pop để đổ / tải lại vào năm 2016 ; không có hoạt động hoặc trả lời từ các nhà phát triển GCC. : /

Liên quan một chút: Lỗi GCC 70408 - sử dụng lại cùng một thanh ghi được bảo toàn cuộc gọi sẽ cung cấp mã nhỏ hơn trong một số trường hợp - nhà phát triển trình biên dịch nói với tôi rằng GCC sẽ phải mất một lượng lớn công việc để có thể thực hiện tối ưu hóa đó vì nó yêu cầu chọn thứ tự đánh giá của hai foo(int)cuộc gọi dựa trên những gì sẽ làm cho mục tiêu trở nên đơn giản hơn.


Nếu foo không tự lưu / khôi phục rbx, sẽ có sự đánh đổi giữa thông lượng (số lệnh) so với lưu trữ bổ sung / độ trễ tải lại trên chuỗi x-> chuỗi phụ thuộc retval.

Trình biên dịch thường ưu tiên độ trễ hơn thông lượng, ví dụ sử dụng 2x LEA thay vì imul reg, reg, 10(độ trễ 3 chu kỳ, thông lượng 1 / xung nhịp), vì hầu hết mã trung bình thấp hơn đáng kể 4 uops / đồng hồ trên các đường ống 4 chiều thông thường như Skylake. (Tuy nhiên, nhiều hướng dẫn / uops chiếm nhiều không gian hơn trong ROB, làm giảm khoảng cách phía trước cùng một cửa sổ không theo thứ tự có thể nhìn thấy, và việc thực thi thực sự bùng nổ với các quầy hàng có thể chiếm một số ít hơn 4 uops / đồng hồ trung bình.)

Nếu fooRBX đẩy / bật, thì sẽ không có nhiều để đạt được độ trễ. Việc khôi phục xảy ra ngay trước khi retthay vì chỉ sau đó có lẽ không liên quan, trừ khi có lỗi retdự đoán sai hoặc I-cache làm chậm quá trình tìm nạp mã tại địa chỉ trả về.

Hầu hết các hàm không tầm thường sẽ lưu / khôi phục RBX, do đó, thường không phải là một giả định tốt khi để lại một biến trong RBX sẽ thực sự có nghĩa là nó thực sự nằm trong một thanh ghi trong suốt cuộc gọi. (Mặc dù việc chọn ngẫu nhiên các chức năng của các thanh ghi được bảo toàn cuộc gọi chọn có thể là một ý tưởng tốt để giảm thiểu điều này đôi khi.)


Vì vậy, có push rdi/ pop raxsẽ hiệu quả hơn trong trường hợp này và đây có lẽ là một tối ưu hóa bị bỏ lỡ cho các hàm không có lá nhỏ, tùy thuộc vào điều gì foovà sự cân bằng giữa độ trễ lưu trữ / tải lại thêm cho xso với nhiều hướng dẫn hơn để lưu / khôi phục trình gọi rbx.

Có thể cho siêu dữ liệu stack-bung đại diện cho các thay đổi đối với RSP ở đây, giống như nếu nó được sử dụng sub rsp, 8để đổ / tải lại xvào một vị trí ngăn xếp. (Tuy nhiên, trình biên dịch không biết tối ưu hóa này một trong hai, của việc sử dụng pushkhông gian dự trữ và khởi tạo một biến. Có gì C / C ++ biên dịch có thể sử dụng hướng dẫn đẩy pop để tạo các biến địa phương, thay vì chỉ tăng đặc biệt một lần? . Và làm điều đó hơn một var cục bộ sẽ dẫn đến .eh_framesiêu dữ liệu thư giãn ngăn xếp lớn hơn bởi vì bạn đang di chuyển con trỏ ngăn xếp riêng biệt với mỗi lần đẩy. Tuy nhiên, điều đó không ngăn các trình biên dịch sử dụng Push / pop để lưu / khôi phục regs được bảo toàn cuộc gọi.)


IDK nếu nó đáng để các trình biên dịch giảng dạy tìm kiếm tối ưu hóa này

Đây có thể là một ý tưởng tốt xung quanh toàn bộ một chức năng, không phải qua một cuộc gọi bên trong một chức năng. Và như tôi đã nói, nó dựa trên giả định bi quan foosẽ cứu / khôi phục RBX. (Hoặc tối ưu hóa thông lượng nếu bạn biết rằng độ trễ từ giá trị x để trả về giá trị không quan trọng. Nhưng trình biên dịch không biết điều đó và thường tối ưu hóa cho độ trễ).

Nếu bạn bắt đầu thực hiện giả định bi quan đó bằng nhiều mã (như xung quanh các hàm gọi đơn bên trong các hàm), bạn sẽ bắt đầu nhận được nhiều trường hợp hơn khi RBX không được lưu / khôi phục và bạn có thể đã tận dụng lợi thế.

Bạn cũng không muốn có thêm lưu / khôi phục đẩy / bật trong một vòng lặp, chỉ cần lưu / khôi phục RBX bên ngoài vòng lặp và sử dụng các thanh ghi được bảo toàn cuộc gọi trong các vòng lặp thực hiện các cuộc gọi hàm. Ngay cả khi không có vòng lặp, trong trường hợp chung, hầu hết các hàm thực hiện nhiều lệnh gọi hàm. Ý tưởng tối ưu hóa này có thể áp dụng nếu bạn thực sự không sử dụng xgiữa bất kỳ cuộc gọi nào, ngay trước cuộc gọi đầu tiên và sau cuộc gọi cuối cùng, nếu không , bạn gặp vấn đề trong việc duy trì căn chỉnh ngăn xếp 16 byte cho mỗi cuộc gọi callnếu bạn thực hiện một lần bật sau gọi, trước một cuộc gọi khác.

Trình biên dịch không tuyệt vời ở các chức năng nhỏ nói chung. Nhưng nó cũng không tuyệt vời cho CPU. Các lệnh gọi hàm không nội tuyến có tác động đến tối ưu hóa vào thời điểm tốt nhất, trừ khi các trình biên dịch có thể nhìn thấy phần bên trong của callee và đưa ra nhiều giả định hơn bình thường. Cuộc gọi hàm không nội tuyến là một rào cản bộ nhớ ngầm: người gọi phải giả định rằng một chức năng có thể đọc hoặc ghi bất kỳ dữ liệu nào có thể truy cập toàn cầu, vì vậy tất cả các bình như vậy phải được đồng bộ hóa với máy trừu tượng C. (Phân tích thoát cho phép giữ cho người dân địa phương trong các thanh ghi qua các cuộc gọi nếu địa chỉ của họ không thoát khỏi chức năng.) Ngoài ra, trình biên dịch phải giả định rằng các thanh ghi bị chặn bị chặn đều bị ghi đè. Điều này hút cho điểm nổi trong x86-64 System V, không có các thanh ghi XMM được bảo toàn cuộc gọi.

Các chức năng nhỏ như bar()tốt hơn là nội tuyến vào người gọi của họ. Biên dịch với -fltođể điều này có thể xảy ra ngay cả trên các ranh giới tệp trong hầu hết các trường hợp. (Con trỏ hàm và ranh giới thư viện dùng chung có thể đánh bại điều này.)


Tôi nghĩ một lý do khiến các trình biên dịch không bận tâm để thực hiện các tối ưu hóa này là nó sẽ yêu cầu một loạt các mã khác nhau trong các phần tử trình biên dịch , khác với ngăn xếp thông thường so với mã phân bổ đăng ký biết cách lưu giữ cuộc gọi đăng ký và sử dụng chúng.

tức là sẽ có rất nhiều việc phải thực hiện, và rất nhiều mã để duy trì, và nếu nó quá nhiệt tình về việc này thì nó có thể làm cho mã tệ hơn .

Và cũng là nó (hy vọng) không đáng kể; nếu vấn đề, bạn nên nội tuyến barvào gọi của nó, hoặc nội tuyến foovào bar. Điều này là tốt trừ khi có rất nhiều barchức năng giống như khác nhau và foolớn, vì một số lý do, họ không thể nội tuyến vào người gọi của họ.


không chắc chắn có ý nghĩa hỏi tại sao một số trình biên dịch dịch mã theo cách đó, khi có thể sử dụng tốt hơn .., nếu không có lỗi trong dịch thuật. ví dụ có thể hỏi tại sao tiếng kêu lạ (không được tối ưu hóa) lại vượt qua vòng lặp này , so sánh với gcc, icc và thậm chí msvc
RbMm

1
@RbMm: Tôi không hiểu quan điểm của bạn. Trông giống như một tối ưu hóa bị bỏ lỡ hoàn toàn riêng biệt cho tiếng kêu, không liên quan đến câu hỏi này là gì. Lỗi tối ưu hóa bị thiếu tồn tại, và trong hầu hết các trường hợp nên được sửa chữa. Hãy tiếp tục và báo cáo về bug.llvm.org
Peter Cordes

vâng, ví dụ mã của tôi hoàn toàn không liên quan đến câu hỏi ban đầu. chỉ đơn giản là một ví dụ khác về bản dịch lạ (cho cái nhìn của tôi) (và chỉ cho trình biên dịch clang duy nhất). Nhưng kết quả mã asm dù sao cũng đúng. chỉ không tốt nhất và eveen không bản địa so sánh gcc / icc / msvc
RbMm
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.