program story

"캐시 친화적"코드 란 무엇입니까?

inputbox 2020. 9. 30. 10:39
반응형

"캐시 친화적"코드 란 무엇입니까?


" cache unfriendly code "와 " cache friendly "코드 의 차이점은 무엇입니까 ?

캐시 효율적인 코드를 작성하려면 어떻게해야합니까?


예선

최신 컴퓨터에서는 가장 낮은 수준의 메모리 구조 ( 레지스터 ) 만 단일 클록 주기로 데이터를 이동할 수 있습니다. 그러나 레지스터는 매우 비싸고 대부분의 컴퓨터 코어에는 수십 개 미만의 레지스터가 있습니다 ( 수백에서 수천 바이트 까지). 메모리 스펙트럼 ( DRAM ) 의 다른 쪽 끝 에서 메모리는 매우 저렴하지만 (즉, 말 그대로 수백만 배 더 저렴 ) 데이터 수신 요청 후 수백 사이클이 걸립니다. 슈퍼 빠르고 비용 및 슈퍼 슬로우 및 저가 사이의 격차를 해소 할 수있는 캐시 메모리는, 감소하는 속도와 비용에서 L1, L2, L3. 아이디어는 대부분의 실행 코드가 작은 변수 세트에 자주 충돌하고 나머지 (훨씬 큰 변수 세트)는 드물게 발생한다는 것입니다. 프로세서가 L1 캐시에서 데이터를 찾을 수 없으면 L2 캐시를 찾습니다. 없는 경우 L3 캐시,없는 경우 주 메모리. 이러한 "실패"각각은 시간적으로 비용이 많이 듭니다.

(비유는 시스템 메모리가 너무 하드 디스크 스토리지이기 때문에 캐시 메모리가 시스템 메모리에 대한 것입니다. 하드 디스크 스토리지는 매우 저렴하지만 매우 느립니다).

캐싱은 대기 시간 의 영향을 줄이는 주요 방법 중 하나입니다 . Herb Sutter (아래 링크 참조)를 의역하면 : 대역폭을 늘리는 것은 쉽지만 대기 시간에서 벗어날 수는 없습니다 .

데이터는 항상 메모리 계층 구조를 통해 검색됩니다 (가장 작음 == 가장 빠름에서 가장 느림). 캐시 적중 / 미스는 일반적으로 CPU의 캐시의 최고 수준의 히트 / 미스를 말한다 - 최고 수준으로 내가 가장 큰 == 느린 것을 의미한다. 캐시 적중률은 모든 캐시 미스로 인해 RAM에서 데이터를 가져 오게되므로 (또는 더 나쁘게) 많은 시간 (RAM의 경우 수백주기, HDD의 경우 수천만주기 )이 걸리기 때문에 성능에 중요합니다 . 이에 비해 (가장 높은 수준) 캐시에서 데이터를 읽는 데는 일반적으로 몇 번의 주기만 소요됩니다.

현대 컴퓨터 아키텍처에서 성능 병목 현상은 CPU 다이를 떠나고 있습니다 (예 : RAM 이상 액세스). 이것은 시간이 지남에 따라 악화 될 것입니다. 프로세서 주파수의 증가는 현재 더 이상 성능 향상과 관련이 없습니다. 문제는 메모리 액세스입니다. 따라서 CPU의 하드웨어 설계 노력은 현재 캐시, 프리 페치, 파이프 라인 및 동시성 최적화에 중점을두고 있습니다. 예를 들어, 최신 CPU는 다이의 약 85 %를 캐시에 사용하고 최대 99 %를 데이터 저장 / 이동에 사용합니다!

이 주제에 대해 할 말이 꽤 많습니다. 다음은 캐시, 메모리 계층 및 적절한 프로그래밍에 대한 몇 가지 훌륭한 참고 자료입니다.

캐시 친화적 인 코드의 주요 개념

캐시 친화적 인 코드의 매우 중요한 측면은 지역성 원칙에 관한 것입니다. 목표는 관련 데이터를 메모리에 가깝게 배치하여 효율적인 캐싱을 허용하는 것입니다. CPU 캐시와 관련하여 이것이 어떻게 작동하는지 이해하기 위해 캐시 라인을 인식하는 것이 중요합니다. 캐시 라인은 어떻게 작동합니까?

