0CCh Blog

返回值优化和拷贝消除的一点补充

在我写的《现代C++语言核心特性解析》中有一个小节是讲解的返回值优化,在这篇文章中,我将对这部分内容进行一点补充,将更多细节展示出来。
首先还是来看看书中的这段代码:

#include <iostream>
class X {
public:
X() { std::cout << "X ctor" << std::endl; }
X(const X& x) { std::cout << "X copy ctor" << std::endl; }
~X() { std::cout << "X dtor" << std::endl; }
};
X make_x() {
X x1;
return x1;
}
int main() {
X x2 = make_x();
}

这段代码在开启和关闭拷贝消除的运行情况是不同的,不过书中只使用了两种情况讨论,但是实际上我漏掉了C++17关闭拷贝消除的情况,以下是正确的对比表格:

拷贝消除 C++14 关闭拷贝消除 C++17 关闭拷贝消除
X ctor X ctor X ctor
X dtor X copy ctor X copy ctor
X dtor X dtor
X copy ctor X dtor
X dtor
X dtor

可以看到C++17和C++14的行为是不同的。开启拷贝消除的很明显,优化让构造直接发生在main函数中:

make_x(): # @make_x()
push rbx
mov rbx, rdi
call X::X() [base object constructor]
mov rax, rbx
pop rbx
ret
main: # @main
push rbx
sub rsp, 16
lea rdi, [rsp + 8]
call make_x()

C++14的行为也很明确,和书中介绍了一样,发生了三次构造:

make_x(): # @make_x()
push r14
push rbx
push rax
mov rbx, rdi
mov r14, rsp
mov rdi, r14
call X::X() [base object constructor]
mov rdi, rbx
mov rsi, r14
call X::X(X const&) [base object constructor]
mov rdi, rsp
call X::~X() [base object destructor]
mov rax, rbx
add rsp, 8
pop rbx
pop r14
ret

main: # @main
push rbx
sub rsp, 16
mov rbx, rsp
mov rdi, rbx
call make_x()
lea rdi, [rsp + 8]
mov rsi, rbx
call X::X(X const&) [base object constructor]
mov rdi, rsp
call X::~X() [base object destructor]
lea rdi, [rsp + 8]
call X::~X() [base object destructor]
xor eax, eax
add rsp, 16
pop rbx
ret

但是C++17的行为相对就比较奇怪了,关闭拷贝消除但并没有完全关闭:

make_x(): # @make_x()
push r14
push rbx
push rax
mov rbx, rdi
mov r14, rsp
mov rdi, r14
call X::X() [base object constructor]
mov rdi, rbx
mov rsi, r14
call X::X(X const&) [base object constructor]
mov rdi, rsp
call X::~X() [base object destructor]
mov rax, rbx
add rsp, 8
pop rbx
pop r14
ret

main: # @main
push rbx
sub rsp, 16
lea rbx, [rsp + 8]
mov rdi, rbx
call make_x()
mov rdi, rbx
call X::~X() [base object destructor]
xor eax, eax
add rsp, 16
pop rbx
ret

只有两次构造,x1拷贝到临时对象,临时对象拷贝到x2的过程合并成了一次,也就是x1直接拷贝到了x2,这是为什么呢?
其实是因为C++17对临时对象进行了特殊规定:

6.7.7 Temporary objects [class.temporary]

The materialization of a temporary object is generally delayed as long as possible in order to avoid creating unnecessary temporary objects.

在提案文档p0135r1中也对拷贝消除的描述进行了修改(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0135r1.html
)。

至此,我们已经了解了C++17关闭拷贝消除后的特殊情况的因由。最后补充一点,关于拷贝消除,除了在返回值上可以做优化,还有下面这些情况都可以进行优化,当然有一些优化是没有实现的:

  1. return语句中返回类类型,返回对象类型和函数返回类型相同,并且要求类型是非易失且有自动存储周期的对象。
  2. throw表达式,操作数类型也要求是非易失且有自动存储周期的对象,并且作用域不超过最内侧的try。
  3. 异常处理(其实就是try-catch中catch(){}),声明的对象如果和抛出对象类型相同,可以将声明对象看作抛出对象的别名,前提条件是这个对象在这个过程中除了构造和析构是不会被改变的。
  4. 在协程中,协程参数的拷贝可以被忽略,也就是直接引用参数本身,当然也有前提条件,就是在处理对象的过程中除了构造和析构是不会被改变的。

谨慎使用std::async

std::async是C++11标准引入的函数模板,它用于异步执行某些任务,通常在单独的线程或者线程池中运行,它会返回一个std::future用于等待和获取异步执行的结果。
为什么这里说需要谨慎使用呢?其实原因上面一句话也提到了,就是它可能是在单独的线程中运行的。那么躲过我们想并发执行多个异步任务,会导致系统产生多个线程,执行完任务后退出。熟悉操作系统的朋友应该知道,创建线程的操作是非常耗时的,它需要让系统进入到内核,并且执行很多进程和线程相关的操作,另外过多的线程并不能真正的做到异步,因为我们的CPU的执行单元是有限的。所以调用std::async是应该谨慎一些的。
接下来让我们看看三大编译器的std::async的实现:
首先来看GCC:
https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/include/std/future

// Shared state created by std::async().
// Starts a new thread that runs a function and makes the shared state ready.
template<typename _BoundFn, typename _Res>
class __future_base::_Async_state_impl final
: public __future_base::_Async_state_commonV2
{
public:
template<typename... _Args>
explicit
_Async_state_impl(_Args&&... __args)
: _M_result(new _Result<_Res>()),
_M_fn{{std::forward<_Args>(__args)...}}
{
_M_thread = std::thread{&_Async_state_impl::_M_run, this};
}

// Must not destroy _M_result and _M_fn until the thread finishes.
// Call join() directly rather than through _M_join() because no other
// thread can be referring to this state if it is being destroyed.
~_Async_state_impl()
{
if (_M_thread.joinable())
_M_thread.join();
}

private:
void
_M_run()
{
__try
{
_M_set_result(_S_task_setter(_M_result, _M_fn));
}
__catch (const __cxxabiv1::__forced_unwind&)
{
// make the shared state ready on thread cancellation
if (static_cast<bool>(_M_result))
this->_M_break_promise(std::move(_M_result));
__throw_exception_again;
}
}

typedef __future_base::_Ptr<_Result<_Res>> _Ptr_type;
_Ptr_type _M_result;
_BoundFn _M_fn;
};

很显然使用了std::thread创建新线程。
然后再来看Clang:
https://github.com/llvm/llvm-project/blob/main/libcxx/include/future

