前言
用 C++ 的同学对 STL 容器(vector, map, …)一定不陌生,但很少有人会关注容器初始化的效率。最近在读《并行程序设计 - 概念与实践》1,里面提到当容器元素非常多时,其初始化的时间消耗其实不容小视。
一、容器初始化做了什么?
std::vector<uint64_t> lst(total);
以 vector 为例,上面这行代码定义了一个元素类型为 uint64_t 的容器对象,其中元素个数是 total。当 total 比较小时,容器初始化时间消耗通常是微秒级别的,但如果 total 很大呢?
constexpr uint64_t total = 1ul << 30;
std::cout << "num of elements: " << total << std::endl;
timerStart(); // 计时开始
std::vector<uint64_t> a(total);
timerEnd("normal"); // 输出计时结果
运行结果:
num of elements: 1073741824
time cost of normal: 1.26e+04 ms
当数据规模达到十亿时,容器初始化耗时已经是秒级别了!这是因为,vector 使用 allocator(默认情况下是std::allocator
) 初始化容器,其包含分配内存空间(速度快)和初始化每个元素(速度慢)两个过程。
当元素类型为类/结构体时,std::allocator
会调用其构造函数;当元素为基础数据类型(int, uint64_t, …)时,std::allocator
会为其设定默认值 0。这里我们主要考虑元素为基础数据类型的情况,因为在很多场景下,元素初始值 0 是完全用不到的(初始化后立刻被改写),此时容器初始化对元素的赋值就完全是浪费时间。如何消除这部分时间消耗呢?
二、取消元素初始化时间消耗
1. 将基础数据类型封装成类
这是《并行程序设计 - 概念与实践》4.3.1 中提供的方法,思路是使用一个类将基础数据类型包起来(no_init_t<T>
),其构造函数不进行任何操作,这样std::allocator
就会调用其构造函数(无操作),而不是给元素赋值。具体定义如下:
// no_init_t.hpp
#include <type_traits>
template <typename T>
struct no_init_t {
// check whether it is a fundamental numeric type
static_assert(std::is_fundamental<T>::value
&& std::is_arithmetic<T>::value,
"must be a fundamental, numeric type");
// constructor does nothing
constexpr no_init_t() noexcept { }
// convertible from a T
constexpr no_init_t(T value) noexcept: v_(value) { }
// act as a T in all conversion contexts
constexpr operator T() const noexcept { return v_; }
// negation on a value-level and bit-level
constexpr no_init_t& operator-() noexcept { v_ = -v_; return *this; }
constexpr no_init_t& operator~() noexcept { v_ = ~v_; return *this; }
// increment/decrement operators
constexpr no_init_t& operator++() noexcept { v_++; return *this; }
constexpr no_init_t operator++(int) noexcept {
no_init_t tmp(*this); operator++(); return tmp; }
constexpr no_init_t& operator--() noexcept { v_--; return *this; }
constexpr no_init_t& operator--(int) noexcept {
no_init_t tmp(*this); operator--(); return tmp; }
// assignment operators
constexpr no_init_t& operator+=(T v) noexcept { v_ += v; return *this; }
constexpr no_init_t& operator-=(T v) noexcept { v_ -= v; return *this; }
constexpr no_init_t& operator*=(T v) noexcept { v_ *= v; return *this; }
constexpr no_init_t& operator/=(T v) noexcept { v_ /= v; return *this; }
// bitwise assignment operators
constexpr no_init_t& operator&=(T v) noexcept { v_ &= v; return *this; }
constexpr no_init_t& operator|=(T v) noexcept { v_ |= v; return *this; }
constexpr no_init_t& operator^=(T v) noexcept { v_ ^= v; return *this; }
constexpr no_init_t& operator>>=(T v) noexcept { v_ >>= v; return *this; }
constexpr no_init_t& operator<<=(T v) noexcept { v_ <<= v; return *this; }
private:
T v_;
};
使用方法:
std::vector<no_init_t<uint64_t>> lst(total);
由于重载了operator T()
和全部操作符,no_init_t<T>
对象在外部可视为与基础类型T
等价。
2. 自定义 allocator
既然默认的std::allocator
行为不是我们所希望的,那就可以用自定义 allocator 来改变容器的初始化行为。参考 cppreference,定义如下:
// no_init_allocator.hpp
#include <cstdlib>
#include <type_traits>
template <class T>
class NoInitAllocator {
public:
static_assert(std::is_fundamental<T>::value
&& std::is_arithmetic<T>::value,
"must be a fundamental, numeric type");
typedef T value_type;
typedef value_type* pointer;
typedef const value_type* const_pointer;
typedef value_type& reference;
typedef const value_type& const_reference;
typedef typename std::size_t size_type;
typedef std::ptrdiff_t difference_type;
template <class tTarget>
struct rebind {
typedef NoInitAllocator<tTarget> other;
};
NoInitAllocator() { }
~NoInitAllocator() { }
template <class T2>
NoInitAllocator(NoInitAllocator<T2> const&) { }
pointer address(reference ref) { return &ref; }
const_pointer address(const_reference ref) { return &ref; }
pointer allocate(size_type count, const void* = 0)
{
size_type byteSize = count * sizeof(T);
void* result = malloc(byteSize);
/* do not init memory */
return reinterpret_cast<pointer>(result);
}
void deallocate(pointer ptr, size_type) { free(ptr); }
template <class T2>
bool operator==(NoInitAllocator<T2> const&) const { return true; }
template <class T2>
bool operator!=(NoInitAllocator<T2> const&) const { return false; }
};
上述代码中,allocate() 函数只分配内存而不对元素进行初始化。外部使用方法:
std::vector<uint64_t, NoInitAllocator<uint64_t>> lst(total);
测试
测试代码:
#include "no_init_allocator.hpp"
#include "no_init_t.hpp"
#include <chrono>
#include <iomanip>
#include <iostream>
#include <vector>
static std::chrono::system_clock::time_point time_start;
void timerStart() { time_start = std::chrono::system_clock::now(); }
void timerEnd(const std::string& msg)
{
using namespace std;
using namespace std::chrono;
auto time_end = system_clock::now();
cout << scientific << setprecision(2) << "time cost of " << msg << ": "
<< duration_cast<nanoseconds>(time_end - time_start).count() / 1000000.
<< " ms" << endl;
}
int main(int argc, char** argv)
{
constexpr uint64_t total = 1ul << 30;
std::cout << "num of elements: " << total << std::endl;
timerStart();
std::vector<uint64_t> a(total);
timerEnd("normal");
timerStart();
std::vector<no_init_t<uint64_t>> b(total);
timerEnd("no_init");
timerStart();
std::vector<uint64_t, NoInitAllocator<uint64_t>> c(total);
timerEnd("allocator");
return 0;
}
测试结果:
num of elements: 1073741824
time cost of normal: 9.71e+03 ms
time cost of no_init: 2.52e-04 ms
time cost of allocator: 6.50e-05 ms
可以看到,在不对元素进行初始赋值的情况下,容器初始化(只分配内存)耗时已经不到 1 微秒了。
总结
当元素规模非常大时,STL 容器默认的初始化操作可能会造成比较可观的时间开销,本文介绍的两种方法能够取消容器初始化过程中对元素的赋值,从而减少时间消耗。
Parallel Programming: Concepts and Practice. Bertil Schmidt, etc. ↩︎