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êmx86 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 r64
là 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, 1
thự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 r32
là 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 -O0
khô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_13
trong 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
). -O0
tố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=bdver3
và g++ -O3 -march=skylake
sẽ 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 n
có 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 n
khô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 long
chỉ 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 edx
thay 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 cmovc
thay 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 n
song 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 rcx
là 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 n
giá 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 n
giá trị đạt được hay không 1
trước khi nhận được một cặp n
giá 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ơ n
chư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ơ n
giá 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 1
phầ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/=2
lầ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 đó. n
Mặ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>>1
Mặ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ì n
khô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_ctzll
ngay 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,2
nhanh hơn SHRD rdi,rdi,2
và 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 k
và sau đó thêm nó vào count
sau 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)