다음 특정 측면은 캐싱을 최적화하는 데 매우 중요합니다.

  1. 시간적 지역성 : 주어진 메모리 위치에 액세스 할 때 가까운 장래에 동일한 위치에 다시 액세스 할 가능성이 있습니다. 이상적으로이 정보는 해당 시점에서 여전히 캐시됩니다.
  2. 공간적 지역성 : 관련 데이터를 서로 가깝게 배치하는 것을 의미합니다. 캐싱은 CPU뿐만 아니라 여러 수준에서 발생합니다. 예를 들어, RAM에서 읽을 때 일반적으로 프로그램이 곧 해당 데이터를 필요로하기 때문에 특별히 요청한 것보다 더 큰 메모리 청크를 가져옵니다. HDD 캐시는 같은 생각을 따릅니다. 특히 CPU 캐시의 경우 캐시 라인 개념 이 중요합니다.

적절한 컨테이너 사용

의 간단한 예를 캐시 친화적 인 캐시 비우호적 인 대 std::vectorstd::list. 의 요소는 std::vector인접한 메모리에 저장되며, 이러한 요소에 액세스 하는 것은 콘텐츠를 사방에 저장 하는 에서 요소에 액세스하는 것보다 훨씬 캐시 친화적 std::list입니다. 이것은 공간적 지역성 때문입니다.

이것에 대한 아주 좋은 그림은이 유튜브 클립 에서 Bjarne Stroustrup 제공합니다 (링크를 위해 @Mohammad Ali Baydoun에게 감사드립니다!).

데이터 구조 및 알고리즘 설계에서 캐시를 무시하지 마십시오.

가능하면 캐시를 최대한 활용할 수있는 방식으로 데이터 구조와 계산 순서를 조정하십시오. 이와 관련하여 일반적인 기술은 캐시 차단 (Archive.org 버전) 으로 고성능 컴퓨팅에서 매우 중요합니다 (예 : ATLAS ).

암시 적 데이터 구조 파악 및 활용

현장의 많은 사람들이 가끔 잊어 버리는 또 다른 간단한 예는 2 차원 배열을 저장하기위한 열 중심 (예 : , ) 대 행 중심 순서 (예 : , )입니다. 예를 들어 다음 행렬을 고려하십시오.

1 2
3 4

행 우선 순서에서 이것은 메모리에 다음과 같이 저장됩니다 1 2 3 4. 열 우선 순위에서 이것은 1 3 2 4. 이 순서를 악용하지 않는 구현은 캐시 문제에 빠르게 부딪 히게됩니다 (쉽게 피할 수 있습니다!). 안타깝게도 제 도메인 (머신 러닝)에서 이와 같은 것을 자주 봅니다. @MatteoItalia는 그의 답변 에서이 예를 더 자세히 보여주었습니다.

메모리에서 행렬의 특정 요소를 가져올 때 그 근처의 요소도 함께 가져와 캐시 라인에 저장됩니다. 순서가 악용되면 메모리 액세스가 줄어 듭니다 (이후 계산에 필요한 다음 몇 가지 값이 이미 캐시 라인에 있기 때문입니다).

단순성을 위해 캐시가 2 개의 매트릭스 요소를 포함 할 수있는 단일 캐시 라인으로 구성되어 있고 주어진 요소가 메모리에서 가져올 때 다음 요소도 있다고 가정합니다. 위의 예제 2x2 행렬의 모든 요소에 대한 합계를 취하고 싶다고 가정 해 보겠습니다 M.

순서 활용하기 (예 : 에서 먼저 열 인덱스 변경 ) :

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

순서를 악용하지 않음 (예 : 에서 먼저 행 인덱스 변경 ) :

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

이 간단한 예에서 순서를 활용하면 실행 속도가 약 두 배가됩니다 (메모리 액세스에는 합계를 계산하는 것보다 훨씬 더 많은주기가 필요하기 때문입니다). 실제로 성능 차이는 훨씬 더 클 수 있습니다 .

예측할 수없는 분기 방지

최신 아키텍처는 파이프 라인을 특징으로하며 컴파일러는 메모리 액세스로 인한 지연을 최소화하기 위해 코드를 재정렬하는 데 매우 능숙합니다. 중요한 코드에 (예측할 수없는) 분기가 포함되어 있으면 데이터를 미리 가져 오는 것이 어렵거나 불가능합니다. 이것은 간접적으로 더 많은 캐시 미스로 이어질 것입니다.

이것은 여기에서 매우설명 됩니다 (링크에 대한 @ 0x90 덕분에). 정렬되지 않은 배열을 처리하는 것보다 정렬 된 배열을 처리하는 것이 더 빠른 이유는 무엇입니까?

가상 기능 피하기

