손으로 쓴 어셈블리보다 Collatz 추측을 빠르게 테스트하기위한 C ++ 코드-왜?


833

어셈블리 및 C ++ 로 Project Euler Q14에 대한이 두 가지 솔루션을 작성했습니다 . 이것들은 Collatz 추측 을 테스트하기위한 동일한 동일한 무차별 접근 방식입니다 . 조립 솔루션은

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

C ++는

g++ p14.cpp -o p14

어셈블리, 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;
}

속도와 모든 것을 향상시키기위한 컴파일러 최적화에 대해 알고 있지만 어셈블리 솔루션을 더 최적화 할 수있는 여러 가지 방법이 없습니다 (프로그램 적으로 수학적으로 말하지 않음).

C ++ 코드는 모든 항과 계수를 짝수 항마다 가지고 있으며 어셈블리는 짝수 항당 하나의 나눗셈입니다.

그러나 어셈블리는 C ++ 솔루션보다 평균 1 초 더 오래 걸립니다. 왜 이런거야? 나는 주로 호기심을 묻습니다.

실행 시간

내 시스템 : 1.4 GHz Intel Celeron 2955U (Haswell 마이크로 아키텍처)의 64 비트 Linux.


232
GCC가 C ++ 프로그램을 위해 생성하는 어셈블리 코드를 조사 했습니까?
ruakh

69
-S컴파일러가 생성 한 어셈블리를 얻기 위해 컴파일 하십시오. 컴파일러는 모듈러스가 동시에 나눗셈을한다는 것을 알기에 영리합니다.
user3386109

267
옵션이 1 이라고 생각합니다 . 측정 기술에 결함이 있습니다. 2. 컴파일러가 더 나은 어셈블리를 작성합니다. 또는 3. 컴파일러가 마법을 사용합니다.
Galik


18
@jefferson 컴파일러는 더 빠른 무차별 대입을 사용할 수 있습니다. 예를 들어 SSE 명령어가있을 수 있습니다.
user253751

답변:


1896

64 비트 DIV 명령어가 2로 나눌 수있는 좋은 방법이라고 생각한다면 컴파일러의 asm 출력이 -O0(빠른 컴파일, 추가 최적화 및 메모리 저장 / 재로드 후) 디버거가 변수를 수정할 수 있도록 모든 C 문 앞에).

효율적인 asm 작성 방법 은 Agner Fog의 최적화 어셈블리 안내서 를 참조하십시오 . 또한 특정 CPU에 대한 특정 세부 사항에 대한 지시 사항 테이블과 마이크로 아치 안내서가 있습니다. 참조 더 많은 perf 링크를위한 wiki 태그.

손으로 작성한 asm을 사용하여 컴파일러를 두드리는 방법에 대한 일반적인 질문도 참조하십시오. 인라인 어셈블리 언어가 네이티브 C ++ 코드보다 느립니까? . TL : DR : 그렇습니다 (예 :이 질문처럼).

일반적으로 컴파일러가 효율적으로 컴파일 할 수있는 C ++를 작성하려고 하면 컴파일러가 그 일을하도록하는 것이 좋습니다 . 또한 컴파일 된 언어보다 어셈블리가 더 빠릅니까? . 답변 중 하나는 다양한 C 컴파일러가 멋진 트릭으로 일부 간단한 함수를 최적화하는 방법을 보여주는 깔끔한 슬라이드로 연결됩니다 . Matt Godbolt의 CppCon2017 talk“ 최근에 컴파일러에서 수행 한 작업은 무엇입니까? 컴파일러의 뚜껑을 풀다 ”는 비슷한 맥락에있다.


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

Intel Haswell에서는 대기 시간이 32-96 사이클 이고 처리량은 21-74 사이클 당 div r6436 개의 uop 입니다. (또한 RBX를 설정하고 RDX를 0으로 설정하는 2 개의 UOP가 있지만 비 순차적 실행은 조기에 실행할 수 있습니다). DIV와 같은 많은 수의 명령은 마이크로 코딩되며, 이로 인해 프런트 엔드 병목 현상이 발생할 수 있습니다. 이 경우 대기 시간이 루프 관련 종속성 체인의 일부이기 때문에 가장 중요한 요소입니다.

shr rax, 1동일한 부호없는 구분을 수행합니다 .1c 대기 시간으로 1uop이며 클럭 사이클 당 2 개를 실행할 수 있습니다.

비교하자면 32 비트 분할은 빠르지 만 여전히 끔찍한 대 시프트입니다. idiv r32Haswell에서 9 개의 Uops, 22-29c의 대기 시간 및 8-11c의 처리량 당 하나입니다.


gcc의 -O0asm 출력 ( Godbolt 컴파일러 탐색기 ) 을 보면 알 수 있듯이 shifts 명령어 만 사용합니다 . clang -O0은 64 비트 IDIV를 두 번 사용하더라도 생각한대로 순진하게 컴파일됩니다. (최적화 할 때 컴파일러는 소스가 분할을 할 때 IDIV의 두 출력을 사용하고 IDIV를 전혀 사용하지 않으면 동일한 피연산자로 모듈러스를 사용합니다)

GCC에는 완전히 순진한 모드가 없습니다. 항상 GIMPLE을 통해 변환되므로 일부 "최적화"를 비활성화 할 수 없습니다 . 여기에는 상수로 구분하고 IDIV를 피하기 위해 시프트 (2의 거듭 제곱) 또는 고정 소수점 곱하기 역 (2의 비 거듭 제곱)을 사용 div_by_13하는 것이 포함됩니다 (위의 godbolt 링크 참조).

gcc -Os불행히도 곱하기 역 코드가 약간 더 크지 만 훨씬 더 빠른 경우에도 (크기 최적화) 비-제곱 나누기에 IDIV를 사용합니다.


컴파일러 돕기

(이 경우 요약 : use uint64_t n)

우선, 최적화 된 컴파일러 출력을 보는 것만 흥미 롭습니다. ( -O3). -O0속도는 기본적으로 의미가 없습니다.

asm 출력을보십시오 (Godbolt에서 또는 GCC / clang 어셈블리 출력에서 ​​"노이즈"를 제거하는 방법? 참조 ). 컴파일러가 처음에 최적의 코드를 만들지 않는 경우 : 컴파일러가 더 나은 코드를 작성하도록 안내하는 방식으로 C / C ++ 소스를 작성하는 것이 일반적으로 가장 좋은 방법 입니다. asm과 효율적인 것이 무엇인지 알아야하지만이 지식을 간접적으로 적용해야합니다. 컴파일러는 좋은 아이디어의 원천이기도합니다. 때로는 clang이 멋진 작업을 수행하고 gcc를 똑같이 수행 할 수 있습니다. 이 답변 과 아래 @Veedrac의 코드에서 언 롤링되지 않은 루프 로 수행 한 작업을 참조하십시오 .)

이 접근 방식은 이식성이 뛰어나 20 년 후에는 미래의 하드웨어 (x86에 관계없이)에서 새로운 ISA 확장 또는 자동 벡터화를 사용하여 효율적으로 컴파일 할 수 있습니다. 15 년 전의 손으로 쓴 x86-64 asm은 일반적으로 Skylake에 맞게 최적으로 조정되지 않았습니다. 예를 들어 compare & branch macro-fusion은 당시에는 없었습니다. 하나의 마이크로 아키텍처에 대한 수제 asm에 대해 현재 최적 인 것은 다른 현재 및 미래의 CPU에 적합하지 않을 수 있습니다. @johnfound의 답변에 대한 의견은 이 코드에 큰 영향을 미치는 AMD Bulldozer와 Intel Haswell의 주요 차이점에 대해 설명합니다. 그러나 이론적으로, g++ -O3 -march=bdver3그리고 g++ -O3 -march=skylake옳은 일을 할 것입니다. (또는 -march=native.) 또는 -mtune=...다른 CPU가 지원하지 않는 명령을 사용하지 않고 그냥 조정하십시오.

