# 2017q Homework (raytracing) contributed by <`tina0405`>,<`zhanyangch`>,<`LinRiver`> ### 開發環境 ~~~ Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian CPU(s): 4 On-line CPU(s) list: 0-3 Thread(s) per core: 2 Core(s) per socket: 2 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 58 Model name: Intel(R) Core(TM) i5-3230M CPU @ 2.60GHz Stepping: 9 CPU MHz: 1200.024 CPU max MHz: 3200.0000 CPU min MHz: 1200.0000 BogoMIPS: 5188.41 Virtualization: VT-x L1d cache: 32K L1i cache: 32K L2 cache: 256K L3 cache: 3072K NUMA node0 CPU(s): 0-3 ~~~ ### 以 `make PROFILE=1` 重新編譯程式碼,並且學習 [gprof](https://sourceware.org/binutils/docs/gprof/) * 這邊使用 `make PROFILE=1` 是因為 Makefile 裡定義,而 `-pg` 的用法在下方GNU gprof 的原理提到 ~~~= ifeq ($(strip $(PROFILE)),1) PROF_FLAGS = -pg CFLAGS += $(PROF_FLAGS) LDFLAGS += $(PROF_FLAGS) endif ~~~ * GNU gprof 的原理,參考 [GNU gprof](https://sourceware.org/binutils/docs/gprof/Introduction.html#Introduction) 分析步驟 : 1. 你應該編譯和連結你的程式使它可使用分析.往下解說如何編譯程式使之可分析 * `-pg` 的這個選項應該要跟著指令一起編譯和連結 * `-pg` 是編譯和連結的選項,如果沒使用就沒有 call-graph data 2. 我們應該要執行我們的程式使他產生分析的資料檔 * 一但程序被編譯成分析用,他還是會像原本那樣輸出,只是會多花時間蒐集和寫入資料 * 程序結束時會寫入 `gmon.out` 的檔案 3. 我們應該要使用 `gprof` 去分析資料 * 通常在編譯時加入 `-pg` , 就代表著每一個函式會去呼叫 `mcount` (或 `_mcount`, 或 `__mcount`, 依靠作業系統和編譯器) 做為第一個操作之一 * 包含 profiling library 的 mcount 程序,通常利用檢查 stack frame 去找 child 的地址和返回 original parent 的地址 * mcount 是一個典型的簡短的組語 stub 程序,利用兩個參數 `frompc` 和 `selfpc` 呼叫 `__mcount_internal` , `__mcount_internal` 是負責維護 in-memory call graph 記錄 frompc (從哪個 program counter 來) , selfpc (自己的 program counter), 和調用次數。 * 首先必須先安裝 3 個套件 , pip 是因為安裝 `gprof2dot` 會用到 ~~~ $sudo apt install python python-pip $sudo pip install --upgrade pip $sudo pip install gprof2dot ~~~ #### 指令的使用 * 一開始如同上述說明,必須先執行一遍 `./raytracing` 將分析資訊儲存在 `gmon.out` 檔案中 * 有了分析資訊 `gmon.out` 就可以執行 gprof 分析 `gprof -p ./raytracing` * -p 是只輸出函式調用圖 * -b 是不會輸出標題的描述 * -q 是只輸出時間消耗 ~~~ Each sample counts as 0.01 seconds. % cumulative self self total time seconds seconds calls s/call s/call name 22.99 0.51 0.51 69646433 0.00 0.00 dot_product 17.58 0.90 0.39 56956357 0.00 0.00 subtract_vector 12.39 1.18 0.28 31410180 0.00 0.00 multiply_vector 9.47 1.39 0.21 10598450 0.00 0.00 normalize 9.01 1.59 0.20 13861875 0.00 0.00 rayRectangularIntersection 8.79 1.78 0.20 17836094 0.00 0.00 add_vector 4.96 1.89 0.11 4620625 0.00 0.00 ray_hit_object 4.51 1.99 0.10 13861875 0.00 0.00 raySphereIntersection 2.70 2.05 0.06 17821809 0.00 0.00 cross_product 1.80 2.09 0.04 2110576 0.00 0.00 compute_specular_diffuse 1.80 2.13 0.04 1048576 0.00 0.00 ray_color 1.13 2.16 0.03 3838091 0.00 0.00 length 0.90 2.18 0.02 2110576 0.00 0.00 localColor 0.68 2.19 0.02 4221152 0.00 0.00 multiply_vectors 0.45 2.20 0.01 2558386 0.00 0.00 idx_stack_empty 0.45 2.21 0.01 1241598 0.00 0.00 refraction 0.45 2.22 0.01 1048576 0.00 0.00 rayConstruction 0.00 2.22 0.00 2520791 0.00 0.00 idx_stack_top 0.00 2.22 0.00 1241598 0.00 0.00 protect_color_overflow 0.00 2.22 0.00 1241598 0.00 0.00 reflection 0.00 2.22 0.00 1204003 0.00 0.00 idx_stack_push 0.00 2.22 0.00 1048576 0.00 0.00 idx_stack_init 0.00 2.22 0.00 113297 0.00 0.00 fresnel 0.00 2.22 0.00 37595 0.00 0.00 idx_stack_pop 0.00 2.22 0.00 3 0.00 0.00 append_rectangular 0.00 2.22 0.00 3 0.00 0.00 append_sphere 0.00 2.22 0.00 2 0.00 0.00 append_light 0.00 2.22 0.00 1 0.00 0.00 calculateBasisVectors 0.00 2.22 0.00 1 0.00 0.00 delete_light_list 0.00 2.22 0.00 1 0.00 0.00 delete_rectangular_list 0.00 2.22 0.00 1 0.00 0.00 delete_sphere_list 0.00 2.22 0.00 1 0.00 0.00 diff_in_second 0.00 2.22 0.00 1 0.00 2.22 raytracing 0.00 2.22 0.00 1 0.00 0.00 write_to_ppm ~~~ * 文中提到 Graphviz 有 Dot 語言直連圖的能力,所以我們使用他將程式視覺化,以方便觀察需改善的函式 * 使用下列指令將程式視覺化 ~~~ $ gprof ./raytracing | gprof2dot | dot -T png -o output.png ~~~ ![](https://i.imgur.com/OJvjE12.png) ### 以 [gprof](https://sourceware.org/binutils/docs/gprof/) 指出效能瓶頸,並且著手改寫檔案 `math-toolkit.h` 的函式實做 * 執行時所花時間,有使用 `PROFILE=1`,跑 10 次的結果: 6.007 秒 ~~~ Performance counter stats for 'sudo chrt -f 99 taskset -c 0 ./raytracing' (10 runs): 34 cache-misses # 27.528 % of all cache refs ( +- 7.22% ) 125 cache-references ( +- 3.76% ) 1676 instructions # 0.31 insn per cycle 5452 cycles ( +- 2.86% ) 6.007348484 seconds time elapsed ( +- 0.14% ) ~~~ * 觀看影片 [vedio1](https://www.youtube.com/watch?v=m1RmfOfSwno),[vedio2](https://www.youtube.com/watch?v=V_rLyzecuaE),參考 [Enhance raytracing program : Team 4](https://embedded2016.hackpad.com/ep/pad/static/f5CCUGMQ4Kp) 的共筆 * 影片中提到如果我們開了最佳化(有使用 PROFILE=1)會得到以下結果,那表示我們目前未使用最佳化 6.007(sec),仍有許多方向要努力 ~~~ tina@tina-X550VB:~/Hw/raytracing$ ./raytracing # Rendering scene Done! Execution time of raytracing() : 0.741573 sec ~~~ * 列出程式熱區的前六名:由程式熱區當目標去改善,就算是少一行也有千萬級次數的影響 ~~~ time seconds seconds calls s/call s/call name 22.99 0.51 0.51 69646433 0.00 0.00 dot_product 17.58 0.90 0.39 56956357 0.00 0.00 subtract_vector 12.39 1.18 0.28 31410180 0.00 0.00 multiply_vector 9.47 1.39 0.21 10598450 0.00 0.00 normalize 9.01 1.59 0.20 13861875 0.00 0.00 rayRectangularIntersection 8.79 1.78 0.20 17836094 0.00 0.00 add_vector ~~~ ### Loop Unrolling [參考](http://book.51cto.com/art/200908/146356.htm) Loop Unrolling 本身是一個相當基礎性的編譯器最佳化技術, 雖然他的方法看似直接了當, 但執行上具備相當大的成效. 其基本的觀念是把 for 迴圈中的表示式線性展開, 並且在每一次迭代中存取線性數據中的一小組來取代單獨一個. 如此一來程式在執行時可讓執行迭代次數減少. 以下為一針對彩色圖片取補色之程式來進行 Loop Unrolling, 其中 BYTE * pixel 為預處理之圖片像素數據, BYTE * tempPixel 為存放已處理之圖片, int width 和 height 為預處理圖片之寬與高 ~~~ void Negative(BYTE * pixel, BYTE * tempPixel, int width, int height) { int i = 0; int sum = width * height * 4; memcpy(pixel, tempPixel, sum); for(i = 0; i < sum; i++) { tempPixel[i] = 255 - tempPixel[i]; } } ~~~ 接著我們嘗試將迴圈次數變為原來三分之一, 因為每一像素具有 R, G, B 三分量, 透過以下改寫 ~~~ void Negative(BYTE * pixel, BYTE * tempPixel, int width, int height) { int i = 0; int sum = width * height * 4; memcpy(pixel, tempPixel, sum); for(i = 0; i < sum; i+=3) { tempPixel[i] = 255 - tempPixel[i]; tempPixel[i+1] = 255 - tempPixel[i+1]; tempPixel[i+2] = 255 - tempPixel[i+2]; } } ~~~ 然而需要特別注意的是, 根據預處理的數組的長度來選擇相對應適當的線性展開性. 例如增加暫存器儲存迭代的暫時變數, 因而導致效能降低. 除此之外, 更應考慮此展開性是否對時間與空間之開銷比例的影響 #### 使用共筆中 loop unrolling (有使用 PROFILE=1) * 但 unrolling 也不是能隨意使用,因為一般現實狀況會有管線危害(pipeline hazard),要確定效能提升才可用 unrolling * 函式次數最高者 `dot_product` 跑 10 次平均的結果 5.473391143 秒,比原版減少 0.5 秒 ~~~c= double dot_product(const double *v1, const double *v2) { double dp = (v1[0]*v2[0])+(v1[1]*v2[1])+(v1[2]*v2[2]); return dp; } ~~~ * 函式次數第二高者 `subtract_vector` 跑 10 次平均的結果 5.277608638 秒,再減少 0.2 秒 ~~~c= static inline void subtract_vector(const double *a, const double *b, double *out) { out[0]=a[0]-b[0]; out[1]=a[1]-b[1]; out[2]=a[2]-b[2]; } ~~~ * Performance counter stats for 'sudo chrt -f 99 taskset -c 0 ./raytracing' (10 runs): ~~~ 29 cache-misses # 24.138 % of all cache refs ( +- 8.68% ) 119 cache-references ( +- 3.04% ) 1676 instructions # 0.30 insn per cycle 5627 cycles ( +- 3.18% ) 5.277608638 seconds time elapsed ( +- 0.44% ) ~~~ * 函式次數第三高者 `multiply_vector` 跑 10 次的平均 5.277 秒,只減少 0.09 #### 使用共筆中 loop unrolling + macro 方法 (有使用 PROFILE=1) * 改善呼叫函式次數最高者 `dot_product` : * `#define dot_product(a,b) ((a[0]*b[0])+(a[1]*b[1])+(a[2]*b[2]))` * 執行跑 10 次的結果 ,執行時間: 4.176112849 秒 (比 `dot_product`+`subtract_vector` unrolling )快了 1.1 秒 ~~~ Performance counter stats for 'sudo chrt -f 99 taskset -c 0 ./raytracing' (10 runs): 51 cache-misses # 24.394 % of all cache refs ( +- 36.23% ) 210 cache-references ( +- 44.10% ) 3863 instructions # 0.38 insn per cycle ( +- 56.62% ) 1,0112 cycles ( +- 45.37% ) 4.176112849 seconds time elapsed ( +- 0.32% ) ~~~ * 改善呼叫函式次數第二高者 `subtract_vector` : 時間再加快 0.8 秒 ~~~ Performance counter stats for 'sudo chrt -f 99 taskset -c 0 ./raytracing' (10 runs): 37 cache-misses # 29.299 % of all cache refs ( +- 10.34% ) 126 cache-references ( +- 4.29% ) 1676 instructions # 0.26 insn per cycle 6480 cycles ( +- 11.39% ) 3.381460604 seconds time elapsed ( +- 0.68% ) ~~~ * `dot_product` 和 `subtract_vector` 已經消失於圖中(函式) ![](https://i.imgur.com/CLvBGvx.png) * 改為 macro 的結果是時間減少但 cache-misses rate 增加,時間的提升原因來自於 69646433 次的`dot_product` 呼叫 + 56956357 次的 `subtract_vector` 呼叫 * 這個實驗中想要比較的是 macro 和 function 的差別 * function : 利用時間換取空間,大家共用一份程式區塊,因此所消耗的時間就是執行時期的 push 和 pop ,節省記憶體空間 * macro : 則是利用空間換取時間,前置處理器會將我們定義的 macro 替換各個區塊所以省下了 push 和 pop 的時間數,但造成空間的浪費 * 參考[前置處理器](http://epaper.gotop.com.tw/pdf/ael005600.pdf): * C 語言的前置處理器是一個巨集處理器,主要用來處理C程式中含有 `#` 符號 1. 檔案含入功能: #include 2. 字串置換和巨集定義: #define、#undef 3. 條件編譯: #if...#elif...#else...#endif,#ifdef...#else...#endif,#ifndef...#else...#endif * 動當編譯器在編譯程式之前前置處理器會自動啟: ![](https://i.imgur.com/gznbT7E.png) * 可是即使時間從原本 6.007 變成 3.302 , 加快 45% 但和最佳化後的 0.741 秒相差甚遠,因此再往 SIMD 和多執行緒的方面前進 ### SIMD * 參考 [春季班 Raytracing](https://hackmd.io/s/HkifAKiae#) ##### math-toolkit.h with SSE * 解釋裡面常用指令,參考 [ Intel Intrinsics Guide ](https://software.intel.com/sites/landingpage/IntrinsicsGuide/#techs=SSE2&expand=3664) 這邊所使用的是 SSE2(一次可操作兩組雙精度符點數做運算) * `__m128d _mm_loadu_pd (double const* mem_addr)` * 操作方法 : ~~~ dst [127:0] := MEM [mem_addr+127:mem_addr] ~~~ * 描述 : 載入 128 bits (由兩組雙精度符點數 64x2 = 128 bits) 從記憶體到 dst,mem_addr 不用對齊特定邊界 * `__m128d _mm_mul_pd (__m128d a, __m128d b)` * 操作方法 : ~~~ FOR j := 0 to 1 i := j*64 dst[i+63:i] := a[i+63:i] * b[i+63:i] ENDFOR ~~~ * 描述 :如上述的 a 和 b 的 0~63 (64-bits) 相乘,放進 dst 0~63 (64-bits); a 和 b 的 64~127 (64-bits) 相乘,放進 dst 64~127 (64-bits) * `__m128d _mm_add_pd (__m128d a, __m128d b)` ~~~ __m128d _mm_load_sd (double const* mem_addr) __m128d _mm_add_pd (__m128d a, __m128d b) __m128d _mm_div_pd (__m128d a, __m128d b) void _mm_storeu_pd (double* mem_addr, __m128d a) void _mm_store_pd1 (double* mem_addr, __m128d a) __m128d _mm_sqrt_pd(__m128d a) _mm_shuffle_pd (__m128d a, __m128d b, int imm8) __m128d _mm_unpackhi_pd (__m128d a, __m128d b) double _mm_cvtsd_f64(__m128d a) ~~~ * 先拿函式使用次數最高者 `dot_product` 來實測效能,所做的事其實就是 $(a[0] \times b[0])+(a[1] \times b[1])+(a[2] \times b[2])$ * force inline : 在影片中提到我們關閉了最佳化 -O0 所以 inline 沒有效果,所以如果要強行執行的話,要加上 `__attribute__((always_inline))` 建議 CPU 執行它 * 所以用 SSE2 上述的指令集實作 `dot_product` 會像: ~~~ low high 取出 (v1[0] v1[1]) 打包放置 I0 取出 (v1[1] v1[2]) 打包放置 I1 取出 (v2[0] v2[1]) 打包放置 I2 取出 (v2[1] v2[2]) 打包放置 I3 |-----------| I0*I2 (|v1[0]*v2[0]| v1[1]*v2[1]) 放置 T1 I1*I3 (|v1[1]*v2[1]| v1[2]*v2[2]) 放置 T2 T2high(|v1[2]*v2[2]| v1[2]*v2[2]) 放置 T3 |-----------| 框起來的部份全部低位相加是我們要的 缺點:會發現使用的空間和浪費的空間是 1 : 1 ~~~ * `dot_product` 程式碼部份: ~~~c= __attribute__((always_inline)) static inline double dot_product(const double *v1, const double *v2) { __m128d I0 = _mm_loadu_pd(v1); __m128d I1 = _mm_loadu_pd(v1+1); __m128d I2 = _mm_loadu_pd(v2); __m128d I3 = _mm_loadu_pd(v2+1); __m128d T1 = _mm_mul_pd(I0,I2); __m128d T2 = _mm_mul_pd(I1,I3); __m128d T3 = _mm_unpackhi_pd(T2,T2); __m128d T4 = _mm_add_pd(T1,T2); __m128d T5 = _mm_add_pd(T3,T4); return _mm_cvtsd_f64(T5); } ~~~ * 比較只改進 `dot_product` 部份,跑 10 次(都有 PROFILE=1) * 原版 : 6.263762944 seconds * `dot_product`(unrolling + macro) : 4.176112849 seconds * `dot_product`(sse) : 4.317184400 seconds * 結果是比 unrolling+macro 慢,參考了春季班的共筆,提出可能性: * 可能因為 128bit 運算 3 個 double , 兩個兩個一組有所浪費 ##### math-toolkit.h with AVX * 參考 [ Intel Intrinsics Guide ](https://software.intel.com/sites/landingpage/IntrinsicsGuide/#techs=SSE2&expand=3664) 這邊所使用的是 AVX (一次可操作四組雙精度符點數做運算) * 先拿函式使用次數最高者 `dot_product` 來實測效能: ~~~c= static inline double dot_product(const double *v1, const double *v2) { double temp[4] = {0, 0, 0, 0}; __m256d ymm0, ymm1; ymm0 = _mm256_loadu_pd (v1); // ymm0 = {a0, a1, a2, 0} ymm1 = _mm256_loadu_pd (v2); // ymm1 = {b0, b1, b2, 0} ymm0 = _mm256_mul_pd (ymm0, ymm1); // ymm0 = {a0*b0, a1*b1, a2*b2, X} const __m128d valupper = _mm256_extractf128_pd(ymm0, 1); // valupper = {a2*b2, 0} ymm1 = _mm256_castpd128_pd256 (valupper); // ymm2 = {a2*b2, 0, X, X} ymm0 = _mm256_hadd_pd (ymm0, ymm0); // ymm0 = {a0*b0+a1*b1, X, X, X} ymm0 = _mm256_add_pd (ymm0, ymm1); // ymm0 = {a0*b0+a1*b1+a2*b2, 0, X, X} _mm256_storeu_pd (temp, ymm0); return *temp; } ~~~ * 但其實這邊也可以看出來 AVX 沒寫好會比 SSE 更浪費空間 #### 整理 dot_product() 比較(有 make PROFILE=1,沒有 force inline) * AVX 的效益比不上浪費空間所付出的代價 * 在 SIMD 和一般程式間轉換也需付出代價 ![](https://i.imgur.com/5dir7vR.png) ### OpenMP * 參考 [hugikun999 的共筆](https://hackmd.io/s/HyHhgcv6#) 以往在單核心處理器時期, 透過作業系統提供的 API 來建立執行緒. 然而在多核心的架構下, 為了讓各個處理器都能夠妥善被使用, 進而使用多執行緒程式設計來達到此目標. 透過 OpenMP 能夠取代使用作業系統之 API 來建立執行緒, 其中有以下三個特點: * CPU 核心數量的擴展性 因為多核心程式設計時是需要考慮到程式效能會隨 CPU 數目增加而有所不同. 這其中要求程式中所建立的執行緒數量需要隨著 CPU 數目改變而變化. 然而使用 OpenMP 無須擔心此 CPU 數目擴展問題 * 便利性 使用 OpenMP 可將同一函式中的程式分成多個執行緒進行, 亦可將一 for 迴圈分解成多個執行緒進行 * 可移植性 各個主流的作業系統之執行緒 API 是互不相容, 然而 OpenMP 本身即是標準規範, 對於支持他的編譯器都是執行同一標準, 具備可移植性之特性 然而有幾項是使用 OpenMP 常見的錯誤使用: * Use of locks without flush * Use of ordered clause without ordered construct * Try to change number of threads in parallel region after start of region * Attempt to change loop variable while in #pragma omp for * Use of critical when atomic would be sufficient * Use of orphaned construct outside parallel region * Use of unnecessary flush * Use of unnecessary critical ### POSIX Threads POSIX 執行緒, 亦可縮寫成 Pthreads, 為 [POSIX](https://zh.wikipedia.org/wiki/POSIX) 的執行緒標準, 定義了創建和操作執行緒的的一套 API, 一般用於 Unix-like POSIX 系統. 其 API 稱作 Pthreads, 大致共有100個函數調用且分為四類, 並以 'pthread_' 開頭: * 執行緒管理 * 互斥鎖 * 條件變量 * 使用了互斥鎖的執行緒間的同步管理 ### 目標 * [raytracing + SIMD]: 延續春季班 Raytracing 成果,確認 SIMD 和多執行緒得以運作 * [小組討論目標](https://hackmd.io/s/Hym6NzE-f#) ### 原作業要求 * 在 GitHub 上 fork [raytracing](https://github.com/sysprog21/raytracing),並思考如何改善程式效能 * 以 `make PROFILE=1` 重新編譯程式碼,並且學習 [gprof](https://sourceware.org/binutils/docs/gprof/) * 參考 [使用 GNU gprof 進行 Linux 平台的程式分析](http://os.51cto.com/art/200703/41426.htm) * 以 [gprof](https://sourceware.org/binutils/docs/gprof/) 指出效能瓶頸,並且著手改寫檔案 `math-toolkit.h` 在內的函式實做,充分紀錄效能差異在共筆 * 注意: 請勿更動編譯器最佳化選項 `-O0` (關閉最佳化) * 檔案 `math-toolkit.h` 定義的若干數學操作函式很重要,可參考 [Investigating SSE cross product performance](http://threadlocalmutex.com/?p=8)、[MathFu](https://google.github.io/mathfu/) 原始程式碼,以及 [2015q3 Homework #1 Ext](http://wiki.csie.ncku.edu.tw/embedded/2015q3h1ext) * 可善用 POSIX Thread, OpenMP, software pipelining, 以及 loop unrolling 一類的技巧來加速程式運作