값이 C 배열에 있는지 빨리 찾으십니까? 결합하고 플래시 대신 RAM에

256 크기의 배열 (바람직하게는 1024이지만 256이 최소값 임)을 반복해야하는 시간 결정적인 ISR이있는 임베디드 응용 프로그램이 있고 값이 배열 내용과 일치하는지 확인합니다. A bool는 true로 설정됩니다.

마이크로 컨트롤러는 NXP LPC4357, ARM Cortex M4 코어이고 컴파일러는 GCC입니다. 나는 이미 최적화 수준 2 (3이 더 느림)를 결합하고 플래시 대신 RAM에 기능을 배치했습니다. 나는 또한 포인터 산술과 for루프를 사용하는데, 이는 up 대신 down-counting을 수행합니다 (if i!=0검사가 if 검사보다 빠릅니다 i<256). 대체로 12.5 µs의 지속 시간으로 끝납니다. 이는 실현 가능하도록 크게 줄여야합니다. 이것은 내가 지금 사용하는 (의사) 코드입니다.

uint32_t i;
uint32_t *array_ptr = &theArray[0];
uint32_t compareVal = 0x1234ABCD;
bool validFlag = false;

for (i=256; i!=0; i--)
{
    if (compareVal == *array_ptr++)
    {
         validFlag = true;
         break;
     }
}

이를 수행하는 가장 빠른 방법은 무엇입니까? 인라인 어셈블리 사용이 허용됩니다. 다른 ‘덜 우아한’트릭도 허용됩니다.



답변

성능이 가장 중요한 상황에서 C 컴파일러는 수동으로 튜닝 된 어셈블리 언어로 할 수있는 작업에 비해 가장 빠른 코드를 생성하지 못할 가능성이 높습니다. 나는 최소한의 저항의 길을 택하는 경향이 있습니다. 이와 같은 작은 루틴의 경우, 저는 asm 코드를 작성하고 실행하는 데 얼마나 많은 사이클이 소요 될지 좋은 아이디어를 가지고 있습니다. C 코드를 조작하고 컴파일러가 좋은 출력을 생성하도록 할 수는 있지만 출력을 조정하는 데 많은 시간을 낭비하게 될 수 있습니다. 컴파일러 (특히 Microsoft)는 지난 몇 년 동안 먼 길을 걸어 왔지만 일반적인 경우가 아닌 특정 상황에서 작업하기 때문에 여전히 귀 사이의 컴파일러만큼 똑똑하지 않습니다. 컴파일러는이 속도를 높일 수있는 특정 명령어 (예 : LDM)를 사용하지 않을 수 있습니다. 루프를 풀기에 충분히 똑똑하지 않을 것입니다. 내 의견에서 언급 한 3 가지 아이디어를 통합하는 방법은 다음과 같습니다. 루프 풀기, 캐시 프리 페치 및 다중로드 (ldm) 명령어 사용. 명령어 사이클 수는 어레이 요소 당 약 3 클럭으로 나오지만 메모리 지연은 고려하지 않습니다.

작동 이론 : ARM의 CPU 설계는 대부분의 명령어를 한 클럭 주기로 실행하지만 명령어는 파이프 라인에서 실행됩니다. C 컴파일러는 중간에 다른 명령어를 삽입하여 파이프 라인 지연을 제거하려고합니다. 원본 C 코드와 같은 타이트한 루프가 제공되면 컴파일러는 메모리에서 읽은 값을 즉시 비교해야하기 때문에 지연을 숨기는 데 어려움을 겪습니다. 아래의 코드는 메모리 자체의 지연과 데이터를 가져 오는 파이프 라인의 지연을 크게 줄이기 위해 4 개의 레지스터로 구성된 2 세트를 번갈아 사용합니다. 일반적으로 대용량 데이터 세트로 작업 할 때 코드가 사용 가능한 레지스터의 대부분 또는 전부를 사용하지 않으면 최대 성능을 얻지 못합니다.

