Cache與內存二三事

本文蒐集了幾個與內存和緩存相關的技巧,對於代碼調優比較有幫助。即使你在工作中不需要寫出極致性能的代碼,也應該讀一下這篇文章。因爲看似對軟件工程師透明的內存以及CPU Cache,其實並不“透明”,代碼的細微差別可能明顯的影響緩存以及內存的性能。

1.會導致缺頁中斷的內存分配

下文代碼採用兩種方式分配pBuffer,請比對兩種不同方式的耗時。

#include "stdafx.h"
#include <iostream>
#include <chrono>
#include <math.h>
using namespace std::chrono;

const int buff_size = 32 * 1024 * 1024;

int main()
{
	int * pBuffer = new int[buff_size]; //第一種分配方式
	//int * pBuffer = new int[buff_size] {}; //第二種分配方式

	auto start = std::chrono::system_clock::now();
	for (int i = 0; i < buff_size; ++i) {
		pBuffer[i] *= 3;
	}
	auto end = std::chrono::system_clock::now();

	std::chrono::duration<double> elapsed_seconds = end - start;
	std::cout << "elapsed time: " << elapsed_seconds.count() * 1000 << "ms\n";

	delete[] pBuffer;
}

在我的電腦上,第一種分配方式的運行時間大約爲90ms,第二種分配方式的運行時間大約爲26ms。兩種分配方式唯一的區別在於後者在分配內存的時候添加了 {}。

當我們從堆裏分配一個數組的時候,如果沒有做任何初始化,那麼操作系統只會返回一個虛擬地址映射,並不會真正地分配一塊物理內存。當這個數組被訪問的時候,一個缺頁中斷會被觸發,內核纔會真正分配一塊物理內存。第一種分配方式在分配內存的時候,沒有做初始化,所以訪問內存的時候會導致大量缺頁中斷;第二種分配方式添加了{}強制初始化,因此在分配數組的時候內核就已經分配了物理內存。

在本例中,第一種方式(觸發缺頁中斷)與第二種方式(初始化時分配物理內存)的時間差爲64ms,這個時間差基本就是操作系統處理缺頁中斷並完成物理內存分配所需的時間。第一種分配方式中缺頁中斷耗費的時間甚至超過了業務邏輯本需要的時間,比較明顯地影響了程序的性能。

說句題外話,我們分配了32X1024X1024字節的內存,而內存頁的size一般爲4096字節,所以一共觸發了32X1024X1024/4096次缺頁中斷,對麼?錯誤。雖然內存頁的size一般爲4096字節,但是操作系統以及硬件並不是以4096字節爲單位映射內存,而是以一個略大的單位--一般是4096的2的n次冪倍(比如1MB)分配物理內存,具體細節因操作系統以及物理硬件而異。

2. 如何測量cpu cache的容量

下面這段代碼可以在linux上測量緩存的容量。基本思路是,

step1:以某個步長逐一訪問一塊內存的存儲單元,然後統計訪問時間。

step2:增加步長,重複step1。

step3:最後比較相鄰兩次的訪問時間,找到差別最大的一次,這個步長就是cache容量。

這個方法的原理是,當緩存命中的時候即使步長不相等,訪問內存的時間是差不多的;當緩存沒有命中的時候,因爲需要重新導入內存到緩存,會導致內存訪問時間大大增加。所以,若要充分利用緩存,我們最好以地址連續的方式訪問內存,儘量避免緩存的內容抖動。

以下代碼來自這個鏈接,但是我在原代碼的基礎上略做了簡化 https://github.com/SudarsunKannan/memlatency

#include <cstdlib>
#include <iostream>
#include <sys/time.h>
#include <vector>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define ITER   1
#define MB    (1024*1024)
#define MAX_N 16*MB
#define STEPS_N 16*MB
#define KB    1024
#define CACHE_LN_SZ 64
#define START_SIZE 1*MB
#define STOP_SIZE  64*MB
#define MIN_LLC    256*KB
#define MAX_STRIDE 1024
#define INT_MIN     (-2147483647 - 1)

using namespace std;
unsigned int memrefs;
volatile void* global;

