基于編譯器特性淺析C++程序性能優化

最近在惡補計算機基礎知識,學到CSAPP第五章的內容,在這里總結并且展開一下C++程序性能優化相關的內容。

衡量程序性能的方式

一般而言,程序的性能可以用CPE(Cycles Per Element)來衡量,其指的是處理每個元素所需的CPU時鐘周期數,計算公式為:CPE = 總執行周期數/處理的元素數量。

計算方式為:

#include <iostream>
#include <chrono>const int N = 1000000;
int arr[N];void test_function() {for (int i = 0; i < N; i++) {arr[i] = i * 2;}
}int main() {auto start = std::chrono::high_resolution_clock::now();test_function();auto end = std::chrono::high_resolution_clock::now();double elapsed_cycles = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count() * 2.5; // 假設CPU 2.5 GHzdouble cpe = elapsed_cycles / N; // 計算 CPEstd::cout << "CPE: " << cpe << std::endl;return 0;
}

影響編譯器優化的因素

用gcc時,gcc -Og可以指定優化方式,但隨著優化等級升高,程序規模也可能增加。

gcc優化等級

  • -O1:不會進行激進優化(如函數內聯、代碼重排序),不會影響可讀性,編譯時間仍然較短。優化包括死代碼消除、常量傳播、循環優化等。
  • -O2:在基本優化的基礎上增加更高級優化:消除冗余計算、循環展開、指令調度、函數內聯、分支預測優化,仍然不會進行極端優化。
  • -O3:實現更激進的循環展開、自動使用SIMD指令,使函數盡可能內聯,并消除冗余加載和存儲,對復雜的數學運算進行優化。但可能導致代碼膨脹,過度優化會導致性能下降,如緩存效率降低。
  • -Os:基于-O2,但會避免增加代碼大小的優化,適合嵌入式系統。

以下為一些妨礙編譯器優化的因素:

內存別名使用

對于以下看似相同的代碼段:

//代碼段1
void twiddle1(long *xp, long *yp){*xp += *yp;*xp += *yp;
}//代碼段2
void twiddle2(long *xp, long* yp){*xp += 2* *yp;
}

很顯然,代碼段2的執行所需耗費時間更短,其需要的內存訪問次數更少。

然而,編譯器無法將代碼1優化為代碼2,因為當yp=xp時,代碼1等效于xp = 4 xp, 而代碼2等效于 *xp = 3 * *xp,編譯器不知道函數該如何被調用。這種兩個指針可能指向同一個內存位置的情況稱為內存別名使用,在只執行安全的優化中,編譯器必須假設不同的指針可能會指向內存中同一位置。

修改全局程序狀態的函數

對于以下看似相同的代碼段:

long counter = 0;
long f(){return counter++;
}
//代碼段1
long func1(){return f()+f()+f()+f();
}
//代碼段2
long func2(){return 4*f();
}

當函數f的返回值涉及到全局變量counter時,可以看出,func1的輸出為6,而func2的輸出為0。

將函數定義為內聯函數,可以直接將函數調用替換為函數體,例如,代碼段1在o1優化下可以展開為:

long funclin(){long t = counter++;t += counter++;t += counter++;t += counter++;return t;
}

如果使用-o2及以上優化,可能會展開為:

long funclin() {long tmp = counter;counter += 4;return tmp + (tmp + 1) + (tmp + 2) + (tmp + 3);
}

直接優化方法

為了舉例說明優化方法是如何實現的,我們定義向量數據結構如下:

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

data_t代表基本元素的數據類型。

定義初始化該向量、訪問向量元素以及獲取向量長度的方法如下:

/* Create vector of specified length */
vec_ptr new_vec(long len)
{/* Allocate header structure */vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));data_t *data = NULL;if (!result)return NULL;  /* Couldn't allocate storage */result->len = len;/* Allocate array */if (len > 0) {data = (data_t *)calloc(len, sizeof(data_t));if (!data) {free((void *) result);return NULL; /* Couldn't allocate storage */}}/* Data will either be NULL or allocated array */result->data = data;return result;
}/** Retrieve vector element and store at dest.* Return 0 (out of bounds) or 1 (successful)*/
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;
}/* Return length of vector */
long vec_length(vec_ptr v)
{return v->len;
}

采用計算向量元素乘積的初始代碼如下:

