分类 编程语言 下的文章

最近写CMU15-445的时候,project 1里面有个任务类似于写unique_ptr智能指针PageGuard,创建时增加引用计数,销毁时减少引用计数。然后这里有个让人疑惑的地方,就是他要求写移动构造函数。当时我想着,这跟用= default应该没啥区别,后来不放心,还是做了个实验验证下,发现和想象中的还是有些出入。

废话不多说,直接贴代码:

#include <bits/stdc++.h>

using namespace std;

struct A {
    int val = 0;
    string msg = "";
};

int main()
{
    A a1{114514, "1919810"};
    printf("a1{%d,%s}\n", a1.val, a1.msg.c_str());

    A a2(std::move(a1));
    printf("a2{%d,%s}\n", a2.val, a2.msg.c_str());
    printf("a1{%d,%s}\n", a1.val, a1.msg.c_str());

    return 0;
}

然后结果是,a2能成功继承a1的所有数据,但是在move后的a1中,只有std::string的值被清空,而int类型的数据仍然保持了原有值,而非初始化到0。这个结果就与成员变量的平凡性有关了:对于平凡类型,move和直接复制该类型没有任何区别。也就是说,仅仅对于非平凡类型而言,才会存在真实的move动作,并将被move对象重置为设定的某个中间状态——平凡类型要求使用编译器隐式生成的移动构造函数,因而并不会对原有数据进行修改,move之后还是保持原有的值。

由于规定以C语言生成的所有数据结构都是平凡的,我们可以推测指针本身也是一种平凡类型,move后不会被清空。那么这下问题就解决了:要求我们自己实现移动构造函数的原因是,在对象中使用了C指针而非智能指针,并不能通过move的方式来解除被move对象与内存区域的绑定,需要自己手动使用nullptr来重置这些指针,防止它们在预期的生命周期外修改数据,制造未定义行为。

最近试图在gem5里做一个数据导出的工作,即在运行过程中记录每次内存访问的时间、位置和数据。

本来是打算当一个单线程来写的,但是考虑到本来跑一个程序用时就长,把关键路径做得尽可能短对工作效率比较友好,就开始考虑把io相关的工作单独提取到一个新线程完成,结果不知不觉地把这个问题演变成一个多线程问题了。

由于涉及到并发编程,原子操作和锁是绕不开的两个概念,一轻一重两大门神守护着多线程环境下的数据安全性,而两者在C++下分别对应有最为常用的头文件,<atomic><mutex>。由于锁的基本使用较为简单,此文主要记录原子类型变量的基本使用方法。

原子类型所要完成的任务是,确保对该类型的指定操作以原子方式完成。至于为什么一开始不把类型设计成原子操作的,这里涉及硬件设计与软件语义之间的抽象层次差异,换句话说,以硬件成本来看,不值得。数据类型难以有个规范,要是为每种数据类型单独设计一条指令,那光是原子操作指令都能上天。VCISC,超复杂指令集的奠基者

于是乎,现有计算机系统采用了折中方案,即硬件提供一些最基本的指令,再使用一些软件手段,以时间/空间为代价,来拓宽原子操作特性的可用范围。以此为契机,出现了<atomic>这种向用户提供软(也可能是硬)原子操作的语言基础库。

原子操作最常见的用法是使用类模板定义对应类型的变量:

std::atomic<int> atomic_int{0};  // use (0) instead of {0} is okay

上面的代码定义了一个原子操作的int类型变量,并且赋初值为0。注意这里用的是括号初始化,而不是等号赋值。这是因为在定义变量时,operator =并不是将值赋予该变量,而是执行对象复制操作,而翻阅文档,有如下定义:

atomic& operator=( const atomic& ) = delete;

也就是说,语言禁止了atomic对象的复制,只能通过值初始化的方式在变量声明时设置初始值。

介绍完初始化操作之后,就是读写操作了。std::atomic提供了两种对原子变量的读写方式,一是像普通变量一样正常使用,二是load/store接口:

std::atomic<int> val{0};
int tmp = val.load();      // equivalent to `int tmp = val;`
val.store(1);              // equivalent to `val = 1;`

虽然说像普通变量一样写很方便,但是真到读代码的时候原子变量和普通变量混在一起还是挺麻烦的。个人还是觉得用显式的load/store比较好。

然后就是大名鼎鼎的复杂原子操作了。复杂原子操作具有共同的特征:RMW(Read-Modify-Write,读-改-写),而CAS(Compare-And-Swap,比较并交换)则是其中最为基础的RMW操作,所有的复杂RMW操作都可以基于CAS操作完成。对于CAS,<atomic>头文件提供了两种相似的实现方案,compare_exchange_weakcompare_exchange_strong。cplusplus对于这两种方法的区别介绍的非常隐晦,只是说strong方法用起来符合直觉,运行结果是标准的CAS;而weak方法用起来性能更好,但是可能出现假阴性的情况(即使compare结果是允许swap的,但还是判定为不允许,走失败逻辑)。

先是compare_exchange_strong的使用样例。

std::atomic<int> val{0};
int prev = val.load();
val.compare_exchange_strong(prev, newval);

这里取原始值为prev,以该值作为CAS的对比值,如果执行compare_exchange_strong的时候读取到的val仍然是该值的话,则执行CAS操作,将val值设置为newval,并给出返回值true;否则不执行CAS操作,反而将prev值设置为最新读取到的val值,并返回false

其次是compare_exchange_weak。由于这里可能出现假阴性的逻辑错误,需要额外对代码做一点修改,来保证代码仍然能完成正确的逻辑。

std::atomic<int> val{0};
int prev = val.load();
while(!val.compare_exchange_weak(prev, newval));