void LLCCacheSizeTest()
{    
  double retval,prev;
  struct timespec tps, tpe;
  int max =INT_MIN,index = 0, maxidx = 0, iter = 0, i=0;
  int diff =0, stride = 0, j = MIN_LLC;
  double change_per[10];
  char *array = (char *)malloc(sizeof(char) *CACHE_LN_SZ * STEPS_N);

  while( j < STOP_SIZE) {

    stride = j-1;
    clock_gettime(CLOCK_REALTIME, &tps);

    for (iter = 0; iter < ITER; iter++)
      for (unsigned int i = 0; i < STEPS_N; i++) {
      	array[(i*CACHE_LN_SZ) & stride] *= 100;
      }
    clock_gettime(CLOCK_REALTIME, &tpe);
    retval = ((tpe.tv_sec-tps.tv_sec)*1000000000  + tpe.tv_nsec-tps.tv_nsec)/1000;

    if(index > 0){
      diff = retval - prev;
      if(max < diff) {
      	max= diff;
      	maxidx =j;
      }
    }
    cout << "LLCCacheSizeTest "<< retval <<" stride "<<j<< " diffs: "<<diff<<endl;
    prev = retval;
    if(index == 0) j = MB;
    else j = j + (MB);
    index++;
  }
  free(array);
  cout<<"Effective LLC Cache Size "<<maxidx/MB<<" MB"<<endl;
}

struct node {
  int val;
  struct node *next;
  struct node *prev;
  char padding[CACHE_LN_SZ - 2 * sizeof(struct node*) - sizeof(int)];
};

struct node* delete_node( struct node *j) {
  if(!j) return NULL;

  j->prev->next = j->next;
  j->next->prev = j->prev;
  return j;
}

struct node* insert_node( struct node *i, struct node *j) {
  if(!j) return NULL;

  i->next->prev = j;
  j->next = i->next;
  j->prev = i;
  i->next = j;
  return i;
}

int main(int argc, char *argv[]){
  LLCCacheSizeTest();
  return 0;
}

下面是測試結果:

uslinux01:/home/gbuilder/jge> ./lat
LLCCacheSizeTest 102465 stride 262144 diffs: 0
LLCCacheSizeTest 71912 stride 1048576 diffs: -30553
LLCCacheSizeTest 60810 stride 2097152 diffs: -11102
LLCCacheSizeTest 60840 stride 3145728 diffs: 30
LLCCacheSizeTest 60841 stride 4194304 diffs: 1
LLCCacheSizeTest 60886 stride 5242880 diffs: 45
LLCCacheSizeTest 60978 stride 6291456 diffs: 92
LLCCacheSizeTest 60857 stride 7340032 diffs: -121
LLCCacheSizeTest 60555 stride 8388608 diffs: -302
LLCCacheSizeTest 60821 stride 9437184 diffs: 266
LLCCacheSizeTest 60825 stride 10485760 diffs: 4
LLCCacheSizeTest 60824 stride 11534336 diffs: -1
LLCCacheSizeTest 60696 stride 12582912 diffs: -128
LLCCacheSizeTest 60858 stride 13631488 diffs: 162
LLCCacheSizeTest 60696 stride 14680064 diffs: -162
LLCCacheSizeTest 60696 stride 15728640 diffs: 0
LLCCacheSizeTest 60727 stride 16777216 diffs: 31
LLCCacheSizeTest 60906 stride 17825792 diffs: 179
LLCCacheSizeTest 60819 stride 18874368 diffs: -87
LLCCacheSizeTest 60869 stride 19922944 diffs: 50
LLCCacheSizeTest 60768 stride 20971520 diffs: -101
LLCCacheSizeTest 61037 stride 22020096 diffs: 269
LLCCacheSizeTest 60756 stride 23068672 diffs: -281
LLCCacheSizeTest 60810 stride 24117248 diffs: 54
LLCCacheSizeTest 60574 stride 25165824 diffs: -236
LLCCacheSizeTest 60874 stride 26214400 diffs: 300
LLCCacheSizeTest 60880 stride 27262976 diffs: 6
LLCCacheSizeTest 60799 stride 28311552 diffs: -81
LLCCacheSizeTest 60756 stride 29360128 diffs: -43
LLCCacheSizeTest 60837 stride 30408704 diffs: 81
LLCCacheSizeTest 60794 stride 31457280 diffs: -43
LLCCacheSizeTest 60884 stride 32505856 diffs: 90
LLCCacheSizeTest 85404 stride 33554432 diffs: 24520
LLCCacheSizeTest 60930 stride 34603008 diffs: -24474
LLCCacheSizeTest 60862 stride 35651584 diffs: -68
LLCCacheSizeTest 60877 stride 36700160 diffs: 15
LLCCacheSizeTest 60832 stride 37748736 diffs: -45
LLCCacheSizeTest 60917 stride 38797312 diffs: 85
LLCCacheSizeTest 60830 stride 39845888 diffs: -87
LLCCacheSizeTest 60860 stride 40894464 diffs: 30
LLCCacheSizeTest 60588 stride 41943040 diffs: -272
LLCCacheSizeTest 60952 stride 42991616 diffs: 364
LLCCacheSizeTest 60833 stride 44040192 diffs: -119
LLCCacheSizeTest 60888 stride 45088768 diffs: 55
LLCCacheSizeTest 60759 stride 46137344 diffs: -129
LLCCacheSizeTest 60902 stride 47185920 diffs: 143
LLCCacheSizeTest 60817 stride 48234496 diffs: -85
LLCCacheSizeTest 60904 stride 49283072 diffs: 87
LLCCacheSizeTest 66884 stride 50331648 diffs: 5980
LLCCacheSizeTest 61001 stride 51380224 diffs: -5883
LLCCacheSizeTest 60925 stride 52428800 diffs: -76
LLCCacheSizeTest 60963 stride 53477376 diffs: 38
LLCCacheSizeTest 60925 stride 54525952 diffs: -38
LLCCacheSizeTest 61015 stride 55574528 diffs: 90
LLCCacheSizeTest 60996 stride 56623104 diffs: -19
LLCCacheSizeTest 61065 stride 57671680 diffs: 69
LLCCacheSizeTest 69275 stride 58720256 diffs: 8210
LLCCacheSizeTest 61172 stride 59768832 diffs: -8103
LLCCacheSizeTest 61129 stride 60817408 diffs: -43
LLCCacheSizeTest 61293 stride 61865984 diffs: 164
LLCCacheSizeTest 70822 stride 62914560 diffs: 9529
LLCCacheSizeTest 61608 stride 63963136 diffs: -9214
LLCCacheSizeTest 72650 stride 65011712 diffs: 11042
LLCCacheSizeTest 74397 stride 66060288 diffs: 1747
Effective LLC Cache Size 32 MB