template <class _Rp, class _Fp>
_LIBCPP_INLINE_VISIBILITY future<_Rp>
__make_async_assoc_state(_Fp&& __f)
{
unique_ptr<__async_assoc_state<_Rp, _Fp>, __release_shared_count>
__h(new __async_assoc_state<_Rp, _Fp>(_VSTD::forward<_Fp>(__f)));
_VSTD::thread(&__async_assoc_state<_Rp, _Fp>::__execute, __h.get()).detach();
return future<_Rp>(__h.get());
}

同样的,采用了创建新线程的方法。
最后来看看MSVC提供的STL的实现:
https://github.com/microsoft/STL/blob/main/stl/inc/future

template <class _Rx>
class _Task_async_state : public _Packaged_state<_Rx()> {
// class for managing associated synchronous state for asynchronous execution from async
public:
using _Mybase = _Packaged_state<_Rx()>;
using _State_type = typename _Mybase::_State_type;

template <class _Fty2>
_Task_async_state(_Fty2&& _Fnarg) : _Mybase(_STD forward<_Fty2>(_Fnarg)) {
_Task = ::Concurrency::create_task([this]() { // do it now
this->_Call_immediate();
});

this->_Running = true;
}

~_Task_async_state() noexcept override {
_Wait();
}

void _Wait() override { // wait for completion
_Task.wait();
}

_State_type& _Get_value(bool _Get_only_once) override {
// return the stored result or throw stored exception
_Task.wait();
return _Mybase::_Get_value(_Get_only_once);
}

private:
::Concurrency::task<void> _Task;
};

可以看到,微软提供的std::async的实现好了一些,它使用了线程池来进行异步操作,这样效率会好不少。值得一提的是,这里使用的是微软提供Parallel Patterns Library (PPL)库,专门用于多线程和并行计算的,和Intel的TBB比较类似。
由此可见,如果需要大量使用异步操作执行任务,依赖std::async的效率是不太可靠的,我们最好是能够使用更高效的线程池的方案。

值传参

我在大学里学C++的时候,印象最为深刻的是老师反复告诫我们,应该如何传递函数参数。为了避免发生不必要的内存拷贝和复杂的对象构造,一般来说对于复杂对象都会采取使用传递引用的方式,当然如果参数不会被改变,最好使用常量引用,只有一些基础类型可以通过值传递参数。
按照上述方式写代码确实不会任何问题,不过C++向来是一门追求极致的语言,在效率方面更是如此,所以在C++17引入了std::string_view,并且推荐使用值传递的方式作为参数来传递,例如:

size_t ret_sv_byval(std::string_view sv) return sv.size(); }

上面的代码通过值来传递std::string_view,而不是通过引用,下面我们就来探讨为何这里更加推荐使用通过值来传递参数。
首先,也是最容易理解的一点,能够使用值传递std::string_view必然是因为它足够简单。它的典型的实现只有两个成员:指向常量字符串的指针和字符串大小。值得一提的是,std::string_view并不是C++17才出现在我们视野中的,实际上在chromium和llvm中,早就出现了类似的实现。在C++标准的草案也可以追述到2012年的n3442,当时std::string_view还被称为string_ref。后来到了2014年,经过了大约7个版本的修订,才有了我们今天看到的std::string_view
我们当然不能因为std::string_view足够简单认为使用传值的方式比传递引用的方式高效,这需要我们拿出其他的证据。

通过传值使用std::string_view可以消除引用中的内存操作

我们都知道,对象的拷贝是在caller中发生的,例如下面这两行代码:

size_t ret_str_byref(const std::string& s) { return s.size(); }
size_t ret_str_byval(std::string s) { return s.size(); }

在使用-O2的优化选项进行编译的情况下,他们生成的汇编代码是相同的,都是:

ret_str_byref:
mov eax, DWORD PTR [rdi+8]
ret
ret_str_byval:
mov eax, DWORD PTR [rdi+8]
ret

因为临时对象的拷贝在调用者函数中发生,所以这里不会有任何区别。可以看到,这里都使用了内存访问,访问了rdi+8的数据。这里如果我们使用std::string_view会如何呢?

size_t ret_sv_byval(std::string_view sv) { return sv.size(); }

对应的汇编代码为:

ret_sv_byval:
mov eax, edi
ret

显然,这里直接使用了寄存器,没有涉及到任何内存的访问,这样访问效率必然是有所提升的。
引用的另一个劣势是,在一个不需要涉及内存的操作中,因为引用语义和内存相关,导致编译器会强行将对象设置在内存中,来看看下面这个例子:

size_t sv_call_val(std::string_view sv) {return ret_sv_byval(sv);}
size_t sv_call_ref(std::string_view sv) {return ret_sv_byref(sv);}

这两个函数非常简单,直接使用参数调用后续函数,不过编译后的代码截然不同:

sv_call_val
jmp ret_sv_byval
sv_call_ref
sub rsp, 24
mov qword ptr [rsp + 8], rdi
mov qword ptr [rsp + 16], rsi
lea rdi, [rsp + 8]
call ret_sv_byref
add rsp, 24
ret

可以看出,前者可以直接执行jmp,跳到目标函数。后者,也就是穿引用的函数,则是需要先将数据写到栈上,然后在调用函数,显然前者的效率更高。

通过传值使用std::string_view可以帮助编译器进行优化

程序的编译优化并不是容易的事情,编译器要考虑非常多的因素,例如外部对内部的影响等。传值和传引用的区别在于,传递引用的对象可能会被其他外部因素干扰导致编译器没办法进行优化,但是传值就不存在这样的问题,因为传值是拷贝,不会被外部影响,编译器优化起来更加得心应手,来看看下面的代码:

size_t ret_sv_byval(std::string_view sv, size_t& troublemaker) {
size_t temp = troublemaker;
troublemaker++;
size_t retval = sv.size();
troublemaker = temp;
return retval;
}

size_t ret_sv_byref(const std::string_view& sv, size_t& troublemaker) {
size_t temp = troublemaker;
troublemaker++;
size_t retval = sv.size();
troublemaker = temp;
return retval;
}

上面两个函数唯一的区别就是sv是传值还是传引用,看似没有太大区别,但是我们来看看汇编代码:

ret_sv_byval
mov rax, rdi
ret
ret_sv_byref
mov rcx, qword ptr [rsi]
lea rax, [rcx + 1]
mov qword ptr [rsi], rax
mov rax, qword ptr [rdi]
mov qword ptr [rsi], rcx
ret

