C++面向对象时意料之外的性能开销-构造函数和析构函数的惊人11.7倍开销

ProLightsfx 2024-12-4 125 12/4

 

导语

最近总结了一些可以提高C++执行性能的代码设计案例,觉得挺有意思,在这里分享给大家。此处不讨论算法复杂度对执行性能的影响,而是假定算法足够好,或者算法已经没法继续优化下去了,通过优化C++写法来提高执行性能。

本文的性能狭义地解释为性能中的时间效率。

面向对象时意料之外的开销

1. 构造函数和析构函数的惊人开销

比如笔者写了个咸鱼日志类(仅用于讨论问题,实际生产环境不会这么实现日志类),定义了日志级别DEBUG、INFO、WARNING、ERROR

struct log_level {
    enum type {
        DEBUG = 1,
        INFO = 2,
        WARNING = 3,
        ERROR = 4
    };
};

每个级别都有一个xx_log函数来打日志,会根据设置的级别判断是否输出日志,日志内容是函数名和出入的日志文本。

inline void debug_log(const std::string& func_name, const std::string& log_message) {
    if (allow_log_level_ <= log_level::DEBUG) {
        std::cout << "DEBUG|" << func_name << ": " << log_message << "\n";
    }
}
inline void error_log(const std::string& func_name, const std::string& log_message) {
    if (allow_log_level_ <= log_level::ERROR) {
        std::cout << "ERROR|" << func_name << ": " << log_message << "\n";
    }
}

在构造类对象的时候从进程配置里读取可输出的日志级别,比如配置了ERROR级别。

auto log_handle = std::make_shared<my_log>(get_log_level_from_config());

然后for循环1亿次,所以i ++了1亿次,并且calc1 ++了1亿次,以及调了1亿次debug_log,但是我们设置的ERROR级别,所以log不会打出来,所以我们可能认为debug_log不会产生多大开销。

但笔者用执行了1亿次除了不掉debug_log以为完全一样的代码,结果非常惊人。

请看完整代码

// Copyright 程序喵Plus
// gcc version 14.2.0 (GCC) 

#include <iostream>
#include <memory>
#include <chrono>

class my_log {
public:
    struct log_level {
        enum type {
            DEBUG = 1,
            INFO = 2,
            WARNING = 3,
            ERROR = 4
        };
    };
    my_log(log_level::type input_allow_log_level) : allow_log_level_(input_allow_log_level) {}
    ~my_log() {}

public:
    inline void debug_log(const std::string& func_name, const std::string& log_message) {
        if (allow_log_level_ <= log_level::DEBUG) {
            std::cout << "DEBUG|" << func_name << ": " << log_message << "\n";
        }
    }
    inline void error_log(const std::string& func_name, const std::string& log_message) {
        if (allow_log_level_ <= log_level::ERROR) {
            std::cout << "ERROR|" << func_name << ": " << log_message << "\n";
        }
    }
    // 其它略

private:
    log_level::type allow_log_level_;
};

inline my_log::log_level::type get_log_level_from_config() {
    // balabala
    // ....
    return my_log::log_level::ERROR;
}

int main()
{
    auto log_handle = std::make_shared<my_log>(get_log_level_from_config());

    auto ms_start = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::system_clock::now().time_since_epoch()
    );
    int32_t calc1 = 0;
    for (int32_t i = 0; i <= 100000000; i++) {
        calc1++;
    }
    auto ms_step = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::system_clock::now().time_since_epoch()
    );

    int32_t calc2 = 0;
    for (int32_t i = 0; i <= 100000000; i++) {
        calc2++;
        log_handle->debug_log("main""debug一下");
    }
    auto ms_end = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::system_clock::now().time_since_epoch()
    );

    log_handle->error_log("main""step1_cost: " + std::to_string(ms_step.count() - ms_start.count()) +
        "ms, step2_cost:" + std::to_string(ms_end.count() - ms_step.count()) + "ms");
    return 0;
}

输出的结果是

ERROR|main: case1_cost: 74ms
ERROR|main: case2_cost: 7371ms

在调debug_log但不打日志的情况竟然比不调debug_log,慢了92.5倍。

让我们重新看下这个咸鱼函数干了啥:

inline void debug_log(const std::string& func_name, const std::string& log_message) {
    if (allow_log_level_ <= log_level::DEBUG) {
        std::cout << "DEBUG|" << func_name << ": " << log_message << "\n";
    }
}
// ......
log_handle->debug_log("main""debug一下");

首先这里使用了建议编译器inline,并且函数足够短也没有循环或者迭代或者递归,所以这里忽略函数调用开销(编译的时候究竟有没有内联需要编译器大哥说了算,关于C++的内联机制这里不做讨论,之后再出文章讨论吧)。

那么仅仅是每次循环多调了2次构造函数和析构函数。

但考虑到毕竟函数里有个if判断语句,保险起见,笔者加个case,在我们构造好2个string对象,调用debug_log的时候不需要进行构造,如下

std::string mains = "main", debugs = "debug一下";
for (int32_t i = 0; i <= 100000000; i++) {
    calc1++;
    log_handle->debug_log(mains, debugs);
}
auto ms_end_plus = std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::system_clock::now().time_since_epoch()
);

结果是

ERROR|main: case1_cost: 74ms
ERROR|main: case2_cost: 7371ms
ERROR|main: case3_cost: 626ms

case2比case3慢了11.7倍。它们唯一的差别是,case2额外多进行了,1亿次的:

  • 传入"main"构造了一个string对象func_name
  • 传入"debug一下"构造了一个string对象log_message
  • 调用结束之后析构string对象log_message
  • 调用结束之后析构string对象func_name

由此可见只要调用的频率足够高,尤其是很多计算密集型场景,构造函数和析构函数的性能消耗比我们往常预期的要大的多,此时我们需要尽量避开大规模构造和析构对象。

2.剩下的内容下期再讨论了

剩下的内容,比如延迟构造、冗余构造、继承、虚函数在某些情况下产生的惊人的性能开销,下期再讨论了。笔者该睡了,明天还得上班呢。(文章定时明天早上8:00发布)

 

本博所有文章均为博主原创,未经许可不得转载。

https://www.prolightsfxjh.com/article/cpp-constructors-destructors/

Thank you!

                                                                                                                                             ------from ProLightsfx

如果你对推荐系统、游戏开发、C++优化等领域感兴趣或者想参与讨论的话,欢迎关注笔者公众号。

C++面向对象时意料之外的性能开销-构造函数和析构函数的惊人11.7倍开销

- THE END -

ProLightsfx

12月04日10:12

最后修改:2024年12月4日
1

非特殊说明,本博所有文章均为博主原创,未经许可不得转载。

共有 0 条评论