; r0 = count, r1 = source ptr, r2 = comparison value

   stmfd sp!,{r4-r11}   ; save non-volatile registers
   mov r3,r0,LSR #3     ; loop count = total count / 8
   pld [r1,#128]
   ldmia r1!,{r4-r7}    ; pre load first set
loop_top:
   pld [r1,#128]
   ldmia r1!,{r8-r11}   ; pre load second set
   cmp r4,r2            ; search for match
   cmpne r5,r2          ; use conditional execution to avoid extra branch instructions
   cmpne r6,r2
   cmpne r7,r2
   beq found_it
   ldmia r1!,{r4-r7}    ; use 2 sets of registers to hide load delays
   cmp r8,r2
   cmpne r9,r2
   cmpne r10,r2
   cmpne r11,r2
   beq found_it
   subs r3,r3,#1        ; decrement loop count
   bne loop_top
   mov r0,#0            ; return value = false (not found)
   ldmia sp!,{r4-r11}   ; restore non-volatile registers
   bx lr                ; return
found_it:
   mov r0,#1            ; return true
   ldmia sp!,{r4-r11}
   bx lr

업데이트 :
내 경험이 일화적이고 가치가 없다고 생각하고 증거가 필요하다고 생각하는 많은 회의론자들이 있습니다. GCC 4.8 (Android NDK 9C)을 사용하여 최적화 -O2 ( 루프 언 롤링포함하여 모든 최적화가 켜짐)으로 다음 출력을 생성했습니다. . 위의 질문에 제시된 원본 C 코드를 컴파일했습니다. GCC가 생산 한 것은 다음과 같습니다.

.L9: cmp r3, r0
     beq .L8
.L3: ldr r2, [r3, #4]!
     cmp r2, r1
     bne .L9
     mov r0, #1
.L2: add sp, sp, #1024
     bx  lr
.L8: mov r0, #0
     b .L2

GCC의 출력은 루프를 풀지 않을뿐만 아니라 LDR 이후 스톨에서 클록을 낭비합니다. 어레이 요소 당 최소 8 개의 클럭이 필요합니다. 루프를 종료 할시기를 알기 위해 주소를 사용하는 것이 좋지만 컴파일러가 수행 할 수있는 모든 마법 같은 작업은이 코드에서 찾을 수 없습니다. 타겟 플랫폼에서 코드를 실행 한 적은 없지만 (저는 소유하지 않음) ARM 코드 성능 경험이있는 사람이라면 누구나 내 코드가 더 빠르다는 것을 알 수 있습니다.

업데이트 2 :
Microsoft의 Visual Studio 2013 SP2에 코드를 더 잘 처리 할 수있는 기회를주었습니다. NEON 명령어를 사용하여 배열 초기화를 벡터화 할 수 있었지만 OP에 의해 작성된 선형 값 검색은 GCC가 생성 한 것과 비슷하게 나왔습니다 (더 읽기 쉽게 레이블 이름을 변경했습니다).

loop_top:
   ldr  r3,[r1],#4
   cmp  r3,r2
   beq  true_exit
   subs r0,r0,#1
   bne  loop_top
false_exit: xxx
   bx   lr
true_exit: xxx
   bx   lr

내가 말했듯이, 나는 OP의 정확한 하드웨어를 소유하고 있지 않지만, 3 가지 버전의 nVidia Tegra 3 및 Tegra 4에서 성능을 테스트하고 곧 여기에 결과를 게시 할 것입니다.

업데이트 3 :
Tegra 3 및 Tegra 4 (Surface RT, Surface RT 2)에서 내 코드와 Microsoft의 컴파일 된 ARM 코드를 실행했습니다. 모든 것이 캐시에 있고 측정하기 쉽도록 일치 항목을 찾지 못하는 루프를 1000000 번 반복했습니다.

             My Code       MS Code
Surface RT    297ns         562ns
Surface RT 2  172ns         296ns  

두 경우 모두 내 코드가 거의 두 배 빠르게 실행됩니다. 대부분의 최신 ARM CPU는 비슷한 결과를 제공 할 것입니다.


답변

최적화를위한 트릭이 있습니다 (한 번 면접에서이 질문을 받았습니다).

  • 배열의 마지막 항목에 찾고있는 값이 있으면 true를 반환합니다.
  • 찾고있는 값을 배열의 마지막 항목에 씁니다.
  • 찾고있는 값을 만날 때까지 배열을 반복합니다.
  • 배열의 마지막 항목 이전에 발생한 경우 true를 반환합니다.
  • 거짓 반환

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    uint32_t x = theArray[SIZE-1];
    if (x == compareVal)
        return true;
    theArray[SIZE-1] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    theArray[SIZE-1] = x;
    return i != SIZE-1;
}

이렇게하면 반복 당 두 개의 분기 대신 반복 당 하나의 분기가 생성됩니다.


최신 정보:

에 배열을 할당 SIZE+1할 수있는 경우 “마지막 항목 교체”부분을 제거 할 수 있습니다.

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    theArray[SIZE] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    return i != SIZE;
}

theArray[i]대신 다음을 사용하여에 포함 된 추가 산술을 제거 할 수도 있습니다 .

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t *arrayPtr;
    theArray[SIZE] = compareVal;
    for (arrayPtr = theArray; *arrayPtr != compareVal; arrayPtr++);
    return arrayPtr != theArray+SIZE;
}

