Mã C ++ để kiểm tra phỏng đoán Collatz nhanh hơn lắp ráp viết tay - tại sao?


833

Tôi đã viết hai giải pháp này cho Project Euler Q14 , trong phần lắp ráp và trong C ++. Chúng là cách tiếp cận lực lượng vũ phu giống hệt nhau để kiểm tra phỏng đoán Collatz . Giải pháp lắp ráp được lắp ráp với

nasm -felf64 p14.asm && gcc p14.o -o p14

C ++ được biên dịch với

g++ p14.cpp -o p14

Hội,, tổ hợp, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Tôi biết về tối ưu hóa trình biên dịch để cải thiện tốc độ và mọi thứ, nhưng tôi không thấy nhiều cách để tối ưu hóa giải pháp lắp ráp của mình hơn nữa (nói theo lập trình không phải là toán học).

Mã C ++ có mô-đun mỗi thuật ngữ và phân chia mọi thuật ngữ chẵn, trong đó lắp ráp chỉ là một phân chia cho mỗi thuật ngữ chẵn.

Nhưng quá trình lắp ráp mất trung bình 1 giây so với giải pháp C ++. Tại sao lại thế này? Tôi đang hỏi chủ yếu là tò mò.

Thời gian thực hiện

Hệ thống của tôi: Linux 64 bit trên Intel Celeron 2955U 1,4 GHz (vi kiến ​​trúc Haswell).


232
Bạn đã kiểm tra mã lắp ráp mà GCC tạo cho chương trình C ++ của bạn chưa?
ruakh

69
Biên dịch với -Sđể có được lắp ráp mà trình biên dịch tạo ra. Trình biên dịch đủ thông minh để nhận ra rằng mô đun thực hiện phép chia cùng một lúc.
dùng3386109

267
Tôi nghĩ các tùy chọn của bạn là 1. Kỹ thuật đo lường của bạn không hoàn hảo, 2. Trình biên dịch viết phần lắp ráp tốt hơn mà bạn hoặc 3. Trình biên dịch sử dụng phép thuật.
Galik


18
@jefferson Trình biên dịch có thể sử dụng lực lượng vũ phu nhanh hơn. Ví dụ có thể với hướng dẫn SSE.
dùng253751

Câu trả lời:


1896

Nếu bạn nghĩ rằng lệnh DIV 64 bit là một cách tốt để chia cho hai, thì không có gì lạ khi đầu ra asm của trình biên dịch đánh bại mã viết tay của bạn, ngay cả với -O0(biên dịch nhanh, không tối ưu hóa thêm và lưu trữ / tải lại vào bộ nhớ sau / trước mỗi câu lệnh C để trình gỡ lỗi có thể sửa đổi các biến).

Xem hướng dẫn lắp ráp tối ưu hóa của Agner Fog để tìm hiểu cách viết mã asm hiệu quả. Ông cũng có các bảng hướng dẫn và một hướng dẫn vi mô để biết chi tiết cụ thể cho các CPU cụ thể. Xem thêm thẻ wiki cho các liên kết hoàn hảo hơn.

Xem thêm câu hỏi chung chung này về việc đánh bại trình biên dịch bằng mã asm viết tay: Ngôn ngữ lắp ráp nội tuyến có chậm hơn mã C ++ bản địa không? . TL: DR: có nếu bạn làm sai (như câu hỏi này).

Thông thường, bạn sẽ ổn khi để trình biên dịch thực hiện công việc của mình, đặc biệt nếu bạn cố gắng viết C ++ có thể biên dịch hiệu quả . Cũng thấy là lắp ráp nhanh hơn ngôn ngữ biên dịch? . Một trong những câu trả lời liên kết đến các slide gọn gàng này cho thấy các trình biên dịch C khác nhau tối ưu hóa một số chức năng thực sự đơn giản với các thủ thuật hay. CppCon2017 của Matt Godbolt nói chuyện về Trình biên dịch của tôi đã làm gì cho tôi gần đây? Mở khóa nắp máy tính của Compiler nằm trong một mạch tương tự.


even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

Trên Intel Haswell, div r64là 36 uops, với độ trễ từ 32-96 chu kỳ và thông lượng là một trên 21-74 chu kỳ. (Cộng với 2 uops để thiết lập RBX và RDX bằng 0, nhưng thực thi không theo thứ tự có thể chạy những thứ đó sớm). Các hướng dẫn đếm uop cao như DIV được mã hóa, điều này cũng có thể gây ra tắc nghẽn phía trước. Trong trường hợp này, độ trễ là yếu tố phù hợp nhất vì đó là một phần của chuỗi phụ thuộc mang theo vòng lặp.

shr rax, 1thực hiện cùng một phép chia không dấu: Đó là 1 uop, với độ trễ 1c và có thể chạy 2 trên mỗi chu kỳ đồng hồ.

Để so sánh, phân chia 32 bit nhanh hơn, nhưng vẫn khủng khiếp so với thay đổi. idiv r32là 9 uops, độ trễ 22-29c và một thông lượng trên 8-11c trên Haswell.


Như bạn có thể thấy khi nhìn vào -O0đầu ra asm của gcc ( trình thám hiểm trình biên dịch Godbolt ), nó chỉ sử dụng các lệnh dịch chuyển . clang -O0không biên dịch một cách ngây thơ như bạn nghĩ, thậm chí sử dụng IDIV 64 bit hai lần. (Khi tối ưu hóa, trình biên dịch sẽ sử dụng cả hai đầu ra của IDIV khi nguồn thực hiện phép chia và mô đun với cùng một toán hạng, nếu chúng hoàn toàn sử dụng IDIV)

GCC không có chế độ hoàn toàn ngây thơ; nó luôn biến đổi thông qua GIMPLE, có nghĩa là một số "tối ưu hóa" không thể bị vô hiệu hóa . Điều này bao gồm nhận biết phân chia theo hằng số và sử dụng các ca (công suất 2) hoặc nghịch đảo nhân số điểm cố định (không công suất 2) để tránh IDIV (xem div_by_13trong liên kết godbolt ở trên).

gcc -Os(tối ưu hóa cho kích thước) không sử dụng IDIV cho phân chia không có công suất 2, không may ngay cả trong trường hợp mã nghịch đảo nhân chỉ lớn hơn một chút nhưng nhanh hơn nhiều.


Giúp trình biên dịch

(tóm tắt cho trường hợp này: sử dụng uint64_t n)

Trước hết, thật thú vị khi xem kết quả đầu ra của trình biên dịch được tối ưu hóa. ( -O3). -O0tốc độ về cơ bản là vô nghĩa.

Nhìn vào đầu ra asm của bạn (trên Godbolt hoặc xem Cách loại bỏ "nhiễu" khỏi đầu ra lắp ráp GCC / clang? ). Khi trình biên dịch không tạo mã tối ưu ở vị trí đầu tiên: Viết nguồn C / C ++ của bạn theo cách hướng dẫn trình biên dịch tạo mã tốt hơn thường là cách tiếp cận tốt nhất . Bạn phải biết asm, và biết những gì hiệu quả, nhưng bạn áp dụng kiến ​​thức này một cách gián tiếp. Trình biên dịch cũng là một nguồn ý tưởng tốt: đôi khi clang sẽ làm điều gì đó hay ho và bạn có thể nắm tay gcc để làm điều tương tự: xem câu trả lời này và những gì tôi đã làm với vòng lặp không được kiểm soát trong mã của @ Veedrac bên dưới.)

Cách tiếp cận này có thể mang theo được và trong 20 năm, một số trình biên dịch trong tương lai có thể biên dịch nó thành bất cứ thứ gì hiệu quả trên phần cứng trong tương lai (x86 hoặc không), có thể sử dụng phần mở rộng ISA mới hoặc tự động vector hóa. Viết tay x86-64 asm từ 15 năm trước thường sẽ không được điều chỉnh tối ưu cho Skylake. ví dụ, so sánh và hợp nhất vĩ mô nhánh không tồn tại trước đó. Bây giờ những gì tối ưu cho asm thủ công cho một vi kiến ​​trúc có thể không tối ưu cho các CPU hiện tại và tương lai khác. Nhận xét về câu trả lời của @ johnfound thảo luận về sự khác biệt lớn giữa AMD Bulldozer và Intel Haswell, có ảnh hưởng lớn đến mã này. Nhưng trên lý thuyết, g++ -O3 -march=bdver3g++ -O3 -march=skylakesẽ làm điều đúng đắn. (Hoặc -march=native.) Hoặc -mtune=...chỉ điều chỉnh mà không sử dụng các hướng dẫn mà các CPU khác có thể không hỗ trợ.

Cảm giác của tôi là hướng dẫn trình biên dịch asm đó là tốt cho CPU hiện tại mà bạn quan tâm không nên là vấn đề đối với các trình biên dịch trong tương lai. Họ hy vọng sẽ tốt hơn các trình biên dịch hiện tại trong việc tìm cách chuyển đổi mã và có thể tìm ra cách hoạt động cho các CPU trong tương lai. Bất kể, x86 trong tương lai có thể sẽ không tệ với bất cứ điều gì tốt trên x86 hiện tại và trình biên dịch trong tương lai sẽ tránh mọi cạm bẫy cụ thể của asm trong khi thực hiện một cái gì đó như chuyển động dữ liệu từ nguồn C của bạn, nếu nó không thấy điều gì tốt hơn.

