1년 반 쯤 전에 쓴 글에서 memcpy() 계열을 굳이 더 최적화할 필요가 없다는 글[각주:1]을 썼었다.
그런데, 스택 오버플로우에서 재미있는 글타래(faster alternative to memcpy?)를 발견했다.
댓글들의 요지는 AVX2 레지스터를 활용하면 더 빠른 복사를 할 수 있고, 이를 병렬수행하면 더 효과가 높아진다는 것.
전자의 경우 기존의 memcpy()에서도 활용할 것 같지만, 후자는 효과가 있을지 여부가 좀 궁금해졌다.
메모리 대역폭이 명령어 실행 사이클보다 충분히 크다면 효과가 없지는 않을 것 같았다.
위 글의 코드들을 대폭 참조한 코드를 만들어 돌려봤다.
코드의 구성들은 대략 아래와 같았고, 동작환경은 AMD Ryzen 9 5900X, 64GB 메모리.
테스트는 일정한 개수의 double 값들을 무작위로 생성하여 복사하는 것으로 진행했다.
균일한 난수를 생성한 뒤 합을 구해서 난수가 정상적으로 생성되는 것과 복사가 정상적으로 진행됨을 확인.
대략의 결과는 아래와 같았다.
1. 4번과 5번은 개념은 동일하고, 스택 오버플로우의 답글에서는 4번만을 구현했는데, 돌려보니 5번이 더 빠름.
2. 병렬수행시 스레드의 개수는 4개 이상에서는 유의미한 차이가 없어 4개로 제한. 메모리 대역폭이 무한하지 않기 때문인 듯.
3. IPP를 활용한 복사는 아이고 의미 없다...
좀 더 상세하게 보면 아래와 같았다.
우선, 1차적으로 생성한 난수들에 대한 연산 시간은 아래 표와 같았다.
표를 다 읽을 필요는 없고, 100만개 정도(즉, 800만 바이트 정도)부터는 병렬수행을 통한 복사가 더 빨라진다는 점만 보면 될듯.
이를 한 눈에 볼 수 있도록 그래프로 그리면 아래와 같다.
뭔가 하나가 튀어서 나머지 그래프를 보기 힘든데, 튀는 것이 ippsCopy_64f()이다.
불필요한 IPP 함수를 제거하면 아래와 같다.
그래프의 y축은 시간이므로 클 수록 느리다는 뜻.
위의 둘이 1번과 3번이다. 다시 말해 적어도 VS2022에서는 memcpy()는 AVX2 레지스터를 활용한 구현이 되어있다는 뜻.
아래쪽이 병렬수행한 결과인데, 데이터의 양이 어떤 수준이 되면 유의미하게 빠른 속도를 보여준다.
다음은 데이터의 양이 얼마 정도가 되면 이러한 병렬수행이 힘을 발휘하는가를 확인할 차례.
확인 결과 데이터가 대략 400만 바이트 이상이 되면 병렬수행 쪽이 더 빨라진다.
더불어, thread를 직접 생성하는 것보다 execution을 활용하는 쪽이 유의미하게 빠른 속도[각주:2]를 보여준다.
그래프로 보면 아래와 같다.
병렬수행 방안에 따른 성능 차이는 존재하지만 memcpy()와 비교해보면 큰 의미가 없다는 생각도 든다.
300만..400만 바이트 구간에서 더 위쪽으로 가는 둘이 병렬수행을 하지 않는 memcpy()와 AVX2의 결과.
이 내용을 모두 갈아넣은 코드는 아래와 같다.
void MemcpyFast(void* pvDest, void* pvSrc, size_t nBytes, size_t nThreads = 4) {
// check alignment
if (((intptr_t(pvDest) & 31) | (intptr_t(pvSrc) & 31)) ||
(nBytes < 4000000)) {
memcpy(pvDest, pvSrc, nBytes);
return;
}
const __m256i* pSrc = reinterpret_cast<const __m256i*>(pvSrc);
__m256i* pDest = reinterpret_cast<__m256i*>(pvDest);
intptr_t nVects = nBytes / sizeof(*pSrc);
const intptr_t nVectsPerThread = (nVects + nThreads - 1) / nThreads;
std::vector<int> v;
v.resize(nThreads);
iota(v.begin(), v.end(), 0);
std::for_each(std::execution::par, v.begin(), v.end(), [&](int p) {
const intptr_t curStart = p * nVectsPerThread;
const intptr_t nextStart = std::min(curStart + nVectsPerThread, nVects);
intptr_t nVects = nextStart - curStart;
const __m256i* pSrc0 = pSrc + curStart;
__m256i* pDest0 = pDest + curStart;
for (; nVects > 0; nVects--, pSrc0++, pDest0++) {
const __m256i loaded = _mm256_stream_load_si256(pSrc0);
_mm256_stream_si256(pDest0, loaded);
}
});
_mm_sfence();
const size_t remain = nBytes & 31;
if (remain) {
intptr_t nVects = nBytes / sizeof(*pSrc);
const char* pSrcChar = reinterpret_cast<const char*>(pSrc + nVects);
char* pDestChar = reinterpret_cast<char*>(pDest + nVects);
memcpy(pDestChar, pSrcChar, remain);
}
}
한 줄 요약: 400 MB 이상에선 병렬처리가 성능을 향상시키고 그 외엔 무조건 memcpy()
주의해서 사용해야 하는 파이썬 Numpy 배열의 메모리 내 저장 순서 (0) | 2023.08.09 |
---|---|
정수 범위에서 제곱근의 최적화된 구현 방법은? (1) | 2022.12.24 |
자신보다 크거나 같은 최소의 2의 제곱수는? (1) | 2022.09.18 |
CMap vs std::map (4) | 2022.09.13 |
Visual C++에서 Epoch time 계산하기 (0) | 2022.08.28 |