내 생각에 당신이 관심있는 현재 CPU에 좋은 컴파일러를 asm으로 인도하는 것은 미래의 컴파일러에게는 문제가되지 않아야합니다. 코드를 변환하는 방법을 찾는 데 현재의 컴파일러보다 더 나아졌으며 향후 CPU에 적합한 방법을 찾을 수 있습니다. 그럼에도 불구하고, 미래 x86은 현재 x86에서 좋은 점에서 끔찍하지 않을 것입니다. 향후 컴파일러는 무언가 더 잘 보이지 않으면 C 소스의 데이터 이동과 같은 것을 구현하는 동안 asm 관련 함정을 피할 것입니다.

손으로 쓴 asm은 옵티마이 저의 블랙 박스이므로 인라인이 입력을 컴파일 타임 상수로 만들면 상수 전파가 작동하지 않습니다. 다른 최적화도 영향을받습니다. asm을 사용하기 전에 https://gcc.gnu.org/wiki/DontUseInlineAsm 을 읽으십시오 . (그리고 MSVC 스타일 인라인 asm을 피하십시오 : 입출력은 오버 헤드를 추가하는 메모리를 거쳐야 합니다 .)

이 경우 : n서명 된 유형이 있으며 gcc는 올바른 반올림을 제공하는 SAR / SHR / ADD 시퀀스를 사용합니다. (음수 입력의 경우 IDIV 및 산술 시프트 "라운드"가 다릅니다. SAR insn set ref manual entry 참조 ). (gcc n가 부정적이거나 무엇 을 증명할 수 없었지만 IDK가 실패한 경우 IDK . 서명 된 오버플로는 정의되지 않은 동작이므로 가능했을 것입니다.)

을 사용해야 uint64_t n하므로 SHR 만 가능합니다. 따라서 long32 비트 (예 : x86-64 Windows) 인 시스템에 이식 가능합니다 .


BTW, gcc의 최적화 된 asm 출력은 (을 사용하여 unsigned long n) 꽤 좋아 보입니다 : 인라인 내부 루프 main()가 다음 을 수행합니다.

 # 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

내부 루프는 분기가 없으며 루프 수행 종속성 체인의 중요한 경로는 다음과 같습니다.

  • 3 성분 LEA (3 사이클)
  • cmov (Haswell에서 2 사이클, Broadwell에서 1c 이상).

총 : 반복 당 5주기, 대기 시간 병목 현상 . 비 순차적 실행은 이것과 병행하여 다른 모든 것을 처리합니다.

FLAGS 입력 cmov(TEST에서 생산)은 RAX 입력 (LEA-> MOV에서)보다 생성 속도가 빠르므로 중요한 경로에 있지 않습니다.

마찬가지로 CMOV의 RDI 입력을 생성하는 MOV-> SHR도 LEA보다 빠르기 때문에 중요한 경로를 벗어납니다. IvyBridge 이상의 MOV는 대기 시간이 없습니다 (레지스터 이름 변경시 처리됨). (여전히 파이프 라인의 슬롯과 슬롯이 필요하므로 여유 시간이 없으며 대기 시간이 없습니다.) LEA dep 체인의 추가 MOV는 다른 CPU의 병목 현상의 일부입니다.

cmp / jne도 임계 경로의 일부가 아닙니다. 제어 경로는 임계 경로에 대한 데이터 종속성과 달리 분기 예측 + 추측 실행으로 처리되므로 루프로 전달되지 않습니다.


컴파일러 구타

GCC는 여기서 꽤 잘했습니다. P4와 부분 플래그 수정 명령에 대한 거짓 의존성을 신경 쓰지 않기 때문에 inc edx대신을add edx, 1 사용하여 하나의 코드 바이트를 절약 할 수 있습니다.

또한 모든 MOV 명령어를 저장할 수 있으며 TEST : SHR은 CF = 비트 시프트 된 비트를 설정하므로 / cmovc대신 사용할 수 있습니다 .testcmovz

 ### 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)

또 다른 영리한 트릭에 대한 @ johnfound의 답변을 참조하십시오 : SHR의 플래그 결과를 분기하여 CMMP를 사용하여 CMP를 제거하십시오 .n은 1 (또는 0)로 시작하는 경우에만 0입니다. (재미있는 사실 : Nehalem 또는 그 이전 버전에서 카운트가 1 = 1 인 SHR은 플래그 결과를 읽으면 실속을 일으 킵니다 . 그런 식으로 단일 우팝 방식으로 만들었습니다 .shift-by-1 특수 인코딩은 괜찮습니다.)

MOV를 피하면 Haswell에서 대기 시간이 전혀 도움이되지 않습니다 ( x86의 MOV가 실제로 "무료"일 수 있습니까? 왜 이것을 전혀 재현 할 수 없습니까? ). MOV가 지연 시간이 0이 아닌 Intel pre-IvB 및 AMD Bulldozer 제품군과 같은 CPU에서 크게 도움 이됩니다. 컴파일러의 낭비 된 MOV 명령어는 중요한 경로에 영향을줍니다. BD의 복합 LEA 및 CMOV는 대기 시간이 각각 짧고 (각각 2c와 1c), 대기 시간의 더 큰 부분입니다. 또한 처리량 병목 현상에는 두 개의 정수 ALU 파이프 만 있기 때문에 문제가됩니다. @ johnfound 's answer 에서 AMD CPU의 타이밍 결과를 확인할 수 있습니다.

Haswell에서도이 버전은 중요하지 않은 uop이 중요 경로의 포트에서 실행 포트를 훔쳐 실행주기를 1주기 지연시키는 경우에 따라 약간의 지연을 피함으로써 조금 도움이 될 수 있습니다. 이를 자원 충돌이라고합니다. 또한 n인터리브 루프에서 여러 값을 병렬로 수행 할 때 도움이되는 레지스터를 저장합니다 (아래 참조).

LEA의 대기 시간은 주소 지정 모드 , Intel SnB 제품군 CPU에 따라 다릅니다 . 3 개의 구성 요소에 대해 3c ( [base+idx+const], 두 개의 개별 추가가 필요함), 구성 요소가 2 개 이하인 1c 만 (하나의 추가). 일부 CPU (예 : Core2)는 단일 주기로 3 개 구성 요소 LEA를 수행하지만 SnB 제품군은 그렇지 않습니다. 더 나쁜, 더 2C의 마이크로 연산이 없기 때문에 인텔 SNB-가족은 대기 시간을 표준화 , 그렇지 않으면 3 요소 레아 불도저 같은 단지 2C 될 것이다. (3- 컴포넌트 LEA는 AMD에서도 느리게 진행됩니다).

따라서 lea rcx, [rax + rax*2]/ inc rcxlea rcx, [rax + rax*2 + 1]Haswell과 같은 Intel SnB 제품군 CPU에서 2c의 대기 시간으로,보다 빠릅니다 . BD에서는 손익 분기점이되고 Core2에서는 더 나빠집니다. 일반적으로 1c 대기 시간을 절약 할 가치가없는 추가 uop가 필요하지만 대기 시간이 여기에 큰 병목 현상이 있으며 Haswell은 추가 uop 처리량을 처리 할 수있는 충분한 파이프 라인을 가지고 있습니다.

gcc, icc 또는 clang (godbolt)은 항상 AND 또는 TEST를 사용하여 SHR의 CF 출력을 사용하지 않았습니다 . 바보 컴파일러. : P 그들은 복잡한 기계의 훌륭한 조각이지만, 영리한 인간은 종종 소규모 문제로 그들을 이길 수 있습니다. (물론 그것에 대해 생각하는 데 수천에서 수백만 시간이 더 걸립니다!) 컴파일러는 모든 가능한 모든 방법을 검색하기 위해 철저한 알고리즘을 사용하지 않습니다. 많은 인라인 코드를 최적화 할 때 시간이 너무 오래 걸리기 때문입니다. 또한 목표 마이크로 아키텍처에서 파이프 라인을 모델링하지 않으며 적어도 IACA 또는 기타 정적 분석 도구 와 동일하게 자세히 설명하지는 않으며 일부 휴리스틱 만 사용합니다.)