컴파일러가 아직 적용하지 않은 경우이 함수는 확실히 적용합니다. 반면에 최적화 프로그램에서 루프를 풀기가 더 어려워 질 수 있으므로 생성 된 어셈블리 코드에서 확인해야합니다.


답변

알고리즘 최적화에 대한 도움을 요청하고 있는데, 이로 인해 어셈블러로 이동할 수 있습니다. 그러나 알고리즘 (선형 검색)은 그렇게 영리하지 않으므로 알고리즘 변경을 고려해야합니다. 예 :

완벽한 해시 함수

256 개의 “유효한”값이 정적이고 컴파일 타임에 알려진 경우 완벽한 해시 함수를 사용할 수 있습니다 . 입력 값을 0 .. n 범위의 값에 매핑하는 해시 함수를 찾아야합니다. 여기서 중요한 모든 유효한 값에 대해 충돌 이 없습니다 . 즉, 두 개의 “유효한”값이 동일한 출력 값으로 해시되지 않습니다. 좋은 해시 함수를 검색 할 때 다음을 목표로합니다.

  • 해시 함수를 합리적으로 빠르게 유지하십시오.
  • n을 최소화 하십시오 . 얻을 수있는 최소값은 256 (최소 완전 해시 함수)이지만 데이터에 따라 달성하기 어려울 수 있습니다.

효율적인 해시 함수를 위해 n 은 종종 2의 거듭 제곱이며 이는 하위 비트의 비트 마스크 (AND 연산)와 동일합니다. 해시 함수의 예 :

  • 입력 바이트의 CRC, 모듈로 n .
  • ((x << i) ^ (x >> j) ^ (x << k) ^ ...) % n(많은으로 따기 i, j, k, … 필요에 따라 왼쪽 또는 오른쪽으로 이동 포함)

그런 다음 해시가 입력 값을 테이블 의 인덱스 i 에 매핑하는 n 항목 의 고정 테이블을 만듭니다 . 유효한 값의 경우 테이블 항목 i 에 유효한 값이 포함됩니다. 다른 모든 테이블 항목의 경우, 인덱스의 각 항목이 있는지 확인 에 해시를하지 않는 다른 잘못된 값이 포함되어 난을 .

그런 다음 인터럽트 루틴에서 입력 x :

  1. x 를 인덱스 i로 해시 (0..n 범위에 있음)
  2. 테이블에서 i 항목을 찾아 x 값이 포함되어 있는지 확인하십시오 .

이것은 256 또는 1024 값의 선형 검색보다 훨씬 빠릅니다.

나는 한 일부 파이썬 코드 작성 합리적인 해시 함수를 찾을 수 있습니다.

이진 검색

256 개의 “유효한”값으로 구성된 배열을 정렬 하면 선형 검색 대신 이진 검색을 수행 할 수 있습니다 . 즉, 256 개 항목 테이블을 8 단계 ( log2(256))로 검색하거나 1024 개 항목 테이블을 10 단계 로 검색 할 수 있어야 합니다. 다시 말하지만 이것은 256 또는 1024 값의 선형 검색보다 훨씬 빠릅니다.


답변

테이블을 정렬 된 순서로 유지하고 Bentley의 펼쳐진 이진 검색을 사용합니다.

i = 0;
if (key >= a[i+512]) i += 512;
if (key >= a[i+256]) i += 256;
if (key >= a[i+128]) i += 128;
if (key >= a[i+ 64]) i +=  64;
if (key >= a[i+ 32]) i +=  32;
if (key >= a[i+ 16]) i +=  16;
if (key >= a[i+  8]) i +=   8;
if (key >= a[i+  4]) i +=   4;
if (key >= a[i+  2]) i +=   2;
if (key >= a[i+  1]) i +=   1;
return (key == a[i]);

요점은

  • 테이블이 얼마나 큰지 안다면 얼마나 많은 반복이 있을지 알고 있으므로 완전히 펼칠 수 있습니다.
  • 그런 다음 ==마지막 반복을 제외하고 해당 케이스의 확률이 너무 낮아서 테스트에 시간을 소비하는 것을 정당화 할 수 없기 때문에 각 반복마다 케이스에 대한 포인트 테스트가 없습니다 . **
  • 마지막으로 테이블을 2의 거듭 제곱으로 확장하여 최대 1 개의 비교와 최대 2 개의 스토리지 요소를 추가합니다.