#define IDENT 1
#define OP *
/* Implementation with maximum use of data abstraction */
void combine1(vec_ptr v, data_t *dest)
{long i;*dest = IDENT;for (i = 0; i < vec_length(v); i++) {data_t val;get_vec_element(v, i, &val);*dest = *dest OP val;}
}

對于這段初始代碼,有一些方向可以進行優化改進。

提高循環效率

代碼移動

代碼移動指的是將在循環里需要執行多次但計算結果不會改變的計算移動到循環外:

#define IDENT 1
#define OP *
/* Implementation with maximum use of data abstraction */
void combine2(vec_ptr v, data_t *dest)
{long i;long length = vec_length(v);*dest = IDENT;for (i = 0; i < length; i++) {data_t val;get_vec_element(v, i, &val);*dest = *dest OP val;}
}

減少過程調用

上述函數可以繼續簡化為:

data_t *get_vec_start(vec_ptr v)
{return v->data;
}/* Direct access to vector data */
void combine3(vec_ptr v, data_t *dest)
{long i;long length = vec_length(v);data_t *data = get_vec_start(v);*dest = IDENT;for (i = 0; i < length; i++) {*dest = *dest OP data[i];}
}

這種寫法和combine2相比,減少了索引與數組邊界的比較,但優化效果并不明顯。

消除不必要的內存引用

對于combine3的賦值過程:

*dest = *dest OP data[i];

需要訪問*dest指針的值,再根據這個地址從內存中取dest數組的值,并在計算完成后賦值到對應的內存上,在每次迭代過程中都要完成這樣一個從內存讀寫數據的過程,將函數繼續簡化,減少對內存的讀寫:

void combine4(vec_ptr v, data_t *dest)
{long i;long length = vec_length(v);data_t *data = get_vec_start(v);data_t cur = IDENT;for (i = 0; i < length; i++) {cur = cur OP data[i];}*data = cur;
}

考慮機器特性的優化方法

上述優化方法都沒有依賴目標機器的任何特性,如果要進一步提升性能,則需要考慮利用處理器微體系結構進行優化。

現代處理器結構

現代微處理器的簡化示意圖如下圖所示,其可以分為指令控制單元ICU和執行單元EU兩部分。

在這里插入圖片描述

  • 取指控制:ICU從指令高速緩存中讀取指令,并在譯碼后將對應的操作發送到EU。一般來說,會在當前執行的指令很早之前就進行取指。然而當程序遇到分支時,處理器采用分支預測技術,會猜測是否選擇該分支并預測其目標地址。使用投機執行技術,處理器會在確定分支預測是否正確前就跳到分支對應的指令,甚至開始執行這些對應的操作。如果分支預測錯誤,則將狀態重新設為分支點的狀態。
  • 指令譯碼:接收實際的程序指令并將其轉換為一組基本操作。
  • 加載和存儲單元:內置加法器,用于讀寫內存。
  • 分支單元:向ICU返回分支預測是否正確的結果。
  • 算術運算單元:執行整數和浮點數操作的不同組合。
  • 退役單元:記錄正在進行的處理,并確保其遵守機器級程序的語義。退役單元包含了多種寄存器,并控制這些寄存器的更新。指令譯碼時,其信息被放置在一個隊列中,直到分支點預測結果出現,若預測正確,則程序寄存器的更新將被實際執行。任何對程序寄存器的更新都只會在指令退役的時候才會發生。

功能單元的性能

對于功能單元進行運算的性能,有以下幾個指標可以用來衡量:

延遲L:表示完成運算所需要的總時間

發射時間I:表示兩個連續的同類型運算之間需要的最小周期數

容量C:表示能夠執行該運算的功能單元的數量

操作的吞吐量=C/I

對于一個執行n個乘法的函數,若其需要L*n+K個周期,其中K為調用函數和初始化等開銷,此時CPE=L,對于單個按照順序執行的功能單元組成的函數,延遲L表明了CPE的最小值,而對于多個功能單元組成的函數,還需要考慮其吞吐量。

處理器操作的抽象模型

將函數combine4的循環部分轉換為匯編代碼:

Inner loop of combine44. data_t = double, OP = *
acc in %xmm0, data+i in %rdx, data+length in %rax
1 .L25:
2 vmulsd (%rdx), %xmm0, %xmm0    loop: Multiply acc by data[i]
3 addq $8, %rdx                  Increment data+i
4 cmpq %rax, %rdx                Compare to data+length
5 jne .L25                       If !=, goto loop