간단한 루프 언 롤링은 도움이되지 않습니다 . 이 루프 병목 현상은 루프 오버 헤드 / 처리량이 아니라 루프 수행 종속성 체인의 대기 시간에 병목 현상이 발생합니다. 이는 CPU가 두 개의 스레드에서 명령을 인터리브하는 데 많은 시간이 있기 때문에 하이퍼 스레딩 (또는 다른 종류의 SMT)과 잘 작동한다는 것을 의미합니다. 이것은 루프를 병렬화한다는 것을 의미 main하지만 각 스레드는 n값 범위를 확인 하고 결과적으로 정수 쌍을 생성 할 수 있기 때문에 좋습니다.

단일 스레드 내에서 손으로 인터리빙하는 것도 가능할 수 있습니다 . 각각 한 쌍의 레지스터 만 취하므로 모두 같은 max/를 업데이트 할 수 있기 때문에 한 쌍의 숫자에 대한 시퀀스를 병렬로 계산할 수 있습니다 maxi. 이것은 더 많은 명령어 수준의 병렬 처리를 만듭니다 .

트릭은 다른 시작 값 쌍을 얻기 전에 모든 n값에 도달 할 때까지 기다릴 것인지 아니면 다른 시퀀스의 레지스터를 건드리지 않고 종료 조건에 도달 한 단 하나의 새로운 시작점을 나눌 수 있는지 여부를 결정하는 것입니다. 아마도 각 체인이 유용한 데이터를 처리하도록하는 것이 가장 좋습니다. 그렇지 않으면 조건부로 카운터를 증가시켜야합니다.1n


아직 n도달 1하지 않은 벡터 요소의 카운터를 조건부로 증가시키기 위해 SSE 압축 비교 항목을 사용하여이 작업을 수행 할 수도 있습니다. 그런 다음 SIMD 조건부 증가 구현의 훨씬 더 긴 대기 시간을 숨기려면 더 많은 n값의 벡터를 대기 에 유지해야합니다 . 256b 벡터 (4x uint64_t) 로만 가치가 있습니다.

1"sticky"를 감지하는 가장 좋은 전략 은 카운터를 증가시키기 위해 추가 한 모든 벡터를 마스킹하는 것입니다. 따라서 1요소에서를 본 후에 증가 벡터는 0이되고 + = 0은 작동하지 않습니다.

수동 벡터화를위한 검증되지 않은 아이디어

# 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.

직접 작성한 asm 대신 내장 함수를 사용하여이를 구현할 수 있으며 구현해야합니다.


알고리즘 / 구현 개선 :

보다 효율적인 asm으로 동일한 로직을 구현하는 것 외에도 로직을 단순화하거나 중복 작업을 피하는 방법을 찾으십시오. 예를 들어 시퀀스의 공통 엔딩을 감지하기 위해 메모합니다. 또는 더 나은 방법은 한 번에 8 개의 후행 비트를 보는 것입니다.

@EOF는 tzcnt(또는 bsf)를 사용하여 한 번에 여러 n/=2반복 을 수행 할 수 있다고 지적합니다 . 아마도 SIMD 벡터화보다 낫습니다. SSE 또는 AVX 명령으로는 그렇게 할 수 없습니다. 그래도 n다른 정수 레지스터에서 여러 스칼라 를 병렬 로 수행하는 것과 여전히 호환됩니다 .

따라서 루프는 다음과 같습니다.

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);

이는 반복 횟수를 크게 줄일 수 있지만 BMI2가없는 Intel SnB 제품군 CPU에서는 가변 카운트 이동이 느립니다. 3 개의 UOP, 2C 레이턴시. (카운트 = 0은 플래그가 수정되지 않았 음을 의미하기 때문에 FLAGS에 대한 입력 종속성이 있습니다. 데이터 종속성으로이를 처리하고, UOP는 2 개의 입력 만 가질 수 있기 때문에 여러 UOP를 가져옵니다 (HSW / BDW 사전)). 이것은 x86의 미친 CISC 디자인에 대해 불평하는 사람들이 말하는 종류입니다. ISA가 오늘날 처음부터 거의 비슷한 방식으로 설계되었을 때보 다 x86 CPU가 느려집니다. (즉, 이것은 속도 / 전력을 소비하는 "x86 세금"의 일부입니다.) SHRX / SHLX / SARX (BMI2)는 큰 승리입니다 (1 uop / 1c 대기 시간).

또한 tzcnt (Haswell 이상에서 3c)를 임계 경로에 배치하므로 루프로 전달되는 종속성 체인의 총 대기 시간이 크게 길어집니다. 그러나 CMOV가 필요하거나 레지스터 유지를 준비 할 필요가 없습니다 n>>1. @Veedrac의 대답은 여러 반복에 대해 tzcnt / shift를 지연시켜이 모든 것을 극복합니다. 이는 매우 효과적입니다 (아래 참조).

BSF 또는 TZCNT를 상호 호환 적으로 안전하게 사용할 n수 있습니다. 그 시점에서는 절대 0이 될 수 없기 때문 입니다. TZCNT의 기계 코드는 BMI1을 지원하지 않는 CPU에서 BSF로 디코딩합니다. 의미없는 접두사는 무시되므로 REP BSF는 BSF로 실행됩니다.

TZCNT는이를 지원하는 AMD CPU에서 BSF보다 성능이 훨씬 뛰어나므로 REP BSF출력이 아니라 입력이 0 인 경우 ZF 설정에 신경 쓰지 않아도을 사용하는 것이 좋습니다 . __builtin_ctzll와 함께 사용할 때 일부 컴파일러에서이 작업을 수행합니다 -mno-bmi.

인텔 CPU에서도 동일하게 수행되므로 중요한 경우 바이트를 저장하십시오. Intel의 TZCNT (Skylake 이전)는 입력 = 0 인 BSF가 대상을 수정하지 않은 상태로 유지하는 문서화되지 않은 동작을 지원하기 위해 BSF와 마찬가지로 가정 된 쓰기 전용 출력 피연산자에 대해 여전히 잘못된 종속성을 가지고 있습니다. 따라서 Skylake에만 최적화하지 않는 한이 문제를 해결해야하므로 여분의 REP 바이트에서 얻을 것이 없습니다. 는 x86 ISA 설명서는 말아야 무엇인가에 따라 널리 사용되는 코드를 깨는 방지하기 위해, 필요, 또는 소급 허용되지 않습니다 어떤 (인텔는 종종 위 넘는다. 예를 들어, 윈도우 9x의는 TLB 엔트리에는 투기 프리 페치지지 않습니다 안전했다, 인텔이 TLB 관리 규칙을 업데이트하기 전에 코드 작성 시점 )

어쨌든 Haswell의 LZCNT / TZCNT는 POPCNT와 같은 잘못된 결과를 가지고 있습니다 : 이 Q & A를보십시오 . 이것이 @Veedrac의 코드에 대한 gcc의 asm 출력에서 dst = src를 사용하지 않을 때 TZCNT의 대상으로 사용하려는 레지스터에서 xor-zeroing 으로 dep 체인을 깨는 것을 볼 수 있습니다. TZCNT / LZCNT / POPCNT는 대상을 정의되지 않거나 수정하지 않은 상태로 두지 않으므로 인텔 CPU의 출력에 대한이 잘못된 종속성은 성능 버그 / 제한입니다. 아마도 동일한 실행 장치로 이동하는 다른 UOP처럼 행동하게하는 것은 일부 트랜지스터 / 전력의 가치가 있습니다. 유일한 성능 향상은 또 다른 uarch 제한과의 상호 작용 입니다. 인덱싱 된 주소 지정 모드로 메모리 피연산자를 마이크로 퓨즈 할 수 있습니다 Haswell에서, 그러나 Intel이 LZCNT / TZCNT에 대한 잘못된 뎁을 제거한 Skylake에서는 인덱싱 된 어드레싱 모드를 "라미네이팅"하지 않지만 POPCNT는 여전히 가산기 모드를 마이크로 퓨즈 할 수 있습니다.


다른 답변의 아이디어 / 코드 개선 :

@hidefromkgb의 답변 은 3n + 1 후에 한 번의 올바른 이동을 보장한다는 훌륭한 관찰 결과가 있습니다. 단계 사이의 점검을 생략하는 것보다 훨씬 더 효율적으로 계산할 수 있습니다. 그 대답의 asm 구현은 깨졌지만 (OF에 의존하고, SHRD 후에 카운트가> 1로 정의되지 않음) 느리다 : ROR rdi,2보다 빠르다.보다 SHRD rdi,rdi,2중요한 경로에서 두 개의 CMOV 명령어를 사용하는 것이 여분의 테스트보다 느리다. 병렬로 실행할 수 있습니다.

