1. 程式人生 > >《深入理解計算機系統》之淺析程式效能優化

《深入理解計算機系統》之淺析程式效能優化

此文已由作者餘笑天授權網易雲社群釋出。

歡迎訪問網易雲社群,瞭解更多網易技術產品運營經驗。

  本文主要是基於我之前學習《深入理解計算機系統》(以下簡稱CSAPP)這本書第五章優化程式效能內容的回顧以及總結。主要內容並沒有從大而全的方面去闡述如何優化程式,而是從一些細節著手來看待優化程式碼質量這個大問題。由於我之前接觸C/C++程式較多,因此示例程式碼都是用C++編寫,但是我認為無論是什麼語言,一些基本的優化原則是相通的。

 1.程式優化原則

    在CSAPP作者看來效能好的程式要有以下幾種特點:

    (1)合適的資料結構和演算法,都說程式=演算法+資料結構,因此這兩方面的優化是程式優化的基石。

    (2)儘量的寫出編譯器可以有效優化的程式碼,現代編譯器都會對原始碼進行優化,以提高程式的效能。比如Linux下的GCC編譯器就能控制優化的等級,優化等級高,對應的程式效能好。如果你的程式編譯器並不能確定是否能進行安全優化,那麼對於一些的成熟的編譯器而言,它並不會採用一些激進的優化方式,這部分內容在優化安全性會有具體介紹。

    (3)對於處理運算量特別大的計算,可以將一個任務拆分為多個任務。甚至可以考慮到在多核和對處理器上進行平行計算,這部分內容在CSAPP中的12章會有詳細敘述。

    (4)在實現和維護程式碼的簡單性和執行速度之間做出權衡,比如呼叫系統的排序演算法可以滿足日常大部分的排序需求,但是進行特殊的優化可能要針對排序的資料進行分析然後對應修改排序演算法,這個過程耗費的時間和最後的優化結果以及優化後可能帶來的可讀性、模組性的降低需要作出權衡。

    1.1優化的安全性

    對於C/C++程式,大多數的編譯器會指定優化級別,以GCC為例子:gcc -o指令就可以設定優化級別:

    -o0:關閉所有優化

    -o1:最基本的優化級別,編譯器試圖以較少的時間生成更快以及體積更小的程式碼。

    -o2:推薦的優化級別,o1的進階。

    -o3:較危險的優化等級,這個等級會延長編譯時間,編譯後會產生更大的二進位制檔案,會帶來一些無法預知的問題。

    -os:優化程式碼體積,通常適用於磁碟空間緊張或者CPU快取較小的機器。

     所謂優化的安全性,我們不妨看以下一個栗子:

     5f9e1020-e66d-4cca-9031-9ba795702c6f

可以看出看上去以上兩個函式實現的功能是一致的,都是將yp所指向的int值的兩倍加到xp所指向的值。但是f2的效能要比f1更好一些,因為f2有3次引用,f1有6次引用(2次讀xp,2次讀yp,2次寫xp)。我們期望編譯器會幫我們進行以上優化,但是成熟的編譯器不會這麼做的,這是因為該程式存在記憶體別名使用(memory aliasing)的問題。就是說xp,yp可能指向同一位置:

9e06d97e-60fe-48d5-aa62-711c7891896dc4edadb7-6f8b-42b4-a6c4-bcf6ab16a1dd

    可以看出當出現以上情況時,兩個函式的行為並不一致,這類程式的編寫就成為了編譯器優化它的阻礙因素,對應到優化原則的第二條。

    其次函式呼叫同樣會阻礙編譯器的優化,編譯器是不會對函式內容作出假設,因此針對函式呼叫,編譯器一般不會貿然進行優化,同樣可以舉出一個栗子:

    1a0de479-83fc-45b7-9c3e-0d72e5c0a956

    可以看出f1呼叫了f()兩次,而f2()只調用了一次,函式的呼叫涉及到棧幀的操作這需要消耗一些系統資源,因此按理來說f2()的效能優於f1(),但是編譯器針對這種情況同樣不會進行優化,考慮到以下程式碼:

    ea9a847c-a16d-49b4-8bdd-74f220d5b32d4eca4917-0a04-4dc7-b020-ce887f6f251b

    同樣可以看出在這種情況下,兩個函式行為同樣會不一致。

    2消除低效的迴圈

    我們編寫了一個迴圈累加的程式來測試在不同迴圈下,程式效能的開銷,首先定義了這樣一個數據結構:

typedef struct {	long int len;
	data_t *data;
}vec_rec, *vec_ptr;

   vec_rec表示為data_t的陣列,data_t表示為自定義的資料型別,len為該陣列的長度。

    原書中針對date_t進行了兩種定義分別是:整數以及浮點數,並對各自的型別進行加法和乘法的操作,分別統計各自的效能情況,於此同時還定義了效能衡量標準CPE即每元素時鐘週期,舉個栗子:計算一個數組中所有元素之和,分別統計陣列元素個數不同的情況下該程式所用的時鐘週期,然後得出每加入一個元素平均多耗費的時鐘週期,這個值就是CPE。下面是該書的作者統計的CPE值,這部分由於本人並沒有做實驗,因此只貼出作者的結果以供參考:

b1990159-fb66-4407-ba34-07a3b99c96f5

  可以看出目前的CPU對於浮點操作的優化使其效能接近甚至略好於對整數的操作,同時對於程式至少進行o1級別的優化同樣是有必要的。

    下面貼出具體的迴圈呼叫程式碼:

#include"stdlib.h"#include"time.h"#include#ifndef _CLOCK_T_DEFINED
#define _CLOCK_T_DEFINED
#endif
typedef long clock_t;
using namespace std;
typedef int data_t;
typedef struct 
{	long int len;
	data_t *data;
}vec_rec, *vec_ptr;vec_ptr new_vec(long len){
	vec_ptr res = (vec_ptr)malloc(sizeof(vec_rec));
	data_t *data = NULL;	if (!res)		return NULL;
	res->len = len;	if (len > 0)
	{
		data = (data_t *)calloc(len, sizeof(data_t));		if (!data)
		{
			free((void*)res);			return NULL;
		}
	}
	res->data = data;	return res;
}long vec_length(vec_ptr v){	return v->len;
}int get_vec_element(vec_ptr v,long index, data_t  *dest){	if (index < 0 || index >= v->len)		return 0;
	*dest = v->data[index];	return 1;
}void combine1(vec_ptr v, data_t  *dest) {	long i;
	*dest = 0;	for (i = 0; i < vec_length(v); ++i)
	{
		data_t val;
		get_vec_element(v, i, &val);
		*dest = *dest + val;
	}
}

    該程式分別依次取陣列元素的值然後加到dest所指的位置中去,這是一般的迴圈累加的寫法,可以看到每次迭代求值都會對測試條件進行求值操作,另一方面針對這種情況,陣列的長度並不會隨著迴圈而更改,因此我們定義了combine2如下:

void combine2(vec_ptr v, data_t  *dest){	long  i;	long len = vec_length(v);
	*dest = 0;	for (i = 0; i < len; ++i)
	{
		data_t val;
		get_vec_element(v, i, &val);
		*dest = *dest + val;
	}
}

    為了對比效能,我做了以下實驗:

int main()
{
	vec_ptr vec = new_vec(100000000);	int* tmp = new int[100000000];
	vec->data = (int *)tmp;	int res = 0;
	clock_t start, finish;	double totaltime;
	start = clock();
	combine1(vec, &res);
	finish = clock();
	totaltime = (double)(finish - start) / CLOCKS_PER_SEC;	cout << "\n此程式的執行時間為" << totaltime << "秒!" << endl;
	start = clock();
	combine2(vec, &res);
	finish = clock();
	totaltime = (double)(finish - start) / CLOCKS_PER_SEC;	cout << "\n此程式的執行時間為" << totaltime << "秒!" << endl;
	system("pause");
}

得到如下結果:

91ff821f-6326-4a23-8f6d-7563092c174a

    3減少過程呼叫

    一個函式的呼叫基本過程大致如下:

    1、呼叫者函式把被調函式所需要的引數按照與被調函式的形參順序相反的順序壓入棧中

    2、呼叫者函式使用call指令呼叫被調函式,並把call指令的下一條指令的地址當成返回地址壓入棧中

    3、在被調函式中,被調函式會先儲存呼叫者函式的棧底地址(push ebp),然後再儲存呼叫者函式的棧頂地址

    4、在被調函式中,從ebp的位置處開始存放被調函式中的區域性變數和臨時變數,並且這些變數的地址按照定義時的順序依次減小

    可以看出在函式呼叫過程中,需要做一些壓棧出棧操作,同時需要一些暫存器幫助儲存和恢復環境,這些都將帶來系統開銷。因此減少一些函式呼叫將會提高程式效能。以上面的程式為例,可以看到combine函式在迴圈中呼叫了get_vec_element操作,這部分操作可以移到迴圈內部而不必呼叫函式,具體做法如下:

    增加get_vec_start函式獲取陣列起始位置:

data_t *get_vec_start(vec_ptr v)
{	return v->data;
}

  修改combine函式:

void combine3(vec_ptr v, data_t  *dest){	long  i;	long len = vec_length(v);
	data_t *data = get_vec_start(v);
	*dest = 0;	for (i = 0; i < len; ++i)
	{
		*dest = *dest + data[i];
	}
}

     修改後的程式效能對比如下:

840ef827-eafd-4712-9b6e-5bb756f8b7a8

  4消除不必要的引用

    combine3將計算後的值累加在dest指標後,一下貼出段程式碼的彙編結果:

    d2cb2ddc-9bb0-4d21-b949-b225e478fdd4

  從這段程式碼可以看出dest指標放在暫存器rax中,每次迭代,data指標加1。每次迭代後。累積的數值從記憶體中讀出再寫入到記憶體中,這樣頻繁的讀寫記憶體將會影響程式的效能。

  這類頻繁的記憶體讀寫是可以避免的,可以引入一個臨時變數儲存*dest的值,迴圈中只取變數的值,直至迴圈結束將結果寫到dest指標所指的位置中。程式碼如下:

void combine4(vec_ptr v, data_t  *dest){	long  i;	long len = vec_length(v);
	data_t *data = get_vec_start(v);	long acc = 0;	//*dest = 0;
	for (i = 0; i < len; ++i)
	{
		acc = acc + data[i];
	}
	*dest = acc;
}

  這段程式碼的彙編結果如下:

9177e566-5fb2-450e-8606-fb67cdf15385

   可以看出該部分彙編程式碼用rax儲存累計值沒有涉及到取記憶體的操作,因此在迴圈中的記憶體操作變成只有取data陣列這一次。

      以下貼出結果對比:

    bc82d426-b013-44cf-abb4-7773859b3eff

  可以看出combine4在之前的基礎上效能又稍有提高。

    5迴圈展開

    迴圈展開是一種程式變換,通過增加每次迴圈的計算量,減少迴圈次數從而改程序序效能。迴圈展開對程式效能的影響有兩點,其一是它減少了迴圈中的輔助計算量例如迴圈索引和條件分支(該書5.7節詳細介紹了條件分支對效能的影響)。第二它減少了關鍵路徑的運算元量。下面給出迴圈展開的一個版本:

void combine5(vec_ptr v, data_t  *dest){	long  i=0;	long len = vec_length(v);	long limit = len - 1;
	data_t *data = get_vec_start(v);
	data_t acc = 0;	for (int i = 0; i < limit; i += 3)
	{
		acc = (acc + data[i]) + data[i + 1];
	}        if (i < len)
	{
		acc = acc + data[i];
	}
	*dest = acc;
}

  下面是迴圈展開後的程式效能:

  0c51b90f-ebc2-4c26-9230-47abf30a010d

  該版本的迴圈展開將原有的迴圈次數減少了一半,延續這個思想,可將迴圈按任意因子k展開,下面是作者將改程式迴圈展開後多次後效能表現情況:

    3f42f253-2b86-4037-942c-8628c24746ef

  可以看出對於該優化不會超過延遲界限值,檢視迴圈展開操作的彙編程式碼:

aaabb7af-d446-4a84-98e7-6cfd2d1497a9

  可以看到該操作會導致兩條vmulsd操作,一條將data[i]加到acc上,第二條將data[i+1]加到acc上。每條vmulsd被翻譯成兩個操作:一個操作是從記憶體中載入一個數組元素,另一個是把這個值乘以已有的累計值。可以看到,迴圈的每次執行中,對暫存器%xmm0讀和寫兩次。從中可以看到,迭代的次數減半了,但是每次迭代中還是有兩個順序的乘法操作。這個關鍵路徑是迴圈沒有展開程式碼的效能制約因素。具體彙編程式碼過程圖示如下:

    71eacc6f-f016-48bc-860b-6b198915c0b4

  至此,完成了該程式的初步優化,關於迴圈展開部分,該書第五章後半段有進階的內容,有興趣的同學可以一起學習交流。

更多網易技術、產品、運營經驗分享請點選

相關推薦

深入理解計算機系統淺析程式效能優化

此文已由作者餘笑天授權網易雲社群釋出。歡迎訪問網易雲社群,瞭解更多網易技術產品運營經驗。  本文主要是基於我之前學習《深入理解計算機系統》(以下簡稱CSAPP)這本書第五章優化程式效能內容的回顧以及總結。主要內容並沒有從大而全的方面去闡述如何優化程式,而是從一些細節著手來看待

cs app深入理解計算機系統:第五章 優化程式效能 幾個優化的java實現