의 맥락 에서 virtual메서드는 캐시 미스와 관련하여 논란의 여지가있는 문제를 나타냅니다 (성능 측면에서 가능하면 피해야한다는 일반적인 합의가 있습니다). 가상 함수는 룩업 동안 캐시 미스를 유도 할 수 있지만,이 경우에만 발생하는 경우 이 일부에 의해 비 문제로 간주하므로, 특정 기능이 자주 호출되지 않습니다 (그렇지 않으면 가능성이 캐시 될 것이다). 이 문제에 대한 참조 는 C ++ 클래스에서 가상 메서드를 사용할 때의 성능 비용은 얼마입니까?

일반적인 문제

다중 프로세서 캐시를 사용하는 현대 아키텍처의 일반적인 문제는 거짓 공유 입니다. 이것은 각 개별 프로세서가 다른 메모리 영역의 데이터를 사용하려고 시도하고 동일한 캐시 라인에 데이터를 저장하려고 할 때 발생합니다 . 이로 인해 다른 프로세서가 사용할 수있는 데이터가 포함 된 캐시 라인을 반복해서 덮어 씁니다. 효과적으로 다른 스레드는이 상황에서 캐시 미스를 유도하여 서로를 기다리게합니다. 참조 (링크에 대한 @Matt에게 감사) : 캐시 라인 크기에 언제 어떻게 정렬해야합니까?

RAM 메모리의 캐싱 불량의 극단적 인 증상 (이 문맥에서 의미하는 바가 아닐 수 있음)은 소위 스 래싱 입니다. 이것은 프로세스가 디스크 액세스를 필요로하는 페이지 폴트 (예 : 현재 페이지에없는 메모리 액세스)를 지속적으로 생성 할 때 발생합니다.


@Marc Claesen의 답변 외에도 캐시 친화적이지 않은 코드의 유익한 고전적인 예는 C 2 차원 배열 (예 : 비트 맵 이미지)을 행 단위가 아닌 열 단위로 스캔하는 코드라고 생각합니다.

행에 인접한 요소는 메모리에서도 인접하므로 순서대로 액세스한다는 것은 메모리 오름차순으로 액세스하는 것을 의미합니다. 캐시는 연속적인 메모리 블록을 프리 페치하는 경향이 있으므로 캐시 친화적입니다.

대신 같은 열에있는 요소가 서로 메모리에서 멀리 떨어져 있기 때문에 (특히, 해당 거리가 행의 크기와 같기 때문에) 이러한 요소에 열 방식으로 액세스하는 것은 캐시에 적합하지 않으므로이 액세스 패턴을 사용할 때 메모리에서 뛰어 다니며 잠재적으로 메모리 근처의 요소를 검색하는 캐시의 노력을 낭비합니다.

그리고 성능을 망치는 데 필요한 모든 것은

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

...에

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

이 효과는 캐시가 작거나 큰 어레이로 작업하는 시스템 (예 : 현재 컴퓨터에서 10 메가 픽셀 이상 24bpp 이미지)에서 상당히 극적 일 수 있습니다 (속도에서 몇 배 정도). 이러한 이유로 수직 스캔을 많이해야하는 경우 이미지를 먼저 90도 회전하고 나중에 다양한 분석을 수행하여 캐시 친화적이지 않은 코드를 회전으로 제한하는 것이 좋습니다.


Optimizing cache usage largely comes down to two factors.

Locality of Reference

The first factor (to which others have already alluded) is locality of reference. Locality of reference really has two dimensions though: space and time.

  • Spatial

The spatial dimension also comes down to two things: first, we want to pack our information densely, so more information will fit in that limited memory. This means (for example) that you need a major improvement in computational complexity to justify data structures based on small nodes joined by pointers.

Second, we want information that will be processed together also located together. A typical cache works in "lines", which means when you access some information, other information at nearby addresses will be loaded into the cache with the part we touched. For example, when I touch one byte, the cache might load 128 or 256 bytes near that one. To take advantage of that, you generally want the data arranged to maximize the likelihood that you'll also use that other data that was loaded at the same time.

For just a really trivial example, this can mean that a linear search can be much more competitive with a binary search than you'd expect. Once you've loaded one item from a cache line, using the rest of the data in that cache line is almost free. A binary search becomes noticeably faster only when the data is large enough that the binary search reduces the number of cache lines you access.

  • Time

The time dimension means that when you do some operations on some data, you want (as much as possible) to do all the operations on that data at once.

Since you've tagged this as C++, I'll point to a classic example of a relatively cache-unfriendly design: std::valarray. valarray overloads most arithmetic operators, so I can (for example) say a = b + c + d; (where a, b, c and d are all valarrays) to do element-wise addition of those arrays.