可以看到weak的用法与strong略有差别,这里多套了一层while循环。如前面所说,使用while的目的是对付compare_exchange_weak中即使满足CAS条件,但仍然执行失败流程的情况。这种方式并不适用于顺序敏感型的应用,比如乘加操作,不同线程的操作顺序会导致产生截然不同的输出结果;但是对只需要将数据加入指定结构而不要求顺序的类链表操作的话,这个方法还是具有更高的效率的(因为有神秘的性能优化)。

对于部分常见基础类型,std::atomic预先提供了常见运算方式的原子操作实现,比如原子加fetch_add、原子减fetch_sub,对于char/int及其各种衍生基础类,还提供有原子位或fetch_or/位与fetch_or/位异或fetch_xor等原子逻辑操作的实现。

离大谱

用VS+Boost写程序的时候,Intellisense没有给出任何报错,但是编译的时候给出如下信息:

LNK2038: mismatch detected for 'boost_log_abi': value 'v2s_mt_nt6' doesn't match value 'v2s_mt_nt62'

到网上转了一圈,看到别人遇到了类似问题,但又不完全一样,包括:

  • 编译Boost库的环境与编译应用程序的环境不一致(v2s_mt_nt5/v2s_mt_nt6
  • 用动态库链接静态对象文件(v2_mt_nt6/v2s_mt_nt6

但我这个情况,库版本没问题,编译环境也完全一致,报错也跟上述有点不一样。。。

地铁老人手机.jpg

找了好久原因,后来想起之前遇到过相同的问题,从哪里找到的解决方案忘记了,但方法奏效了:原因是我将其他库(某不愿透露姓名的PMDK)和Boost一起使用,然后可能是两个库使用了相同的宏变量,把Boost的头文件include放在最前面就好了。

如果有人遇到了相同的问题,可以考虑试一下交换include顺序,可能就是不同库碰巧使用了同一宏变量呢= =

最近因为项目需要,去看了一下libsodium库的实现,发现里面各种宏定义骚操作真是肯尼迪坐敞篷车,脑洞大开啊。

其中负责加解密一块的逻辑使用了硬件指令集来加速,用到了AES专用指令集一类的。作为专用硬件指令集,调用方法之一自然是在代码里写__asm__了,然而这样会让代码显得很丑,逻辑也不够清晰。于是另一种方法就是封装,反正原理大家都懂,调用的时候做成黑盒就好看多了,于是就有了各种XXXintrin.h,封装了从MMX、SSE直到AVX等SIMD指令集的C语言接口函数。

然而大家似乎并不关心这些函数的命名规则,亦或者说是这个领域的工作者足够细心严谨,所有人都是先读完intel的手册再开始干活的,以至于当我习惯性地试图从函数名推断出这个函数的功能或者数据特征的时候,一脸茫然= =

根据 牙膏厂的官方文档 所说,这些过渡函数的命名是三段式的:

Most intrinsic names use the following notational convention: _mm_<intrin_op>_<suffix>

第一段是固定的_mm,然而intel并没有解释这个mm是什么含义,我个人猜测是同MMX中的Multi-Media,作为一种历史习惯遗留下来;第二段是函数对应汇编指令的功能;而第三段表示的是该汇编指令接收的数据格式与类型,进一步由两部分组成。

以文中提到的_mm_set_pd为例,该函数的作用是从内存中加载两个双精度浮点数据到128位的SIMD寄存器。同样地,以AES-NI指令集的核心指令之一AESENC来说,它的C形式是_mm_aesenc_si128,表示该函数对应的指令为AES一轮加密(_aesenc),传入的数据应该是一个完整(_s)的128位整型数据(i128)。

然而,AVX系列的函数可能并不完全遵循如上规则。比如说涉及数据类型转换的_mm256_castps256_ps128,功能是将一组256位单精度浮点转换为一组128位单精度浮点数(看描述,似乎就是舍弃了高128位?),该函数的第三段_ps128代表的不再是输入数据格式,而是输出数据格式了。

类似地,AVX512的函数前缀就变成了_mm512,应该就是和对应的数据宽度为512相关联吧。

起因是之前闲着没事,自己动手编译了PyTorch和TensorFlow,然后把生成的轮子(.whl)留了下来,打算在之后需要使用的时候自己安装。这里顺便吐槽一句,PyTorch官方的wheel包里自带了cudaRT和CUDNN,搞得安装包动不动上1G(自己编译的非自带版本仅200M+),而且服务器还不稳定,下着下着就自动断线了= =

然后经典安装本地轮子命令:

pip install torch-1.9.0a0+gitdfbd030-cp36-cp36m-win_amd64+cu101.whl

然后直接提示说不支持我装这个包。Python版本是对的,编译和安装wheel时用的也都是64位版本,网上那些Python版本都搞不对的低级错误我怎么可能犯,当时一度怀疑是pip出了问题。试图到网上找pip的verbose选项,结果发现对于pip install指令并不支持这个功能,于是只好回到无头苍蝇状态,继续搜索有关轮子装不上的问题。

然后恰好看到这个提问,里面说pip会使用文件名来判断当前wheel是否支持目标平台。(使用pip debug --verbose可以查看当前平台pip支持的所有目标平台标识符)

然后仔细一想,为了区分CPU和不同CUDA版本下编译出来的轮子,我在每个文件末尾添加了CUDA版本信息,如上面命令中的+cu101,于是猜测是不是这个动作干扰了pip对轮子信息的判定。结果删了之后马上就好了= =

于是得出结论,不要轻易修改python wheel的文件名;如果一定要添加辅助信息的话,可以在版本号后面用加号+链接添加所需要的信息,例如PytTorch自己编译时自动添加的git版本号。