package combine; import java.util.Random; /** * csapp優化程式效能從不同角度 * @author Administrator * */ public class Combine { static double

深入理解計算機系統--數值儲存(二)--C程式列印變數的每一位元組或者位

大端與小端 前面我們提到了依據CPU端模式的不同,資料的儲存順序也不一樣。 採用大小模式對資料進行存放的主要區別在於在存放的位元組順序,BE big-endian 大端模式 ,最直觀的位元組序 ,地址低位儲存值的高位,地址高位儲存值的低位 ,不需要考慮對

深入理解計算機系統虛擬存儲器

fragment 策略 動態鏈接 字段 索引 ~~ cti 錯誤 個數 http://blog.csdn.net/al_xin/article/details/38590931 進程提供給應用程序的關鍵抽象: 一個獨立的邏輯控制流,它提供一個假象,好像我們的程序獨占地

深入理解計算機系統整型與浮點型

在計算機儲存系統裡面,算術型別可以分為兩類:整型(intergral type,包括字元和布林型別在內)和浮點型。在看簡單地看了深入理解計算機系統的第二章後,有了稍微深刻但是有非常淺顯的理解,然後又看了阮師兄的一篇博文,所以做了一點筆記。 下面先來看一個例子程

深入理解計算機系統--數值儲存(三)-- 原碼、反碼、補碼和移碼詳解

原碼 如果機器字長為n,那麼一個數的原碼就是用一個n位的二進位制數,其中最高位為符號位:正數為0,負數為1。剩下的n-1位表示概數的絕對值。 PS:正數的原、反、補碼都一樣:0的原碼跟反碼都有兩個,因為這裡0被分為+0和-0。 原碼就是符號位

深入理解計算機系統--記憶體定址(四)--linux中分段機制的實現方式

linux中的分段機制 前面說了那麼多關於分段機制的實現,其實,Linux以非常有限的方式使用分段。因為,Linux基本不使用分段的機制(注:並不是不使用,使用分段方式還是必須的,會簡化程式的編寫和執行方式),或者說,Linux中的分段機制只是為了相容IA

深入理解計算機系統--數值儲存(一)-CPU大端和小端模式詳解

大端與小端 在嵌入式開發中,大端(Big-endian)和小端(Little-endian)是一個很重要的概念。 MSB與LSB 最高有效位(MSB)指二進位制中最高值的位元。在16位元的數字音訊中,其第1個位元便對16bit的字的數值有最大的

深入理解計算機系統----第五章優化程式效能

轉載地址https://www.jianshu.com/p/4586dc676807 編寫執行的快的程式有三個因素:①選擇合適的演算法和資料結構;②理解編譯器的能力,使用有效的方式讓編譯器能進行優化;③對於運算量特別大的程式,可能還需要進行任務分解。在這一過程中可能還需要對程式的可讀性和執

優化程式效能的幾個方法(來自於《深入理解計算機系統》)

int get_vec_element(vec_ptr v, long int index, data_t *dest) { if (index<0||index >= v->len) return 0; *dest = v->data[

深入理解計算機系統 perflab 程式效能優化實驗

自從上次實驗3bomb已經過去很久了,昨天週六下午剛剛驗收完所帶班級的必做實驗的最後兩個,最近一直很忙,也沒有來更新了,其實不是最近應該是每天都好忙,最近一直還在看論文做實驗。 驗收前週五拿出時間來看了一下最後兩個必做實驗。一個是perflab,效能實驗,這個實驗主要是考

優化程式效能—《深入理解計算機系統

第一部分:基本策略 1)高階設計:適當的演算法和資料結構 2)基本編碼原則:使編譯器產生高效的程式碼,理解 編譯器 的能力和侷限性,消除不必要的內容 ·消除連續的函式呼叫 ·消除不必要的儲存器引用,要考慮是否為 同一地址 以上兩點,也是妨礙編譯器優化的主要因素,編譯器很難判

深入理解計算機系統 第三章 程式的機器級表示 part1

  如題所示,這一章講解了程式在機器中是怎樣表示的,主要講組合語言與機器語言。   學習什麼,為什麼學,以及學了之後有什麼用 我們不用學習如何建立機器級的程式碼,但是我們要能夠閱讀和理解機器級的程式碼。 雖然現代的優化編譯器能夠很有效的將高階程式碼翻譯成機器級的程式碼,但是,為了

深入理解計算機系統——程式結構和執行

前言   第一部分 程式結構和執行    正文   1.資訊儲存   虛擬記憶體:是一個非常大的位元組陣列   記憶體的地址:記憶體的每個位元組都由一個唯一的數字來標識   虛擬地址空間:所有可能地址的集合      2.十六進位制的表示法    插播一下 進位制的轉化,(數學渣)會進位制

深入理解計算機系統 第三章 程式的機器級表示 part2

  這周由於時間和精力有限,只讀一小節:3.4.4  壓入和彈出棧資料   棧是一種特殊的資料結構,遵循“後進先出”的原則,可以用陣列實現,總是從陣列的一端插入和刪除元素,這一端被稱為棧頂。   棧有兩個常用指令: push:把資料壓入棧中 pop:刪除資

深入理解計算機系統筆記第二章(一)

資訊的表示和處理(一) 大多數計算機使用8位的塊(也就是一個位元組byte),由此可以看到32位(4個位元組)系統和64位(8個位元組)系統的區別。32位系統在於cpu可以同時處理4個位元組(32位)的資料,那麼64位系統cpu可以同時處理8個位元組(64位)的資料。 一個

深入理解計算機系統 第三章 程式的機器級表示 part3

  這周看了劉老師提供的相關視訊,以及書中對應的章節“3.7 過程”   這一節分為執行時棧、轉移控制、資料傳送、棧上的區域性儲存、暫存器中的區域性儲存空間和遞迴過程這 6 個小節   其中前 3 小節看懂了一部分內容,後面兩個還沒來得及看,下週看完補上  

深入理解計算機系統(原書第三版)》pdf附網盤下載連結+(附一個菜鳥的java學習路)

技術書閱讀方法論 一.速讀一遍(最好在1~2天內完成) 人的大腦記憶力有限,在一天內快速看完一本書會在大腦裡留下深刻印象,對於之後複習以及總結都會有特別好的作用。 對於每一章的知識,先閱讀標題,弄懂大概講的是什麼主題,再去快速看一遍,不懂也沒有關係,但是一定要在不懂的

深入理解計算機系統----程式的機器級表示

轉載地址 https://www.jianshu.com/p/c60a9c2131c3 目  錄 精通細節是理解更深和更基本概念的先決條件,這一章節首先講解了C程式碼、彙編程式碼與機器程式碼的關係,再次重申了彙編的承上啟下的重要作用。接著從IA32的細節一步步講起,如何儲存資料、如

深入理解計算機系統_第一部分_第三章_程式的機器級表示

深入,並且廣泛 -沉默犀牛 文章導讀 計算機執行機器程式碼,用位元組序列編碼低階的操作,包括處理資料、管理記憶體、讀寫儲存裝置上的資料,以及利用網路通訊。編譯器基於程式語言的規則、目標機器的指令集和作業系統遵循的慣例,經過一系列的階段生成機器程式碼。GCC C語言編譯器以彙