ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C/C++程序运行时间测量全指南:从VS Code配置到chrono高精度计时

C/C++程序运行时间测量全指南:从VS Code配置到chrono高精度计时 写C/C这么多年有一个话题我几乎天天都要碰就是“这段代码到底跑得多快”。优化算法要测运行时间对比两套实现方案要测运行时间课程设计答辩要拿运行时间当证据甚至在图像处理这类项目里一段矩阵运算的耗时直接决定了整个系统帧率能不能达标。而“C/C 计算程序运行的时间”这件事说起来简单真正做的时候坑不少——有的人用clock()一测结果永远是0有的人开了优化之后发现代码被编译器“偷走”了算出来的时间假得离谱还有人在VS Code里配了半天的C/C环境连g都跑不起来更别提测时间了。这篇文章我就把整个链路完整讲一遍从VS Code配置C/C运行环境开始到Windows下高精度计时再到跨平台的C11 chrono方案最后用一个排序算法对比的实战案例把所有东西串起来。适合刚学C语言的大学生、做数据结构和算法课程设计的同学以及想把自己代码优化做扎实的开发者。搞清楚这些以后写性能对比就能少走很多弯路。1. 为什么C/C要算运行时间计时到底在测什么1.1 性能优化的第一步是量化很多人在学数据结构时会背“快排平均复杂度O(n log n)、冒泡是O(n²)”但复杂度只是理论分析它描述的是规模增长的趋势而不是某个具体机器上跑出来的真实表现。同一个O(n log n)算法不同的常数、不同的内存访问模式、不同的编译器优化等级实际耗时可差出好几倍。更别提现代CPU有缓存、分支预测、乱序执行这些硬件优化理论的复杂度模型根本覆盖不了。所以我一直认为性能优化一定要先量化拿一个可靠的计时工具把关键代码段的耗时测出来再谈怎么改。没有数据支撑的优化都是玄学。比如你想判断冒泡排序能不能改为把内层循环的边界优化一下与其猜不如先跑一个1万随机数的排序看耗时到底卡在哪。1.2 真实时间与CPU时间别混为一谈计算运行时间时首先要分清两个概念墙上时钟时间wall clock time和CPU时间。墙上时钟时间就是我们平时用秒表掐出来的那个时间从代码开始执行到结束真实流过的时间。如果程序在中间等了一次磁盘IO、sleep了1秒或者被其他进程抢占了CPU这个时间都会变大。它反映的是“用户感知到的耗时”。CPU时间则只统计程序实际占用CPU执行指令的时间等待IO、被调度出去的时间都不算。C语言里经典的clock()函数在大多数平台上返回的就是进程占用的CPU时间而不是墙钟时间。这两者的区别很重要。测一个纯计算的算法模块用哪种都行但如果你测的是带读取文件、打印日志这种IO操作的函数用clock()测出的结果可能明显偏小因为阻塞等待的时间不计入。反过来说用steady_clock这类墙钟计时去测一个被系统调度严重影响的任务结果又会波动很大。理解这一点能少踩很多盲区。1.3 量化误差别被一个数字骗了我见过不少人拿单次运行的时间就下结论“A算法比B算法快3倍。”其实单次测量极其不可靠。CPU频率会动态调整睿频/降频后台可能有进程抢资源内存缓存可能是冷的也可能是热的系统可能中途给你来个中断都会造成耗时波动。哪怕是同样的代码连续跑十次结果也经常是天差地别。正确的做法是多跑几轮记录最小值、平均值或者中位数。最小值最接近代码本身的“纯粹耗时”平均值更贴近日常体验但容易受异常值影响。后面我会在实战章节给出一个完整的多轮测量封装。2. 环境准备VS Code配置C/C运行环境2.1 为什么选VS Code MinGW-w64工欲善其事必先利其器。很多读者在热词里搜“vscode配置c/c环境”说明大家看中VS Code轻量、免费、跨平台的特点。但这里要澄清一个概念VS Code本身只是编辑器它不负责把C/C代码变成可执行文件真正干活的是编译器。在Windows平台上一般有两类选择MSVCVisual Studio自带的cl.exe功能强、调试体验好但整套环境较重命令行使用也不如g直观。MinGW-w64提供的g开源、轻量、能在命令行里直接用和Linux/macOS的g、clang体验接近写小工具、算法练习、课程设计完全够用。我推荐Windows用户装MinGW-w64因为它和VS Code配合得很顺手编译命令也简单。macOS用户可以把g换成clangLinux用户直接用系统自带的g命令和代码完全通用。2.2 下载安装与PATH配置MinGW-w64推荐从WinLibs这类封装好的发行版下载解压出来就是完整工具链。假设解压到D:\mingw64那bin目录就是D:\mingw64\bin。这个bin目录必须加进系统PATH否则在命令行里输入g会提示“不是内部或外部命令”。具体步骤右键“此电脑”选择“属性”。点“高级系统设置”再点“环境变量”。在“系统变量”里找到Path点编辑新建一行填入D:\mingw64\bin。保存后新开一个cmd窗口输入g --version看到版本信息就说明成功了。这里有个容易踩的坑环境变量改完后已经打开的VS Code终端不会自动更新PATH必须把VS Code完全关掉重开一次或者在新开的终端里测试。另外如果你安装的是较新的MinGW-w64版本运行时可能会弹一个“已检测到匹配的 Visual C Redistributable跳过安装”的提示框这其实是正常的说明系统里运行库已经存在不用管它。2.3 用tasks.json一键编译运行安装好编译器之后在VS Code里装C/C扩展扩展ID是ms-vscode.cpptools。然后准备一个最简单的.cpp文件测试#include iostream int main() { std::cout Hello, Timer! std::endl; return 0; }为了按一个快捷键就完成编译和运行可以在项目根目录的.vscode文件夹下创建tasks.json{ version: 2.0.0, tasks: [ { label: build and run, type: shell, command: g, args: [ -O2, -stdc17, main.cpp, -o, main.exe ], group: { kind: build, isDefault: true }, problemMatcher: [] } ] }保存后按CtrlShiftBVS Code会调用g编译出main.exe。想一条龙跑起来可以在tasks.json里再加一个“run”任务或者在VS Code终端里手动执行.\main.exe。如果懒得配装个Code Runner扩展也能达到“右键Run Code”的效果但我还是建议理解编译命令本身是怎么运作的不然出了问题不会排查。2.4 环境配置常见坑我根据经验整理了三个最常见的问题第一终端的“g not found”问题。如果cmd里g能用但VS Code终端不能用多半是环境变量修改后没有重开VS Code。第二编译报“undefined reference tomain”。这通常是文件名或编译命令写错了比如把main.cpp拼错或者tasks.json里编译了一个不存在的源文件。检查一下args数组里的文件名和实际文件是否一致。第三中文乱码。Windows下VS Code终端默认可能用GBK编码去显示UTF-8的源码输出中文就会花屏。最稳妥的办法是源码文件统一保存为UTF-8然后在tasks.json里给g加一个-fexec-charsetUTF-8参数或者干脆让程序只输出英文避免乱码干扰观察结果。3. 基础计时方案从clock()到高精度时钟3.1 clock()最普及但精度有限大部分人在C语言教材里第一次接触到的计时函数是clock()它来自time.h。代码长这样#include stdio.h #include time.h int main() { clock_t start clock(); // 这里放要测量的代码 for (volatile int i 0; i 100000000; i) { } clock_t end clock(); double seconds (double)(end - start) / CLOCKS_PER_SEC; printf(elapsed: %.6f s\n, seconds); return 0; }clock()返回的是从程序启动到当前时刻的CPU时钟计数除以CLOCKS_PER_SEC就得到秒数。这个方案入门简单但它有几个硬伤在Windows的某些实现下它的粒度比较粗大约15.6毫秒测量很短的操作时会直接约等于0。它衡量的是CPU时间如果中间有IO等待或者sleep测出来的时间会“偏小”。不同平台的CLOCKS_PER_SEC不一样跨平台比较时容易出问题。所以clock()适合用来做“粗测”判断一段代码是不是明显变慢了但不适合精确对比微秒甚至纳秒级的差异。3.2 Windows高精度计时QueryPerformanceCounter如果你在Windows上做性能测量想用官方的高精度方案最经典的就是QueryPerformanceCounter和QueryPerformanceFrequency这两个Win32 API。前者返回一个“计时器当前计数”后者返回“每秒计数次数”两者相除就是秒数#include windows.h class QPCTimer { public: void start() { QueryPerformanceCounter(start_); } double stop() { LARGE_INTEGER end; QueryPerformanceCounter(end); LARGE_INTEGER freq; QueryPerformanceFrequency(freq); return (double)(end.QuadPart - start_.QuadPart) / (double)freq.QuadPart; } private: LARGE_INTEGER start_; };使用起来很简单QPCTimer timer; timer.start(); // 被测代码 double seconds timer.stop(); printf(elapsed: %.9f s\n, seconds);这个方案在Windows下可以达到微秒甚至纳秒级的精度而且是官方支持的高分辨率时钟。缺点也很明显它是Windows专属API代码拿到Linux或者macOS上就编译不过跨平台性差。如果你的项目只在Windows上跑用它没问题如果代码要在多个平台编译我更推荐下面要讲的chrono库。3.3 高精度时钟背后的原理为什么QueryPerformanceCounter能做到高精度因为它背后的时钟源通常是处理器的时间戳计数器或者高精度事件定时器HPET频率由QueryPerformanceFrequency查询得到。你可以把整个机制理解成一个“高频计数器”计数器每个时钟周期加一我们只需要记录开始和结束两个计数然后除以计数频率就能换算成时间。这就好比跑步计时用秒表秒表小指针走得越快读出的时间越精确。QPC的“秒表频率”通常有上百万甚至上千万Hz所以它能分辨微秒级的时间差。要注意的是这个频率在每台机器上可能不同所以不能写死一定要用QueryPerformanceFrequency去查询。这也是很多自己写计时器的人容易犯错的地方。4. C11的chrono库跨平台首选4.1 三种时钟怎么选如果你的项目用的是C11或更高版本完全没必要自己折腾平台API。标准库 已经提供了一套跨平台的高精度计时方案而且在Windows、Linux、macOS上行为一致。chrono里常用的有三种时钟std::chrono::system_clock系统实时时钟对应的是“墙上时间”会随着用户修改系统时间、NTP校时而跳变适合取当前日期时间不适合测运行时间。std::chrono::steady_clock单调时钟只增不减不受系统时间调整影响专门为测量时间间隔设计。std::chrono::high_resolution_clock理论上“最高精度”的时钟实际上是system_clock或steady_clock的别名具体是哪个取决于实现。测量运行时间首选steady_clock。这是C标准委员会专门留给计时场景的方案不会因为改系统时间而乱跳。很多新手一上来用system_clock计时结果把系统时间往回调了1小时测出来的负数就让人摸不着头脑。4.2 封装一个通用的计时工具用chrono写一个通用计时器其实很简洁。我在项目里维护了一个很小的Timer类直接复制出去就能用#include chrono #include iostream class Timer { public: Timer() : start_(std::chrono::steady_clock::now()) {} void reset() { start_ std::chrono::steady_clock::now(); } double elapsed_seconds() const { auto end std::chrono::steady_clock::now(); return std::chrono::durationdouble(end - start_).count(); } double elapsed_milliseconds() const { auto end std::chrono::steady_clock::now(); return std::chrono::durationdouble, std::milli(end - start_).count(); } double elapsed_microseconds() const { auto end std::chrono::steady_clock::now(); return std::chrono::durationdouble, std::micro(end - start_).count(); } private: std::chrono::time_pointstd::chrono::steady_clock start_; };用起来很直观Timer timer; // 被测代码段 double ms timer.elapsed_milliseconds(); std::cout cost: ms ms\n;这里的duration 模板参数决定了输出的时间单位关键是明确你想要的单位后直接写在模板参数里代码可读性会好很多不用每次自己乘除。如果你需要测量某个函数的耗时还可以写一层模板封装省得每次手动写开始、结束、打印template typename Func double measure_ms(Func func) { auto start std::chrono::steady_clock::now(); func(); auto end std::chrono::steady_clock::now(); return std::chrono::durationdouble, std::milli(end - start).count(); }用的时候传一个lambda进去就行double cost measure_ms([] { bubble_sort(data); }); std::cout bubble sort cost: cost ms\n;4.3 多次测量取最优还是取平均我在第1部分说过单次测量不靠谱要多次测量。但多人会忽略一个重要问题多次测量之后该取哪个数这里有一个常见分歧。取最小值适合算法性能对比。因为最小值最接近代码在“没有干扰”的理想状态下跑出来的耗时。系统调度、缓存冷启动这些噪音都会让结果变大而最小值能把这些噪音滤掉大半。所以我会用“跑N轮取最小耗时”作为算法对比的基准。取平均值则更贴近用户体验。如果你的程序要和用户交互用户感受到的往往是平均耗时和最大耗时的加权而不是那个最理想的最小值。实际项目里我是这样用的做算法选型对比时看最小值做系统整体性能评估时看平均值。如果你想让对比更稳健还可以把所有耗时排序后取中位数这样能同时抵抗极少数异常大值的干扰。5. 实战排序算法耗时对比完整案例5.1 生成随机数对比冒泡排序和std::sort理论知识说了这么多我们来个完整案例。假设我有一个数组包含10万个随机整数分别用自己实现的冒泡排序和标准库的std::sort排序看耗时差距到底有多大。先写一个生成随机数组并拷贝的函数#include iostream #include vector #include algorithm #include random #include chrono std::vectorint make_random_data(int n) { std::vectorint data(n); std::mt19937 rng(12345); // 固定随机种子保证每次运行数据一致 for (int i 0; i n; i) { data[i] rng(); } return data; } void bubble_sort(std::vectorint a) { int n (int)a.size(); for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { std::swap(a[j], a[j 1]); swapped true; } } if (!swapped) break; } }主函数里我用measure_ms分别测两个版本int main() { const int N 100000; std::vectorint base make_random_data(N); std::vectorint a base; double t1 measure_ms([] { bubble_sort(a); }); std::cout bubble_sort: t1 ms\n; std::vectorint b base; double t2 measure_ms([] { std::sort(b.begin(), b.end()); }); std::cout std::sort: t2 ms\n; // 防止编译器把没用的结果优化掉 volatile long long sum 0; for (int x : a) sum x; for (int x : b) sum x; return 0; }这里有个细节为什么排序完之后还要把所有元素加起来因为编译器优化等级高的时候如果它发现某个变量排序后没有被读取它有可能把整个排序过程优化掉导致测出来的时间几乎为0。我在后面的问题章节还会专门讲。5.2 实测结果与解读在我自己的机器上用-O2编译10万随机数排序的结果大致如下规模冒泡排序std::sort1000约2 ms约0.03 ms10000约180 ms约0.6 ms100000约17000 ms约8 ms注意不同机器结果差异很大但这个数量级变化是稳定的冒泡的时间随n的增长呈平方级上涨而std::sort基本接近线性对数增长。10万规模下两者耗时相差接近2000倍这就是算法复杂度从理论到实践的直观体现。我在多次运行后发现冒泡排序的耗时波动相对较小因为它的循环模式固定分支预测的规律性很强而std::sort的耗时会受数据排列方式影响随机数据、逆序数据、接近有序的数据表现各不相同。如果你在课程设计或者面试里要展示算法对比最好用多个不同分布的数据集避免“随机数赢了就代表一切”的误导。5.3 测量代码本身对结果的影响在跑上面的例子时我特别在意一件事测量行为本身有没有污染结果。这里涉及的坑主要有两个。第一个是编译器优化。如果你用g -O2编译却把排序结果丢在一边不用编译器可能真的把整个排序循环优化没了。上面代码里的volatile long long sum就是为了防止这种“死代码消除”的招数。第二个是耗时太短导致的测量噪声。0.03毫秒这个量级已经接近单次计时的分辨率边缘跑一次根本不够看。正确做法是把函数放进一个循环里多跑几轮比如跑100轮取最小值。我在工程里更喜欢一个更直接的函数template typename Func double best_time_ms(Func func, int rounds 5) { double best 1e100; for (int i 0; i rounds; i) { double t measure_ms(func); if (t best) best t; } return best; }用这个去测std::sort10万随机数在我的机器上大约能稳定在8毫秒左右比单次测量的可信度高得多。6. 常见问题与排查技巧实录6.1 耗时为零怎么办很多新手跑高精度计时的代码发现输出永远是0.000000第一反应是计时器坏了。其实大概率是测量对象跑得太快超出了所用计时方案的精度。举例来说如果你用的是clock()且系统时钟粒度是约15.6毫秒那么一个耗时1毫秒的操作测出来就是0。解决方案有三种把被测函数重复执行很多次比如1万次测总耗时再除以次数平均单次耗时就出来了。改用更高精度的计时方案比如chrono的steady_clock或者Windows的QueryPerformanceCounter。如果你确定代码量足够大但耗时还是0那要怀疑是否被编译器优化掉了。用volatile修饰输出变量或者把结果打印出来能有效阻止死代码消除。6.2 优化开关导致结果天差地别性能对比最怕的就是一边开着优化一边关着优化。我自己就犯过这种错开-O0编译两个排序算法做比较结果std::sort竟然只比冒泡快几十倍和-O2下的几千倍差距相去甚远。原因是标准库大量使用了模板和inline展开这些优化在-O0下发挥不出来。所以做性能测试时编译命令统一用-O2g -O2 -stdc17 main.cpp -o main.exe调试阶段用-O0没毛病但千万不要用-O0的测试结果去下性能结论。另外也不要让被测代码里夹着大量调试输出printf都会拖慢执行时间影响数据纯净度。6.3 计时结果波动大如何处理即使用了高精度时钟测量结果仍然经常剧烈抖动。原因包括系统后台进程、CPU频率调节、内存页面换入换出等。我的做法有这几步把其他占CPU的应用能关就关尤其是浏览器一个后台进程都可能把结果拉高10%。多跑几轮取最小值对比算法取平均值评估体感。我上面的best_time_ms就是这个思路。如果代码本身确实对cache非常敏感可以给被测数据多准备几份副本每轮测量用不同的副本避免第一轮“热”缓存把后续结果拉低。在Linux或macOS上可以用taskset或nice调整进程的CPU绑定和优先级减少调度抖动Windows上也可以在任务管理器里设置“相关性”和“优先级”不过一般测试没必要做到这一步。我把这些经验汇总成一个速查表问题典型原因解决方法耗时始终为0计时精度低或者代码被优化掉循环多次测量用chrono用volatile优化前后差距异常大编译器优化等级不同统一用-O2编译结果一次一个样系统噪音、缓存冷热多次测量取最小值或中位数Windows下QPC编译不过API只在Windows存在用C11的steady_clock替代chrono测IO操作结果偏小IO等待不占CPU时间改用system_clock或墙钟计时最后再说一个我这些年用得最多的tips把计时器单独封装成一个工具头文件比如timer.h里面只放几个轻量函数然后任何项目都能引入即用。做性能测试时固定使用同一套计时工具和编译参数这样不同时期的测试数据才具备可比性。我自己踩过的最大教训就是计时器换了一版又一版结果历史数据全部作废没法横向比较。希望你看完这篇能从环境到工具一次配齐少走这些弯路。
返回列表