The problem with this is that it walks through one pair of inputs, puts results in a temporary, walks through another pair of inputs, and so on. With a lot of data, the result from one computation may disappear from the cache before it's used in the next computation, so we end up reading (and writing) the data repeatedly before we get our final result. If each element of the final result will be something like (a[n] + b[n]) * (c[n] + d[n]);, we'd generally prefer to read each a[n], b[n], c[n] and d[n] once, do the computation, write the result, increment n and repeat 'til we're done.2

Line Sharing

The second major factor is avoiding line sharing. To understand this, we probably need to back up and look a little at how caches are organized. The simplest form of cache is direct mapped. This means one address in main memory can only be stored in one specific spot in the cache. If we're using two data items that map to the same spot in the cache, it works badly -- each time we use one data item, the other has to be flushed from the cache to make room for the other. The rest of the cache might be empty, but those items won't use other parts of the cache.

To prevent this, most caches are what are called "set associative". For example, in a 4-way set-associative cache, any item from main memory can be stored at any of 4 different places in the cache. So, when the cache is going to load an item, it looks for the least recently used3 item among those four, flushes it to main memory, and loads the new item in its place.

The problem is probably fairly obvious: for a direct-mapped cache, two operands that happen to map to the same cache location can lead to bad behavior. An N-way set-associative cache increases the number from 2 to N+1. Organizing a cache into more "ways" takes extra circuitry and generally runs slower, so (for example) an 8192-way set associative cache is rarely a good solution either.