以下爲我的linux cpu配置:

uslinux01:/home/gbuilder/jge> lscpu
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                56
On-line CPU(s) list:   0-55
Thread(s) per core:    2
Core(s) per socket:    14
Socket(s):             2
NUMA node(s):          2
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 63
Model name:            Intel(R) Xeon(R) CPU E5-2695 v3 @ 2.30GHz
Stepping:              2
CPU MHz:               1201.660
CPU max MHz:           3300.0000
CPU min MHz:           1200.0000
BogoMIPS:              4601.56
Virtualization:        VT-x
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              35840K

從以上測試結果中,可以看出步長爲33 554 432的時候,前後兩次的訪問時間差最大,因此可以確定緩存容量爲32M。但是我的linux配置顯示L3 cache爲35840K,這是怎麼回事呢?話說筆者也對此頗爲費解。據筆者瞭解,硬件廠商統計物理緩存容量的單位可能並不是1024字節,而是1000字節。另外,L3 cache爲35840K也頗爲古怪,爲什麼Cache的容量不是2的n次冪呢?但是即使如此也不能完全解釋這個問題。那也許是僅僅通過應用程序層面無法100%準確的測量緩存的容量。總之,如果讀者能找到更合理的解釋請讓筆者知道,謝謝。

 

3.Cache line:

現代CPU訪問某個內存地址之後會把該內存地址周邊的相鄰地址的內容以cache line爲單位導入緩存,cache line的一般容量是64字節,也就是16個整型。以下代碼可以用來驗證cache line的容量。基本思路以及原理同cache容量的測試,此處不贅述。

#include "stdafx.h"
#include <iostream>
#include <chrono>
#include <math.h>
using namespace std::chrono;

const int buff_size = 32 * 1024 * 1024;

void test_cache_hit(int * buffer, int size, int step)
{
	for (int i = 0; i < size;) {
		buffer[i] *= 3;
		i += step;
	}
}

int main()
{
	int * buffer = new int[buff_size]{};

	for (int i = 0; i < 16; ++i) {
		int step = (int)pow(2, i);

		auto start = std::chrono::system_clock::now();
		test_cache_hit(buffer, buff_size, step);
		auto end = std::chrono::system_clock::now();

		std::chrono::duration<double> elapsed_seconds = end - start;
		std::cout << "step: " << step << ", elapsed time: " << elapsed_seconds.count() * 1000 << "ms\n";
	}

	delete[] buffer;
}

下面是代碼的運行結果:

step: 1, elapsed time: 66.3925ms
step: 2, elapsed time: 34.1113ms
step: 4, elapsed time: 26.5611ms
step: 8, elapsed time: 27.8086ms
step: 16, elapsed time: 27.8812ms
step: 32, elapsed time: 19.7227ms
step: 64, elapsed time: 11.3431ms
step: 128, elapsed time: 4.7402ms
step: 256, elapsed time: 2.4116ms
step: 512, elapsed time: 2.0155ms
step: 1024, elapsed time: 1.7971ms
step: 2048, elapsed time: 1.1497ms
step: 4096, elapsed time: 0.5761ms
step: 8192, elapsed time: 0.2719ms
step: 16384, elapsed time: 0.1615ms
step: 32768, elapsed time: 0.1099ms
Press any key to continue . . .

步長爲2、4、8、16的時候耗時都差不多,這是因爲cache line的容量是64字節,因此這幾個步長對內存的訪問量雖然大大不同,但是耗時都接近。值得注意的是,步長1和2的耗時差別相當大。我覺得cache line在這兩個步長依然起作用的,只是兩者的“計算消耗”耗時差別比較大,例如循環計數加法運算等等,所以1和2之間的差別可以理解爲運算量的差別。

 

4. 多核cpu cache的false sharing問題

當我們運行多線程程序的時候,一般每個線程都會佔用一個核。而對於多核cpu,每個核都有一個cache。當多個線程訪問幾個相鄰的內存地址的時候,如果這些地址可以被映射爲同一個cache line的話,那麼這段size爲64字節(也就是cache line的size)的內存會被導入各個cpu核的cache中。因此,各個核的cache line需要同步,這可能導致程序的運行時間大大增加。這個問題稱爲 false sharing。

下圖展示了這種情況。

兩個線程1和2分別運行在core0和core1上面,它們分別訪問內存地址0xA000(保存了值爲1的整數)以及0xA004(保存了值爲2的整數)。因爲這兩個地址可以被映射爲同一個cache line,所以core0和core1的cache都導入了一條包含0xA000以及0xA004的cache line。當線程1寫0xA000的時候,即使線程2並沒有訪問0xA000,它的cache line也要做同步。而這有潛在的性能問題。

下面的代碼來自這個鏈接: https://vorbrodt.blog/2019/02/02/cache-lines/

#include "stdafx.h"
#include <iostream>
#include <thread>
#include <chrono>
#include <cstdlib>

using namespace std;
using namespace chrono;

const int CACHE_LINE_SIZE = 64;//sizeof(int);
const int SIZE = 2; //第一種方式
//const int SIZE = CACHE_LINE_SIZE / sizeof(int) + 1; //第二種方式
const int COUNT = 100'000'000;

int main(int argc, char** argv)
{
	srand((unsigned int)time(NULL));

	int* p = new int[SIZE];

	auto proc = [](int* data) {
	 for (int i = 0; i < COUNT; ++i)
	  *data = *data + rand();
	

};

	auto start_time = high_resolution_clock::now();

	std::thread t1(proc, &p[0]);
	std::thread t2(proc, &p[SIZE - 1]);

	t1.join();
	t2.join();

	auto end_time = high_resolution_clock::now();
	cout << "Duration: " << duration_cast<microseconds>(end_time - start_time).count() / 1000.f << " ms" << endl;

	getchar();

	return 1;
}

在以上的代碼中,我們可以試着用兩種不同的size分配內存並測量運行時間,我們發現第一種方式和第二種方式有明顯的性能差別。在我的電腦上,第一種方式耗時7030ms,第二種方式耗時3369ms。

第一種方式中,兩個線程同時訪問兩個相鄰的內存地址,並且這兩個地址會被映射爲同一個cache line,所以這兩個線程會把同一段cache line的內存導入各自的cache。當一個線程修改一個內存地址的內容的時候,另一個線程不得不同步cache line,而從測試結果來看同步操作的成本相當高。

解決這個問題的方法就是避免兩個線程訪問的內存地址落入同一個cache line;第二種方式增加了兩個內存地址間的偏移,從而避免了false sharing的問題。

 

5. 一些有用的鏈接

 

intel關於false sharing的鏈接 https://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads

Igor有一篇關於cache的好文,列舉了cache相關的各種技巧,並且採用C#範例代碼 http://igoro.com/archive/gallery-of-processor-cache-effects/

wiki上的cpu cache條目包含了部分關於cache line的內容 https://en.wikipedia.org/wiki/CPU_cache

下面的git裏,包含一份可以測量cache line以及cache容量的工業級代碼。但是基本思路還是和上文的範例一樣 https://github.com/SudarsunKannan/memlatency

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章