將其抽象為數據流圖,并去除不影響數據流的指令:

在這里插入圖片描述

可以看出,乘法和加法運算是制約循環性能的兩個因素,而浮點乘法的延遲約為整數加法的5倍,其成為了最關鍵的制約原因,程序的CPE為5。循環中的其他操作與乘法器并行地執行。

循環展開

循環展開是一種程序變換,通過增加每次迭代計算元素的數量來減少循環的迭代次數。

其優點為,可以提高緩存命中率,增加循環體內語句并發執行的可能性,同時減少分支預測失敗的可能性。

用循環展開繼續改進上述代碼為:

/* 2 x 1 loop unrolling */
void combine5(vec_ptr v, data_t *dest)
{long i;long length = vec_length(v);long limit = length - 1;data_t *data = get_vec_start(v);data_t cur= IDENT;/* Combine 2 elements at a time */for (i = 0; i < limit; i += 2) {cur= (cur OP data[i]) OP data[i + 1];}/* Finish any remaining elements */for (; i < length; i++) {cur =  cur OP data[i];}*dest = cur;
}

編譯器可以很輕松地執行循環展開,用GCC的優化等級大于等于3時就會執行循環展開。

提高并行性

我們知道,乘法操作和加法操作是可以并行化的,也就是說,不需要等待對方完成即可進行下一次操作,可以在每個時鐘周期就開始一次新的操作。但目前的代碼還并不能更高速率地執行乘法和加法,這是因為我們將累積值放在一個單獨的變量cur中,在前面計算完成之前都不能計算cur的新值。

為了提高并行性,我們可以用多個累積變量分別計算:

void combine6(vec_ptr v, data_t *dest){long i;long length = vec_length(v);long limit = length - 1;data_t cur0 = IDNET;data_t cur1 = IDNET;for(i = 0; i <limit; i+=2){cur0 = cur0 OP data[i];cur1 = cur1 OP data[i+1];}for(; i < length; i++)cur0 = cur0 OP data[i];*dest = cur0 OP cur1;
}

我們可以將多個累積變量變換歸納為將循環展開k次,以及并行累積k個值,得到k×k的循環展開,當k足夠大時,程序在所有情況下幾乎都能達到吞吐量界限。通常,只有保持能執行該操作的所有功能單元的流水線都是滿的,程序才能達到這個操作的吞吐量界限,對延遲為L,容量為C的操作而言,要求循環展開因子k ≥ L*C即可達到最大吞吐量。

除了以上并行累計的方式以外,還可以通過重新結合變換的方式對combine5進行繼續優化:

void combine7(vec_ptr v, data_t *dest){long i;long length = vec_length(v);long limit = length-1;data_t *data = get_vec_start(v);data_t cur = IDENT;for(i = 0; i < limit; i+=2){cur = cur OP (data[i] OP data{i+1]);}for(; i < length; i++)cur = cur OP data[i];*dest = cur;
}

combine7和combine5的區別在于**data[i] OP data[i+1]**計算的先后順序不同,而(data[i] OP data[i+1])時可以被并行計算的,因為它不依賴于cur的計算結果,可以提前計算。(現代CPU的超標量架構,可以在一個時鐘周期內執行多個獨立的指令,如果兩個指令沒有數據依賴,CPU可以同時執行它們。)

書寫適用于條件傳送的代碼

條件傳送(Conditional Move, CMOV) 是一種 CPU 指令優化技術,它允許根據條件決定是否執行數據傳送而不使用傳統的條件跳轉(branching)
在 x86 架構中,CMOV 指令集(如 CMOVZ, CMOVNZ, CMOVL 等)可以在滿足某些條件時,將值從一個寄存器傳送到另一個寄存器,而不會觸發分支預測失敗的問題。