** 확률 측면에서 생각하는 데 익숙하지 않은 경우 모든 결정 지점에는 실행하여 학습하는 평균 정보 인 엔트로피 가 있습니다. 를 들어 >=테스트, 각 지점의 확률은 그래서 당신은 하나 개의 지점을 가지고가는 경우에 방법 당신은 1 개 비트를 배우고, 당신이 다른 분기를 가지고가는 경우에 당신은 한 비트, 평균 배울 것을, 0.5, 및 -log2 (0.5) 1에 관한 것입니다 각 분기에서 배운 내용의 합계에 해당 분기의 확률을 곱한 것입니다. 따라서 테스트 1*0.5 + 1*0.5 = 1의 엔트로피 >=는 1입니다. 학습 할 비트가 10 개이므로 분기가 10 개 필요합니다. 그것이 빠른 이유입니다!

반면에, 첫 번째 테스트는 if (key == a[i+512)? 참일 확률은 1/1024이고 거짓 일 확률은 1023/1024입니다. 그래서 그것이 사실이라면 당신은 모든 10 비트를 배웁니다! 그러나 그것이 거짓이라면 -log2 (1023/1024) = .00141 비트, 사실상 아무것도 배운다! 따라서 그 테스트에서 배우는 평균 양은 10/1024 + .00141*1023/1024 = .0098 + .00141 = .0112비트입니다. 약 100 분의 1 비트.
그 테스트는 그 무게를 지탱하지 않습니다!


답변

테이블의 상수 집합을 미리 알고있는 경우 완벽한 해싱 을 사용 하여 테이블에 한 번만 액세스하도록 할 수 있습니다 . 완벽한 해싱은 모든 흥미로운 키를 고유 한 슬롯에 매핑하는 해시 함수를 결정합니다 (해시 테이블은 항상 조밀하지는 않지만 조밀하지 않은 테이블은 일반적으로 더 간단한 해싱 함수로 이어지는 테이블의 조밀함을 결정할 수 있음).

일반적으로 특정 키 집합에 대한 완벽한 해시 함수는 비교적 계산하기 쉽습니다. 여러 프로브를 수행하는 데 더 많은 시간을 할애하기 때문에 길고 복잡하지 않기를 바랍니다.

완벽한 해싱은 “1-probe max”체계입니다. k 프로브를 만드는 데 걸리는 시간과 해시 코드 계산의 단순성을 교환해야한다는 생각으로 아이디어를 일반화 할 수 있습니다. 결국 목표는 최소한의 프로브 나 가장 단순한 해시 함수가 아니라 “조회하는 데 필요한 최소 총 시간”입니다. 그러나 나는 아무도 k-probes-max 해싱 알고리즘을 구축하는 것을 본 적이 없습니다. 나는 그것을 할 수 있다고 생각하지만 그것은 아마도 연구 일 것입니다.

한 가지 다른 생각 : 프로세서가 매우 빠르다면 완벽한 해시에서 메모리에 대한 하나의 프로브가 실행 시간을 지배 할 것입니다. 프로세서가 그다지 빠르지 않으면 k> 1 프로브보다 실용적 일 수 있습니다.


답변

해시 세트를 사용하십시오. O (1) 조회 시간을 제공합니다.

다음 코드는 0실제 데이터에서 발생하지 않는 ‘빈’값으로 값 을 예약 할 수 있다고 가정합니다 . 그렇지 않은 상황에서 솔루션을 확장 할 수 있습니다.

#define HASH(x) (((x >> 16) ^ x) & 1023)
#define HASH_LEN 1024
uint32_t my_hash[HASH_LEN];

int lookup(uint32_t value)
{
    int i = HASH(value);
    while (my_hash[i] != 0 && my_hash[i] != value) i = (i + 1) % HASH_LEN;
    return i;
}

void store(uint32_t value)
{
    int i = lookup(value);
    if (my_hash[i] == 0)
       my_hash[i] = value;
}

bool contains(uint32_t value)
{
    return (my_hash[lookup(value)] == value);
}

이 예제 구현에서 조회 시간은 일반적으로 매우 낮지 만 최악의 경우 저장된 항목 수까지 될 수 있습니다. 실시간 애플리케이션의 경우보다 예측 가능한 조회 시간을 갖는 이진 트리를 사용하는 구현을 고려할 수도 있습니다.


답변

이 경우 Bloom 필터를 조사하는 것이 좋습니다 . 그들은 값이 존재하지 않는다는 것을 신속하게 설정할 수 있습니다. 이는 가능한 2 ^ 32 값의 대부분이 1024 요소 배열에 없기 때문에 좋은 것입니다. 그러나 추가 검사가 필요한 오 탐지가 있습니다.

테이블이 정적으로 보이기 때문에 Bloom 필터에 대해 어떤 오 탐지가 존재하는지 확인하고이를 완벽한 해시에 넣을 수 있습니다.