프로그램 - 어셈블리가 C보다 언제 더 빠릅니까?




c언어란 (20)

어셈블러를 알기위한 언급 된 이유 중 하나는 때로는 높은 수준의 언어, 특히 C로 코드를 작성하는 것보다 더 뛰어난 코드를 작성하는 데 사용할 수 있다는 것입니다. 그러나 전적으로 틀린 것은 아니지만 어셈블러를 실제로 더 많은 코드를 생성하는 데 사용할 수있는 경우는 매우 드물고 어셈블리에 대한 전문 지식과 경험이 필요하다는 것을 여러 번 언급했습니다.

이 질문은 어셈블러 지침이 기계 특정 및 이식 가능하지 않거나 어셈블러의 다른 측면에 포함되지 않는다는 사실 에까지 이르지 못합니다. 물론이 외에도 어셈블리를 아는 데는 여러 가지 이유가 있지만 어셈블리 언어와 고급 언어에 대한 확장 된 담론이 아니라 예제와 데이터를 요구하는 특정 질문을 의미합니다.

누구든지 최신 컴파일러를 사용하여 잘 작성된 C 코드보다 어셈블리가 더 빠른 경우에 대한 몇 가지 구체적인 예 를 제공 할 수 있으며 프로파일 링 증거로 해당 주장을 뒷받침 할 수 있습니까? 나는이 사건들이 존재한다고 확신한다. 그러나 나는이 사건이 얼마나 난해한지를 정말로 알고 싶어한다. 왜냐하면 그것이 어떤 논란의 지점 인 것처럼 보이기 때문이다.