나는 깔끔하고 개선 된 C (컴파일러가 더 나은 asm을 생성하도록 안내)를 넣고 Godbolt에 대해 더 빠른 asm (C 아래 주석에서)을 테스트하고 작동 : @hidefromkgb의 답변 링크를 참조하십시오 . (이 답변은 큰 Godbolt URL에서 30k 자 제한에 도달했지만 goo.gl에 대해서는 짧은 링크가 썩을 수 있고 너무 길었습니다.)

또한 출력 인쇄를 개선하여 문자열로 변환하고 한 write()번에 한 문자 씩 쓰는 대신 문자열을 만들었 습니다. 이것은 perf stat ./collatz(성능 카운터를 기록하기 위해) 전체 프로그램의 타이밍에 미치는 영향을 최소화하며 중요하지 않은 일부 asm을 난독 화했습니다.


@Veedrac의 코드

우리가 아는 한 오른쪽 이동에서 약간의 속도 향상을 얻었습니다. 필요로하는 에서 루프를 계속 확인하면서 . Core1Duo (Merom)에서 제한값 1e8의 경우 7.5 초에서 풀림 계수가 16의 7.275로 줄어 듭니다.

Godbolt에 대한 코드 + 주석 . 이 버전을 clang과 함께 사용하지 마십시오. 지연 루프와 함께 어리석은 일을합니다. tmp 카운터를 사용하고 나중에 k추가하면 countclang의 기능이 변경되지만 gcc 가 약간 손상됩니다.

의견에서 토론 참조 : Veedrac의 코드는 BMI1 (예 : Celeron / Pentium)이있는 CPU에서 우수합니다 .


4
나는 전에 벡터화 된 접근법을 시도했지만 도움이되지 않았습니다 (스칼라 코드에서 훨씬 더 잘 할 수 tzcnt있고 벡터화 된 경우 벡터 요소 중에서 가장 오래 실행되는 시퀀스에 잠겨 있기 때문에 ).
EOF

3
@EOF : 아니오, 벡터 요소 중 하나모두1아닌 (PCMPEQ / PMOVMSK로 쉽게 감지 가능) 벡터 요소 중 하나 가 닿으 면 내부 루프 가 끊어졌습니다. 그런 다음 PINSRQ 및 항목을 사용하여 종료 된 요소 (및 해당 카운터)로 바이올린을 연주하고 루프로 돌아갑니다. 내부 루프를 너무 자주 끊을 때 쉽게 손실로 변할 수 있지만 내부 루프를 반복 할 때마다 유용한 작업의 2 ~ 4 개 요소를 항상 얻는다는 의미입니다. 그래도 메모에 대한 좋은 지적입니다.
Peter Cordes

4
@jefferson Best 내가 관리하는 것은 godbolt.org/g/1N70Ib 입니다. 더 똑똑한 일을 할 수 있기를 바랐지만 그렇지 않은 것 같습니다.
Veedrac

87
이와 같은 놀라운 답변에 대해 놀랍게 생각하는 것은 그러한 세부 사항에 표시된 지식입니다. 나는 그 수준의 언어 나 시스템을 결코 알지 못할 것이며 어떻게 알지 못할 것입니다. 잘하셨습니다.
camden_kid

8
전설적인 답변 !!
Suin Jain

104

C ++ 컴파일러가 유능한 어셈블리 언어 프로그래머보다 더 최적의 코드를 생성 할 수 있다고 주장하는 것은 매우 잘못된 실수입니다. 그리고 특히이 경우. 인간은 항상 컴파일러가 할 수있는 코드를 더 좋게 만들 수 있으며, 이러한 특정 상황은이 주장을 잘 보여줍니다.

당신이보고있는 타이밍 차이는 문제의 어셈블리 코드가 내부 루프에서 최적과 거리가 멀기 때문입니다.

아래 코드는 32 비트이지만 64 비트로 쉽게 변환 할 수 있습니다.

예를 들어, 시퀀스 기능은 5 개의 명령으로 만 최적화 할 수 있습니다.

    .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

전체 코드는 다음과 같습니다.

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

이 코드를 컴파일하려면 FreshLib 이 필요합니다.

내 테스트에서 (1 GHz AMD A4-1200 프로세서) 위의 코드는 문제의 C ++ 코드보다 약 4 배 빠르며 (430ms 대 1900ms로 컴파일 할 때 -O0) 2 배 이상 빠릅니다 (430 C ++ 코드가로 컴파일 될 때 ms vs. 830 ms) -O3.

두 프로그램의 출력은 동일합니다. i = 837799에서 최대 시퀀스 = 525입니다.


6
허, 영리 해요 SAX는 EAX가 1 (또는 0) 인 경우에만 ZF를 설정합니다. gcc의 -O3출력을 최적화 할 때 놓쳤지만 내부 루프에 대해 다른 모든 최적화를 발견했습니다. (그러나 INC 대신 카운터 증가에 LEA를 사용하는 이유는 무엇입니까? 해당 시점에서 플래그를 클로버하는 것이 좋으며 P4 이외의 항목에서는 속도가 느려질 수 있습니다. 많은 포트에서 실행되지 않고 리소스 충돌로 인해 중요한 경로가 더 자주 지연 될 수 있습니다.)
Peter Cordes

4
아, 실제로 Bulldozer는 컴파일러 출력으로 처리량에 병목 현상을 일으킬 수 있습니다. Haswell보다 대기 시간이 짧은 CMOV 및 3 구성 요소 LEA를 가지고 있기 때문에 루프 캐리 뎁 체인은 코드에서 3주기에 불과합니다. 또한 정수 레지스터에 대한 대기 시간이 0 인 MOV 명령이 없으므로 g ++의 낭비되는 MOV 명령이 실제로 중요한 경로의 대기 시간을 늘리고 불도저에게는 큰 문제입니다. 따라서 수동 최적화는 실제로 쓸모없는 명령을 씹을만큼 초현대적이지 않은 CPU의 경우 컴파일러를 능가합니다.
Peter Cordes

95
" C ++ 컴파일러를 더 잘 주장하는 것은 매우 나쁜 실수입니다. 특히이 경우. 인간은 항상 코드를 더 좋게 만들 수 있습니다.이 특정 문제는이 주장을 잘 설명 하고 있습니다 . " . " 인간 이 더 좋다고 주장하는 것은 매우 나쁜 실수입니다. 특히이 경우. 사람은 항상 코드를 더 나쁘게 만들 수 있으며이 특정 질문 은이 주장에 대한 좋은 예입니다. "따라서 여기에는 요점이 없습니다 . 그러한 일반화가 잘못되었습니다.
luk32

5
@ luk32-그러나 어셈블리 언어에 대한 지식이 거의 없기 때문에 문제의 저자는 전혀 논쟁의 여지가 없습니다. 인간 대 컴파일러에 대한 모든 주장은 인간에게 최소한 중간 수준의 asm 지식을 암시 적으로 가정합니다. 더보기 : "사람이 작성한 코드는 항상 컴파일러에서 생성 한 코드와 동일하거나 더 좋을 것"이라는 정리는 공식적으로 증명하기가 매우 쉽습니다.
johnfound

30
@ luk32 : 숙련 된 사람은 컴파일러 출력으로 시작할 수 있습니다. 따라서 튜닝하려는 대상 하드웨어에서 실제로 더 빠른지 확인하려는 시도를 벤치마킹하면 컴파일러보다 더 나빠질 수 없습니다. 그러나, 나는 그것이 약간의 강한 진술이라는 것에 동의해야합니다. 컴파일러는 보통 초보자 asm 코더보다 훨씬 낫습니다. 그러나 일반적으로 컴파일러와 비교하여 명령을 저장하는 것이 가능합니다. (그러나 uarch에 따라 항상 중요한 경로에있는 것은 아닙니다). 그것들은 매우 유용한 복잡한 기계이지만, "똑똑한"것은 아닙니다.
Peter Cordes