Asm viết tay là một hộp đen cho trình tối ưu hóa, vì vậy việc truyền liên tục không hoạt động khi nội tuyến làm cho đầu vào trở thành hằng số thời gian biên dịch. Tối ưu hóa khác cũng bị ảnh hưởng. Đọc https://gcc.gnu.org/wiki/DontUseInlineAsm trước khi sử dụng asm. (Và tránh mã asm nội tuyến theo kiểu MSVC: đầu vào / đầu ra phải đi qua bộ nhớ có thêm chi phí .)

Trong trường hợp này : bạn ncó loại đã ký và gcc sử dụng chuỗi SAR / SHR / ADD để làm tròn chính xác. (IDIV và thay đổi số học "làm tròn" khác nhau cho các đầu vào âm, xem mục nhập thủ công của bộ nội dung SAR ). (IDK nếu gcc đã cố gắng và không chứng minh được rằng nkhông thể âm tính hoặc là gì. Lỗi tràn đã ký là hành vi không xác định, vì vậy nó đã có thể.)

Bạn nên sử dụng uint64_t n, vì vậy nó chỉ có thể SHR. Và do đó, nó có thể di động tới các hệ thống longchỉ có 32 bit (ví dụ: Windows x86-64).


BTW, đầu ra asm được tối ưu hóa của gcc trông khá tốt (sử dụng )unsigned long n : vòng lặp bên trong mà nó main()thực hiện để thực hiện điều này:

 # from gcc5.4 -O3  plus my comments

 # edx= count=1
 # rax= uint64_t n

.L9:                   # do{
    lea    rcx, [rax+1+rax*2]   # rcx = 3*n + 1
    mov    rdi, rax
    shr    rdi         # rdi = n>>1;
    test   al, 1       # set flags based on n%2 (aka n&1)
    mov    rax, rcx
    cmove  rax, rdi    # n= (n%2) ? 3*n+1 : n/2;
    add    edx, 1      # ++count;
    cmp    rax, 1
    jne   .L9          #}while(n!=1)

  cmp/branch to update max and maxi, and then do the next n

Vòng lặp bên trong là không phân nhánh và đường dẫn quan trọng của chuỗi phụ thuộc mang theo vòng lặp là:

  • LEA 3 thành phần (3 chu kỳ)
  • cmov (2 chu kỳ trên Haswell, 1c trên Broadwell trở lên).

Tổng cộng: 5 chu kỳ mỗi lần lặp, tắc nghẽn độ trễ . Việc thực hiện không theo thứ tự sẽ xử lý mọi thứ khác song song với điều này (về lý thuyết: Tôi chưa thử nghiệm với các bộ đếm hoàn hảo để xem liệu nó có thực sự chạy ở tốc độ 5c / iter không).

Đầu vào FLAGS của cmov(do TEST sản xuất) sản xuất nhanh hơn đầu vào RAX (từ LEA-> MOV), do đó, nó không nằm trên đường dẫn quan trọng.

Tương tự, MOV-> SHR tạo đầu vào RDI của CMOV nằm ngoài đường dẫn quan trọng, vì nó cũng nhanh hơn LEA. MOV trên IvyBridge và sau đó có độ trễ bằng không (được xử lý tại thời điểm đăng ký đổi tên). (Nó vẫn mất một uop và một khe trong đường ống, vì vậy nó không miễn phí, chỉ là độ trễ bằng không). MOV bổ sung trong chuỗi dep LEA là một phần của nút cổ chai trên các CPU khác.

Cp / jne cũng không phải là một phần của đường dẫn quan trọng: nó không mang theo vòng lặp, vì các phụ thuộc điều khiển được xử lý với dự đoán nhánh + thực thi đầu cơ, không giống như phụ thuộc dữ liệu trên đường dẫn quan trọng.


Đánh bại trình biên dịch

GCC đã làm một công việc khá tốt ở đây. Nó có thể lưu một byte mã bằng cách sử dụng inc edxthay vìadd edx, 1 , vì không ai quan tâm đến P4 và các phụ thuộc sai của nó cho các hướng dẫn sửa đổi cờ một phần.

Nó cũng có thể lưu tất cả các lệnh MOV và TEST: SHR đặt CF = bit được dịch chuyển ra, vì vậy chúng ta có thể sử dụng cmovcthay vì test/ cmovz.

 ### Hand-optimized version of what gcc does
.L9:                       #do{
    lea     rcx, [rax+1+rax*2] # rcx = 3*n + 1
    shr     rax, 1         # n>>=1;    CF = n&1 = n%2
    cmovc   rax, rcx       # n= (n&1) ? 3*n+1 : n/2;
    inc     edx            # ++count;
    cmp     rax, 1
    jne     .L9            #}while(n!=1)