C는 8 비트, 16 비트, 32 비트, 64 비트 데이터의 로우 레벨 조작에 "근접"하지만 C가 지원하지 않는 수학 연산이 있습니다. 이는 특정 어셈블리 명령어에서 종종 우아하게 수행 할 수 있습니다 세트 :

  1. 고정 소수점 곱셈 : 두 개의 16 비트 숫자의 곱은 32 비트 숫자입니다. 그러나 C의 규칙에 따르면 두 개의 16 비트 숫자의 곱은 16 비트 숫자이고 두 개의 32 비트 숫자의 곱은 32 비트 숫자입니다. 두 경우 모두 아래쪽 절반입니다. 16x16 곱하기 또는 32x32 곱셈의 위쪽 절반을 원하면 컴파일러로 게임을해야합니다. 일반적인 방법은 필요한 비트 너비보다 커야합니다, 곱하기, 아래로 이동 및 다시 캐스팅하는 것입니다.

    int16_t x, y;
    // int16_t is a typedef for "short"
    // set x and y to something
    int16_t prod = (int16_t)(((int32_t)x*y)>>16);`
    

    이 경우 컴파일러는 실제로 똑똑해서 실제로 16x16 곱셈의 위쪽 절반을 얻고 시스템의 기본 16x16 곱셈을 사용하여 올바른 작업을 수행하려고한다는 것을 알 수 있습니다. 아니면 바보 같을 수도 있고 라이브러리 호출을 통해 32x32 곱셈을 할 수 있습니다. 과부하 인 이유는 제품의 16 비트 만 필요하기 때문입니다. 그러나 C 표준은 자신을 표현할 방법을 제공하지 않습니다.

  2. 특정 비트 이동 작업 (회전 / 운반) :

    // 256-bit array shifted right in its entirety:
    uint8_t x[32];
    for (int i = 32; --i > 0; )
    {
       x[i] = (x[i] >> 1) | (x[i-1] << 7);
    }
    x[0] >>= 1;
    

    이것은 C에서 너무 우아하지는 않지만, 컴파일러가 당신이하고있는 것을 깨닫기에 충분히 똑똑하지 않으면, "불필요한"작업을 많이하게 될 것입니다. 많은 어셈블리 명령어 세트를 사용하면 캐리 레지스터에서 결과를 좌 / 우로 회전하거나 이동할 수 있으므로 34 개의 명령에서 포인터를로드하고 캐리를 지우고 32 개의 8 비트 명령을 수행 할 수 있습니다. 비트 오른쪽 시프트, 포인터에 자동 증가.

    다른 예를 들면, 어셈블리에서 우아하게 수행되는 선형 피드백 시프트 레지스터 (LFSR)가 있습니다. N 비트 (8, 16, 32, 64, 128 등)의 청크를 가져 와서 모든 것을 1만큼 오른쪽으로 이동합니다 알고리즘), 결과 캐리가 1이면 다항식을 나타내는 비트 패턴을 XOR합니다.

그런 말로하면, 심각한 성능 제약 조건을 갖지 않으면 이러한 기술에 의지하지 않을 것입니다. 다른 사람들이 말했듯이, 어셈블리는 C 코드보다 문서화 / 디버그 / 테스트 / 유지 관리가 훨씬 어렵습니다. 성능 향상에는 상당한 비용이 듭니다.

편집 : 3. 어셈블리에서 오버 플로우 감지가 가능합니다 (실제로 C로 처리 할 수는 없습니다). 일부 알고리즘을 훨씬 쉽게 할 수 있습니다.


답이 아닌 것을 지적하십시오.
프로그래밍을하지 않더라도 적어도 하나의 어셈블러 명령어 세트를 아는 것이 유용하다는 것을 알았습니다. 이것은 프로그래머가 더 잘 알고 더 나아질 수있는 끝없는 모험의 일부입니다. 또한 프레임 워크에 들어갈 때 소스 코드가 없으며 진행중인 작업에 대한 대략적인 아이디어가없는 경우 유용합니다. 또한 JavaByteCode 및 .Net IL도 어셈블러와 비슷하므로 이해하는 데 도움이됩니다.

소량의 코드 나 많은 시간이있을 때 질문에 대답하십시오. 칩의 복잡성이 낮고 컴파일러의 경쟁이 열악하여 임베디드 칩에 사용하기에 가장 유용합니다. 또한 제한된 장치의 경우 컴파일러에게 지시하기 어려운 방식으로 코드 크기 / 메모리 크기 / 성능을 자주 거래합니다. 예를 들어이 사용자 작업이 자주 호출되지 않기 때문에 코드 크기가 작고 성능이 좋지 않을 것입니다. 그러나 이와 비슷한 다른 기능이 매 초마다 사용되므로 코드 크기가 커지고 성능이 향상됩니다. 이것은 숙련 된 어셈블리 프로그래머가 사용할 수있는 일종의 거래입니다.

또한 C 코드로 코딩하고 어셈블리를 검사 한 다음 C 코드를 변경하거나 어셈블리로 유지 관리 할 수있는 많은 중간 영역을 추가하고 싶습니다.

내 친구는 마이크로 컨트롤러에서 작동하며, 현재 작은 전기 모터를 제어하기위한 칩입니다. 그는 낮은 수준의 C와 어셈블리의 조합으로 작동합니다. 그는 일전 좋은 하루를 말해주었습니다. 메인 루프를 48 개 명령에서 43 개로 줄였습니다. 코드가 256k 칩을 채우기 위해 커졌고 비즈니스에서 새로운 기능을 원하고있는 것처럼 선택 사항에 직면했습니다.

  1. 기존 기능 제거
  2. 기존 기능 중 일부 또는 전부의 크기를 성능 저하로 줄이십시오.
  3. 고비용, 높은 전력 소비 및 더 큰 폼 팩터로 더 큰 칩으로 이동하는 것을지지하십시오.

꽤 많은 포트폴리오 나 언어, 플랫폼, 응용 프로그램 유형을 상업용 개발자로 추가하고 싶습니다. 필자는 결코 어셈블리를 작성해야 할 필요성을 느끼지 못했습니다. 나는 내가 그것에 대해 얻은 지식을 항상 어떻게 평가 해왔 는가. 그리고 때로는 그것에 디버깅.

나는 "왜 어셈블러를 배워야 하는가?"라는 질문에 더 많은 대답을했지만, 더 빨리 질문 할 때 더 중요한 질문이라고 생각합니다.

다시 한 번 시도해보십시오. 어셈블리에 대해 생각해야합니다.

  • 낮은 수준의 운영 체제 기능에서 작동
  • 컴파일러 작업.
  • 매우 제한된 칩, 임베디드 시스템 등에서 작업하기

어셈블리를 컴파일러와 비교하여 어느 것이 더 빠르거나 더 작거나 더 나은지 확인하십시오.

데이빗.


몇 년 전 나는 C로 프로그램 할 사람을 가르치고있었습니다.이 운동은 그래픽을 90도 회전시키는 것이 었습니다. 그는 그가 곱셈과 나눗셈 등을 사용했기 때문에 완료하는데 몇 분이 걸린 해결책으로 돌아 왔습니다.

비트 교대를 사용하여 문제를 재 작성하는 방법을 보여 주었고 처리 할 시간이 최적화되지 않은 컴파일러에서 약 30 초가되었습니다.

나는 최적화 컴파일러를 가지고 있었고 동일한 코드가 5 초 이내에 그래픽을 회전 시켰습니다. 나는 컴파일러가 생성하고있는 어셈블리 코드를 보았고 거기에서 결정한 것을보고 나서 어셈블러 작성의 필자는 끝났다.


일부 특수 용도의 명령어 세트를 사용할 때만 컴파일러가 지원하지 않습니다.

여러 개의 파이프 라인과 예측 브랜치가있는 최신 CPU의 컴퓨팅 성능을 극대화하려면 어셈블리 프로그램을 다음과 같은 방법으로 구성해야합니다. a) 인간이 작성하는 것이 거의 불가능합니다. b) 유지 관리가 더 이상 불가능합니다.

또한 더 나은 알고리즘, 데이터 구조 및 메모리 관리 기능을 사용하면 어셈블리에서 수행 할 수있는 미세 최적화보다 훨씬 더 우수한 성능을 얻을 수 있습니다.


짧은 답변? 때때로.

기술적으로 모든 추상화에는 비용이 들며 프로그래밍 언어는 CPU가 작동하는 방식에 대한 추상입니다. 그러나 C는 매우 가깝습니다. 몇 년 전 필자는 유닉스 계정에 로그온했을 때 큰 웃음을 짓고 다음과 같은 행운의 메시지를 받았다.

C 프로그래밍 언어 - 어셈블리 언어의 유연성과 어셈블리 언어의 유연성을 결합한 언어입니다.

그것은 사실이므로 재미 있습니다. C는 휴대용 어셈블리 언어와 같습니다.

어셈블리 언어가 실행되지만 사용자가 직접 작성한다는 점은 주목할 가치가 있습니다. 그러나 C와 어셈블리 언어 사이에 컴파일러가 있습니다. C 코드가 얼마나 빠른지 컴파일러가 얼마나 좋은지와 관련이 있기 때문에 매우 중요 합니다.

gcc가 등장했을 때 많은 인기를 끌었던 것들 중 하나가 상용 UNIX 유적과 함께 제공되는 C 컴파일러보다 훨씬 좋았습니다. ANSI C (K & R C 쓰레기 중 아무 것도 아님) 일뿐만 아니라보다 견고하고 일반적으로 더 우수한 (더 빠른) 코드를 생성했습니다. 항상은 아니지만 자주.

왜냐하면 C에 대한 객관적인 기준이 없기 때문에 C와 어셈블러의 속도에 관한 담요 규칙이 없기 때문입니다.

마찬가지로 어셈블러는 실행중인 프로세서, 시스템 사양, 사용중인 명령어 세트 등에 따라 많이 다릅니다. 역사적으로 CISC와 RISC의 두 가지 CPU 아키텍처 제품군이있었습니다. CISC에서 가장 큰 플레이어는 Intel x86 아키텍처 (및 명령어 세트)였습니다. RISC는 UNIX 세계를 지배했습니다 (MIPS6000, Alpha, Sparc 등). CISC는 마음과 마음을 얻기위한 전투에서 승리했습니다.

어쨌든 내가 더 젊은 개발자 일 때 널리 쓰이는 지혜는 수작업으로 작성된 x86이 C보다 훨씬 빠를 수 있다는 것입니다. 왜냐하면 아키텍처가 작동하는 방식, 인간이이 작업을 수행 할 때 얻게되는 복잡성 때문 이었기 때문입니다. 반면에 RISC는 컴파일러 용으로 설계된 것처럼 보였습니다. 아무도 (내가 알기 론) Sparc 어셈블러에 다음과 같이 적었습니다. 나는 그런 사람들이 존재했다고 확신하지만 의심의 여지없이 그들은 미쳐 가고 지금까지 제도화되었다.

명령어 세트는 동일한 프로세서 제품군에서도 중요한 포인트입니다. 특정 인텔 프로세서에는 SSE에서 SSE4와 같은 확장 기능이 있습니다. AMD는 그들 만의 SIMD 명령어를 가지고있었습니다. C와 같은 프로그래밍 언어의 이점은 누군가가 자신의 라이브러리를 작성할 수있어서 실행중인 프로세서에 최적화되어 있다는 것입니다. 그것은 어셈블러에서 힘든 작업이었습니다.

컴파일러가 만들 수없는 어셈블러에서 여전히 만들 수있는 최적화가 있으며 잘 작성된 어셈블러 알고리즘은 C보다 빠르거나 빠를 것입니다. 더 큰 문제는 가치가있는 것인가?

궁극적으로 어셈블러는 시간의 산물이었고 CPU 사이클이 비싼시기에 인기가있었습니다. 현재 Intel Atom을 제조하는 데 5-10 달러가 소요되는 CPU는 누구나 원하는 모든 것을 할 수 있습니다. 어셈블러를 작성하는 유일한 이유는 운영 체제의 일부와 같은 저수준 (심지어 Linux 커널의 대다수가 C로 작성된 경우 라 할지라도), 장치 드라이버, 아마도 임베디드 장치 (C는 그곳에서 지배적 인 경향이 있지만 너무) 등등. 아니면 단지 킥을 위해서 (다소 어록 적이다).


컴파일러가 언제든지 부동 소수점 코드를 볼 때마다 손으로 작성된 버전이 더 빠를 것입니다. 가장 큰 이유는 컴파일러가 강력한 최적화를 수행 할 수 없기 때문입니다. 주제에 대한 토론 은 MSDN의이 기사를 참조하십시오 . 다음은 어셈블리 버전이 C 버전 (VS2K5로 컴파일 됨)의 두 배 속도 인 예제입니다.

#include "stdafx.h"
#include <windows.h>

float KahanSum
(
  const float *data,
  int n
)
{
   float
     sum = 0.0f,
     C = 0.0f,
     Y,
     T;

   for (int i = 0 ; i < n ; ++i)
   {
      Y = *data++ - C;
      T = sum + Y;
      C = T - sum - Y;
      sum = T;
   }

   return sum;
}

float AsmSum
(
  const float *data,
  int n
)
{
  float
    result = 0.0f;

  _asm
  {
    mov esi,data
    mov ecx,n
    fldz
    fldz
l1:
    fsubr [esi]
    add esi,4
    fld st(0)
    fadd st(0),st(2)
    fld st(0)
    fsub st(0),st(3)
    fsub st(0),st(2)
    fstp st(2)
    fstp st(2)
    loop l1
    fstp result
    fstp result
  }

  return result;
}

int main (int, char **)
{
  int
    count = 1000000;

  float
    *source = new float [count];

  for (int i = 0 ; i < count ; ++i)
  {
    source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
  }

  LARGE_INTEGER
    start,
    mid,
    end;

  float
    sum1 = 0.0f,
    sum2 = 0.0f;

  QueryPerformanceCounter (&start);

  sum1 = KahanSum (source, count);

  QueryPerformanceCounter (&mid);

  sum2 = AsmSum (source, count);

  QueryPerformanceCounter (&end);

  cout << "  C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
  cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;

  return 0;
}

내 PC에서 기본 릴리스 빌드 *를 실행하는 일부 숫자는 다음과 같습니다.

  C code: 500137 in 103884668
asm code: 500137 in 52129147

관심 밖에서 나는 루프를 dec / jnz로 바꿨으며 타이밍에 차이가 없었습니다. 때로는 더 빠르고 때로는 느려졌습니다. 나는 메모리가 제한된면이 다른 최적화를 드워프라고 생각합니다.

우물쭈름하게, 나는 약간 다른 버전의 코드를 돌리고 있었고 숫자를 잘못된 방법으로 출력했다 (즉, C는 빠름). 결과를 수정하고 업데이트했습니다.


현실 세계의 예입니다 : 고정 점은 오래된 컴파일러에 곱합니다.

이들은 부동 소수점이없는 장치에서 유용 할뿐 아니라 예측 가능한 오류가있는 32 비트 정밀도를 제공하므로 정밀도 측면에서 빛을 발합니다 (부동 소수점은 23 비트이고 정밀도 손실 예측은 어렵습니다). 즉, 균일 한 상대 정밀도 ( float )가 아닌 전체 범위에서 균일 한 절대 정밀도입니다.

현대 컴파일러는이 고정 소수점 예제를 최적화합니다. 따라서 컴파일러 관련 코드가 여전히 필요한 최신 예제는

  • 64 비트 정수 곱셈의 중요 부분 얻기 : 32x32 => 64 비트 곱셈에 대해 uint64_t 를 사용하는 휴대용 버전은 64 비트 CPU에서 최적화되지 않으므로 64 비트 시스템에서 효율적인 코드를 위해 내장 함수 또는 __int128 이 필요합니다.
  • Windows 32 비트에서 _umul128 : 64 비트로 32 비트 정수를 곱하면 MSVC가 항상 좋은 작업을하는 것은 아니므로 내장 함수가 많은 도움이되었습니다.

C는 완전 곱셈 연산자 (N 비트 입력의 2N 비트 결과)를 가지고 있지 않습니다. C로 표현하는 일반적인 방법은 입력을 더 넓은 유형으로 변환하고 컴파일러가 입력의 상위 비트가 흥미 롭지 않다는 것을 인식하기를 바란다 :

// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
  long long a_long = a; // cast to 64 bit.

  long long product = a_long * b; // perform multiplication

  return (int) (product >> 16);  // shift by the fixed point bias
}

이 코드의 문제점은 C 언어로 직접 표현할 수없는 작업을한다는 것입니다. 우리는 두 개의 32 비트 숫자를 곱하고 중간 32 비트를 반환하는 64 비트 결과를 얻고 싶습니다. 그러나 C에서이 곱셈은 존재하지 않습니다. 당신이 할 수있는 일은 정수를 64 비트로 승격시키고 64 * 64 = 64 곱하기입니다.

하지만 x86 (및 ARM, MIPS 및 기타)은 단일 명령어로 곱셈을 수행 할 수 있습니다. 일부 컴파일러는이 사실을 무시하고 런타임 라이브러리 함수를 호출하여 곱셈을 수행하는 코드를 생성했습니다. 16 시프트는 종종 라이브러리 루틴에 의해 수행됩니다 (또한 x86은 그러한 시프트를 수행 할 수 있습니다).

그래서 우리는 단지 하나 또는 두 개의 라이브러리 호출이 여러 번 남았습니다. 이것은 심각한 결과를 초래합니다. 쉬프트가 더 느릴뿐만 아니라 함수 호출을 통해 레지스터가 보존되어야하며 인라인 및 코드 언 롤링에도 도움이되지 않습니다.

(인라인) 어셈블러에서 동일한 코드를 다시 작성하면 상당한 속도 향상을 얻을 수 있습니다.

이외에도 ASM을 사용하는 것이 문제를 해결하는 최선의 방법은 아닙니다. 대부분의 컴파일러에서는 C로 표현할 수없는 경우 내장형 형식의 어셈블러 명령어를 사용할 수 있습니다. 예를 들어 VS.NET2008 컴파일러는 32 * 32 = 64 비트 mul을 __emul로, 64 비트 시프트를 __ll_rshift로 나타냅니다.

내장 함수를 사용하면 C 컴파일러가 무슨 일이 벌어지고 있는지 이해할 수있는 방법으로 함수를 다시 작성할 수 있습니다. 이렇게하면 코드가 인라인되고, 레지스터가 할당되고, 공통 하위 식 제거 및 상수 전파가 수행 될 수 있습니다. 그렇게 손으로 쓴 어셈블러 코드보다 성능이 크게 향상 될 것입니다.

참고 : VS.NET 컴파일러의 고정 소수점 mul에 대한 최종 결과는 다음과 같습니다.

int inline FixedPointMul (int a, int b)
{
    return (int) __ll_rshift(__emul(a,b),16);
}

고정 소수점 나누기의 성능 차이는 더욱 커집니다. 나는 두 개의 asm-lines을 작성하여 division heavy fixed point 코드에 대해 10 배까지 개선했다.

Visual C ++ 2013을 사용하면 두 가지 방법으로 동일한 어셈블리 코드를 사용할 수 있습니다.

2007 년의 gcc4.1은 또한 순수한 C 버전을 멋지게 최적화합니다. (Godbolt 컴파일러 탐색기에는 이전 버전의 gcc가 설치되어 있지 않지만 이전 버전의 GCC 버전도 내장 함수없이이 작업을 수행 할 수 있습니다.)

Godbolt 컴파일러 탐색기 에서 x86 (32 비트) 및 ARM의 경우 source + asm을 참조하십시오. (불행히도 간단한 순수 C 버전에서 나쁜 코드를 생성 할만큼 오래된 컴파일러는 없다.)

최신 CPU는 C가 전혀 작동하지 않는 작업을 수행 할 수 있습니다 (예 : popcnt 또는 비트 스캔으로 첫 번째 또는 마지막 세트 비트를 찾습니다) . POSIX에는 ffs() 함수가 있지만 그 의미가 x86 bsf / bsr 과 일치하지 않습니다 ( https://en.wikipedia.org/wiki/Find_first_set ).

일부 컴파일러는 정수의 설정 비트 수를 계산하고 popcnt 명령어 (컴파일 타임에 활성화 된 경우)로 컴파일하는 루프를 인식 할 수 있지만 GNU C 또는 x86에서 __builtin_popcnt 를 사용하는 것이 훨씬 안정적입니다. <immintrin.h> _mm_popcnt_u32 SSE4.2를 사용하는 하드웨어 만 대상으로합니다.

또는 C ++에서 std::bitset<32> 할당하고 .count() 합니다. (이것은 언어가 popcount의 최적화 된 구현을 표준 라이브러리를 통해 이식 가능하게 노출시키는 방법을 발견하여 항상 올바른 것으로 컴파일되고 대상이 지원하는 모든 것을 활용할 수있는 방법입니다.) https://en.wikipedia.org/wiki/Hamming_weight#Language_support .

비슷하게, ntohl 은 그것을 가지고있는 일부 C 구현에서 bswap (엔디안 변환을위한 x86 32 비트 바이트 스왑)을 컴파일 할 수 있습니다.

intrinsics 또는 hand-written asm의 또 다른 주요 영역은 SIMD 명령어를 사용한 수동 벡터화입니다. 컴파일러는 dst[i] += src[i] * 10.0; 같은 간단한 루프에서는 나쁘지 않습니다 dst[i] += src[i] * 10.0; 하지만 상황이 더욱 복잡해지면 종종 잘못 처리되거나 자동 벡터화되지 않습니다. 예를 들어, SIMD를 사용하여 atoi를 구현하는 방법 과 같은 것을 얻을 수는 없을 것입니다 . 스칼라 코드에서 컴파일러에 의해 자동으로 생성됩니다.



PolyPascal (형제에서 터보 파스칼로)의 CP / M-86 버전에 대한 가능성 중 하나는 "사용 - 바이오스 - 출력 - 문자 - 화면"기능을 기계 언어 루틴으로 대체하는 것이 었습니다. x와 y, 그리고 거기에 넣을 문자열이 주어졌습니다.

이로써 이전보다 훨씬 빠르게 화면을 업데이트 할 수있었습니다!

바이너리에는 기계 코드 (수백 바이트)를 내장 할 공간이 있었고 거기에 다른 것들도 있었기 때문에 가능한 한 많이 짜내는 것이 필수적이었습니다.

화면이 80x25 였기 때문에 두 좌표가 각각 1 바이트에 맞을 수 있으므로 두 가지 모두 2 바이트 단어로 맞출 수 있습니다. 이렇게하면 단일 추가가 두 값을 동시에 조작 할 수 있기 때문에 더 적은 바이트로 필요한 계산을 수행 할 수 있습니다.

내 지식으로는 레지스터에 여러 값을 병합 할 수있는 C 컴파일러가 없으며 나중에 SIMD 명령어를 사용하여 나중에 다시 나눌 수 있습니다. 어쨌든 기계 명령어가 짧아 질 것이라고 생각하지 않습니다.


SIMD 명령어를 사용하는 매트릭스 연산은 아마 컴파일러가 생성 한 코드보다 빠릅니다.


나는 각 인터럽트마다 192 또는 256 비트에서 50 마이크로 초마다 발생해야하는 비트의 조 변경 작업을 수행합니다.

그것은 고정 된 맵 (하드웨어 제약)에 의해 발생합니다. C를 사용하면 약 10 마이크로 초가 소요됩니다. 이 맵의 특정 기능, 특정 레지스터 캐싱 및 비트 지향 연산 사용을 고려하여 이것을 Assembler로 변환했을 때. 그것은 수행하는 데 3.5 마이크로 초보다 적게 걸렸다.


내 경험에 대한 몇 가지 예 :

  • 예를 들어 x86-64, IA-64, DEC Alpha 및 64 비트 MIPS 또는 PowerPC와 같은 많은 아키텍처는 64 비트 x 64 비트의 곱셈을 지원하여 128 비트 결과를 생성합니다. GCC는 최근 이러한 지침에 대한 액세스를 제공하는 확장을 추가했지만 그 전에 어셈블리가 필요했습니다. 이 지침에 대한 액세스는 RSA와 같은 것을 구현할 때 64 비트 CPU에서 큰 차이를 만들 수 있습니다 - 때로는 성능면에서 4 배 향상됩니다.

  • CPU 특정 플래그에 대한 액세스. 나에게 많이 물린 것 중 하나가 캐리 플래그입니다. 다중 정밀도를 추가 할 때 CPU 캐리 비트에 액세스 할 수없는 경우 대신 결과를 비교하여 오버플로되었는지 확인해야합니다.이 오버플로는 사지 당 3-5 개 이상의 명령어가 필요합니다. 그리고 최악의 경우, 데이터 액세스면에서 꽤 연속적이어서 현대 슈퍼 스칼라 프로세서에서 성능을 저하시킵니다. 이러한 수천 개의 정수를 연속적으로 처리 할 때 addc를 사용할 수 있다는 것은 엄청난 승리입니다 (캐리 비트에도 경합이있는 수퍼 스칼라 문제가 있지만 최신 CPU는이를 잘 처리합니다).

  • SIMD. 자동 컴파일 컴파일러조차도 비교적 간단한 경우에만 수행 할 수 있습니다. 따라서 좋은 SIMD 성능을 원하면 불행히도 코드를 직접 작성해야합니다. 물론 어셈블리 대신 intrinsics를 사용할 수는 있지만 일단 내장 클래스를 사용하면 컴파일러를 레지스터 할당 자 및 (명목상) 명령 스케줄러로 사용하여 기본적으로 어셈블리를 작성하게됩니다. (컴파일러가 함수 프롤로그를 생성 할 수 있기 때문에 필자는 SIMD에 내장 함수를 사용하는 경향이 있으므로 함수 호출 규칙과 같은 ABI 문제를 처리하지 않고도 Linux, OS X 및 Windows에서 동일한 코드를 사용할 수 있지만 다른 것은 SSE 내장 함수는 실제로 좋지 않습니다. Altivec는 그다지 경험이 없지만 더 좋을 것 같습니다.)(현재의) 벡터화 컴파일러가 알아낼 수없는 것들의 예로서,비트 슬리핑 AES 또는 SIMD 오류 수정 - 알고리즘을 분석하고 그러한 코드를 생성 할 수있는 컴파일러를 상상할 수 있지만 현명한 컴파일러가 기존 (기껏해야)에서 최소 30 년 이상 떨어져있는 것처럼 느껴집니다.

다른 한편, 멀티 코어 머신과 분산 시스템은 다른 방향으로 가장 많은 성능을 얻었습니다. 어셈블리에서 내부 루프를 작성하는 데 20 %의 속도 향상을 주거나 여러 코어에서 실행하는 경우 300 % 또는 이를 기계 클러스터 전체에서 실행합니다. 물론 고급 수준의 최적화 (선물, 메모 작성 등)는 ML이나 Scala와 같은 고급 언어에서 C 나 asm보다 훨씬 쉽게 수행 할 수 있으며 종종 더 큰 성능을 얻을 수 있습니다. 따라서 언제나처럼 상충 관계가 있습니다.


어셈블러가 더 빠른 일반적인 경우는 스마트 어셈블리 프로그래머가 컴파일러의 출력을보고 "이것은 성능을위한 중요한 경로이며 더 효율적으로 작성할 수 있습니다"라고 말한 다음 해당 어셈블러를 수정하거나 다시 작성하는 경우입니다. 기스로부터.


런타임시 기계 코드 생성어떻습니까?

오빠 (2000 년경)는 런타임에 코드를 생성하여 매우 빠른 실시간 레이 트레이서를 실현했습니다. 세부 사항을 기억할 수는 없지만 객체를 순환하는 일종의 메인 모듈이 있었고 각 객체에 특정한 기계 코드를 준비하고 실행하고있었습니다.

그러나 시간이 지남에 따라이 방법은 새로운 그래픽 하드웨어에 의해 강력 해졌으며 쓸모 없게되었습니다.

오늘날, 필자는 피벗 테이블, 드릴링, 온더 플라이 (on-the-fly) 계산 등과 같은 거대한 데이터 (수백만 개의 레코드)에 대한 일부 작업이이 방법으로 최적화 될 수 있다고 생각합니다. 문제는 가치있는 노력인가?


간단한 대답 ... 어셈블리를 아는 사람 이라면 (예를 들어 그 옆에 참조가 있으며 모든 작은 프로세서 캐시와 파이프 라인 기능 등을 활용하고 있습니다) 모든 컴파일러 보다 훨씬 빠른 코드를 생성 할 수 있습니다 .

그러나 요즘의 차이점은 일반적인 애플리케이션에서는 중요하지 않습니다.


긴 생각, 한가지 제한이 있습니다 : 시간. 모든 코드 변경을 최적화하고 레지스터 할당에 시간을 투자 할 자원이 없으면 컴파일러는 매번이 코드를 유출하지 않고 최적화하지 않을 것입니다. 코드를 수정하고, 다시 컴파일하고 측정합니다. 필요한 경우 반복하십시오.

또한 높은 수준의 측면에서 많은 일을 할 수 있습니다. 또한 결과 어셈블리를 검사하면 코드가 잘못되었다는 인상을 줄 수 있지만 실제로는 더 빨리 수행 할 것이라고 생각하는 것보다 더 빨리 실행됩니다. 예:

int y = data [i]; // 여기에 물건을 넣는다 .. call_function (y, ...);

컴파일러는 데이터를 읽어 스택 (누출)하고 나중에 스택에서 읽고 인수로 전달합니다. 똥 소리 야? 실제로는 매우 효과적인 대기 시간 보정이되어 런타임이 빨라질 수 있습니다.

// 최적화 된 버전 call_function (data [i], ...); // 결국별로 최적화되지 않았습니다.

최적화 된 버전의 아이디어는 레지스터 압력을 줄이고 누출을 방지한다는 것입니다. 그러나 실제로, "엿 같은"버전은 더 빠릅니다!

어셈블리 코드를보고 지시 사항을보고 결론을 내리면 : 더 많은 지시 사항은 오판 될 것입니다.

여기서 주목해야 할 것은 많은 어셈블리 전문가들이 많은 것을 알고 있지만 거의 알지 못한다고 생각 합니다. 규칙은 아키텍처에서 다음으로 변경됩니다. 예를 들어 항상 가장 빠른 x86 코드는 없습니다. 요즘 엄지 손가락으로 보는 것이 더 좋습니다.

  • 메모리가 느리다.
  • 캐시가 빠름
  • 더 나은 캐싱을 사용하려고 시도하십시오.
  • 얼마나 자주 놓칠까요? 대기 시간 보상 전략이 있습니까?
  • 하나의 단일 캐시 누락에 대해 10-100 ALU / FPU / SSE 명령을 실행할 수 있습니다
  • 응용 프로그램 아키텍처가 중요합니다 ..
  • ..하지만 문제가 아키텍처에 없을 때 도움이되지 않습니다.

또한, 컴파일러에 너무 많은 것을 믿어서 가볍게 생각한 C / C ++ 코드를 "이론적으로 최적의"코드로 변형시키는 것은 희망적인 생각입니다. 이 저수준에서 "성능"에 관심이 있다면 사용하는 컴파일러와 툴 체인을 알아야합니다.

C / C ++의 컴파일러는 일반적으로 하위 표현식의 순서를 바꾸는 데별로 좋지 않습니다. 기능에 부작용이 있기 때문입니다. 기능적 언어는이 경고에서 벗어나지 만 현재의 생태계에 잘 맞지 않습니다. 컴파일러 옵션은 컴파일러 / 링커 / 코드 생성기로 작업 순서를 변경할 수있는 고정밀 규칙을 허용합니다.

이 주제는 막 다른 골목입니다. 대부분의 경우 관련성이없고 나머지는 이미 어쨌든 무엇을하고 있는지 알고 있습니다.

이 모든 것은 다음과 같이 요약됩니다. "당신이하고있는 것을 이해하는 것"입니다. 당신이하는 일을 아는 것과는 조금 다릅니다.


나는 아무도 이것을 말하지 않았다는 것에 놀랐다. 이 strlen()함수는 어셈블리로 작성하면 훨씬 빠릅니다! C 언어로 할 수있는 최선의 방법은

int c;
for(c = 0; str[c] != '\0'; c++) {}

어셈블리 중에는 속도를 상당히 높일 수 있습니다.

mov esi, offset string
mov edi, esi
xor ecx, ecx

lp:
mov ax, byte ptr [esi]
cmp al, cl
je  end_1
cmp ah, cl
je end_2
mov bx, byte ptr [esi + 2]
cmp bl, cl
je end_3
cmp bh, cl
je end_4
add esi, 4
jmp lp

end_4:
inc esi

end_3:
inc esi

end_2:
inc esi

end_1:
inc esi

mov ecx, esi
sub ecx, edi

길이는 ecx이다. 이것은 시간에 4 문자를 비교하므로 4 배 빠릅니다. eax와 ebx의 상위 단어를 사용한다고 생각 하면 이전 C 루틴 보다 8 배 더 빠릅니다 !


너무 오래 전 이었기 때문에 구체적인 예제를 드릴 수는 없지만 수작업으로 작성한 어셈블러가 모든 컴파일러를 능가 할 수있는 경우가 많이있었습니다. 이유 :

  • 레지스터에서 레지스터를 전달하면서 호출 규칙을 벗어날 수 있습니다.

  • 레지스터를 사용하는 방법을 신중하게 고려하고 변수를 메모리에 저장하는 것을 피할 수 있습니다.

  • 점프 테이블과 같은 것들을 위해서, 당신은 경계를 확인하는 것을 피할 수 있습니다.

기본적으로 컴파일러는 최적화가 잘되어 거의 항상 "훌륭합니다". 그러나 매 사이클마다 값을 치르는 그래픽 렌더링과 같은 상황에서는 코드를 알고 있기 때문에 바로 가기를 사용할 수 있습니다. 컴파일러는 안전한쪽에 있어야하기 때문에 컴파일러가 할 수 없습니다.

실제로, 선 그리기 또는 다각형 채우기 루틴과 같은 루틴이 실제로 스택에 작은 기계 블록 블록을 생성하여 거기에서 실행하여 연속적인 의사 결정을 피할 수있는 그래픽 렌더링 코드에 대해 들어 봤습니다. 선 스타일, 폭, 패턴 등에 대해

즉, 컴파일러에서 수행하기를 원하는 것은 나를위한 훌륭한 어셈블리 코드를 생성하는 것이지만 너무 영리하지는 않으며 대부분 그렇게합니다. 사실, Fortran을 싫어하는 것 중 하나는 코드를 "최적화"하려는 시도에서 코드를 스크램블링하는 것입니다. 일반적으로 중요한 목적은 아닙니다.

일반적으로 앱에 성능 문제가있는 경우 이는 낭비적인 디자인 때문입니다. 요즘은 전반적인 앱이 이미 1 인치 이내에 조정되었지만 여전히 충분히 빠르지 않고 엄격한 내부 루프에서 모든 시간을 보내지 않는 한 성능을 위해 어셈블러를 추천하지 않습니다.

덧붙여서 : 나는 어셈블리 언어로 작성된 많은 응용 프로그램을 보았고 C, Pascal, Fortran 등과 같은 언어보다 주요한 속도 이점은 프로그래머가 어셈블러에서 코딩 할 때 훨씬 더주의했기 때문입니다. 그 사람은 언어에 관계없이 하루에 약 100 줄의 코드를 작성하고, 컴파일러 언어는 3 개 또는 400 개의 명령어로 구성됩니다.


올바른 프로그래머라면 Assembler 프로그램은 항상 C 코드보다 빠르다 (최소한 약간만). Assembler의 적어도 하나의 명령어를 꺼낼 수없는 C 프로그램을 만드는 것은 어려울 것입니다.


이미지로 재생할 때와 같이 촘촘한 루프는 이미지가 수백만 픽셀로 구성 될 수 있기 때문에 가능합니다. 아래에 앉아서 제한된 수의 프로세서 레지스터를 최대한 활용하는 방법을 찾는 것이 효과적 일 수 있습니다. 실제 삶의 견본은 다음과 같습니다.

http://danbystrom.se/2008/12/22/optimizing-away-ii/

그렇다면 종종 프로세서에는 컴파일러가 신경을 쓰는 데 너무 전문화 된 몇 가지 복잡한 명령어가 있지만, 때때로 어셈블러 프로그래머가이를 잘 활용할 수 있습니다. 예를 들어 XLAT 명령을 사용하십시오. 정말 좋은 당신은 루프에서 테이블 룩업을 수행해야하는 경우 테이블은 256 바이트로 제한됩니다!

업데이트 : 아, 그냥 일반적으로 루프를 말할 때 가장 중요한 점을 생각해보십시오. 컴파일러는 흔히 반복되는 반복 수에 대한 단서가 없습니다. 프로그래머 만이 반복 횟수를 여러 번 반복한다는 점을 알고 있으므로 추가 작업을 통해 루프를 준비하는 것이 유익 할 것입니다. 또는 반복 횟수보다 반복되는 반복 횟수가 너무 적으므로 예상했다.





assembly