基于编译器特性浅析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 GHz
    double cpe = elapsed_cycles / N; // 计算 CPE

    std::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;
	else
		return 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 指令

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/984917.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

多模态融合的分类、跨模态对齐的方法

两者的主要区别 维度扩模态对齐扩模态融合目标对齐模态间的表示&#xff0c;使其语义一致融合模态间的信息&#xff0c;生成联合表示关注点模态间的相似性和语义一致性模态间的互补性和信息整合空间映射到共享的公共语义空间生成新的联合特征空间方法对比学习、共享空间、注意…

计算机网络--访问一个网页的全过程

文章目录 访问一个网页的全过程应用层在浏览器输入URL网址http://www.aspxfans.com:8080/news/index.aspboardID5&ID24618&page1#r_70732423通过DNS获取IP地址生成HTTP请求报文应用层最后 传输层传输层处理应用层报文建立TCP连接传输层最后 网络层网络层对TCP报文进行处…

自动化测试脚本语言选择

测试人员在选择自动化测试脚本语言时面临多种选项。Python、Java、C#、JavaScript 和 Ruby 都是常见选择&#xff0c;但哪种语言最适合&#xff1f;本文将详细分析这些语言的特点、适用场景和优劣势&#xff0c;结合行业趋势和社会现象&#xff0c;为测试人员提供全面指导。 选…

Oracle 字符类型对比

本文以 Oracle12c 为例 1.主要区别对比 类型存储方式最大长度字符集支持适用场景备注​CHAR(M)固定长度空格填充2000 字节&#xff0c;M 代表字节长度默认字符集固定长度编码实际存储长度固定为定义长度&#xff08;如 CHAR(10) 始终占 10 字节&#xff09;​VARCHAR2(M)可变长…

Nginx(基础安装+配置文件)

目录 一.Nginx基础 1.基础知识点 2.异步非阻塞机制 二.Nginx安装 2.1安装nginx3种方式 1.包管理工具安装&#xff08;yum/apt&#xff09; 2.本地包安装&#xff08;rpm/dpkg&#xff09; 3.源码编译安装 3.1 源码编译安装nginx流程&#xff08;ubuntu&#xff09; 1.…

PyCharm 接入 DeepSeek、OpenAI、Gemini、Mistral等大模型完整版教程(通用)!

PyCharm 接入 DeepSeek、OpenAI、Gemini、Mistral等大模型完整版教程&#xff08;通用&#xff09;&#xff01; 当我们成功接入大模型时&#xff0c;可以选中任意代码区域进行解答&#xff0c;共分为三个区域&#xff0c;分别是选中区域、提问区域以及回答区域&#xff0c;我…

Python——计算机网络

一.ip 1.ip的定义 IP是“Internet Protocol”的缩写&#xff0c;即“互联网协议”。它是用于计算机网络通信的基础协议之一&#xff0c;属于TCP/IP协议族中的网络层协议。IP协议的主要功能是负责将数据包从源主机传输到目标主机&#xff0c;并确保数据能够在复杂的网络环境中正…

【LeetCode合并区间C++实现】【c++】【合并区间】

LeetCode合并区间C实现 LeetCode 56题思路图示完整代码运行结果代码或思路哪里有误还请指正&#xff01;&#xff01;thank you!! LeetCode 56题 以数组 intervals 表示若干个区间的集合&#xff0c;其中单个区间为 intervals[i] [starti, endi] 。请你合并所有重叠的区间&am…

笔记六:单链表链表介绍与模拟实现

在他一生中&#xff0c;从来没有人能够像你们这样&#xff0c;以他的视角看待这个世界。 ---------《寻找天堂》 目录 文章目录 一、什么是链表&#xff1f; 二、为什么要使用链表&#xff1f; 三、 单链表介绍与使用 3.1 单链表 3.1.1 创建单链表节点 3.1.2 单链表的头插、…

使用Modelsim手动仿真

FPGA设计流程 在设计输入之后,设计综合前进行 RTL 级仿真,称为综合前仿真,也称为前仿真或 功能仿真。前仿真也就是纯粹的功能仿真,主旨在于验证电路的功能是否符合设计要求,其特点是不考虑电路门延迟与线延迟。在完成一个设计的代码编写工作之后,可以直接对代码进行仿真,…