可以看到,前者就是简单了一条寄存器操作就返回了,temptroublemaker都没有给函数带来任何影响。而后者就完全不同了,因为传递的是引用,即使是常量引用,也导致编译器无法对代码进行优化。因为对于编译器而言,并不知道troublemaker是否会对sv的内部有所影响,只能按照代码进行编译。
至此,我们可以得到结论是,对于简单对象,例如使用寄存器就能传递其数据的对象,我们可以使用传值的方式传递参数,例如简单的std::pairstd::span等等。当然比较复杂的对象,还是要使用传递引用的方式的。

oneAPI 组件简介

在oneAPI的一系列的产品中,我首先要介绍的是DPC和它的编译器,因为这部分内容十分有趣。我们都知道,一个程序运行效率高低,跟语言本身和他的编译器是息息相关的,比如我们不能指望python的程序在算法和运行环境相同的情况下跑过C和C的程序。

组件

Intel的DPC团队当然考虑到了这一点,DPC和他的编译器正是基于高效的C语言以及时下先进的编译器clang/llvm的。在llvm的支持下,让DPC能够轻松的在不同的平台上使用。

DPC是基于标准C和SYCL的,也就是说,我们可以用C一种语言来编写各种加速平台的程序,这样就让程序员能脱离学习专属语言的麻烦。至于SYCL标准,DPC实际上在它的基础上也做了比较多的优化,方便程序员编写代码。

因为DPC++的编译器是基于clang/llvm的,所以它也是一个开源的编译器,我们可以在Github上找到他的源代码。并且通过翻阅提交记录来初步了解编译器是怎么构建起来的,总的来说这是一个在前中后端都有进行开发的编译器。

oneAPI还提供了一套兼容性工具,这套工具可以将CUDA编写的代码转换为标准的c代码,便于使用DPC进行编译。不过需要注意的是,并不是所有的代码都可以。CUDA代码大约可以有90%到95%能够正确的转换为DPC++的代码,当然剩下的一部分会注释留白,让开发人员进行转换。这个转换工具会尽可能的转换出开发人员可读的源代码程序。

兼容性工具

dpc-compatibility-tool

感兴趣的朋友可以访问以上链接,这里有非常详细的代码和操作例子。

再来说一下oneAPI提供的API,使用API可以让除了C以外的语言也享受到oneAPI提供的强大的高性能计算功能。例如,DPC库,这个还是基于的C++,它是优化了C++标准算法,包括并行算法等,并且能够保证在不同的硬件平台上高效运行。另外,为了让开发人员快速学习,它基于的是pstl和boost.compute库。再例如oneDNN这个库,主要就是用来做深度学习框架的,在oneAPI里提供的tensorflow的底层就是由oneDNN做的支持。

oneAPI提供的库有很多,有兴趣的朋友可以直接上官网查看,会有非常详细的介绍。其实在了解oneAPI之前,我就用过这其中的TBB库,这是一个做并行编程和多线程的库,和微软的PPL很像。简单来说就是在使用这个库的时候,我们不需要关心线程本身,也不需要关心硬件环境使用多少线程效率最高,如何做线程调度效率最高,直接把任务扔到接口就行了,非常方便,即使不编写高性能计算程序的朋友也可以去了解一下。

要介绍的最后一部分是分析和调试工具,这一部分中的GDB,我想大家再熟悉不过,不过oneAPI提供的GDB是有一些不同的,它除了能调试普通程序,还支持通过双机来调试异构程序。也就是说,它可以调试到设备内核代码中。请注意,这里的内核不是指操作系统内核,而是在加速设备上执行代码。使用普通的调试器是无法做到这一点的,虽然看起来都像是C++的代码,但其实编译出的程序并不是像主机端的代码一样可调的。大家也可以试一试,如果使用普通的调试器对内核代码下断点,跑起来的程序是肯定不会中断下来的。

第二个工具是Advisor,这个工具可以对异构程序进行分析,并且提供优化建议,包括怎么使用内存,怎么使用多线程,怎么使用并发。这个工具我是用的不多,有兴趣的朋友还是可以看官方文档。

Vtune

第三个工具是Vtune,这个工具就厉害了,我想做过性能优化的朋友肯定是用过的。这个工具在做性能优化方面并不局限于异构程序,其实很早之前我就接触过它了。它可以对程序性能的缺陷做非常系统的分析,包括IO,线程、内存、指令集的使用等等,分析的粒度可以从指令到代码行再到函数块,支持的架构从CPU、GPU到FPGA,总之做性能优化的朋友千万不要错过这个工具。

以上就是对Intel oneAPI的一个大概的介绍,想了解更多信息还是要访问官网,另外,如果有朋友想进一步的做实验,Intel还提供了DevCloud这样一个免费的实验平台给大家,有兴趣的朋友不妨一试。

2021小结

这是一篇想到哪写到哪的流水账,和技术无关和工作无关,就是想写点东西。

让我想想从哪里开始写起,那就从春节开始说起吧,今年春节我们家贴了一副非常可爱的“牛转乾坤”春联。

熟悉我的朋友都知道今年我出版了一本书,也是我的第一本书,其实整个期间我是很忐忑的,所以从2020年的10月开始我对编辑小姐姐的催促就没有间断过。

2021年2月到3月书其实正经历二审和三审,当时我并不知道三审后才是校对,并且需要申请出版号,天真的以为5月之前书一定可以出版。后来才知道后面还有很多步骤,例如质量审查、申请图书出版号、封面设计等等,预想出版的时间从5月推迟到6月,然后又推迟到7月,最后一直推迟到了9月,当时真的只能安慰自己好事多磨。这些步骤中最令人抓狂的就是质量审查环节,因为我问不到进度,而且一旦质量审查没通过就得修正好再审查一次,相当消耗时间。我是真的希望能一次性通过质量审查,当然大家应该能猜到,谁也逃不过遵循墨菲定律,果然第一次质量审查是没有通过的,所以后来多花了将近一个月的时候修改并且通过质量审查。当然了,其实这并不是意见坏事,毕竟作为自己写的书,并且还是第一本,对它的质量要求是很高的,我希望我能拿出一本让读者满意的书,也不枉费4年的时间。

2021年中秋节,终于,我拿到自己写的书,我很难描述当时的心情,感觉和高考结束后有点类似,就是那种好像一切都结束了的感觉。没有表现的特别高兴,反而还有点惆怅,真的没办法形容。当然这种心情持续并不久,我就开始关心书的销量了,关心这个词可能用的并不准确,用担心也许会更好吧。幸运的是,这种心情也没持续多久,因为销量还挺不错的,一度还蹭上了当当计算机类新书榜前十,还是听鼓舞人心的。