24

성능 향상을 위해 : 간단한 변화는 n = 3n + 1 이후에 n이 짝수이므로 즉시 2로 나눌 수 있음을 관찰합니다. 그리고 n은 1이 아니므로 테스트 할 필요가 없습니다. 따라서 몇 가지 if 문을 저장하고 쓸 수 있습니다.

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;
    }
}

가장 큰 승리 는 다음과 같습니다 . n의 가장 낮은 8 비트를 보면 2 8 배로 나눌 때까지의 모든 단계는 8 비트로 완전히 결정됩니다. 예를 들어, 마지막 8 비트가 0x01이면 이진수로 숫자는 ???? 다음 단계는 0000 0001입니다.

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  -> ???? ???? ????

따라서이 모든 단계를 예측할 수 있으며 256k + 1은 81k + 1로 대체됩니다. 모든 조합에 대해 비슷한 것이 발생합니다. 따라서 큰 switch 문으로 루프를 만들 수 있습니다.

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;
    ...
}

n ≤ 128이 될 때까지 루프를 실행하십시오. 그 시점에서 n은 2로 8 나누기 미만으로 1이 될 수 있고 한 번에 8 개 이상의 단계를 수행하면 처음으로 1에 도달하는 지점을 놓칠 수 있습니다. 그런 다음 "정상"루프를 계속하거나 1 단계에 도달해야하는 단계 수를 알려주는 테이블을 준비하십시오.

추신. Peter Cordes의 제안이 더 빠를 것이라고 생각합니다. 하나를 제외하고는 조건부 분기가 전혀 없으며 루프가 실제로 종료되는 경우를 제외하고는 정확하게 분기됩니다. 코드는 다음과 같습니다

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];
}

실제로, 한 번에 n의 마지막 9, 10, 11, 12 비트를 처리하는 것이 더 빠른지 여부를 측정 할 수 있습니다. 각 비트에 대해 테이블의 항목 수가 두 배가되고 테이블이 L1 캐시에 더 이상 맞지 않으면 속도가 느려집니다.

PPS. 연산 수가 필요한 경우 : 각 반복에서 정확히 8 개의 나누기를 2로 나누고 가변 개수의 (3n + 1) 연산을 수행하므로 연산을 계산하는 확실한 방법은 다른 배열입니다. 그러나 실제로 루프의 반복 횟수에 따라 단계 수를 계산할 수 있습니다.

문제를 약간 재정의 할 수 있습니다. 홀수이면 n을 (3n + 1) / 2로 바꾸고, 짝수이면 n을 n / 2로 바꿉니다. 그런 다음 모든 반복은 정확히 8 단계를 수행하지만 부정 행위를 고려할 수 있습니다 :-) 따라서 r 연산 n <-3n + 1 및 s 연산 n <-n / 2가 있다고 가정하십시오. n <-3n + 1은 n <-3n * (1 + 1 / 3n)을 의미하기 때문에 결과는 꽤 정확하게 n '= n * 3 ^ r / 2 ^ s가됩니다. 로그를 취하면 r = (s + log2 (n '/ n)) / log2 (3)이됩니다.

n ≤ 1,000,000까지 루프를 수행하고 시작 지점 n ≤ 1,000,000에서 필요한 반복 횟수가 미리 계산 된 테이블을 갖는 경우 r을 가장 가까운 정수로 반올림하여 r을 계산하면 s가 실제로 크지 않으면 올바른 결과를 얻을 수 있습니다.


2
또는 곱하기에 대한 데이터 조회 테이블을 만들고 스위치 대신 상수를 추가하십시오. 두 개의 256 엔트리 테이블을 인덱싱하는 것은 점프 테이블보다 빠르며 컴파일러는 아마도 해당 변환을 찾지 않을 것입니다.
Peter Cordes

1
흠, 나는이 관찰이 Collatz 추측을 증명할 수 있다고 생각했지만, 물론 아닙니다. 가능한 모든 후행 8 비트에 대해 모두 사라질 때까지 유한 한 단계가 있습니다. 그러나 8 비트 패턴을 따르는 일부는 비트 문자열의 나머지 부분을 8 이상 연장하므로 무한 성장 또는 반복주기를 배제 할 수는 없습니다.
Peter Cordes

를 업데이트하려면 count세 번째 배열이 필요합니까? adders[]몇 번의 오른쪽 교대가 수행되었는지는 알려주지 않습니다.
Peter Cordes

더 큰 테이블의 경우 더 좁은 유형을 사용하여 캐시 밀도를 높이는 것이 좋습니다. 대부분의 아키텍쳐에서 0으로 확장되는로드 uint16_t는 매우 저렴합니다. x86에서는 32 비트에서 0으로 확장하는 것만 큼 저렴 unsigned int합니다 uint64_t. (Intel CPU의 메모리에서 MOVZX는로드 포트 uop 만 필요하지만 AMD CPU에도 ALU가 필요합니다.) 오, BTW, 왜 사용 size_t하고 lastBits있습니까? 이있는 32 비트 유형 -m32이며 짝수 -mx32(32 비트 포인터가있는 긴 모드)입니다. 에 대한 유형이 잘못되었습니다 n. 그냥 사용하십시오 unsigned.
Peter Cordes

20

다소 관련이없는 메모 : 더 많은 성능 해킹!

  • [첫번째«추정»은 마침내 @ShreevatsaR에 의해 디 deb 크되었다; 제거]

  • 시퀀스를 순회 할 때 현재 요소의 2- 인접 이웃에서 가능한 세 가지 경우 만 얻을 수 있습니다 N(먼저 표시됨).

    1. [홀수]
    2. [홀수] [짝수]
    3. [짝수] [짝수]

    이 두 요소를 뛰어 넘는 것은 각각 (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1및 을 계산하는 것을 의미합니다 N >> 2.

    (1)과 (2) 두 경우 모두 첫 번째 공식을 사용할 수 있음을 증명해 봅시다 (N >> 1) + N + 1.

    사례 (1)은 명백하다. 사례 (2) (N & 1) == 1는 (일반성을 잃지 않고) N이 2 비트 길이이고 그 비트가 ba가장 중요하지 않은 것부터 가장 중요 하다고 가정 할 경우 a = 1다음을 유지한다.

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

    어디서 B = !b? 첫 번째 결과를 오른쪽으로 이동하면 원하는 것을 정확하게 얻을 수 있습니다.

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

    입증 된 바와 같이 단일 삼항 연산을 사용하여 한 번에 시퀀스 2 요소를 탐색 할 수 있습니다. 또 다른 2 배 시간 단축.

결과 알고리즘은 다음과 같습니다.

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;
}

여기서는 n > 2시퀀스의 총 길이가 홀수 인 경우 프로세스가 1 대신 2에서 멈출 수 있기 때문에 비교 합니다.

[편집하다:]

이것을 어셈블리로 번역하자!

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;

다음 명령을 사용하여 컴파일하십시오.

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

Godbolt의 Peter Cordes 가 개발 한 asm의 C 및 개선 / 버그 수정 버전을 참조하십시오 . (편집자 주 : 답변에 물건을 넣은 것에 대해 죄송하지만 내 답변은 Godbolt 링크 + 텍스트에서 30k 자 제한에 도달했습니다!)


2
Q그런 일체가 없습니다 12 = 3Q + 1. 첫 번째 요점이 맞지 않습니다.
Veedrac

1
@ Veedrac : 이것으로 놀아 : ROR / TEST와 하나의 CMOV 만 사용 하여이 답변의 구현보다 더 나은 asm으로 구현할 수 있습니다. 이 asm 코드는 내 CPU의 무한 루프입니다 .OF에 의존하기 때문입니다. OF는 SHRD 또는 ROR 다음에 카운트> 1로 정의되지 않습니다. 또한 피하기 위해 많은 노력을 기울이고 mov reg, imm32바이트를 절약 하려고 합니다. 에도 64 비트 버전의 레지스터 xor rax, rax이므로 불필요한 REX 접두사가 많이 있습니다. n오버 플로우를 피하기 위해 내부 루프를 유지하는 레지스터에는 REX 만 있으면 됩니다.
Peter Cordes