Xem câu trả lời của @ johnfound để biết một mẹo thông minh khác: xóa CMP bằng cách phân nhánh trên kết quả cờ của SHR cũng như sử dụng nó cho CMOV: không chỉ khi n là 1 (hoặc 0) để bắt đầu. (Sự thật thú vị: SHR với số đếm! = 1 trên Nehalem hoặc trước đó gây ra sự cố nếu bạn đọc kết quả cờ . Đó là cách họ đã tạo ra một lần duy nhất. Mặc dù vậy, mã hóa đặc biệt là 1 lần.

Tránh MOV hoàn toàn không giúp ích gì cho độ trễ trên Haswell ( MOV của x86 có thực sự "miễn phí" không? Tại sao tôi không thể tái tạo điều này? ). Nó giúp ích đáng kể cho các CPU như Intel pre-IvB và AMD Bulldozer-Family, trong đó MOV không có độ trễ bằng không. Các lệnh MOV bị lãng phí của trình biên dịch có ảnh hưởng đến đường dẫn quan trọng. Các phức-LEA và CMOV của BD đều có độ trễ thấp hơn (lần lượt là 2c và 1c), do đó, đây là một phần lớn hơn của độ trễ. Ngoài ra, tắc nghẽn thông lượng trở thành một vấn đề, bởi vì nó chỉ có hai ống ALU nguyên. Xem câu trả lời của @ johnfound , nơi anh ta có kết quả thời gian từ CPU AMD.

Ngay cả trên Haswell, phiên bản này có thể giúp ích một chút bằng cách tránh một số sự chậm trễ thỉnh thoảng trong đó một uop không quan trọng đánh cắp một cổng thực thi từ một trên đường dẫn quan trọng, trì hoãn thực hiện trong 1 chu kỳ. (Điều này được gọi là xung đột tài nguyên). Nó cũng lưu một thanh ghi, có thể giúp ích khi thực hiện nsong song nhiều giá trị trong một vòng lặp xen kẽ (xem bên dưới).

Độ trễ của LEA phụ thuộc vào chế độ địa chỉ , trên CPU gia đình Intel SnB. 3c cho 3 thành phần ( [base+idx+const], có hai phần bổ sung riêng biệt), nhưng chỉ có 1c với 2 hoặc ít thành phần hơn (một phần bổ sung). Một số CPU (như Core2) thậm chí còn thực hiện LEA 3 thành phần trong một chu kỳ, nhưng gia đình SnB thì không. Tồi tệ hơn, gia đình Intel SnB chuẩn hóa độ trễ để không có 2c uops , nếu không LEA 3 thành phần sẽ chỉ có 2c như Bulldozer. (LEA 3 thành phần cũng chậm hơn trên AMD, chỉ là không nhiều như vậy).

Vì vậy lea rcx, [rax + rax*2]/ inc rcxlà chỉ độ trễ 2c, nhanh hơn lea rcx, [rax + rax*2 + 1], trên Intel CPU SNB-gia đình như Haswell. Hòa vốn trên BD và tệ hơn trên Core2. Nó tốn thêm một khoản tiền, thường không đáng để tiết kiệm độ trễ 1c, nhưng độ trễ là nút cổ chai lớn ở đây và Haswell có một đường ống đủ rộng để xử lý thông lượng uop thêm.

Cả gcc, icc, hay clang (trên godbolt) đều không sử dụng đầu ra CF của SHR, luôn luôn sử dụng AND hoặc TEST . Trình biên dịch ngớ ngẩn. : P Chúng là những mảnh lớn của máy móc phức tạp, nhưng một con người thông minh thường có thể đánh bại chúng trong các vấn đề quy mô nhỏ. (Tất nhiên, để suy nghĩ về nó hàng ngàn đến hàng triệu lần! Trình biên dịch không sử dụng thuật toán toàn diện để tìm kiếm mọi cách có thể để làm mọi việc, bởi vì điều đó sẽ mất quá nhiều thời gian khi tối ưu hóa nhiều mã được in nghiêng, đó là những gì Họ làm tốt nhất. Họ cũng không mô hình hóa đường ống trong kiến ​​trúc vi mô mục tiêu, ít nhất là không cùng chi tiết với IACA hoặc các công cụ phân tích tĩnh khác; họ chỉ sử dụng một số phương pháp phỏng đoán.)


Unrolling unrolling sẽ không giúp đỡ ; vòng lặp này tắc nghẽn về độ trễ của chuỗi phụ thuộc vòng lặp, không phải trên chi phí / thông lượng vòng lặp. Điều này có nghĩa là nó sẽ hoạt động tốt với siêu phân luồng (hoặc bất kỳ loại SMT nào khác), vì CPU có nhiều thời gian để xen kẽ các hướng dẫn từ hai luồng. Điều này có nghĩa là song song hóa vòng lặp main, nhưng điều đó tốt vì mỗi luồng chỉ có thể kiểm tra một phạm vi ngiá trị và tạo ra một cặp số nguyên.

Việc xen kẽ bằng tay trong một chủ đề cũng có thể khả thi . Có thể tính toán chuỗi cho một cặp số song song, vì mỗi số chỉ mất một vài thanh ghi và tất cả chúng có thể cập nhật cùng max/ maxi. Điều này tạo ra sự song song ở cấp độ chỉ dẫn .

Bí quyết là quyết định xem có nên đợi cho đến khi tất cả các ngiá trị đạt được hay không 1trước khi nhận được một cặp ngiá trị bắt đầu khác , hoặc có thoát ra và nhận điểm bắt đầu mới cho chỉ một điều kiện đạt đến điều kiện kết thúc hay không, mà không chạm vào các thanh ghi cho chuỗi khác. Có lẽ tốt nhất là giữ cho mỗi chuỗi hoạt động trên dữ liệu hữu ích, nếu không, bạn phải tăng điều kiện truy cập một cách có điều kiện.


Bạn thậm chí có thể làm điều này với các công cụ so sánh được đóng gói SSE để tăng bộ đếm một cách có điều kiện cho các phần tử vectơ nchưa đạt tới 1. Và sau đó để che giấu độ trễ thậm chí lâu hơn của việc triển khai gia tăng có điều kiện của SIMD, bạn cần giữ nhiều vectơ ngiá trị hơn trong không khí. Có lẽ chỉ có giá trị với vector 256b (4x uint64_t).

Tôi nghĩ rằng chiến lược tốt nhất để thực hiện phát hiện 1"dính" là che dấu vectơ của tất cả những thứ bạn thêm vào để tăng bộ đếm. Vì vậy, sau khi bạn nhìn thấy một 1phần tử, vectơ gia tăng sẽ có số 0 và + = 0 là số không.

Ý tưởng chưa được kiểm tra cho vector hóa thủ công

# starting with YMM0 = [ n_d, n_c, n_b, n_a ]  (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1):  increment vector
# ymm5 = all-zeros:  count vector

.inner_loop:
    vpaddq    ymm1, ymm0, xmm0
    vpaddq    ymm1, ymm1, xmm0
    vpaddq    ymm1, ymm1, set1_epi64(1)     # ymm1= 3*n + 1.  Maybe could do this more efficiently?

    vprllq    ymm3, ymm0, 63                # shift bit 1 to the sign bit

    vpsrlq    ymm0, ymm0, 1                 # n /= 2

    # FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
    vpblendvpd ymm0, ymm0, ymm1, ymm3       # variable blend controlled by the sign bit of each 64-bit element.  I might have the source operands backwards, I always have to look this up.

    # ymm0 = updated n  in each element.

    vpcmpeqq ymm1, ymm0, set1_epi64(1)
    vpandn   ymm4, ymm1, ymm4         # zero out elements of ymm4 where the compare was true

    vpaddq   ymm5, ymm5, ymm4         # count++ in elements where n has never been == 1

    vptest   ymm4, ymm4
    jnz  .inner_loop
    # Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero

    vextracti128 ymm0, ymm5, 1
    vpmaxq .... crap this doesn't exist
    # Actually just delay doing a horizontal max until the very very end.  But you need some way to record max and maxi.

Bạn có thể và nên thực hiện điều này với nội tại thay vì viết bằng tay.


Cải thiện thuật toán / triển khai:

Bên cạnh việc chỉ thực hiện cùng một logic với asm hiệu quả hơn, hãy tìm cách để đơn giản hóa logic hoặc tránh công việc dư thừa. ví dụ ghi nhớ để phát hiện các kết thúc phổ biến cho chuỗi. Hoặc thậm chí tốt hơn, nhìn vào 8 bit trailing cùng một lúc (câu trả lời của gnasher)

@EOF chỉ ra rằng tzcnt(hoặc bsf) có thể được sử dụng để thực hiện nhiều n/=2lần lặp trong một bước. Điều đó có lẽ tốt hơn so với vector hóa SIMD; không có lệnh SSE hoặc AVX nào có thể làm được điều đó. nMặc dù vậy, nó vẫn tương thích với việc thực hiện nhiều vô hướng trong các thanh ghi số nguyên khác nhau.

Vì vậy, vòng lặp có thể trông như thế này:

goto loop_entry;  // C++ structured like the asm, for illustration only
do {
   n = n*3 + 1;
  loop_entry:
   shift = _tzcnt_u64(n);
   n >>= shift;
   count += shift;
} while(n != 1);

Điều này có thể thực hiện các bước lặp ít hơn đáng kể, nhưng các thay đổi số lượng biến đổi chậm trên các CPU gia đình SnB của Intel mà không có BMI2. 3 uops, độ trễ 2c. . Đây là loại mà mọi người phàn nàn về thiết kế CISC điên rồ của x86 đang đề cập đến. Nó làm cho CPU x86 chậm hơn so với trước đây nếu ISA được thiết kế từ đầu ngày nay, thậm chí theo cách gần như tương tự. (tức là đây là một phần của "thuế x86" có chi phí tốc độ / sức mạnh.) SHRX / SHLX / SARX (BMI2) là một chiến thắng lớn (độ trễ 1 uop / 1c).

Nó cũng đặt tzcnt (3c trên Haswell và sau này) trên đường dẫn quan trọng, do đó, nó kéo dài đáng kể tổng độ trễ của chuỗi phụ thuộc mang theo vòng lặp. n>>1Mặc dù vậy, nó không loại bỏ bất kỳ nhu cầu nào đối với CMOV hoặc để chuẩn bị đăng ký . Câu trả lời của @ Veedrac khắc phục tất cả điều này bằng cách trì hoãn tzcnt / shift cho nhiều lần lặp, có hiệu quả cao (xem bên dưới).

Chúng ta có thể sử dụng BSF hoặc TZCNT một cách an toàn thay thế cho nhau, bởi vì nkhông bao giờ có thể bằng 0 tại thời điểm đó. Mã máy của TZCNT giải mã thành BSF trên các CPU không hỗ trợ BMI1. (Tiền tố vô nghĩa bị bỏ qua, vì vậy REP BSF chạy dưới dạng BSF).

TZCNT hoạt động tốt hơn nhiều so với BSF trên các CPU AMD hỗ trợ nó, vì vậy có thể nên sử dụng REP BSF, ngay cả khi bạn không quan tâm đến việc đặt ZF nếu đầu vào bằng 0 thay vì đầu ra. Một số trình biên dịch làm điều này khi bạn sử dụng __builtin_ctzllngay cả với -mno-bmi.

Chúng hoạt động tương tự trên CPU Intel, vì vậy chỉ cần lưu byte nếu đó là tất cả những gì quan trọng. TZCNT trên Intel (trước Skylake) vẫn phụ thuộc sai vào toán hạng đầu ra được cho là chỉ ghi, giống như BSF, để hỗ trợ hành vi không có giấy tờ mà BSF với input = 0 khiến đích đến của nó không được sửa đổi. Vì vậy, bạn cần phải giải quyết vấn đề đó trừ khi chỉ tối ưu hóa cho Skylake, vì vậy không có gì để kiếm được từ byte REP bổ sung. (Intel thường vượt lên trên và vượt xa những gì hướng dẫn sử dụng ISA x86 yêu cầu, để tránh phá vỡ mã được sử dụng rộng rãi phụ thuộc vào thứ gì đó không nên hoặc không được phép hồi tố. Ví dụ: Windows 9x giả định không tìm nạp trước các mục nhập TLB , an toàn khi mã được viết, trước khi Intel cập nhật các quy tắc quản lý TLB .)

Dù sao, LZCNT / TZCNT trên Haswell có cùng một dep sai như POPCNT: xem phần Hỏi & Đáp này . Đây là lý do tại sao trong đầu ra asm của gcc cho mã của @ Veedrac, bạn thấy nó phá vỡ chuỗi dep với xor-zeroing trên thanh ghi, nó sắp sử dụng làm đích của TZCNT khi nó không sử dụng dst = src. Do TZCNT / LZCNT / POPCNT không bao giờ để điểm đến của chúng không được xác định hoặc không được sửa đổi, nên sự phụ thuộc sai này vào đầu ra trên CPU Intel là một lỗi / giới hạn hiệu năng. Có lẽ nó đáng giá một số bóng bán dẫn / sức mạnh để chúng hoạt động giống như các uops khác đi đến cùng một đơn vị thực thi. Ưu điểm hoàn hảo duy nhất là sự tương tác với một giới hạn uarch khác: chúng có thể kết hợp một toán hạng bộ nhớ với chế độ địa chỉ được lập chỉ mục trên Haswell, nhưng trên Skylake, nơi Intel đã loại bỏ dep sai cho LZCNT / TZCNT, họ "un-laminate" lập chỉ mục các chế độ địa chỉ trong khi POPCNT vẫn có thể kết hợp bất kỳ chế độ addr nào.


Cải tiến cho ý tưởng / mã từ các câu trả lời khác:

Câu trả lời của @ hidefromkgb có một nhận xét thú vị rằng bạn được đảm bảo có thể thực hiện một ca đúng sau 3n + 1. Bạn có thể tính toán điều này thậm chí còn hiệu quả hơn là chỉ bỏ qua các kiểm tra giữa các bước. Tuy nhiên, việc triển khai asm trong câu trả lời đó đã bị hỏng (điều này phụ thuộc vào OF, không được xác định sau SHRD với số lượng> 1) và chậm: ROR rdi,2nhanh hơn SHRD rdi,rdi,2và sử dụng hai lệnh CMOV trên đường dẫn quan trọng chậm hơn so với TEST thêm có thể chạy song song.

Tôi đã đặt Tidied / cải tiến C (hướng dẫn trình biên dịch để tạo ra asm tốt hơn) và đã kiểm tra + làm việc nhanh hơn (trong các bình luận bên dưới C) trên Godbolt: xem liên kết trong câu trả lời của @ hidefromkgb . (Câu trả lời này đạt giới hạn 30k char từ các URL Godbolt lớn, nhưng các liên kết ngắn có thể bị thối và quá dài cho goo.gl.)

Đồng thời cải thiện việc in đầu ra để chuyển đổi thành một chuỗi và tạo một write()thay vì viết một char mỗi lần. Điều này giảm thiểu tác động đến việc định thời gian cho toàn bộ chương trình với perf stat ./collatz(để ghi lại các bộ đếm hiệu suất) và tôi đã làm xáo trộn một số asm không quan trọng.


@ Mã của Veedrac

Tôi đã có một sự tăng tốc nhỏ từ việc chuyển sang phải nhiều như chúng ta biết cần phải làm và kiểm tra để tiếp tục vòng lặp. Từ 7,5 giây cho giới hạn = 1e8 xuống còn 7.275 giây, trên Core2Duo (Merom), với hệ số không kiểm soát là 16.

mã + nhận xét về Godbolt . Đừng sử dụng phiên bản này với tiếng kêu; nó làm một cái gì đó ngớ ngẩn với vòng lặp defer. Sử dụng bộ đếm tmp kvà sau đó thêm nó vào countsau này sẽ thay đổi những gì clang làm, nhưng điều đó hơi làm tổn thương gcc.

Xem thảo luận trong các nhận xét: Mã của Veedrac là tuyệt vời trên CPU có BMI1 (tức là không phải Celeron / Pentium)


4
Tôi đã thử cách tiếp cận véc tơ cách đây một thời gian, nó không có ích gì (vì bạn có thể làm tốt hơn nhiều trong mã vô hướng tzcntvà bạn bị khóa với chuỗi chạy dài nhất trong số các phần tử vectơ của bạn trong trường hợp véc tơ).
EOF

3
@EOF: không, ý tôi là thoát ra khỏi vòng lặp bên trong khi bất kỳ một trong các phần tử vectơ nào chạm vào 1, thay vì khi tất cả chúng có (dễ dàng phát hiện với PCMPEQ / PMOVMSK). Sau đó, bạn sử dụng PINSRQ và các công cụ để tìm hiểu một phần tử đã kết thúc (và bộ đếm của nó) và nhảy trở lại vào vòng lặp. Điều đó có thể dễ dàng biến thành mất mát, khi bạn thoát ra khỏi vòng lặp bên trong quá thường xuyên, nhưng điều đó có nghĩa là bạn luôn nhận được 2 hoặc 4 yếu tố công việc hữu ích được thực hiện mỗi lần lặp của vòng lặp bên trong. Điểm tốt về ghi nhớ, mặc dù.
Peter Cordes

4
@jefferson Tốt nhất tôi quản lý là godbolt.org/g/1N70Ib . Tôi đã hy vọng tôi có thể làm một cái gì đó thông minh hơn, nhưng có vẻ như không.
Veedrac

87
Điều làm tôi ngạc nhiên về những câu trả lời đáng kinh ngạc như đây là kiến ​​thức được thể hiện chi tiết như vậy. Tôi sẽ không bao giờ biết một ngôn ngữ hoặc hệ thống đến mức đó và tôi sẽ không biết làm thế nào. Làm tốt lắm thưa ngài.
camden_kid

8
Câu trả lời huyền thoại !!
Sumit Jain

104

Cho rằng trình biên dịch C ++ có thể tạo ra mã tối ưu hơn một lập trình viên ngôn ngữ lắp ráp có thẩm quyền là một sai lầm rất tệ. Và đặc biệt trong trường hợp này. Con người luôn có thể làm cho mã tốt hơn mà trình biên dịch có thể, và tình huống cụ thể này là minh họa tốt cho tuyên bố này.

Sự khác biệt về thời gian mà bạn đang thấy là do mã lắp ráp trong câu hỏi rất xa so với tối ưu trong các vòng lặp bên trong.

(Mã dưới đây là 32 bit, nhưng có thể dễ dàng chuyển đổi thành 64 bit)

Ví dụ, chức năng chuỗi có thể được tối ưu hóa chỉ 5 hướng dẫn:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Toàn bộ mã trông như:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Để biên dịch mã này, FreshLib là cần thiết.

Trong các thử nghiệm của tôi, (bộ xử lý AMD A4-1200 1 GHz), đoạn mã trên nhanh hơn khoảng bốn lần so với mã C ++ từ câu hỏi (khi được biên dịch với -O0: 430 ms so với 1900 ms) và nhanh hơn hai lần (430 ms so với 830 ms) khi mã C ++ được biên dịch với -O3.

Đầu ra của cả hai chương trình là như nhau: chuỗi tối đa = 525 trên i = 837799.


6
Huh, thật thông minh. SHR chỉ đặt ZF nếu EAX là 1 (hoặc 0). Tôi đã bỏ lỡ điều đó khi tối ưu hóa -O3đầu ra của gcc , nhưng tôi đã phát hiện ra tất cả các tối ưu hóa khác mà bạn đã thực hiện cho vòng lặp bên trong. (Nhưng tại sao bạn lại sử dụng LEA cho số lần truy cập thay vì INC? Bạn có thể sử dụng cờ clobber vào thời điểm đó và dẫn đến chậm lại bất cứ điều gì ngoại trừ P4 (phụ thuộc sai vào cờ cũ cho cả INC và SHR). LEA có thể ' T chạy trên nhiều cổng và có thể dẫn đến xung đột tài nguyên làm trì hoãn đường dẫn quan trọng thường xuyên hơn.)
Peter Cordes

4
Ồ, thực sự Bulldozer có thể tắc nghẽn thông lượng với đầu ra của trình biên dịch. Nó có độ trễ CMOV và LEA 3 thành phần thấp hơn Haswell (mà tôi đang xem xét), do đó chuỗi dep mang theo vòng lặp chỉ có 3 chu kỳ trong mã của bạn. Nó cũng không có các lệnh MOV độ trễ bằng không cho các thanh ghi số nguyên, vì vậy các lệnh MOV bị lãng phí của g ++ thực sự làm tăng độ trễ của đường dẫn quan trọng và là một vấn đề lớn đối với Bulldozer. Vì vậy, yeah, tối ưu hóa bằng tay thực sự đánh bại trình biên dịch theo cách đáng kể cho các CPU không đủ hiện đại để nhai các hướng dẫn vô dụng.
Peter Cordes

95
" Yêu cầu trình biên dịch C ++ tốt hơn là một sai lầm rất tệ. Và đặc biệt trong trường hợp này. Con người luôn có thể làm cho mã tốt hơn và vấn đề cụ thể này là minh họa tốt cho khiếu nại này. " Bạn có thể đảo ngược nó và nó sẽ hợp lệ . " Yêu cầu một con người tốt hơn là một sai lầm rất tệ. Và đặc biệt trong trường hợp này. Con người luôn có thể làm cho mã trở nên tồi tệ hơncâu hỏi đặc biệt này là minh họa tốt cho tuyên bố này. " Vì vậy, tôi không nghĩ rằng bạn có một điểm ở đây , khái quát như vậy là sai.
luk32

5
@ luk32 - Nhưng tác giả của câu hỏi hoàn toàn không thể tranh luận, bởi vì kiến ​​thức về ngôn ngữ lắp ráp của anh ta gần bằng không. Mọi tranh luận về con người và trình biên dịch, mặc nhiên giả định con người có ít nhất một số kiến ​​thức asm ở mức trung bình. Thêm: Định lý "Mã viết của con người sẽ luôn tốt hơn hoặc giống như mã được tạo bởi trình biên dịch" rất dễ được chứng minh chính thức.
johnfound

30
@ luk32: Một người có kỹ năng có thể (và thường nên) bắt đầu với đầu ra của trình biên dịch. Vì vậy, miễn là bạn đánh giá các nỗ lực của mình để đảm bảo chúng thực sự nhanh hơn (trên phần cứng mục tiêu bạn đang điều chỉnh), bạn không thể làm tồi hơn trình biên dịch. Nhưng vâng, tôi phải đồng ý rằng đó là một tuyên bố mạnh mẽ. Trình biên dịch thường làm tốt hơn nhiều so với các lập trình viên asm mới làm quen. Nhưng thông thường có thể lưu một hoặc hai lệnh so với trình biên dịch đưa ra. (Tuy nhiên, không phải lúc nào cũng trên con đường quan trọng, tùy thuộc vào uarch). Chúng là những phần rất hữu ích của máy móc phức tạp, nhưng chúng không "thông minh".
Peter Cordes

24

Để có hiệu suất cao hơn: Một thay đổi đơn giản là quan sát thấy rằng sau n = 3n + 1, n sẽ là số chẵn, do đó bạn có thể chia cho 2 ngay lập tức. Và sẽ không là 1, vì vậy bạn không cần phải kiểm tra nó. Vì vậy, bạn có thể lưu một vài câu lệnh if và viết:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Đây là một chiến thắng lớn : Nếu bạn nhìn vào 8 bit thấp nhất của n, tất cả các bước cho đến khi bạn chia cho 2 tám lần hoàn toàn được xác định bởi tám bit đó. Ví dụ: nếu tám bit cuối cùng là 0x01, thì đó là số nhị phân, số của bạn là ???? 0000 0001 thì các bước tiếp theo là:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Vì vậy, tất cả các bước này có thể được dự đoán và 256k + 1 được thay thế bằng 81k + 1. Một cái gì đó tương tự sẽ xảy ra cho tất cả các kết hợp. Vì vậy, bạn có thể tạo một vòng lặp với một tuyên bố chuyển đổi lớn:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Chạy vòng lặp cho đến n ≤ 128, vì tại thời điểm đó n có thể trở thành 1 với ít hơn tám phép chia cho 2 và thực hiện tám bước trở lên tại một thời điểm sẽ khiến bạn bỏ lỡ điểm bạn đạt 1 lần đầu tiên. Sau đó tiếp tục vòng lặp "bình thường" - hoặc chuẩn bị một bảng cho bạn biết cần bao nhiêu bước nữa để đạt được 1.

Tái bút Tôi hoàn toàn nghi ngờ đề nghị của Peter Cordes sẽ khiến nó nhanh hơn nữa. Sẽ không có nhánh có điều kiện nào ngoại trừ một nhánh và nhánh đó sẽ được dự đoán chính xác trừ khi vòng lặp thực sự kết thúc. Vì vậy, mã sẽ là một cái gì đó như

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

Trong thực tế, bạn sẽ đo xem việc xử lý 9, 10, 11, 12 bit cuối cùng của n tại một thời điểm có nhanh hơn không. Đối với mỗi bit, số lượng mục trong bảng sẽ tăng gấp đôi và tôi sẽ giảm tốc độ khi các bảng không vừa với bộ đệm L1 nữa.

PPS. Nếu bạn cần số lượng thao tác: Trong mỗi lần lặp, chúng tôi thực hiện chính xác tám phép chia cho hai và một số lượng hoạt động (3n + 1) khác nhau, vì vậy một phương pháp rõ ràng để đếm các hoạt động sẽ là một mảng khác. Nhưng chúng ta thực sự có thể tính toán số bước (dựa trên số lần lặp của vòng lặp).

Chúng ta có thể xác định lại vấn đề một chút: Thay n bằng (3n + 1) / 2 nếu lẻ và thay n bằng n / 2 nếu chẵn. Sau đó, mỗi lần lặp sẽ thực hiện chính xác 8 bước, nhưng bạn có thể xem xét việc gian lận đó :-) Vì vậy, giả sử có r hoạt động n <- 3n + 1 và s hoạt động n <- n / 2. Kết quả sẽ hoàn toàn chính xác n '= n * 3 ^ r / 2 ^ s, bởi vì n <- 3n + 1 có nghĩa là n <- 3n * (1 + 1 / 3n). Lấy logarit, chúng ta tìm thấy r = (s + log2 (n '/ n)) / log2 (3).