在书出版了以后,我就开始了另外项目,也就是录一个系列的C++课程,这个课程在张银奎老实的盛格塾小程序上连载。我是一个口才普通的人,普通话也不算标准,所以录制课程对我来说调整其实挺大的。尤其是刚刚开始的时候,10分钟的课程我录制加剪辑可以耗上一下午。多亏爱人的工作和剪辑相关,给我在剪辑方面提供一些帮助,否则话的时间更久。不过熟能生巧这个事情到哪都是通用的,课程一共42讲,录到10讲以后速度明显快很多了,录过了35讲就基本上已经游刃有余了。而且这段时间感觉自己说话都变利索了不少。

生活的奇妙之处就是这样,每件事情有时候衔接的特别理想。在课程录制到倒数第二讲的时候,我开始了另外一件事情,就是11月去上海参加K+技术峰会。不得不说要感谢之前录制课程的经历,否则练习演讲估计得弄的我直翻白眼。整体来说这次上海之旅还算挺顺利的,除了回来后社区打电话来说上海闹疫情了,庆幸自己没在上海到处浪,这疫情也不知道什么时候是个头啊。

K+技术峰会结束后,Intel找到了我让我参加DPC++认证讲师培训,这也是个挺好的事情所以就参加了。当然培训最后也有考核,就是录一个课程“而已”,如果说半年前我还对这种事情很畏惧,那么现在已经轻车熟路了,果然是每一份付出都是有收获的,感谢自己的付出让自己变的更好了。

2021年的倒数第二天,出版社的杨社长又给我带来了一个好消息,是我获得异步社区的年度影响力作者,居然还有奖杯可以拿,可把我高兴坏了。

上面这些是我业余耗费最多精力的事情,也是我很重视的一些事情。当然2021年发生的事情远远不止这些,比如工作上取得了从未有过的收获,这也是巨大成就感的来源之一。不过最最最重要的是家里多了一个小成员,几乎每天回去都要和他折腾很久,每天都很累但是却很开心。

2021年对我来说是非常重要的一年,完成了很多以前想都没想过的事情,我觉得我可以把他称为我人生中最自豪的一年,果然很“牛转乾坤”。

2022年虎年希望自己在“牛转乾坤”之后再接再厉,福虎生旺、龙腾虎跃!

DPC++中的现代C++语言特性

Ⅰ DPC++简介

DPC++是Data Parallel C++(数据并行C++)的首字母缩写,它是Intel为了将SYCL引入LLVM和oneAPI所开发的开源项目。SYCL是为了提高各种加速设备上的编程效率而开发的一种高级别的编程模型,简单来说它是一种跨平台的抽象层,用户不需要关心底层的加速器具体是什么,按照标准编写统一的代码就可以在各种平台上运行。可以说SYCL大大提高了编写异构计算代码的可移植性和编程效率,已经成为了异构计算的行业标准。值得一提的是SYCL并不是由多个单词的首字母的缩写。DPC++正是建立在SYCL和现代C++语言之上,具体来说是建立在C++17标准之上的。
写本篇文章的目是为了讨论现代C++语言在DPC++中的应用,算是对《现代C++语言核心特性解析》一书的补充,而不是要探究异构计算的原理,因为这是一个庞大的话题,需要资深专家才好驾驭。
关于实验环境,我选择的是本地安装Intel oneApi Toolkit,因为本地工具用起来还是更加方便一些。不过,如果读者朋友们的硬件条件不允许,那么我们可以注册使用DevCloud。DevCloud是Intel公司提供的远程开发环境,包含了最新的Intel 硬件和软件集群。

Ⅱ DPC++背景

1.什么是数据并行编程

数据并行编程既可以被描述为一种思维方式,也可以被描述为一种编程方式。 数据由一组并行的处理单元进行操作。 每个处理单元都是能够对数据进行计算的硬件设备。这些处理单元可能存在于单个设备上,也可能存在于我们计算机系统中的多个设备上。 我们可以指定代码以内核的形式处理我们的数据。
内核是数据并行编程中一个重要的概念,它的功能是让设备上的处理单元执行计算。这个术语在SYCL、OpenCL、CUDA 和 DPC++都有使用到。

2.什么是异构系统

异构系统是包含多种类型的计算设备的任何系统。 例如,同时具有CPU和GPU的系统就是异构系统。现在已经有很多中这样的计算设备了,包括 CPU、GPU、FPGA、DSP、ASIC和AI 芯片。异构系统的出现带来了一个很大的挑战,就是刚刚提到的这些设备,每一种都具有不同的架构,也具有不同的特性,这就导致对每个设备有不同编程和优化需求,而DPC++开发一个动机就是帮助解决这样的挑战。

3.为什么需要异构系统

因为异构计算很重要,一直以来计算机架构师致力于限制功耗、减少延迟和提高吞吐量的工作。从1990年到2006年,由于处理器性能每两到三年翻一番(主要是因为时钟频率每两年翻一番),导致那个时候应用程序的性能都跟着有所提升。这种情况在2006年左右结束,一个多核和多核处理器的新时代出现了。由于架构向并行处理的转变为多任务系统带来了性能提升,但是在不改变编程代码的情况下,并没有为大多数现有的单个应用程序带来性能提升。在这个新时代,GPU等加速器因为能够更高效的加速应用程序变得比以往任何时候都流行。这催生了一个异构计算时代,诞生了大量的具有自己的专业处理能力的加速器以及许多不同的编程模型。它们通过更加专业化的加速器设计可以在特定问题上提供更高性能的计算,因为它们不必去处理所有问题。这是一个经典的计算机架构权衡。它通常意味着加速器只能支持为处理器设计的编程语言的子集。事实上,在DPC++中,只有在内核中编写的代码才能在加速器中运行。
加速器架构可以分为几大类,这些类别会影响我们对编程模型、算法以及如何高效使用加速器的决策。例如,CPU是通用代码的最佳选择,包括标量和决策代码,并且通常内置向量加速器。GPU则是寻求加速向量和密切相关的张量。DSP寻求是以低延迟加速特定数学运算,通常用于处理手机的模拟信号等。AI加速器通常用于加速矩阵运算,尽管有些加速器也可能加速图。FPGA和ASIC特别适用于加速计算空间问题。

4.为什么使用DPC++

一方面因为DPC++具有可移植性、高级性和非专有性,同时满足现代异构计算机体系结构的要求。另一方面,它可以让跨主机和计算设备的代码使用相同的编程环境,即现代C++的编程环境。最后,计算机体系结构的未来包括跨越标量、向量、矩阵和空间 (SVMS) 操作的加速器,需要对包括 SVMS 功能在内的异构机器的支持。并且这种支持应该涵盖高度复杂的可编程设备,以及可编程性较低的固定功能或专用的设备。

Ⅲ 初探DPC++