Ultimately, this factor is more difficult to control in portable code though. Your control over where your data is placed is usually fairly limited. Worse, the exact mapping from address to cache varies between otherwise similar processors. In some cases, however, it can be worth doing things like allocating a large buffer, and then using only parts of what you allocated to ensure against data sharing the same cache lines (even though you'll probably need to detect the exact processor and act accordingly to do this).

  • False Sharing

There's another, related item called "false sharing". This arises in a multiprocessor or multicore system, where two (or more) processors/cores have data that's separate, but falls in the same cache line. This forces the two processors/cores to coordinate their access to the data, even though each has its own, separate data item. Especially if the two modify the data in alternation, this can lead to a massive slowdown as the data has to be constantly shuttled between the processors. This can't easily be cured by organizing the cache into more "ways" or anything like that either. The primary way to prevent it is to ensure that two threads rarely (preferably never) modify data that could possibly be in the same cache line (with the same caveats about difficulty of controlling the addresses at which data is allocated).


  1. Those who know C++ well might wonder if this is open to optimization via something like expression templates. I'm pretty sure the answer is that yes, it could be done and if it was, it would probably be a pretty substantial win. I'm not aware of anybody having done so, however, and given how little valarray gets used, I'd be at least a little surprised to see anybody do so either.

  2. In case anybody wonders how valarray (designed specifically for performance) could be this badly wrong, it comes down to one thing: it was really designed for machines like the older Crays, that used fast main memory and no cache. For them, this really was a nearly ideal design.

  3. Yes, I'm simplifying: most caches don't really measure the least recently used item precisely, but they use some heuristic that's intended to be close to that without having to keep a full time-stamp for each access.


Welcome to the world of Data Oriented Design. The basic mantra is to Sort, Eliminate Branches, Batch, Eliminate virtual calls - all steps towards better locality.

Since you tagged the question with C++, here's the obligatory typical C++ Bullshit. Tony Albrecht's Pitfalls of Object Oriented Programming is also a great introduction into the subject.


Just piling on: the classic example of cache-unfriendly versus cache-friendly code is the "cache blocking" of matrix multiply.

Naive matrix multiply looks like

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

If N is large, e.g. if N * sizeof(elemType) is greater than the cache size, then every single access to src2[k][j] will be a cache miss.

There are many different ways of optimizing this for a cache. Here's a very simple example: instead of reading one item per cache line in the inner loop, use all of the items:

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

If the cache line size is 64 bytes, and we are operating on 32 bit (4 byte) floats, then there are 16 items per cache line. And the number of cache misses via just this simple transformation is reduced approximately 16-fold.

Fancier transformations operate on 2D tiles, optimize for multiple caches (L1, L2, TLB), and so on.

Some results of googling "cache blocking":

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

A nice video animation of an optimized cache blocking algorithm.

http://www.youtube.com/watch?v=IFWgwGMMrh0

Loop tiling is very closely related:

http://en.wikipedia.org/wiki/Loop_tiling


Processors today work with many levels of cascading memory areas. So the CPU will have a bunch of memory that is on the CPU chip itself. It has very fast access to this memory. There are different levels of cache each one slower access ( and larger ) than the next, until you get to system memory which is not on the CPU and is relatively much slower to access.

Logically, to the CPU's instruction set you just refer to memory addresses in a giant virtual address space. When you access a single memory address the CPU will go fetch it. in the old days it would fetch just that single address. But today the CPU will fetch a bunch of memory around the bit you asked for, and copy it into the cache. It assumes that if you asked for a particular address that is is highly likely that you are going to ask for an address nearby very soon. For example if you were copying a buffer you would read and write from consecutive addresses - one right after the other.

So today when you fetch an address it checks the first level of cache to see if it already read that address into cache, if it doesn't find it, then this is a cache miss and it has to go out to the next level of cache to find it, until it eventually has to go out into main memory.

Cache friendly code tries to keep accesses close together in memory so that you minimize cache misses.

So an example would be imagine you wanted to copy a giant 2 dimensional table. It is organized with reach row in consecutive in memory, and one row follow the next right after.

If you copied the elements one row at a time from left to right - that would be cache friendly. If you decided to copy the table one column at a time, you would copy the exact same amount of memory - but it would be cache unfriendly.


It needs to be clarified that not only data should be cache-friendly, it is just as important for the code. This is in addition to branch predicition, instruction reordering, avoiding actual divisions and other techniques.

Typically the denser the code, the fewer cache lines will be required to store it. This results in more cache lines being available for data.

The code should not call functions all over the place as they typically will require one or more cache lines of their own, resulting in fewer cache lines for data.

A function should begin at a cache line-alignment-friendly address. Though there are (gcc) compiler switches for this be aware that if the the functions are very short it might be wasteful for each one to occupy an entire cache line. For example, if three of the most often used functions fit inside one 64 byte cache line, this is less wasteful than if each one has its own line and results in two cache lines less available for other usage. A typical alignment value could be 32 or 16.

So spend some extra time to make the code dense. Test different constructs, compile and review the generated code size and profile.


As @Marc Claesen mentioned that one of the ways to write cache friendly code is to exploit the structure in which our data is stored. In addition to that another way to write cache friendly code is: change the way our data is stored; then write new code to access the data stored in this new structure.

This makes sense in the case of how database systems linearize the tuples of a table and store them. There are two basic ways to store the tuples of a table i.e. row store and column store. In row store as the name suggests the tuples are stored row wise. Lets suppose a table named Product being stored has 3 attributes i.e. int32_t key, char name[56] and int32_t price, so the total size of a tuple is 64 bytes.

We can simulate a very basic row store query execution in main memory by creating an array of Product structs with size N, where N is the number of rows in table. Such memory layout is also called array of structs. So the struct for Product can be like:

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

Similarly we can simulate a very basic column store query execution in main memory by creating an 3 arrays of size N, one array for each attribute of the Product table. Such memory layout is also called struct of arrays. So the 3 arrays for each attribute of Product can be like:

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

Now after loading both the array of structs (Row Layout) and the 3 separate arrays (Column Layout), we have row store and column store on our table Product present in our memory.

Now we move on to the cache friendly code part. Suppose that the workload on our table is such that we have an aggregation query on the price attribute. Such as

SELECT SUM(price)
FROM PRODUCT

For the row store we can convert the above SQL query into

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

For the column store we can convert the above SQL query into

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

The code for the column store would be faster than the code for the row layout in this query as it requires only a subset of attributes and in column layout we are doing just that i.e. only accessing the price column.

Suppose that the cache line size is 64 bytes.

In the case of row layout when a cache line is read, the price value of only 1(cacheline_size/product_struct_size = 64/64 = 1) tuple is read, because our struct size of 64 bytes and it fills our whole cache line, so for every tuple a cache miss occurs in case of a row layout.

In the case of column layout when a cache line is read, the price value of 16(cacheline_size/price_int_size = 64/4 = 16) tuples is read, because 16 contiguous price values stored in memory are brought into the cache, so for every sixteenth tuple a cache miss ocurs in case of column layout.

So the column layout will be faster in the case of given query, and is faster in such aggregation queries on a subset of columns of the table. You can try out such experiment for yourself using the data from TPC-H benchmark, and compare the run times for both the layouts. The wikipedia article on column oriented database systems is also good.

So in database systems, if the query workload is known beforehand, we can store our data in layouts which will suit the queries in workload and access data from these layouts. In the case of above example we created a column layout and changed our code to compute sum so that it became cache friendly.


Be aware that caches do not just cache continuous memory. They have multiple lines (at least 4) so discontinous and overlapping memory can often be stored just as efficiently.

What is missing from all the above examples is measured benchmarks. There are many myths about performance. Unless you measure it you do not know. Do not complicate your code unless you have a measured improvement.

참고URL : https://stackoverflow.com/questions/16699247/what-is-a-cache-friendly-code

반응형