Docker搭建Redis哨兵模式【一主两从三哨兵】

Docker搭建Redis哨兵模式 系统: CentOS 7 Dockder 版本: VMware虚拟机 网络适配器 网络连接 桥接模式:直接连接物理网络查看IP命令 ip addr一、哨兵模式概述 1. 官方文档与关联博客 官方文档:https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel关联博…

(更新完)LPZero: Language Model Zero-cost Proxy Search from Zero

LPZero代码 摘要 神经架构搜索 (NAS) 有助于自动执行有效的神经网络搜索&#xff0c;同时需要大量的计算资源&#xff0c;尤其是对于语言模型。零样本 NAS 利用零成本 (ZC) 代理来估计模型性能&#xff0c;从而显着降低计算需求。然而&#xff0c;现有的 ZC 代理严重依赖于深…

【互联网性能指标】QPS/TPS/PV/UV/IP/GMV/DAU/MAU/RPS

&#x1f4d5;我是廖志伟&#xff0c;一名Java开发工程师、《Java项目实战——深入理解大型互联网企业通用技术》&#xff08;基础篇&#xff09;、&#xff08;进阶篇&#xff09;、&#xff08;架构篇&#xff09;清华大学出版社签约作家、Java领域优质创作者、CSDN博客专家、…

【Linux docker】关于docker启动出错的解决方法。

无论遇到什么docker启动不了的问题 就是 查看docker状态sytemctl status docker查看docker日志sudo journalctl -u docker.service查看docker三个配置文件&#xff08;可能是配置的时候格式错误&#xff09;&#xff1a;/etc/docker/daemon.json&#xff08;如果存在&#xf…

拉取gitlab项目时出现500的错误的权限问题

title: 拉取gitlab项目时出现500的错误的权限问题 date: 2025-03-10 18:09:08 tags: gitlabgit拉取gitlab项目时出现500的错误的权限问题 Gitlab克隆代码**我遇到的问题错误**:**问题解决步骤**:1、确定你可以浏览器访问到项目页面2、确定你的邮箱或账号已添加,有权限可以拉…

MobileBERT: 一种适用于资源有限设备的紧凑型任务无关BERT

摘要 近年来&#xff0c;自然语言处理&#xff08;NLP&#xff09;通过使用具有数亿参数的巨大预训练模型取得了巨大成功。然而&#xff0c;这些模型存在模型体积庞大和延迟高的问题&#xff0c;使得它们无法部署到资源有限的移动设备上。在本文中&#xff0c;我们提出了Mobil…

【C】初阶数据结构9 -- 直接插入排序

前面我们学习了数据结构二叉树&#xff0c;接下来我们将开启一个新的章节&#xff0c;那就是在日常生活中经常会用到的排序算法。 所谓排序算法就是给你一堆数据&#xff0c;让你从小到大&#xff08;或从大到小&#xff09;的将这些数据排成一个有序的序列&#xff08;这些数据…

OpenPose初体验

最近机器人的热度有点高&#xff0c;想着要做些应用技术储备&#xff0c;偶然的机会发现了OpenPose&#xff0c;就从它开始吧&#xff01;OpenPose是由卡内基梅隆大学开发的开源实时多人姿态估计库。它基于深度学习算法&#xff0c;能精确识别图像或视频中的人体姿态&#xff0…

从0开始的操作系统手搓教程33:挂载我们的文件系统

目录 代码实现 添加到初始化上 上电看现象 挂载分区可能是一些朋友不理解的——实际上挂载就是将我们的文件系统封装好了的设备&#xff08;硬盘啊&#xff0c;SD卡啊&#xff0c;U盘啊等等&#xff09;&#xff0c;挂到我们的默认分区路径下。这样我们就能访问到了&#xff…

游戏辅助技术培训班教程【A001-初级班】

课程概述&#xff1a; 本教程为游戏辅助技术培训班的初级班课程&#xff0c;本章为第二阶段&#xff0c;旨在帮助学员系统掌握游戏辅助技术的核心技能。课程内容从C/C编程基础到高级内存操作、代码注入、DLL注入及MFC编程&#xff0c;全面覆盖游戏辅助开发的关键知识点。 课程…