在开始讨论现代C++语言在DPC++中的应用之前,让我们先看一遍完整的代码,顺便测试我们的实验环境:

#include <CL/sycl.hpp>
constexpr int N = 16;
using namespace sycl;

class IntelGPUSelector : public device_selector {
public:
int operator()(const device& Device) const override {
const std::string DeviceName = Device.get_info<info::device::name>();
const std::string DeviceVendor = Device.get_info<info::device::vendor>();

return Device.is_gpu() && (DeviceName.find("Intel") != std::string::npos) ? 100 : 0;
}
};

int main() {
IntelGPUSelector d;
queue q(d);
int* data = malloc_shared<int>(N, q);
q.parallel_for(N, [=](auto i) {
data[i] = i;
}).wait();
for (int i = 0; i < N; i++) std::cout << data[i] << " ";
free(data, q);
}

编译运行上面的代码,如果没有问题应该输出:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

简单解释一下这段代码,sycl是DPC++的实体的命名空间,用using namespace sycl;打开命名空间可以简化后续代码。IntelGPUSelector是一个继承了device_selector的设备选择器,其中device_selector是纯虚类,它有个纯虚函数int operator()(const device& Device) const需要派生类来实现,该函数会遍历计算机上的计算设备,并且返回使用设备的优先级,返回数字越高优先级越高,这里选择Intel的GPU作为首选的计算设备,注意这个函数使用了override来说明其目的是覆盖虚函数。queue的目的是指定工作的目标位置,这里设置的是Intel的GPU。函数模板malloc_shared分配了可在设备上使用的工作内存。成员函数parallel_for执行并行计算。值得注意的是free调用的是sycl::free而不是C运行时库的free。在这段代码中,比较明显使用了现在C++语法的地方是函数parallel_for的实参,

[=](auto i) { data[i] = i; }

这是一个lambda表达式。
Ⅳ DPC++和lambda表达式
如果要选出一个对DPC++最重要的现代C++语言特性,我觉得lambda表达式应该可以被选上。因为在DPC++的代码中,内核代码一般都是以lambda表达式的形式出现。比如上面的例子就是将lambda表达式作为对象传入到Intel的GPU设备上然后进行计算的。在这个lambda表达式中,[=]是捕获列表,它可以捕获当前定义作用域内的变量的值,这也是它可以在函数体内使用data[i]的原因。捕获列表[=]之后的是形参列表(auto i),注意这里的形参类型使用的是auto占位符,也就是说,我们将形参类型的确认工作交给了编译器。我们一般称这种lambda表达式为泛型lambda表达式。当然,如果在编译时选择C++20标准,我们还可以将其改为模板语法的泛型lambda表达式:

[=]<typename T>(T i) { data[i] = i; }

lambda表达式的捕获列表功能非常强大,除了捕获值以外,还可以捕获引用,例如:

[&](auto i) { data[i] = i; }

以上代码会捕获当前定义作用域内的变量的引用,不过值得注意的是,由于这里的代码会交给加速核心运行,捕获引用并不是一个正确的做法,会导致编译出错。另外一般来说,我们并不推荐直接捕获所有可捕获的对象,而是有选择的捕获,例如:

[data](auto i) { data[i] = i; }

当然,除了使用lambda表达式,我们也可以选择其他形式的代码来运行设备,比如使用仿函数:

struct AssginTest {
void operator()(auto i) const { data_[i] = i; }
int* data_;
};

AssginTest functor{data};
q.parallel_for(N, functor).wait();

但是很明显,这种方法没有使用lambda表达式来的简单直接。

Ⅴ DPC++和泛型能力

之所以能够让parallel_for这么灵活的接受各种形式的实参,是因为parallel_for本身是一个成员函数模板:

template <typename KernelName = detail::auto_name, typename KernelType>
event parallel_for(range<1> NumWorkItems,
_KERNELFUNCPARAM(KernelFunc) _CODELOCPARAM(&CodeLoc)) {
_CODELOCARG(&CodeLoc);
return parallel_for_impl<KernelName>(NumWorkItems, KernelFunc, CodeLoc);
}

其中KernelFunc就是传入的lambda表达式或者仿函数,KernelTypeKernelFunc的类型。
如果从这里的代码一路运行跟踪下去,会发现它们都是用模板传递实参类型,直到submit_impl

sycld.dll!cl::sycl::queue::submit_impl
dpcpp.exe!cl::sycl::queue::submit
dpcpp.exe!cl::sycl::queue::parallel_for_impl
dpcpp.exe!cl::sycl::queue::parallel_for

这是因为sycld.dll是一个二进制模块,它无法以模板的形式提供代码,所有的类型必须确定下来,为了解决这个问题,cl::sycl::queue::submit_impl使用了std::function

event submit_impl(function_class<void(handler &)> CGH,
const detail::code_location &CodeLoc);

函数模板cl::sycl::queue::parallel_for_implKernelFunc封装到另外一个lambda表达式对象中,并且通过function_class<void(handler &)>来传递整个lambda表达式:

template <typename KernelName = detail::auto_name, typename KernelType,
int Dims>
event parallel_for_impl(
range<Dims> NumWorkItems, KernelType KernelFunc,
const detail::code_location &CodeLoc = detail::code_location::current()) {
return submit(
[&](handler &CGH) {
CGH.template parallel_for<KernelName, KernelType>(NumWorkItems,
KernelFunc);
},
CodeLoc);
}

其中function_class就是std::function。注意这里CGH.template parallel_for需要说明符template否则尖括号会解析出错。DPC++通过这样一系列的操作,最大限度的保留了用户编程的灵活性。

Ⅵ DPC++和模板推导

DPC++代码中大量的运用了C++17标准才引入的模板推导特性,关于这些特性我们还是从一个DPC++的小例子开始:

int main() {
IntelGPUSelector d;
queue q(d);
std::vector<int> v1(N);
std::array<int, N> v2;
{
buffer buf1(v1);
buffer buf2(v2);

q.submit([&](handler& h) {
accessor a1(buf1, h, write_only);
accessor a2(buf2, h, write_only);
h.parallel_for(N, [=](auto i) {
a1[i] = i;
a2[i] = i;
});
});
}
for (int i = 0; i < N; i++) std::cout << v1[i] << v2[i] << " ";
}

这段代码没有使用malloc_shared分配内存,取而代之的是使用bufferaccessor,其中buffer用于封装数据,accessor用于访问数据。这里以buffer为例解析DPC++对模板推导的使用。
首先观察buffer的两个实例,它们的构造函数的实参分别是std::vector<int>std::array<int, N>类型。之所以能够这样调用构造函数,并不是因为buffer为这两个类型重载了它的构造函数,而是因为其构造函数使用了模板。这里涉及到一个C++17标准新特性——类模板的模板实参推导。在以往,类模板的实例化必须是显式传入模板实参,否则会造成编译出错。在新的标准中,类模板的模板实参已经可以根据构造函数来推导了。来看一下buffer的构造函数:

template <typename T, int dimensions = 1,
typename AllocatorT = cl::sycl::buffer_allocator,
typename = typename detail::enable_if_t<(dimensions > 0) &&
(dimensions <= 3)>>
class buffer {
public:
...
template <class Container, int N = dimensions,
typename = EnableIfOneDimension<N>,
typename = EnableIfContiguous<Container>>
buffer(Container &container, AllocatorT allocator,
const property_list &propList = {})
: Range(range<1>(container.size())) {
impl = std::make_shared<detail::buffer_impl>(
container.data(), get_count() * sizeof(T),
detail::getNextPowerOfTwo(sizeof(T)), propList,
make_unique_ptr<detail::SYCLMemObjAllocatorHolder<AllocatorT>>(
allocator));
}

template <class Container, int N = dimensions,
typename = EnableIfOneDimension<N>,
typename = EnableIfContiguous<Container>>
buffer(Container &container, const property_list &propList = {})
: buffer(container, {}, propList) {}
...
};

代码buffer buf1(v1);会执行

buffer(Container &container, const property_list &propList = {})

这条构造函数,值得注意的是该构造函数并没有实际的实现代码,而是通过委托构造函数的方法调用了

buffer(Container &container, AllocatorT allocator, const property_list &propList = {})

委托构造函数是C++11引入的特性,它可以让某个构造函数将构造的执行权交给另外的构造函数。回到模板推导,这里通过构造函数会推导出Containerstd::vector<int>,dimensions的推导结果是1,而后面两个模板参数是用来检查前两个模板参数是否正确的,这里大量的使用了模板元编程的技巧:

template <int dims>
using EnableIfOneDimension = typename detail::enable_if_t<1 == dims>;

template <class Container>
using EnableIfContiguous =
detail::void_t<detail::enable_if_t<std::is_convertible<
detail::remove_pointer_t<decltype(
std::declval<Container>().data())> (*)[],
const T (*)[]>::value>,
decltype(std::declval<Container>().size())>;

首先它们都是使用using定义的别名模板,它们的目的分别是检查dims是否为1和Container是否为连续的。第一个别名模板很简单,直接检查dims是否为1,detail::enable_if_t就是std::enable_if_t。第二个检查连续性的方法稍微麻烦一些,简单来说就是检查容器对象的成员函数data()返回值的类型的数组指针是否能和const T (*)[]转换,这里主要检查两点,第一容器具有data()成员函数,第二返回类型的指针和T const T (*)[]转换。事实上,在标准容器中,只有连续容器有data()成员函数,其他的都会因为没有data()而报错,例如:

no member named 'data' in 'std::list<int>'

仔细阅读上面代码的朋友应该会发现另外一个问题,那就是没有任何地方可以帮助编译器推导出buffer的类模板形参T。这就不得不说DPC++将C++17关于模板推导的新特性用的淋漓尽致了。实际上在代码中,有这样一句用户自定义推导指引的代码:

template <class Container>
buffer(Container &, const property_list & = {})
->buffer<typename Container::value_type, 1>;

用户自定义推导指引是指程序员可以指导编译器如何通过函数实参推导模板形参的类型。最后在这个例子中,需要注意一下,buffer在析构的时候才会将缓存的数据写到v1v2,所以这里用了单独的作用域。

~buffer_impl() {
try {
BaseT::updateHostMemory();
} catch (...) {
}
}

Ⅶ 总结

本篇文章从几个简单的DPC++的例子展开,逐步探究了DPC++对于现代C++语言特性的运用,其中比较重要的包括lambda表达式、泛型和模板推导,当然DPC++运用的新特性远不止这些。从另一方面来看,这些新特性的加入确实的帮助DPC++完成了过去无法完成的工作,这也是近几年C++的发展趋势,越来越多的代码库开始引入新的特性,并且有一些非常”神奇“的代码也孕育而生。DPC++就是其中之一,光是阅读DPC++中使用新特性的代码就已经足够让人拍案叫绝了,更何况还有代码的组织架构、底层的抽象等等。我知道,单单一篇文章并不能讨论清楚DPC++中现代C++语言的特性,所以王婆卖瓜的推荐自己写的书《现代C++语言核心特性解析》和盛格塾课程《现代C++42讲》,相信看完这本书或者经过课程训练后朋友们会对现代C++语言的特性有一个比较深入的理解。

参考文献