Nếu chúng ta thực hiện vòng lặp cho đến n ≤ 1.000.000 và có một bảng được tính toán trước thì cần bao nhiêu lần lặp từ bất kỳ điểm bắt đầu n ≤ 1.000.000 nào sau đó tính r như trên, làm tròn đến số nguyên gần nhất, sẽ cho kết quả đúng trừ khi s thực sự lớn.


2
Hoặc tạo bảng tra cứu dữ liệu cho bội số và thêm hằng số, thay vì chuyển đổi. Lập chỉ mục hai bảng 256 mục nhanh hơn bảng nhảy và trình biên dịch có thể không tìm kiếm sự chuyển đổi đó.
Peter Cordes

1
Hmm, tôi nghĩ trong một phút quan sát này có thể chứng minh phỏng đoán Collatz, nhưng không, tất nhiên là không. Đối với mỗi 8 bit có thể có, có một số bước hữu hạn cho đến khi tất cả chúng biến mất. Nhưng một số mẫu 8 bit kéo dài đó sẽ kéo dài hơn 8 phần còn lại của chuỗi bit, do đó, điều này không thể loại trừ sự tăng trưởng không giới hạn hoặc chu kỳ lặp lại.
Peter Cordes

Để cập nhật count, bạn cần một mảng thứ ba, phải không? adders[]không cho bạn biết có bao nhiêu ca làm việc đúng đã được thực hiện.
Peter Cordes