1
타이밍 결과 (Core2Duo E6600 : Merom 2.4GHz. Complex-LEA = 1c 대기 시간, CMOV = 2c) . 최상의 단일 단계 asm 내부 루프 구현 (Johnfound의) :이 @main 루프의 실행 당 111ms 이 C의 난독 처리 된 버전 (일부 tmp 변수 포함)의 컴파일러 출력 : clang3.8 -O3 -march=core2: 96ms. gcc5.2 : 108ms. clang의 asm 내부 루프의 향상된 버전에서 92ms (복잡한 LEA가 1c가 아닌 3c 인 SnB 제품군에서 훨씬 더 크게 향상되어야 함). 이 asm 루프의 개선 된 + 작동 버전에서 (SHRD가 아닌 ROR + TEST 사용) : 87ms. 인쇄 전 5 회 반복 측정
Peter Cordes

2
다음은 첫 66 개의 레코드 세터 (OEIS의 A006877)입니다. 나는 짝수를 굵게 표시했습니다 : 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, 626331, 837799, 1117065, 1501353, 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
트윗 담아 가기 그리고 당신의 다른 요점도 이제 더 좋아집니다 : 4k + 2 → 2k + 1 → 6k +4 = (4k + 2) + (2k + 1) + 1, 2k +1 → 6k +4 → 3k +2 = ( 2k + 1) + (k) + 1. 좋은 관찰!
ShreevatsaR

6

C ++ 프로그램은 소스 코드에서 기계 코드를 생성하는 동안 어셈블리 프로그램으로 변환됩니다. 어셈블리가 C ++보다 느리다는 것은 사실상 잘못된 것입니다. 또한 생성 된 이진 코드는 컴파일러마다 다릅니다. 따라서 스마트 C ++ 컴파일러 벙어리 어셈블러 코드보다 이진 코드를보다 최적이고 효율적으로 생성 할 수 있습니다.

그러나 프로파일 링 방법에는 특정 결함이 있다고 생각합니다. 다음은 프로파일 링에 대한 일반적인 지침입니다.

  1. 시스템이 정상 / 유휴 상태에 있는지 확인하십시오. 시작했거나 CPU를 많이 사용하는 (또는 네트워크를 통해 폴링) 실행중인 모든 프로세스 (애플리케이션)를 중지하십시오.
  2. 데이터 크기가 커야합니다.
  3. 테스트는 5-10 초 이상 실행되어야합니다.
  4. 하나의 샘플에만 의존하지 마십시오. 테스트를 N 번 수행하십시오. 결과를 수집하고 결과의 평균 또는 중앙값을 계산하십시오.

예, 공식적인 프로파일 링을 수행하지는 않았지만 몇 번 실행했으며 3 초에서 2 초를 알려줄 수 있습니다. 어쨌든 대답 해 주셔서 감사합니다. 나는 이미 많은 정보를
얻었

9
아마 아니라 단지 손으로 쓴 ASM 코드 대신 오른쪽 시프트의 64 비트 DIV 명령어를 사용하는 측정 오차. 내 대답을 참조하십시오. 그러나 그렇습니다. 정확하게 측정하는 것도 중요합니다.
Peter Cordes

7
글 머리 기호는 코드 블록보다 더 적합한 형식입니다. 코드가 아니며 모노 스페이스 글꼴의 이점이 없으므로 텍스트를 코드 블록에 넣지 마십시오.
Peter Cordes

16
나는 이것이 어떻게 질문에 대답하는지 알지 못한다. 이것은 어셈블리 코드 또는 C ++ 코드 더 빠를 지에 대한 모호한 질문이 아닙니다. 실제 코드 에 대한 매우 구체적인 질문 입니다. 귀하의 답변에는 해당 코드가 언급되어 있지 않거나 비교 유형이 없습니다. 물론 벤치마킹 방법에 대한 팁은 기본적으로 정확하지만 실제 답변을하기에는 충분하지 않습니다.
코디 그레이

6

Collatz 문제의 경우 "꼬리"를 캐싱하여 성능을 크게 향상시킬 수 있습니다. 이것은 시간 / 메모리 상충 관계입니다. 메모 ( https://en.wikipedia.org/wiki/Memoization )를 참조하십시오 . 다른 시간 / 메모리 트레이드 오프를위한 동적 프로그래밍 솔루션을 살펴볼 수도 있습니다.

파이썬 구현 예 :

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
gnasher의 답변에 따르면 꼬리를 캐시하는 것보다 훨씬 많은 작업을 수행 할 수 있습니다. 높은 비트는 다음에 발생하는 일에 영향을 미치지 않으며 추가 / mul은 왼쪽으로 전달되는 전파에만 영향을 미치므로 높은 비트는 낮은 비트에 발생하는 일에는 영향을 미치지 않습니다. 즉, LUT 조회를 사용하여 한 번에 8 개 (또는 임의의 수)의 비트를 이동하고 곱하여 상수를 추가하여 나머지 비트에 적용 할 수 있습니다. 꼬리를 기억하는 것은 이와 같은 많은 문제에서 확실히 도움이되며, 더 나은 접근법을 아직 생각하지 않았거나 올바르게 입증하지 못한 경우이 문제에 도움이됩니다.
Peter Cordes

2
gnasher의 아이디어를 올바르게 이해하면 꼬리 메모가 직교 최적화라고 생각합니다. 따라서 두 가지를 모두 할 수 있습니다. 메모를 코더 알고리즘에 추가하여 얻을 수있는 양을 조사하는 것이 흥미로울 것입니다.
Emanuel Landeholm

2
결과의 밀도가 높은 부분 만 저장하면 메모를 더 저렴하게 만들 수 있습니다. N의 상한을 설정하고 그 이상으로 메모리를 확인하지 마십시오. 그 아래에서 해시 함수로 hash (N)-> N을 사용하므로 키 = 배열의 위치이므로 저장할 필요가 없습니다. 의 엔트리 0수단은 아직 존재하지. 테이블에 홀수 N 만 저장하여 추가로 최적화 할 수 있으므로 해시 함수는 n>>11을 버립니다. 항상 n>>tzcnt(n)홀수인지 확인하기 위해 단계 코드를 작성하여 홀수인지 확인하십시오.
Peter Cordes

1
그것은 시퀀스 중간에 매우 큰 N 값이 여러 시퀀스에 공통적이지 않을 것이라는 나의 (비평가) 아이디어를 기반으로합니다. 따라서 우리는 그것들을 기억하지 않는 것을 너무 많이 놓치지 않습니다. 또한 합리적 크기의 N은 매우 긴 N으로 시작하는 것조차 포함하여 많은 긴 시퀀스의 일부가 될 것입니다. 임의의 키를 저장할 수있는 테이블.) 근처의 시작 N이 시퀀스 값에서 유사한 경향이 있는지 확인하기 위해 어떤 종류의 적중률 테스트를 수행 했습니까?
Peter Cordes

2
일부 큰 N에 대해 모든 n <N에 대해 미리 계산 된 결과를 저장할 수 있습니다. 따라서 해시 테이블의 오버 헤드가 필요하지 않습니다. 해당 테이블의 데이터는 마다 시작 값을 결국 사용. Collatz 시퀀스가 ​​항상 (1, 4, 2, 1, 4, 2, ...)로 끝나는 것을 확인하고 싶다면 n> 1 인 것을 증명하는 것과 동등한 것으로 증명 될 수 있습니다. 원래 n보다 작아야합니다. 그리고 그것을 위해, 꼬리를 캐싱하는 것은 도움이되지 않습니다.
gnasher729

5

의견에서 :

그러나이 코드는 절대로 멈추지 않습니다 (정수 오버플로로 인해)!?! 이브 다우 스트

많은 숫자의 경우 오버플로 되지 않습니다 .

이 경우 것이다 오버 플로우 - 그 불운 초기 씨앗 하나를 들어, 오버 플로워 수는 매우 가능성이 다른 오버 플로우없이 한 방향으로 수렴됩니다.

아직도 이것은 흥미로운 질문을 제기합니다. 오버플로 순환 시드 번호가 있습니까?

간단한 최종 수렴 시리즈는 두 값의 거듭 제곱으로 시작합니다 (충분히?)