1.DPC++ Part 1: An Introduction to the New Programming Model [https://simplecore-ger.intel.com/techdecoded/wp-content/uploads/sites/11/Webinar-Slides-DPC-Part-1-An-Introduction-to-the-New-Programming-Model-.pdf]
2.Data Parallel C++: Mastering DPC++ for Programming of Heterogeneous Systems Using C++ and SYCL preview [https://resource-cms.springernature.com/springer-cms/rest/v1/content/17382710/data/v1]
3.Intel® DevCloud [https://software.intel.com/en-us/devcloud/oneapi]
4.New, Open DPC++ Extensions Complement SYCL and C++ [https://insidehpc.com/2020/06/new-open-dpc-extensions-complement-sycl-and-c/]

补编-模块(C++20)

声明:这是一篇对《现代C++语言核心特性解析》中模块小节的补充,因为在我写模块的时间点实验环境并不理想,所以写的比较简单,也有读者提出了疑问,所以这里写一篇补充文来展示C++20中模块这个新特性。

一直以来,在我的个人世界中,C++编译器就是最强大的编译器,它们灵活、稳定并且十分高效。但不幸的是,源代码引入方式的落后导致C++编译器在面对巨型工程的时候总是力不从心,如果读者编译过Chromium、QT、LLVM这种规模的项目应该知道我的意思。

代码引入的问题

在解释C++代码引入的问题之前,我们先看一段这样一段代码:

#include <iostream>
int main()
{
std::cout << "Hello world";
}

上面是一段最简单的“Hello world”的代码,算上空格和换行字符一共仅仅70个字符。但是编译器在编译这份代码的时候却不能只解析这70个字符,它还需要处理<iostream>这个头文件。处理头文件的方式和宏一样,预处理器会把这个头文件完整的替换到源代码中,并且重新扫描源代码再替换源代码中新增的#include的头文件,一直递归处理下去,直到所有的替换都完成。

我们可以使用GCC生成替换后的文件:

gcc -E -P test.cpp -o expand.cpp

然后会发现整个expand.cpp文件有717192个字符!而我们的代码在这份代码中占据不到万分之一,但现实是这正是C++编译器需要处理的内容。事实上,大多数情况下编译器要处理的头文件内容要比原本的代码要多的多,真正我们自己编写的代码不到头文件的十分之一也是很常见的事情。一个更大的问题是,几乎所有的源代码都会包含一些头文件,比如标准库头文件,编译器不得不做巨量重复的工作。

像chromium这样的巨型项目会采用一些办法减少这种情况的出现,例如Jumbo/Unity builds,原理上就是讲多个源文件包含在一个文件中,这样多次的递归替换就能够合并为一次,事实证明这种方式非常有效,chromium的编译时间缩减了50%。但是这种方式对于代码组织非常苛刻,稍有不慎就会造成编译错误。

原始的头文件替换出了造成了编译低效之外,还有一些问题是一个现代语言需要解决的,例如:难以做到组件化、无法做代码隔离、难以支持现代语义感知开发工具等。C++急需一种高效的新方式代替原始的代码替换。

模块介绍

事实上,C++委员会早就发现了这些问题,并且在2007年就逐步开展了研究工作,只不过进度非常缓慢,直到2018年C++委员会才确定了最终草案(微软提供的方案),并在C++20标准中引入了模块。

我们通常可以认为,一个程序是由一组翻译单元(Translated units)组合而成的。这些翻译单元在没有额外信息的情况下是互相独立的,要将他们联系到一起需要这些翻译单元声明外部名称,编译器和链接器就是通过这些外部名称把独立的翻译单元组合起来的,而模块就可以认为是一个或者一组独立的翻译单元以及一组外部名称的组合体。那么模块名(Module name)就是引用组合体符号,模块单元(Module unit)其实就是组合体的翻译单元,模块接口单元(Module interface unit)很显然就是组合体里的那一组外部名称。

正规来说,一个模块由模块单元组成,模块单元分为模块接口单元和模块实现单元(Module implementation unit)。另外一个模块可以有多个模块分区,模块分区也是模块单元,模块分区的目的是方便模块代码的组织。对于每个模块,必须有一个没有分区的模块接口单元,该模块单元称为主模块接口单元。 导入一个模块,实际上导入的就是主模块的接口。

模块的语法

模块的语法应该算是非常简单的了,关键字包括exportimportmodule,其中module可以用来定义模块名、模块分区和模块片段,先来看看定义模块名:

module MyModule;

上面的代码定义了一个名为MyModule的模块单元,但是请注意这个模块不能作为主模块接口单元,因为定义主模块接口单元必须加上export

export module MyModule;

注意,我们以后可能会在一些库中看到如下命名:

import std.core;

这里的std.core是一个模块名,看上去表达的是标准库中的核心模块,.在其中表示层次关系,但是其注意,这里的.并没有任何的语法规定,它在这纯粹是为了一种层次。

在定义了模块名之后,就可以导出指定名称了:

// mymodule.ixx
export module MyModule;

export int x = 1;

export int foo() { return 2; }

export class bar {
public:
int run() { return 3; }
};

export namespace baz {
int foo() { return 4; }
}

上面的代码使用export说明符导出了变量、函数、类以及命名空间,这些名称都是可以导出的。其他源文件可以使用import说明符导入这些名称:

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo();
}

编译运行上面的代码,y的最终结果为10。当然,每个名称都依次使用export导出并不方便,标准还提供了更加简洁的写法:

// mymodule.ixx
export module MyModule;

export {
int x = 1;

int foo() { return 2; }

class bar {
public:
int run() { return 3; }
};

namespace baz {
int foo() { return 4; }
}
}

注意,没有export的名称是不能被import到其他源代码中的:

// mymodule.ixx
export module MyModule;

export {
...
}

int z = 5;

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo() + z; // 编译错误
}

这里test.cpp会编译报错,编译器会提示找不到标识符z

import说明符不仅能引入模块,也能引入头文件,例如:

// mymodule.ixx
export module MyModule;
import <iostream>;
export {
int x = 1;

int foo() { return 2; }

class bar {
public:
int run() { return 3; }
};

namespace baz {
int foo() { return 4; }
}

void print(int n) { std::cout << n; }
}

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo();
print(y);
}

请注意,这里使用了import来引入<iostream>而不是使用#include。在模块单元中不要使用#include来引入头文件因为这样会导致这些内容成为模块单元的一部分。

另外还有一个地方需要特别注意,import进来的头文件是不会被源文件中的宏修改的,例如:

// mymodule.ixx
export module MyModule;
import <iostream>;
export {
#ifdef OUTPUT_HELLO
void print() { std::cout << "hello"; }
#else
void print() { std::cout << "world"; }
#endif
}

// test.cpp
#define OUTPUT_HELLO
import MyModule;
int main()
{
print();
}

上面这段代码在test.cpp中定义了宏OUTPUT_HELLO,然后importMyModule模块,如果OUTPUT_HELLO能够影响引入的模块,那么运行结果输出hello,否则输出world。编译运行这段代码会发现最终结果为worldimport的内容不受宏的影响。但是,如果确实有这样的需求该怎么做呢?标准提供了一种叫做模块片段机制,模块片段通常用来做一些配置相关的工作,它通过module;开始,注意这里的module后直接跟着分号而没有模块名:

module;
// module fragment begin
#define SOME_CONFIG 20211102
#include <some_header>
// module fragment end
export module MyModule;
export {
...
}

模块片段还可以分为全局和私有,上面的代码编写的是全局的模块片段,要设置私有代码片段需要叫上private

module : private;

标准规定,私有模块片段只能出现在主模块接口单元中,并且具有私有模块片段的模块单元应是其模块的唯一模块单元。

最后,让我们来看一看什么是模块分区。如果要导出的模块内容很多,我们不能将所有的代码放到一个文件中,需要将其按照逻辑做合理的物理分割,这个时候就需要用到模块分区了,请看下面的例子:

// part1.ixx
export module MyModule:part1;

void foo_impl() {}
export void foo() { foo_impl(); }

// part2.ixx
export module MyModule:part2;
void bar() {}

// mymodule.ixx
export module MyModule;
export import :part1;
import :part2;

export void print() {
foo();
bar();
}

// test.cpp
import MyModule;
int main()
{
print();
foo();
}

在上面的代码中,part1.ixxpart1.ixx的模块名分别为MyModule:part1MyModule:part2,其中MyModule当然就是模块名,而紧跟在:后的名称则是它们的分区名。主模块接口单元可以通过import将模块分区合并到主模块接口单元中,并且无论模块分区是否导出了它的内容,它的内容都是对主模块接口单元可见的,所以print函数可以调用bar函数。

另外,主模块接口单元还可以决定直接导出分区定义的接口,比如代码中的:

export import :part1;

这样模块分区part1的函数foo也成为了导出接口。

《现代C++语言核心特性解析》上架感言

还记得2015年的时候,在下班回家的地铁上跟好朋友聊起C++11标准引入的一系列新特性,当时我已经学习过一阵这些有趣的新功能了,所以聊天的时候也是信手拈来,也就是这个时候朋友跟我提了一句,要不你去写一本书吧。虽然当时并没有把这句话放在心上,但是也算是在心中埋藏了一颗种子。

时间一晃就到了2017年,随着C++17标准的发布,C++又引入了一大批新特性,很多特性结合在一起甚至能写出“不太像C++代码”的错觉。另外,当时的国外已经诞生一批用新标准规范写的开源代码,这让我感觉到我们是不是有些落后了。于是乎,诞生了一个写一本有关C++新标准书籍的想法。其实,作为一个读书人,写一本书的想法一直都有,但是就是没有好的主题,当然动力有所不足。这次似乎是一个挺好的机会,那就动笔吧。就这样从2017年到2019年,每天都会在工作之余抽出一点点时间写写文字。因为第一次写书,所以刚开始写的时候文笔非常生硬,后面慢慢的熟练起来,不过目录的顺序并不是当时写书的顺序,所以读的时候会发现有的章节写的尚可,有的读起来生硬无比。虽然后来经过了一番修改,但是书中的问题肯定还是不少(发售之后也能找到不少拼写和错别字错误)。就这样,一直写到了C++20标准的草案接近完成,当时我就在想,总不能辛辛苦苦写的书,还没到发售就已经过时了吧?这可不行,于是改变计划,加入了C++20的内容。这一加,就写到了2020年的8月。

2020年8月,书的最后一个章节编写完毕。面对整篇书稿的完工,一方面是高兴兴奋,另一方面却也非常惶恐,因为要面临找出版社的考验了。虽然我对书的内容比较有信心,但是现在C++实在不是一门流行的技术了,至少招人方面已经很难招到合适的了。一个印象很深刻的事情是,我们家在饭桌讨论找出版社的事情,我的父母都表示肯定很难,让我如果找不到也不要伤心。我的爱人鼓励我说,“即使找不到出版社,这本书咱们自己花钱也要出出来”,这也是我当时的想法,不能让心血白费了,至少得有个结果。事实情况其实比我们想象的好很多,我在9月就找了国内一家计算机方面知名的出版社分社,开始讨论了一番以后发现对方对这本书的内容并没有太大兴趣,于是和这个出版社的联系就断了。10月份的时候找到另外一家知名出版社,也就是本书现在的出版社人民邮电出版社。当时出版社的陈编辑非常热情的给我介绍了如何申报图书出版的步骤,我也按照要求把资料提供了上去,大概过了一两周的样子,编辑告诉我出版社决定出版这本书,当时可把我高兴坏了。

从2020年10月到2021年9月,出版这本书整整花了一年的时候,从一审到三审,从一校到三校,还有申请ISBN编号,每一步我都想催着陈编辑快点做完,虽然每次催都特别不好意思,但是控制不住自己啊。幸好陈编辑每次都挺耐心的,真的很nice的人。在这一年期间,我遇到另外一个贵人是《软件调试》的作者张银奎老师,我和张老师认识的挺早的,大概就是《软件调试》刚刚出版后搞的一次书友会上认识的,有过一些交流,那个时候就记住了他的邮箱地址。这个邮箱地址可就发挥的重要作用了,当这本书需要一篇序的时候,我是第一时间想到了张银奎老师,想到了这个邮箱,于是就厚着脸皮发了一份邮件,请求张老师为本书作序,没想到的是张老师爽快的答应了,并且还邀请我做了一个课程。

就是这样,《现代C++语言核心特性解析》这本书在2021年的中秋节前上架了,一时感触非常多,想感谢很多人很多事,但是不知从哪里开始怎么开始,想来想去还是写一篇文章简单记录一下这段宝贵的过程好了,于是就有了这篇文章,大概就是这样了。

moderncpp

《现代C++语言核心特性解析》的出版社链接

QMutex中的优化小细节

在这篇文章中,我们要讨论的并不是QMutex的原理或者是应该如何使用,而是QMutex中一个很少被提到的优化细节。

我们知道在QT中,QMutex通常会和QMutexLocker一起使用,主要的用途是保护一个对象、数据结构或代码段,以便一次只让一个线程可以访问它们。 这一点对于多线程程序来说非常重要,而如今的程序已经离不开多线程,所以QMutex的使用场景也是越来越多,为了提高QMutex的使用效率,QT在QMutex的实现上加入了一个优化细节,这是std::mutex没有的,让我们来看看这个优化具体是什么。

QMutex的基类QBasicMutex有一个类型为QBasicAtomicPointer<QMutexData>成员,这里可以先忽略QBasicAtomicPointer,它只是保证对指针的原子操作,正在发挥作用的是QMutexData*QMutexData类型也没有什么特殊之处,真正的优化是在它的派生类QMutexPrivate,来看一段QMutexPrivate的代码:

class QMutexPrivate : public QMutexData
{
...
void deref() {
if (!refCount.deref())
release();
}
void release();
...
};

void QMutexPrivate::release()
{
freelist()->release(id);
}

可以看到,当引用计数为0的时候调用的release函数并没有真正释放互斥体对象,而是调用了一个freelistrelease函数。追踪freelist()会发现这样一段代码:

namespace {
struct FreeListConstants : QFreeListDefaultConstants {
enum { BlockCount = 4, MaxIndex=0xffff };
static const int Sizes[BlockCount];
};
const int FreeListConstants::Sizes[FreeListConstants::BlockCount] = {
16,
128,
1024,
FreeListConstants::MaxIndex - (16 + 128 + 1024)
};

typedef QFreeList<QMutexPrivate, FreeListConstants> FreeList;

static FreeList freeList_;
FreeList *freelist()
{
return &freeList_;
}
}

这下就豁然开朗了,QFreeList可以被认为是缓存池,用于维护QMutexPrivate的内存,当QMutexPrivate调用release函数的时候,QT并不会真的释放对象,而是将其加入到缓存池中,以便后续代码申请使用。这样不但可以减少内存反复分配带来的开销,也可以减少反复分配内核对象代码的开销,对于程序的性能是有所帮助的。

具体QFreeList的实现并不复杂,大家可以参考QT中的源代码qtbase\src\corelib\tools\qfreelist_p.h,另外除了QMutex以外,QRecursiveMutexQReadWriteLock也用到了相同的技术。