Đối với các bảng lớn hơn, sẽ đáng để sử dụng các loại hẹp hơn để tăng mật độ bộ đệm. Trên hầu hết các kiến ​​trúc, tải không mở rộng từ một uint16_tlà rất rẻ. Trên x86, nó chỉ rẻ bằng 0 - kéo dài từ 32 bit unsigned intsang uint64_t. (MOVZX từ bộ nhớ trên CPU Intel chỉ cần một UOP tải cảng, nhưng CPU AMD không cần ALU là tốt.) Oh BTW, tại sao bạn đang sử dụng size_tcho lastBits? Đó là loại 32 bit có -m32và chẵn -mx32(chế độ dài với con trỏ 32 bit). Đó chắc chắn là loại sai cho n. Chỉ cần sử dụng unsigned.
Peter Cordes

20

Trên một lưu ý khá không liên quan: hack hiệu suất nhiều hơn!

  • [«phỏng đoán» đầu tiên cuối cùng đã được @ShreevatsaR gỡ lỗi; đã xóa]

  • Khi duyệt qua chuỗi, chúng ta chỉ có thể nhận được 3 trường hợp có thể xảy ra trong vùng lân cận 2 của phần tử hiện tại N(hiển thị đầu tiên):

    1. [chẵn lẻ]
    2. [lẻ thậm chí]
    3. [thậm chí] [thậm chí]

    Để vượt qua 2 yếu tố này có nghĩa là tính toán (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1N >> 2, tương ứng.

    Hãy chứng minh rằng đối với cả hai trường hợp (1) và (2) đều có thể sử dụng công thức đầu tiên , (N >> 1) + N + 1.

    Trường hợp (1) là rõ ràng. Trường hợp (2) ngụ ý (N & 1) == 1, vì vậy nếu chúng ta giả sử (không mất tính tổng quát) rằng N dài 2 bit và các bit của nó batừ hầu hết đến quan trọng nhất, thì a = 1, và sau đây giữ:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    nơi B = !b. Chuyển sang phải kết quả đầu tiên cho chúng ta chính xác những gì chúng ta muốn.

    QED : (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Như đã được chứng minh, chúng ta có thể duyệt qua 2 phần tử trình tự cùng một lúc, bằng cách sử dụng một thao tác ternary duy nhất. Giảm thời gian 2 × khác.

Thuật toán kết quả trông như thế này:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Ở đây chúng tôi so sánh n > 2vì quá trình có thể dừng ở 2 thay vì 1 nếu tổng độ dài của chuỗi là số lẻ.

[BIÊN TẬP:]

Hãy dịch nó thành lắp ráp!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Sử dụng các lệnh này để biên dịch:

nasm -f elf64 file.asm
ld -o file file.o

Xem C và phiên bản cải tiến / sửa lỗi của mã asm của Peter Cordes trên Godbolt . (lưu ý của biên tập viên: Xin lỗi vì đã đưa nội dung của tôi vào câu trả lời của bạn, nhưng câu trả lời của tôi đã đạt giới hạn 30k char từ các liên kết + văn bản của Godbolt!)


2
Không có tích phân Qnhư vậy 12 = 3Q + 1. Điểm đầu tiên của bạn là không đúng, methinks.
Veedrac

1
@Veedrac: Được chơi xung quanh với điều này: Nó có thể được thực hiện với asm tốt hơn so với việc thực hiện trong câu trả lời này, sử dụng ROR / TEST và chỉ một CMOV. Mã asm này là các vòng lặp vô hạn trên CPU của tôi, vì nó dường như dựa vào OF, không được xác định sau SHRD hoặc ROR với số lượng> 1. Nó cũng cố gắng tránh rất lâu mov reg, imm32, dường như để lưu byte, nhưng sau đó nó sử dụng Phiên bản 64 bit của thanh ghi ở mọi nơi, ngay cả đối với xor rax, rax, vì vậy nó có rất nhiều tiền tố REX không cần thiết. Chúng tôi rõ ràng chỉ cần REX trên regs giữ ntrong vòng lặp bên trong để tránh tràn.
Peter Cordes

1
Kết quả thời gian (từ Core2Duo E6600: Merom 2.4GHz. Complex-LEA = 1c độ trễ, CMOV = 2c) . Việc thực hiện vòng lặp asm một bước tốt nhất (từ Johnfound): 111ms mỗi lần chạy của vòng lặp @main này. Trình biên dịch đầu ra từ phiên bản khử nhiễu này của C này (với một số vmp tmp): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. Từ phiên bản cải tiến của vòng lặp asm clang của tôi: 92ms (sẽ thấy sự cải thiện lớn hơn nhiều đối với gia đình SnB, trong đó LEA phức tạp là 3c chứ không phải 1c). Từ phiên bản cải tiến + hoạt động của vòng lặp asm này (sử dụng ROR + TEST, không phải SHRD): 87ms. Đo bằng 5 lần lặp lại trước khi in
Peter Cordes

2
Dưới đây là 66 bản ghi đầu tiên (A006877 trên OEIS); Tôi đã đánh dấu các số chẵn in đậm: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 62631, 8377 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
@ leatherfromkgb Tuyệt vời! Và tôi đánh giá cao điểm khác của bạn tốt hơn bây giờ: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1, và 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Quan sát tốt đẹp!
ShreevatsaR

6

Các chương trình C ++ được dịch sang các chương trình lắp ráp trong quá trình tạo mã máy từ mã nguồn. Sẽ là gần như sai khi nói lắp ráp chậm hơn C ++. Hơn nữa, mã nhị phân được tạo ra khác nhau từ trình biên dịch sang trình biên dịch. Vì vậy, một trình biên dịch C ++ thông minh có thể tạo ra mã nhị phân tối ưu và hiệu quả hơn mã biên dịch giả.

Tuy nhiên tôi tin rằng phương pháp hồ sơ của bạn có những sai sót nhất định. Sau đây là những hướng dẫn chung để định hình:

  1. Đảm bảo hệ thống của bạn ở trạng thái bình thường / không hoạt động. Dừng tất cả các quy trình đang chạy (ứng dụng) mà bạn đã bắt đầu hoặc sử dụng CPU mạnh mẽ (hoặc thăm dò qua mạng).
  2. Dữ liệu của bạn phải có kích thước lớn hơn.
  3. Bài kiểm tra của bạn phải chạy trong khoảng hơn 5-10 giây.
  4. Đừng chỉ dựa vào một mẫu. Thực hiện bài kiểm tra của bạn N lần. Thu thập kết quả và tính giá trị trung bình hoặc trung bình của kết quả.

Có, tôi chưa thực hiện bất kỳ hồ sơ chính thức nào nhưng tôi đã chạy cả hai lần và có khả năng nói 2 giây từ 3 giây. Dù sao cũng cảm ơn vì đã trả lời. Tôi đã chọn được rất nhiều thông tin ở đây
con trai jeffer

9
Đây có lẽ không chỉ là lỗi đo lường, mã asm viết tay đang sử dụng lệnh DIV 64 bit thay vì dịch chuyển sang phải. Xem câu trả lời của tôi. Nhưng có, đo chính xác cũng quan trọng.
Peter Cordes

7
Điểm Bullet là định dạng phù hợp hơn một khối mã. Vui lòng dừng đặt văn bản của bạn vào một khối mã, bởi vì đó không phải là mã và không được hưởng lợi từ một phông chữ đơn cách.
Peter Cordes

16
Tôi thực sự không thấy làm thế nào điều này trả lời câu hỏi. Đây không phải là một câu hỏi mơ hồ về việc mã lắp ráp hoặc mã C ++ thể nhanh hơn --- đó là một câu hỏi rất cụ thể về mã thực tế , mà anh ta cung cấp một cách hữu ích trong chính câu hỏi. Câu trả lời của bạn thậm chí không đề cập đến bất kỳ mã nào trong số đó, hoặc thực hiện bất kỳ loại so sánh nào. Chắc chắn, lời khuyên của bạn về cách điểm chuẩn về cơ bản là chính xác, nhưng không đủ để đưa ra một câu trả lời thực tế.
Cody Grey

6

Đối với sự cố Collatz, bạn có thể tăng hiệu suất đáng kể bằng cách lưu trữ "đuôi". Đây là một sự đánh đổi thời gian / bộ nhớ. Xem: ghi nhớ ( https://en.wikipedia.org/wiki/Memoization ). Bạn cũng có thể xem xét các giải pháp lập trình động cho sự đánh đổi thời gian / bộ nhớ khác.

Ví dụ thực hiện python:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
Câu trả lời của gnasher cho thấy rằng bạn có thể làm được nhiều việc hơn là chỉ lưu vào các đuôi: các bit cao không ảnh hưởng đến những gì xảy ra tiếp theo và add / mul chỉ truyền mang sang bên trái, vì vậy các bit cao không ảnh hưởng đến những gì xảy ra với các bit thấp. tức là bạn có thể sử dụng tra cứu LUT để đi 8 (hoặc bất kỳ số lượng) bit nào tại một thời điểm, với bội số và thêm hằng số để áp dụng cho phần còn lại của các bit. ghi nhớ các đuôi chắc chắn là hữu ích trong rất nhiều vấn đề như thế này và đối với vấn đề này khi bạn chưa nghĩ đến cách tiếp cận tốt hơn, hoặc chưa chứng minh nó đúng.
Peter Cordes

2
Nếu tôi hiểu chính xác ý tưởng của gnasher ở trên, tôi nghĩ việc ghi nhớ đuôi là tối ưu hóa trực giao. Vì vậy, bạn có thể hình dung làm cả hai. Sẽ rất thú vị khi điều tra số tiền bạn có thể kiếm được từ việc thêm ghi nhớ vào thuật toán của gnasher.
Emanuel Landeholm

2
Chúng tôi có thể làm cho việc ghi nhớ rẻ hơn bằng cách chỉ lưu trữ phần dày đặc của kết quả. Đặt giới hạn trên cho N và trên đó, thậm chí không kiểm tra bộ nhớ. Bên dưới đó, sử dụng hàm băm (N) -> N làm hàm băm, do đó key = vị trí trong mảng và không cần phải lưu trữ. Một mục của 0phương tiện chưa có mặt. Chúng ta có thể tối ưu hóa hơn nữa bằng cách chỉ lưu trữ N lẻ trong bảng, do đó, hàm băm là n>>1, loại bỏ 1. Viết mã bước để luôn kết thúc bằng một n>>tzcnt(n)hoặc một cái gì đó để đảm bảo rằng nó là số lẻ.
Peter Cordes

1
Điều đó dựa trên ý tưởng (chưa được kiểm tra) của tôi rằng các giá trị N rất lớn ở giữa một chuỗi ít có khả năng phổ biến đối với nhiều chuỗi, vì vậy chúng tôi không bỏ lỡ quá nhiều từ việc không ghi nhớ chúng. Ngoài ra, một N có kích thước hợp lý sẽ là một phần của nhiều chuỗi dài, ngay cả những chuỗi bắt đầu bằng chữ N rất lớn (Điều này có thể là suy nghĩ mơ ước; nếu nó sai thì chỉ lưu vào bộ đệm dày đặc của N liên tiếp có thể bị mất so với hàm băm bảng có thể lưu trữ các khóa tùy ý.) Bạn đã thực hiện bất kỳ loại thử nghiệm tỷ lệ trúng nào để xem N bắt đầu gần đó có xu hướng có bất kỳ sự tương đồng nào trong các giá trị chuỗi của chúng không?
Peter Cordes

2
Bạn chỉ có thể lưu trữ các kết quả được tính toán trước cho tất cả n <N, đối với một số lượng lớn N. Vì vậy, bạn không cần chi phí chung của bảng băm. Dữ liệu trong bảng đó sẽ được sử dụng cuối cùng cho mọi giá trị bắt đầu. Nếu bạn chỉ muốn xác nhận rằng chuỗi Collatz luôn kết thúc bằng (1, 4, 2, 1, 4, 2, ...): Điều này có thể được chứng minh là tương đương với việc chứng minh rằng với n> 1, chuỗi cuối cùng sẽ nhỏ hơn n gốc. Và vì thế, đuôi lưu trữ sẽ không giúp ích gì.
gnasher729

5

Từ ý kiến:

Nhưng, mã này không bao giờ dừng lại (vì tràn số nguyên)!?! Yves Daoust

Đối với nhiều số, nó sẽ không tràn.

Nếu nó sẽ tràn - đối với một trong những hạt giống ban đầu không may mắn đó, số tràn sẽ rất có thể hội tụ về 1 mà không bị tràn nữa.

Vẫn còn câu hỏi thú vị này, có một số hạt giống chu kỳ tràn?

Bất kỳ chuỗi hội tụ cuối cùng đơn giản nào cũng bắt đầu với sức mạnh của hai giá trị (đủ rõ ràng?).

2 ^ 64 sẽ tràn về 0, đó là vòng lặp vô hạn không xác định theo thuật toán (chỉ kết thúc bằng 1), nhưng giải pháp tối ưu nhất trong câu trả lời sẽ kết thúc do shr raxtạo ra ZF = 1.

Chúng tôi có thể sản xuất 2 ^ 64 không? Nếu số bắt đầu là số 0x5555555555555555lẻ, số tiếp theo là 3n + 1, là 0xFFFFFFFFFFFFFFFF + 1= 0. Về mặt lý thuyết ở trạng thái không xác định của thuật toán, nhưng câu trả lời được tối ưu hóa của johnfound sẽ phục hồi bằng cách thoát khỏi ZF = 1. Các cmp rax,1vị Thánh Phêrô Cordes sẽ kết thúc trong vòng lặp vô hạn (QED biến thể 1, "rẻ tiền" thông qua xác định 0số lượng).

Làm thế nào về một số số phức tạp hơn, sẽ tạo ra chu kỳ mà không có 0? Thành thật mà nói, tôi không chắc chắn, lý thuyết Toán học của tôi quá mơ hồ để có bất kỳ ý tưởng nghiêm túc nào, làm thế nào để đối phó với nó một cách nghiêm túc. Nhưng theo trực giác tôi sẽ nói rằng chuỗi sẽ hội tụ thành 1 cho mọi số: 0 <số, vì công thức 3n + 1 sẽ từ từ biến mọi yếu tố không phải là 2 của số gốc (hoặc trung gian) thành một số lũy thừa 2, sớm hay muộn . Vì vậy, chúng ta không cần phải lo lắng về vòng lặp vô hạn cho loạt phim gốc, chỉ có tràn mới có thể cản trở chúng ta.

Vì vậy, tôi chỉ cần đặt một vài số vào bảng và xem xét các số bị cắt 8 bit.

Có ba giá trị tràn tới 0: 227, 17085( 85đi thẳng tới 0, hai khác tiến về phía 85).

Nhưng không có giá trị tạo hạt tràn tuần hoàn.

Thật thú vị, tôi đã làm một kiểm tra, đó là con số đầu tiên bị cắt ngắn 8 bit, và đã 27bị ảnh hưởng! Nó đạt giá trị 9232trong chuỗi không cắt ngắn thích hợp (giá trị cắt ngắn đầu tiên là 322ở bước thứ 12) và giá trị tối đa đạt được cho bất kỳ số đầu vào 2-255 nào theo cách không bị cắt cụt là 13120(đối với 255chính nó), số bước tối đa để hội tụ đến 1khoảng 128(+ -2, không chắc là "1" có được tính không, v.v ...).

Thật thú vị (đối với tôi) số 9232này là tối đa cho nhiều số nguồn khác, nó có gì đặc biệt? : -O 9232= 0x2410... hmmm .. không có ý kiến.

Thật không may, tôi không thể hiểu sâu về loạt bài này, tại sao nó lại hội tụ và ý nghĩa của việc cắt chúng thành k bit, nhưng với cmp number,1điều kiện kết thúc, chắc chắn có thể đưa thuật toán vào vòng lặp vô hạn với giá trị đầu vào cụ thể kết thúc như 0sau cắt ngắn.

Nhưng giá trị 27tràn cho trường hợp 8 bit là loại cảnh báo, điều này giống như nếu bạn đếm số bước để đạt giá trị 1, bạn sẽ nhận được kết quả sai cho phần lớn các số từ tổng số k-bit của số nguyên. Đối với các số nguyên 8 bit, 146 số trong số 256 đã bị ảnh hưởng bởi việc cắt xén (một số trong số chúng vẫn có thể đạt đúng số bước do tình cờ có thể, tôi quá lười để kiểm tra).


"số tràn sẽ rất có thể hội tụ về 1 mà không tràn nữa": mã không bao giờ dừng. (Đó là một phỏng đoán vì tôi không thể đợi đến cuối thời gian để chắc chắn ...)
Yves Daoust

@YvesDaoust oh, nhưng nó thì sao? ... Ví dụ: sê-ri 27với 8b cắt ngắn trông như thế này: 82 41 124 62 31 94 47 142 71 214 107 66 (cắt ngắn) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (phần còn lại của nó hoạt động mà không cắt ngắn). Tôi không hiểu bạn, xin lỗi. Sẽ không bao giờ dừng lại nếu giá trị bị cắt sẽ bằng với một số giá trị đã đạt được trước đó trong chuỗi hiện đang diễn ra và tôi không thể tìm thấy bất kỳ giá trị nào như vậy so với cắt ngắn k-bit (nhưng tôi không thể tìm ra lý thuyết Toán học phía sau, tại sao điều này giữ cho việc cắt giảm 8/16 / 32/64 bit, theo trực giác tôi nghĩ rằng nó hoạt động).
Ped7g

1
Tôi nên kiểm tra mô tả vấn đề ban đầu sớm hơn: "Mặc dù nó chưa được chứng minh (Vấn đề Collatz), người ta cho rằng tất cả các số bắt đầu kết thúc ở mức 1." ... ok, không có thắc mắc tôi không thể nhận nắm bắt nó với mơ hồ hạn chế Math kiến thức của tôi ...: D Và từ thí nghiệm tờ của tôi, tôi có thể đảm bảo với bạn nó hội tụ cho mỗi 2- 255số, hoặc không cắt ngắn (để 1), hoặc với việc cắt ngắn 8 bit (theo dự kiến 1hoặc 0cho ba số).
Ped7g

Hem, khi tôi nói rằng nó không bao giờ dừng lại, ý tôi là ... nó không dừng lại. Mã đã cho chạy mãi mãi nếu bạn thích.
Yves Daoust

1
Upvote để phân tích những gì xảy ra trên tràn. Vòng lặp dựa trên CMP có thể sử dụng cmp rax,1 / jna(tức là do{}while(n>1)) để kết thúc bằng không. Tôi đã nghĩ về việc tạo ra một phiên bản cụ của vòng lặp ghi lại mức tối đa nnhìn thấy, để đưa ra ý tưởng về việc chúng ta tiến gần đến mức nào.
Peter Cordes

5

Bạn đã không đăng mã được tạo bởi trình biên dịch, vì vậy có một số phỏng đoán ở đây, nhưng ngay cả khi không nhìn thấy nó, người ta có thể nói rằng:

test rax, 1
jpe even

... có 50% cơ hội dự đoán sai chi nhánh, và điều đó sẽ trở nên đắt đỏ.

Trình biên dịch gần như chắc chắn thực hiện cả hai tính toán (chi phí cao hơn đáng kể vì div / mod có độ trễ khá dài, do đó, phép nhân bội là "miễn phí") và tiếp theo là CMOV. Điều này, tất nhiên, có một phần trăm không có khả năng bị dự đoán sai.


1
Có một số mô hình để phân nhánh; ví dụ một số lẻ luôn được theo sau bởi một số chẵn. Nhưng đôi khi 3n + 1 để lại nhiều bit 0 ở cuối và đó là khi điều này sẽ sai. Tôi bắt đầu viết về sự phân chia trong câu trả lời của mình và không đề cập đến lá cờ đỏ lớn khác này trong mã của OP. (Cũng lưu ý rằng việc sử dụng điều kiện chẵn lẻ thực sự kỳ lạ, so với chỉ JZ hoặc CMOVZ. Nó cũng tệ hơn đối với CPU, vì CPU Intel có thể hợp nhất TEST / JZ, nhưng không phải TEST / JPE. Agner Fog cho biết AMD có thể hợp nhất bất kỳ KIỂM TRA / CMP với bất kỳ JCC nào, vì vậy trong trường hợp đó, điều đó chỉ tệ hơn đối với độc giả của con người)
Peter Cordes

5

Ngay cả khi không nhìn vào lắp ráp, lý do rõ ràng nhất /= 2có lẽ là được tối ưu hóa >>=1và nhiều bộ xử lý có hoạt động thay đổi rất nhanh. Nhưng ngay cả khi bộ xử lý không có thao tác thay đổi, phép chia số nguyên vẫn nhanh hơn phân chia dấu phẩy động.

Chỉnh sửa: milage của bạn có thể thay đổi trên câu lệnh "chia số nguyên nhanh hơn phân chia dấu phẩy động" ở trên. Các ý kiến ​​dưới đây tiết lộ rằng các bộ xử lý hiện đại đã ưu tiên tối ưu hóa phân chia fp so với phân chia số nguyên. Vì vậy, nếu ai đó đang tìm kiếm nguyên nhân phần nhiều cho tăng tốc mà câu hỏi của chủ đề này yêu cầu tối ưu hóa về, sau đó trình biên dịch /=2như >>=1sẽ là nơi tốt nhất để 1 cái nhìn.


Trên một ghi chú không liên quan , nếu nlà số lẻ, biểu thức n*3+1sẽ luôn là số chẵn. Vì vậy, không cần phải kiểm tra. Bạn có thể thay đổi chi nhánh đó thành

{
   n = (n*3+1) >> 1;
   count += 2;
}

Vì vậy, toàn bộ tuyên bố sau đó sẽ là

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
Phân chia số nguyên không thực sự nhanh hơn phân chia FP trên CPU x86 hiện đại. Tôi nghĩ rằng điều này là do Intel / AMD chi nhiều bóng bán dẫn hơn cho các bộ chia FP của họ, bởi vì đây là một hoạt động quan trọng hơn. (Phân chia số nguyên theo các hằng số có thể được tối ưu hóa để nhân với một nghịch đảo mô-đun). Kiểm tra các bảng trong của Agner Fog và so sánh DIVSD (float chính xác kép) với DIV r32(số nguyên không dấu 32 bit) hoặc DIV r64(số nguyên không dấu 64 bit chậm hơn nhiều). Đặc biệt đối với thông lượng, phân chia FP nhanh hơn nhiều (uop đơn thay vì vi mã hóa và một phần đường ống), nhưng độ trễ cũng tốt hơn.
Peter Cordes

1
ví dụ: trên CPU Haswell của OP: DIVSD là 1 uop, độ trễ 10-20 chu kỳ, thông lượng trên mỗi 8-14c. div r64là 36 uops, độ trễ 32-96c và một thông lượng trên 21-74c. Skylake thậm chí còn có thông lượng phân chia FP nhanh hơn (đường ống ở mức 1 trên 4c với độ trễ không tốt hơn nhiều), nhưng div số nguyên không nhanh hơn nhiều. Mọi thứ tương tự trên gia đình AMD Bulldozer: DIVSD là 1M-op, độ trễ 9-27c, một thông lượng trên mỗi 4,5-11c. div r64là 16M-op, độ trễ 16-75c, một thông lượng trên mỗi 16-75c.
Peter Cordes

1
Không phải phân chia FP về cơ bản giống như các số mũ trừ số nguyên, mantissa chia số nguyên, phát hiện các biến dạng? Và 3 bước đó có thể được thực hiện song song.
MSalters

2
@MSalters: yeah, nghe có vẻ đúng, nhưng với bước chuẩn hóa ở cuối bit shift thay đổi giữa số mũ và mantiss. doublecó mantissa 53 bit, nhưng nó vẫn chậm hơn đáng kể so với div r32Haswell. Vì vậy, đây chắc chắn chỉ là vấn đề về việc Intel / AMD gặp phải bao nhiêu phần cứng, bởi vì họ không sử dụng cùng một bóng bán dẫn cho cả hai bộ chia số nguyên và fp. Số nguyên là vô hướng (không có phân chia SIMD số nguyên) và vectơ số một xử lý các vectơ 128b (không phải 256b như các ALU vectơ khác). Điều quan trọng là div số nguyên có nhiều uops, ảnh hưởng lớn đến mã xung quanh.
Peter Cordes

Err, không thay đổi bit giữa mantissa và lũy thừa, nhưng bình thường hóa mantissa với một ca, và thêm số lượng ca vào số mũ.
Peter Cordes

4

Như một câu trả lời chung chung, không hướng cụ thể vào nhiệm vụ này: Trong nhiều trường hợp, bạn có thể tăng tốc đáng kể bất kỳ chương trình nào bằng cách cải thiện ở mức cao. Giống như tính toán dữ liệu một lần thay vì nhiều lần, tránh hoàn toàn công việc không cần thiết, sử dụng bộ nhớ cache theo cách tốt nhất, v.v. Những điều này dễ dàng hơn nhiều để làm trong một ngôn ngữ cấp cao.

Viết mã trình biên dịch, nó là trình biên dịch có thể cải thiện những gì trình biên dịch tối ưu hóa thực hiện, nhưng đó là công việc khó khăn. Và một khi nó được thực hiện, mã của bạn khó sửa đổi hơn nhiều, vì vậy việc thêm các cải tiến thuật toán sẽ khó khăn hơn nhiều. Đôi khi bộ xử lý có chức năng mà bạn không thể sử dụng từ ngôn ngữ cấp cao, lắp ráp nội tuyến thường hữu ích trong những trường hợp này và vẫn cho phép bạn sử dụng ngôn ngữ cấp cao.

Trong các vấn đề của Euler, hầu hết thời gian bạn thành công bằng cách xây dựng một cái gì đó, tìm ra lý do tại sao nó chậm, xây dựng một cái gì đó tốt hơn, tìm ra lý do tại sao nó chậm, vân vân và vân vân. Đó là rất, rất khó sử dụng lắp ráp. Một thuật toán tốt hơn ở một nửa tốc độ có thể thường sẽ đánh bại một thuật toán kém hơn ở tốc độ tối đa và việc đạt được tốc độ đầy đủ trong trình biên dịch không phải là chuyện nhỏ.


2
Hoàn toàn đồng ý với điều này. gcc -O3đã tạo mã nằm trong 20% ​​tối ưu trên Haswell, cho thuật toán chính xác đó. (Lấy các tốc độ đó là trọng tâm chính của câu trả lời của tôi chỉ vì đó là những gì câu hỏi đã hỏi và có một câu trả lời thú vị, không phải vì đó là cách tiếp cận đúng.) Tăng tốc lớn hơn nhiều từ các phép biến đổi mà trình biên dịch sẽ cực kỳ khó tìm kiếm , như trì hoãn các ca phải, hoặc thực hiện 2 bước một lần. Tăng tốc lớn hơn nhiều so với mức có thể có từ bảng ghi nhớ / tra cứu. Vẫn thử nghiệm toàn diện, nhưng không phải là vũ phu thuần túy.
Peter Cordes

2
Tuy nhiên, việc thực hiện đơn giản rõ ràng là chính xác là cực kỳ hữu ích để thử nghiệm các triển khai khác. Những gì tôi sẽ làm có lẽ chỉ cần nhìn vào đầu ra asm để xem liệu gcc có thực hiện được nó như tôi mong đợi hay không (chủ yếu là vì tò mò), và sau đó chuyển sang cải tiến thuật toán.
Peter Cordes

-2

Câu trả lời đơn giản:

  • thực hiện MOV RBX, 3 và MUL RBX rất tốn kém; chỉ cần thêm RBX, RBX hai lần

  • THÊM 1 có lẽ nhanh hơn INC ở đây

  • MOV 2 và DIV rất đắt tiền; chỉ cần chuyển sang phải

  • Mã 64 bit thường chậm hơn đáng kể so với mã 32 bit và các vấn đề căn chỉnh phức tạp hơn; với các chương trình nhỏ như thế này, bạn phải đóng gói chúng để bạn thực hiện tính toán song song để có bất kỳ cơ hội nào nhanh hơn mã 32 bit

Nếu bạn tạo danh sách lắp ráp cho chương trình C ++ của mình, bạn có thể thấy nó khác với lắp ráp của bạn như thế nào.


4
1): thêm 3 lần sẽ bị câm so với LEA. Ngoài ra mul rbxtrên CPU Haswell của OP là 2 uops với độ trễ 3c (và 1 cho mỗi thông lượng đồng hồ). imul rcx, rbx, 3chỉ có 1 uop, với độ trễ 3c tương tự. Hai lệnh ADD sẽ là 2 uops với độ trễ 2c.
Peter Cordes

5
2) THÊM 1 có lẽ nhanh hơn INC ở đây . Không, OP không sử dụng Pentium4 . Điểm 3 của bạn là phần đúng duy nhất của câu trả lời này.
Peter Cordes

5
4) âm thanh như vô nghĩa. Mã 64 bit có thể chậm hơn với các cấu trúc dữ liệu nặng con trỏ, bởi vì các con trỏ lớn hơn có nghĩa là dấu chân bộ đệm lớn hơn. Nhưng mã này chỉ hoạt động trong các thanh ghi và các vấn đề căn chỉnh mã giống nhau ở chế độ 32 và 64 bit. (Các vấn đề căn chỉnh dữ liệu cũng vậy, không có vấn đề gì bạn đang nói với việc căn chỉnh là vấn đề lớn hơn đối với x86-64). Dù sao, mã thậm chí không chạm vào bộ nhớ trong vòng lặp.
Peter Cordes

Các bình luận không có ý tưởng về những gì đang nói về. Thực hiện MOV + MUL trên CPU 64 bit sẽ chậm hơn khoảng ba lần so với việc thêm một thanh ghi vào chính nó hai lần. Những nhận xét khác của ông đều không chính xác.
Tyler Durden

6
Vâng MOV + MUL chắc chắn là ngu ngốc, nhưng MOV + ADD + ADD vẫn ngớ ngẩn (thực tế làm ADD RBX, RBXhai lần sẽ nhân với 4 chứ không phải 3). Cho đến nay cách tốt nhất là lea rax, [rbx + rbx*2]. Hoặc, với chi phí biến nó thành LEA 3 thành phần, hãy thực hiện +1 cũng như lea rax, [rbx + rbx*2 + 1] (độ trễ 3c trên HSW thay vì 1, như tôi đã giải thích trong câu trả lời của tôi) Quan điểm của tôi là nhân 64 bit không đắt lắm Các CPU Intel gần đây, vì chúng có các đơn vị nhân số nguyên cực nhanh (thậm chí so với AMD, trong đó cùng MUL r64độ trễ là 6c, với thông lượng trên mỗi 4c: thậm chí không được cung cấp đầy đủ.
Peter Cordes
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.