2 ^ 64는 0으로 오버플로되며 알고리즘에 따라 정의되지 않은 무한 루프입니다 (1로만 끝남). 그러나 가장 최적의 솔루션은 shr raxZF = 1 생성 으로 종료됩니다 .

2 ^ 64를 생산할 수 있습니까? 시작 번호가 0x5555555555555555이면 홀수이고 다음 번호는 3n + 1이며 0xFFFFFFFFFFFFFFFF + 1= 0입니다. 이론적으로 알고리즘의 정의되지 않은 상태이지만 johnfound의 최적화 된 답변은 ZF = 1에서 종료하여 복구됩니다. cmp rax,1베드로의 코르는 무한 루프를 종료한다 (QED 변이체 1 내지 정의 "사기꾼" 0수).

좀 더 복잡한 숫자는 0어떻습니까? 솔직히, 내 수학 이론은 진지한 아이디어를 얻기에는 너무 헷갈 리다. 그러나 직관적으로 나는 시리즈가 모든 숫자에 대해 1로 수렴한다고 말합니다. . 따라서 우리는 원본 시리즈의 무한 루프에 대해 걱정할 필요가 없으며 오버플로만 방해 할 수 있습니다.

그래서 시트에 몇 개의 숫자를 넣고 8 비트 잘린 숫자를 보았습니다.

거기에 넘쳐 세 값은 0: 227, 170그리고 85( 85로 직접 이동 0방향으로 진행하는 다른 두, 85).

그러나 순환 오버플로 시드를 생성하는 가치는 없습니다.

재미있게도 나는 8 비트 잘림을 겪은 첫 번째 숫자이며 이미 27영향을 받았다. 이 리치 값 않는 9232적절한 비 절단 시리즈 (제 1 절단 값인에서 32212 단계에서), 비 절단 방법으로 2-255 입력 숫자를 위해 도달되는 최대 값이다 13120(위한 255단계들의 최대 개수 자체) 에 수렴하는 1것은 약 128(+ -2, "1"이 카운트되는지 확실하지 않음 등)입니다.

흥미롭게도 (나를 9232위해) 많은 다른 소스 번호의 경우 최대 수 입니다. 그 점에서 특별한 점은 무엇입니까? : -O 9232= 0x2410... hmmm .. 전혀 모른다.

불행하게도, 난 왜 수렴하고이를 절단의 의미 무엇 않으며,이 시리즈의 깊은 이해를 얻을 수 없다 케이 비트 만과 cmp number,1종료 조건은 특정 입력 값으로 끝나는 무한 루프에 알고리즘을 넣어 확실히 가능 0한 후 잘림.