在 C++ 中,我們可以使用 條件運算符(?:內聯匯編(asm標準庫函數(std::max 以及 SIMD 指令 來實現 條件傳送。

在現代C++編譯器中,使用三元運算符可能被編譯器優化為CMOV指令:

#include <iostream>//傳統條件分支的代碼
int branching(int x, int y){if (x > y)return x;elsereturn y;}
//使用條件傳送的代碼
int conditional_move(int x, int y) {return (x > y) ? x : y;  // 編譯器可能優化為 CMOV
}int main() {int a = 5, b = 10;std::cout << "Max: " << conditional_move(a, b) << std::endl;return 0;
}

除此之外,gcc在 -O2 或更高級別優化下,std::max(a, b) 可能會被優化為 CMOV 指令

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/71848.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/71848.shtml
英文地址,請注明出處:http://en.pswp.cn/web/71848.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

transformer模型介紹——大語言模型 LLMBook 學習(二)

1. transformer模型 1.1 注意力機制 **注意力機制&#xff08;Attention Mechanism&#xff09;**在人工智能中的應用&#xff0c;實際上是對人類認知系統中的注意力機制的一種模擬。它主要模仿了人類在處理信息時的選擇性注意&#xff08;Selective Attention&#xff09;&a…

word甲烷一鍵下標

Sub 甲烷下標()甲烷下標 宏Selection.Find.ClearFormattingSelection.Find.Replacement.ClearFormattingWith Selection.Find.Text "CH4".Replacement.Text "CHguoshao4".Forward True.Wrap wdFindContinue.Format False.MatchCase False.MatchWhole…

Dify 本地部署教程

目錄 一、下載安裝包 二、修改配置 三、啟動容器 四、訪問 Dify 五、總結 本篇文章主要記錄 Dify 本地部署過程,有問題歡迎交流~ 一、下載安裝包 從 Github 倉庫下載最新穩定版軟件包,點擊下載~,當然也可以克隆倉庫或者從倉庫里直接下載zip源碼包。 目前最新版本是V…

2.1 掌握XML基礎知識

本文介紹了結構化、半結構化和非結構化數據的概念與特點。結構化數據以固定格式存儲于數據庫&#xff0c;便于查詢與管理&#xff0c;常用于金融等領域。半結構化數據如XML、JSON&#xff0c;具有一定的組織形式但模式不固定&#xff0c;適用于Web內容和日志文件。非結構化數據…

Android Studio 配置國內鏡像源

Android Studio版本號&#xff1a;2022.1.1 Patch 2 1、配置gradle國內鏡像&#xff0c;用騰訊云 鏡像源地址&#xff1a;https\://mirrors.cloud.tencent.com/gradle 2、配置Android SDK國內鏡像 地址&#xff1a;Index of /AndroidSDK/

超過 37000 臺 VMwareESXi 服務器可能受到持續攻擊威脅

近日&#xff0c;威脅監測平臺影子服務器基金會&#xff08;The Shadowserver Foundation&#xff09;發布報告&#xff0c;指出超 3.7 萬個互聯網暴露的威睿&#xff08;VMware&#xff09;ESXi 實例存在嚴重安全隱患&#xff0c;極易受到 CVE-2025-22224 漏洞的攻擊。該漏洞屬…

npm終端執行時報錯

終端npm執行時報錯下錯誤&#xff1a; 報錯了&#xff0c;就來百度&#xff0c;報錯的原因是 1、這個錯誤是因為 PowerShell 的執行策略&#xff08;Execution Policy&#xff09;限制了腳本的運行 2、默認情況下&#xff0c;Windows 系統可能會禁止運行未簽名的腳本&#x…

JVM類加載器面試題及原理

JVM只會運行二進制文件&#xff0c;類加載器的作用就是將字節碼文件加載到JVM中&#xff0c;從而讓Java程序能夠啟動起來。 1. 類加載器的種類 啟動類加載器&#xff08;BootStrap ClassLoader&#xff09;&#xff1a;加載JAVA_HOME/jre/lib目錄下的庫擴展類加載器&#xff…

C語言每日一練——day_3(快速上手C語言)

引言 針對初學者&#xff0c;每日練習幾個題&#xff0c;快速上手C語言。第三天。&#xff08;會連續更新&#xff09; 采用在線OJ的形式 什么是在線OJ&#xff1f; 在線判題系統&#xff08;英語&#xff1a;Online Judge&#xff0c;縮寫OJ&#xff09;是一種在編程競賽中用…

用Qt手搓AI助手,挑戰24小時開發DeepSeek Assistant!

一、項目需求分析與技術選型 DeepSeekAssistant是一款基于深度求索&#xff08;DeepSeek&#xff09;API的智能對話助手&#xff0c;核心需求包括&#xff1a; 用戶界面友好&#xff1a;支持多輪對話展示數據持久化&#xff1a;歷史記錄存儲與檢索異步網絡通信&#xff1a;AP…

Linux 環境變量快速上手指南

Linux 環境變量快速上手 1. 什么是環境變量 環境變量&#xff08;Environment Variables&#xff09;是操作系統中用于存儲配置信息的一種機制&#xff0c;可以在運行時被進程讀取和使用。常見環境變量示例&#xff1a; PATH: 存放可執行文件搜索路徑。HOME: 當前用戶的家目錄…

萬字技術指南STM32F103C8T6 + ESP8266-01 連接 OneNet 平臺 MQTT/HTTP

此博客為一份詳細的指南&#xff0c;涵蓋 STM32F103C8T6 通過 ESP8266-01 連接 OneNet 平臺&#xff0c;并使用 MQTT/HTTP 進行數據通信的完整流程。這份文檔包括&#xff1a; OneNet 平臺的介紹與功能概覽在 OneNet 上創建和配置設備的方法STM32CubeIDE 的開發環境搭建ESP826…

Go本地緩存設計與實現

本地緩存是一個項目中很常見的組件。在很多人的眼中就是一個簡單的key-value的map存儲即可實現&#xff0c;但實際上&#xff0c;設計一個本地緩存需要考慮的問題遠比你想象的多&#xff0c;比如說&#xff0c;本地緩存是將數據存儲在內存&#xff0c;若數據量激增突破了內存限…

深入解析 JavaScript 原型與原型鏈:從原理到應用

原型和原型鏈是 JavaScript 中實現對象繼承和屬性查找的核心機制。為了更深入地理解它們&#xff0c;我們需要從底層原理、實現機制以及實際應用等多個角度進行分析。 1. 原型&#xff08;Prototype&#xff09; 1.1 什么是原型&#xff1f; 每個 JavaScript 對象&#xff08…

FPGA時序約束的幾種方法

一,時鐘約束 時鐘約束是最基本的一個約束,因為FPGA工具是不知道你要跑多高的頻率的,你必要要告訴工具你要跑的時鐘頻率。時鐘約束也就是經常看到的Fmax,因為Fmax是針對“最差勁路徑”,也就是說,如果該“最差勁路徑”得到好成績,那些不是最差勁的路徑的成績當然比…

Visual Studio Code打開遠程服務器項目,打開服務器Android上百G源碼,SSH免密連接方式

Visual Studio Code打開遠程服務器項目 1&#xff0c;Visual Studio Code拓展中&#xff0c;安裝遠程插件 Remote Development 2&#xff0c;SSH免密連接&#xff0c;A電腦免密連接B&#xff0c;配置B電腦.ssh/authorized_keys A電腦的.ssh/id_rsa.pub中的公鑰內容&#xff0c;…

AWS云編排詳解-Cloud Formation

作者:私語茶館 1.關鍵概念 名詞 說明 軟件: CloudFormation 描述AWS 資源、配置值和互連關系。借助集成設施即代碼加快云部署 CloudFormation Designer 拖拽式圖形化模板編輯界面。 Amazon Simple Notification Service (SNS) SNS可通過電子郵件跟蹤堆棧的創建和刪除進度,…

《PyQt5》——設計Python GUI(圖形用戶界面)實例

PyQt5 PyQt5的配置和基礎使用可以參考這篇文章&#xff1a;《 PyQt5》—— 創建 Python GUI&#xff08;圖形用戶界面&#xff09; Python GUI&#xff08;圖形用戶界面&#xff09;實例 本實例是設計一個通過玉米和豆粕的價格來預測生豬的價格&#xff0c;并顯示預測價格與實…

kali linux 打開 word

Kali Linux是一款專為網絡安全領域而設計的操作系統&#xff0c;它集成了大量的安全工具&#xff0c;幫助用戶進行網絡滲透測試和安全評估。作為一款功能強大的操作系統&#xff0c;Kali Linux可以滿足用戶在網絡安全領域的各種需求&#xff0c;包括滲透測試、漏洞分析、數字取…

hooks useModule自定義hooks (二次封裝AgGridReact ag-table)自定義表頭,自定義表頭搜索

場景業務&#xff1a; 多次運用AgGridReact的table 列表 思路&#xff1a; 運用自定義hooks進行二次封裝&#xff1a; 通用配置例如&#xff1a;傳參的參數&#xff0c;傳參的url&#xff0c;需要緩存的key這些鍵值類 定制化配置例如&#xff1a;需要對table 的一些定制化傳…