그러나 278 비트 경우에 넘쳐나 는 값 은 일종의 경고입니다.이 값에 도달하는 단계 수를 세면 1총 k 비트 정수 세트에서 대부분의 숫자에 대해 잘못된 결과를 얻을 수 있습니다. 8 비트 정수의 경우 256 개 중 146 개의 숫자가 잘림에 의해 계열에 영향을 미쳤습니다 (일부 실수로 우연히 올바른 단계 수에 도달 할 수 있습니다.


"오버플로 된 숫자는 다른 오버플로없이 1로 수렴 할 가능성이 높습니다."코드가 절대 멈추지 않습니다. (나는 확실히 ... 할 시간이 끝날 때까지 기다릴 수 그건 추측입니다)
이브 다어 스트

예를 들어 278b 잘림이 있는 시리즈는 다음과 같습니다. 82 41124 62 31 94 47142 71214107 66 (절사 됨) 33100 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 (나머지는 자르지 않고 작동) 나는 당신을 얻을 수 없습니다, 죄송합니다. 잘린 값이 현재 진행중인 시리즈에서 이전에 도달 한 일부와 같으면 결코 멈추지 않을 것이며 k- 비트 잘림과 같은 값을 찾을 수 없습니다 (그러나 수학 이론을 이해할 수없는 이유는 무엇입니까? 이것은 8/16/32/64 비트 잘림을 견뎌냅니다. 직감적으로 작동한다고 생각합니다).
Ped7g

1
" 원래 문제 설명을 아직 확인하지 않았지만 (Collatz 문제) 모든 시작 숫자가 1에서 끝나는 것으로 생각됩니다." ... 좋아, 내 제한 흐릿한 수학 지식과 그것의 이해를 얻을 수 당연 ... : D 그리고 내 시트 실험에서 나는이 모든에 대한 수렴을 수행 말씀 드릴 수 없습니다 2- 255중 잘림없이 (에, 수 1) 또는 8 비트 잘림 (예상 1또는0 세 숫자).
Ped7g

흠, 내가 멈추지 않는다고 말할 때, 나는 멈추지 않는다는 것을 의미합니다. 원하는 경우 주어진 코드는 영원히 실행됩니다.
이브 다우 스트

1
오버플로에서 발생하는 상황을 분석하기 위해 찬성했습니다. CMP 기반 루프는 cmp rax,1 / jna(즉 do{}while(n>1))를 사용 하여 0에서 종료 할 수도 있습니다. n오버 플로우에 얼마나 근접한지를 알기 위해 계측 된 버전의 루프를 본 최대 값을 기록하는 방법에 대해 생각했습니다.
Peter Cordes

5

컴파일러가 생성 한 코드를 게시하지 않았으므로 여기에 약간의 추측이 있지만 그것을 보지 않아도 다음과 같이 말할 수 있습니다.

test rax, 1
jpe even

... 지점을 잘못 예측할 확률이 50 %이며, 이는 비쌀 것입니다.

컴파일러는 거의 확실하게 두 계산을 모두 수행합니다 (div / mod가 상당히 긴 대기 시간이므로 곱하기 추가는 "무료"이므로 CMOV를 따릅니다.) 물론 0 %의 확률로 오판 될 가능성이 있습니다.


1
분기에는 몇 가지 패턴이 있습니다. 예를 들어 홀수 뒤에는 짝수가옵니다. 그러나 때로는 3n + 1이 여러 개의 후행 0 비트를 남기므로 잘못 예측됩니다. 나는 대답에서 나눗셈에 관해 글을 쓰기 시작했으며 OP 코드 에서이 다른 큰 적신호를 다루지 않았다. (패리티 조건을 사용하는 것은 JZ 또는 CMOVZ에 비해 정말 이상합니다. Intel CPU는 TEST / JZ를 매크로 퓨즈로 사용할 수 있지만 TEST / JPE는 매크로 퓨즈를 사용할 수 없기 때문에 CPU의 경우 더 나쁩니다. Agner Fog는 AMD가 JCC와 함께하는 TEST / CMP,이 경우 인간 독자에게는 더 나빠질 것입니다)
Peter Cordes

5

어셈블리를 보지 않아도 가장 분명한 이유 /= 2는 아마도>>=1 많은 프로세서가 매우 빠른 시프트 작업을 수행하므로 . 그러나 프로세서에 시프트 연산이 없더라도 정수 나누기는 부동 소수점 나누기보다 빠릅니다.

편집 : 귀하의 마일리지는 위의 "정수 나누기가 부동 소수점 나누기보다 빠릅니다"문에 따라 다를 수 있습니다. 아래의 의견은 최신 프로세서가 정수 나누기보다 fp 나누기 최적화를 우선 순위로 지정했음을 나타냅니다. 그래서 만약 누군가가이 thread의 질문에 대해, 다음 컴파일러 최적화 묻는 속도 향상에 대한 가능성이 가장 높은 이유를 찾고 있었다 /=2으로 >>=1보기에 최적의 첫번째 장소가 될 것입니다.


관련이없는 참고 하면, n홀수, 표현은 n*3+1항상도있을 것입니다. 따라서 확인할 필요가 없습니다. 해당 지점을 다음으로 변경할 수 있습니다

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

그러므로 전체 진술은

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

4
정수 부분은 실제로 최신 x86 CPU의 FP 부분보다 빠르지 않습니다. 인텔 / AMD가 FP 분배기에 더 많은 트랜지스터를 사용하기 때문이라고 생각합니다. 더 중요한 작업이기 때문입니다. (정수로 정수 나누기는 모듈 식 역수를 곱하도록 최적화 할 수 있습니다). Agner Fog의 insn 테이블을 확인하고 DIVSD (배정 밀도 부동 소수점)를 DIV r32(32 비트 부호없는 정수) 또는 DIV r64(훨씬 느린 64 비트 부호없는 정수)와 비교하십시오. 특히 처리량의 경우 FP 분할이 훨씬 빠르지 만 (마이크로 코딩 대신 단일 uop 및 부분 파이프 라인) 대기 시간도 더 좋습니다.
Peter Cordes

1
예를 들어 OP의 Haswell CPU에서 : DIVSD는 8-14c 처리량 당 1 개, 10-20 사이클 대기 시간입니다. div r6436 uops, 32-96c 대기 시간 및 21-74c 처리량 당 하나입니다. Skylake는 FP 분할 처리량이 훨씬 빠르지 만 (대기 시간이 짧지 않고 4c 당 1 개 파이프 라인으로 표시됨) 정수 div가 훨씬 빠르지는 않습니다. AMD 불도저 제품군과 비슷한 점은 다음과 같습니다. DIVSD는 4.5-11c 처리량 당 1M-op, 9-27c 대기 시간입니다. div r6416-75c 처리량 당 1, 16M-ops, 16-75c 대기 시간입니다.
Peter Cordes

1
FP 나누기가 기본적으로 정수 빼기 지수, 정수 나누기 가수와 동일하지 않습니까? 그리고이 3 단계를 동시에 수행 할 수 있습니다.
MSalters

2
@MSalters : 예, 그렇습니다. 그러나 지수와 사마귀 사이에서 비트를 이동시키는 정규화 단계가 있습니다. double53 비트 가수가 있지만 div r32Haswell 보다 여전히 느립니다 . 따라서 정수와 fp 분배기에 동일한 트랜지스터를 사용하지 않기 때문에 문제가 발생할 때 하드웨어 / 하드웨어가 얼마나 많은 하드웨어에 문제가 있는지는 확실합니다. 정수 1은 스칼라 (정수 -SIMD 나누기가 없음)이며 벡터 1은 128b 벡터 (다른 벡터 ALU와 마찬가지로 256b가 아님)를 처리합니다. 가장 중요한 것은 정수 div가 많은 코드이며 주변 코드에 큰 영향을 미친다는 것입니다.
Peter Cordes

Err, 가수와 지수 사이에서 비트를 이동시키지 않고, 시프트로 가수를 정규화하고, 시프트 량을 지수에 더합니다.
Peter Cordes

4

일반적으로이 작업을 구체적으로 지시하지는 않습니다. 대부분의 경우 높은 수준에서 개선하여 모든 프로그램의 속도를 크게 높일 수 있습니다. 여러 번이 아니라 한 번만 데이터를 계산하는 것과 같이 불필요한 작업을 완전히 피하고 캐시를 가장 잘 사용하는 등 이러한 작업은 고급 언어로 수행하기가 훨씬 쉽습니다.

어셈블러 코드를 작성 하면 최적화 컴파일러의 기능을 향상시킬 있지만 어려운 작업입니다. 완료되면 코드를 수정하기가 훨씬 어려워 알고리즘 개선을 추가하기가 훨씬 어렵습니다. 때때로 프로세서에는 고급 언어로는 사용할 수없는 기능이 있으며, 이러한 경우 인라인 어셈블리가 종종 유용하며 여전히 고급 언어를 사용할 수 있습니다.

오일러 문제에서 대부분의 경우 무언가를 만들고, 왜 느린 지, 더 좋은 것을 만들고, 왜 느린 지 등을 찾아 성공합니다. 어셈블러를 사용하는 것은 매우 어렵습니다. 가능한 속도의 절반에서 더 나은 알고리즘은 일반적으로 최대 속도에서 더 나쁜 알고리즘을 능가하며, 어셈블러에서 최대 속도를 얻는 것은 결코 쉬운 일이 아닙니다.


2
이것에 전적으로 동의합니다. gcc -O3정확한 알고리즘을 위해 Haswell에서 최적의 20 % 이내의 코드를 만들었습니다. (이러한 속도 향상은 내 대답의 주요 초점이었습니다. 왜냐하면 그것이 질문에 대한 것이기 때문에 흥미로운 대답 이었습니다. 올바른 접근 방식이 아니기 때문에 흥미로운 대답이 있습니다.) 오른쪽 이동을 연기하거나 한 번에 2 단계를 수행하는 것과 같습니다. 그보다 훨씬 빠른 속도는 메모 / 조회 테이블에서 얻을 수 있습니다. 철저한 테스트이지만 순수한 무차별 대입은 아닙니다.
Peter Cordes

2
그럼에도 불구하고 명백한 정확한 구현은 다른 구현을 테스트하는 데 매우 유용합니다. 내가 한 것은 아마도 asc 출력을보고 gcc가 예상대로 (대부분 호기심이 아닌) 분기없이 수행했는지 확인한 다음 알고리즘 개선으로 넘어가는 것입니다.
Peter Cordes

-2

간단한 답변 :

  • MOV RBX, 3을 수행하면 MUL RBX가 비싸다. RBX, RBX를 두 번 추가

  • ADD 1은 아마도 INC보다 빠를 것입니다.

  • MOV 2와 DIV는 매우 비쌉니다. 그냥 오른쪽으로 이동

  • 64 비트 코드는 일반적으로 32 비트 코드보다 현저히 느리고 정렬 문제는 더 복잡합니다. 이와 같은 작은 프로그램을 사용하면 32 비트 코드보다 빠를 수있는 병렬 계산을 수행하도록 포장해야합니다.

C ++ 프로그램에 대한 어셈블리 목록을 생성하면 어셈블리와 어떻게 다른지 확인할 수 있습니다.


4
1) : 3 번을 추가하는 것은 LEA에 비해 바보입니다. 또한 mul rbxOP의 Haswell CPU에는 3c 대기 시간 (및 클럭 당 1 처리량)을 갖춘 2 기가 있습니다. imul rcx, rbx, 3동일한 3c 대기 시간으로 1 uop입니다. 2 개의 ADD 명령어는 2c 대기 시간을 가진 2 개의 UOP입니다.
Peter Cordes

5
2) ADD 1은 아마도 INC보다 빠릅니다 . 아니요, OP는 Pentium4를 사용하지 않습니다 . 귀하의 요점 3) 이이 답변의 유일한 올바른 부분입니다.
Peter Cordes

5
4) 총 말도 안되는 소리. 포인터가 클수록 캐시 공간이 커지므로 포인터가 많은 데이터 구조에서는 64 비트 코드가 느려질 수 있습니다. 그러나이 코드는 레지스터에서만 작동하며 코드 정렬 문제는 32 및 64 비트 모드에서 동일합니다. (따라서 데이터 정렬 문제도 있지만 x86-64에서 정렬 문제가 더 큰 문제라는 것을 전혀 알지 못합니다). 어쨌든 코드는 루프 내부의 메모리를 건드리지 않습니다.
Peter Cordes

댓글 작성자는 무슨 말을하는지 모릅니다. 64 비트 CPU에서 MOV + MUL을 수행하면 레지스터를 두 번 추가하는 것보다 약 3 배 느려집니다. 그의 다른 말도 똑같이 틀렸다.
Tyler Durden

6
MOV + MUL은 확실히 멍청하지만 MOV + ADD + ADD는 여전히 바보입니다 (실제로 ADD RBX, RBX두 번 수행 하면 3이 아니라 4가 곱해집니다). 지금까지 가장 좋은 방법은 lea rax, [rbx + rbx*2]입니다. 또는 3 구성 요소 LEA로 만들면서 lea rax, [rbx + rbx*2 + 1] (1) 대신 HSW에서 3c 대기 시간 (내 대답에서 설명한대로)으로 +1을 수행하십시오. 제 요점은 64 비트 곱하기가 너무 비싸지 않다는 것입니다 최근 Intel CPU는 초고속 정수 곱셈 단위를 갖기 때문에 ( MUL r644c 처리량 당 1 개로 6c 대기 시간 인 AMD와 비교할 때도 마찬가지
Peter Cordes
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.